commit f3bc795d9adb230e4f2b9ba43e6ba59902680a25 Author: Laurence Horrocks-Barlow Date: Sun May 17 08:48:04 2026 +0100 Initial release of Eventbrite for The Events Calendar (v1.0.0) Bidirectional sync between Eventbrite and The Events Calendar, with WooCommerce ticket purchasing that bypasses Eventbrite's processing fees by registering buyers as free attendees via API. Includes venue/ organizer sync, QR code ticket generation, attendee management with CSV export, scheduled sync via WP-Cron, and real-time Eventbrite webhooks. diff --git a/eb4tec.php b/eb4tec.php new file mode 100644 index 0000000..b1af167 --- /dev/null +++ b/eb4tec.php @@ -0,0 +1,64 @@ +query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE 'eb4tec_%'" ); + + // Remove all plugin post meta. + $wpdb->query( "DELETE FROM {$wpdb->postmeta} WHERE meta_key LIKE '\_eb4tec\_%'" ); + + // Remove transients. + $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_eb4tec_%' OR option_name LIKE '_transient_timeout_eb4tec_%'" ); +} + +add_action( 'plugins_loaded', function (): void { + foreach ( glob( EB4TEC_DIR . 'includes/class-eb4tec-*.php' ) as $file ) { + require_once $file; + } + new EB4TEC_Loader(); +} ); diff --git a/includes/class-eb4tec-api-client.php b/includes/class-eb4tec-api-client.php new file mode 100644 index 0000000..6bd63fb --- /dev/null +++ b/includes/class-eb4tec-api-client.php @@ -0,0 +1,242 @@ +token = (string) get_option( 'eb4tec_api_token', '' ); + } + + // --------------------------------------------------------------------------- + // Credentials / user + // --------------------------------------------------------------------------- + + public function get_user_me(): array|WP_Error { + return $this->get( 'users/me/', cacheable: false ); + } + + // --------------------------------------------------------------------------- + // Events + // --------------------------------------------------------------------------- + + public function get_organization_events( string $org_id, int $page = 1 ): array|WP_Error { + return $this->get( "organizations/{$org_id}/events/", [ + 'status' => 'draft,live,started,ended,completed', + 'expand' => 'venue,organizer,ticket_classes', + 'page' => $page, + 'page_size' => 50, + 'order_by' => 'start_asc', + ] ); + } + + public function get_event( string $eb_event_id ): array|WP_Error { + return $this->get( "events/{$eb_event_id}/", [ 'expand' => 'venue,organizer,ticket_classes' ] ); + } + + public function create_event( array $data ): array|WP_Error { + $org_id = (string) get_option( 'eb4tec_org_id', '' ); + return $this->post( "organizations/{$org_id}/events/", $data ); + } + + public function update_event( string $eb_event_id, array $data ): array|WP_Error { + return $this->post( "events/{$eb_event_id}/", $data ); + } + + public function publish_event( string $eb_event_id ): array|WP_Error { + return $this->post( "events/{$eb_event_id}/publish/", [] ); + } + + public function unpublish_event( string $eb_event_id ): array|WP_Error { + return $this->post( "events/{$eb_event_id}/unpublish/", [] ); + } + + // --------------------------------------------------------------------------- + // Ticket classes + // --------------------------------------------------------------------------- + + public function get_ticket_classes( string $eb_event_id ): array|WP_Error { + return $this->get( "events/{$eb_event_id}/ticket_classes/" ); + } + + public function create_ticket_class( string $eb_event_id, array $data ): array|WP_Error { + return $this->post( "events/{$eb_event_id}/ticket_classes/", $data ); + } + + public function update_ticket_class( string $eb_event_id, string $class_id, array $data ): array|WP_Error { + return $this->post( "events/{$eb_event_id}/ticket_classes/{$class_id}/", $data ); + } + + // --------------------------------------------------------------------------- + // Attendees + // --------------------------------------------------------------------------- + + public function create_attendee( string $eb_event_id, array $data ): array|WP_Error { + return $this->post( "events/{$eb_event_id}/attendees/", $data ); + } + + public function get_attendees( string $eb_event_id, int $page = 1 ): array|WP_Error { + return $this->get( "events/{$eb_event_id}/attendees/", [ + 'page' => $page, + 'page_size' => 50, + 'expand' => 'ticket_class', + ], cacheable: false ); + } + + // --------------------------------------------------------------------------- + // Venues + // --------------------------------------------------------------------------- + + public function get_venue( string $venue_id ): array|WP_Error { + return $this->get( "venues/{$venue_id}/" ); + } + + public function create_venue( string $org_id, array $data ): array|WP_Error { + return $this->post( "organizations/{$org_id}/venues/", $data ); + } + + public function update_venue( string $venue_id, array $data ): array|WP_Error { + return $this->post( "venues/{$venue_id}/", $data ); + } + + // --------------------------------------------------------------------------- + // Organizers + // --------------------------------------------------------------------------- + + public function get_organizer( string $organizer_id ): array|WP_Error { + return $this->get( "organizers/{$organizer_id}/" ); + } + + public function create_organizer( string $org_id, array $data ): array|WP_Error { + return $this->post( "organizations/{$org_id}/organizers/", $data ); + } + + public function update_organizer( string $organizer_id, array $data ): array|WP_Error { + return $this->post( "organizers/{$organizer_id}/", $data ); + } + + // --------------------------------------------------------------------------- + // Webhooks + // --------------------------------------------------------------------------- + + public function create_webhook( array $data ): array|WP_Error { + $org_id = (string) get_option( 'eb4tec_org_id', '' ); + return $this->post( "organizations/{$org_id}/webhooks/", $data ); + } + + public function list_webhooks(): array|WP_Error { + $org_id = (string) get_option( 'eb4tec_org_id', '' ); + return $this->get( "organizations/{$org_id}/webhooks/", cacheable: false ); + } + + public function delete_webhook( string $webhook_id ): array|WP_Error { + return $this->delete_request( "webhooks/{$webhook_id}/" ); + } + + // --------------------------------------------------------------------------- + // Cache helpers + // --------------------------------------------------------------------------- + + public function bust_cache( string $endpoint ): void { + delete_transient( $this->cache_key( $endpoint, [] ) ); + } + + // --------------------------------------------------------------------------- + // HTTP internals + // --------------------------------------------------------------------------- + + private function get( string $endpoint, array $query = [], bool $cacheable = true ): array|WP_Error { + if ( $cacheable ) { + $key = $this->cache_key( $endpoint, $query ); + $cached = get_transient( $key ); + if ( false !== $cached ) { + return $cached; + } + } + + $url = self::BASE_URL . $endpoint; + $result = $this->request( 'GET', $url, [ 'body' => $query ] ); + + if ( ! is_wp_error( $result ) && $cacheable ) { + set_transient( $key, $result, self::CACHE_TTL ); + } + + return $result; + } + + private function post( string $endpoint, array $body ): array|WP_Error { + $url = self::BASE_URL . $endpoint; + return $this->request( 'POST', $url, [ + 'body' => wp_json_encode( $body ), + 'content-type' => 'application/json', + ] ); + } + + private function delete_request( string $endpoint ): array|WP_Error { + $url = self::BASE_URL . $endpoint; + return $this->request( 'DELETE', $url, [] ); + } + + private function request( string $method, string $url, array $args ): array|WP_Error { + if ( empty( $this->token ) ) { + return new WP_Error( 'eb4tec_no_token', __( 'Eventbrite API token is not configured.', 'eb4tec' ) ); + } + + $defaults = [ + 'method' => $method, + 'timeout' => 15, + 'headers' => [ + 'Authorization' => 'Bearer ' . $this->token, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ], + ]; + + $args = array_merge( $defaults, $args ); + $response = wp_remote_request( $url, $args ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status = wp_remote_retrieve_response_code( $response ); + $body = wp_remote_retrieve_body( $response ); + + // Track rate limit. + $remaining = wp_remote_retrieve_header( $response, 'x-ratelimit-remaining' ); + if ( '' !== $remaining ) { + $this->rate_limit_remaining = (int) $remaining; + if ( $this->rate_limit_remaining <= 10 ) { + set_transient( 'eb4tec_rate_limit_warning', true, 60 ); + } + } + + $decoded = json_decode( $body, true ); + + if ( $status < 200 || $status >= 300 ) { + $message = $decoded['error_description'] ?? $decoded['error'] ?? __( 'Unknown Eventbrite API error.', 'eb4tec' ); + return new WP_Error( + 'eb4tec_api_error', + $message, + [ 'status' => $status, 'response' => $decoded ] + ); + } + + return $decoded ?? []; + } + + private function cache_key( string $endpoint, array $query ): string { + return 'eb4tec_' . md5( $endpoint . serialize( $query ) ); + } + + public function get_rate_limit_remaining(): int { + return $this->rate_limit_remaining; + } +} diff --git a/includes/class-eb4tec-attendees.php b/includes/class-eb4tec-attendees.php new file mode 100644 index 0000000..c67ade5 --- /dev/null +++ b/includes/class-eb4tec-attendees.php @@ -0,0 +1,278 @@ +'; + echo '

' . esc_html__( 'Eventbrite Attendees', 'eb4tec' ) . '

'; + + $this->render_event_selector( $selected_post_id ); + + if ( ! $selected_post_id ) { + echo '

' . esc_html__( 'Select an event above to view its attendees.', 'eb4tec' ) . '

'; + echo ''; + return; + } + + $eb_event_id = (string) get_post_meta( $selected_post_id, '_eb4tec_event_id', true ); + if ( ! $eb_event_id ) { + echo '

' . esc_html__( 'This event is not linked to an Eventbrite event.', 'eb4tec' ) . '

'; + echo ''; + return; + } + + $response = $this->api->get_attendees( $eb_event_id, $page_num ); + if ( is_wp_error( $response ) ) { + echo '

' . esc_html( $response->get_error_message() ) . '

'; + echo ''; + return; + } + + $attendees = $response['attendees'] ?? []; + $pagination = $response['pagination'] ?? []; + $total = (int) ( $pagination['object_count'] ?? 0 ); + $page_count = (int) ( $pagination['page_count'] ?? 1 ); + + // Export button. + echo '
'; + wp_nonce_field( "eb4tec_export_attendees_{$eb_event_id}", '_eb4tec_export_nonce' ); + echo ''; + echo ''; + echo ''; + submit_button( sprintf( __( 'Export All %d Attendees to CSV', 'eb4tec' ), $total ), 'secondary', 'submit', false ); + echo '
'; + + $this->render_attendee_table( $attendees, $selected_post_id ); + $this->render_pagination( $page_num, $page_count, $selected_post_id ); + + echo ''; + } + + private function render_event_selector( int $selected_post_id ): void { + $events = get_posts( [ + 'post_type' => 'tribe_events', + 'post_status' => [ 'publish', 'draft' ], + 'posts_per_page' => 200, + 'orderby' => 'title', + 'order' => 'ASC', + 'meta_query' => [ [ + 'key' => '_eb4tec_event_id', + 'compare' => 'EXISTS', + ] ], + ] ); + + $base_url = admin_url( 'edit.php?post_type=tribe_events&page=eb4tec-attendees' ); + + echo '

'; + echo ' '; + echo ''; + echo '

'; + } + + private function render_attendee_table( array $attendees, int $post_id ): void { + if ( empty( $attendees ) ) { + echo '

' . esc_html__( 'No attendees found for this event.', 'eb4tec' ) . '

'; + return; + } + + // Build a map of EB attendee ID → WC order number from stored order meta. + $order_map = $this->build_order_map( $post_id ); + + echo ''; + echo ''; + $headers = [ + __( 'Name', 'eb4tec' ), + __( 'Email', 'eb4tec' ), + __( 'WC Order #', 'eb4tec' ), + __( 'Ticket Class', 'eb4tec' ), + __( 'Status', 'eb4tec' ), + __( 'Checked In', 'eb4tec' ), + __( 'Registered', 'eb4tec' ), + ]; + foreach ( $headers as $h ) { + echo ''; + } + echo ''; + + foreach ( $attendees as $attendee ) { + $profile = $attendee['profile'] ?? []; + $name = esc_html( $profile['name'] ?? '' ); + $email = esc_html( $profile['email'] ?? '' ); + $status = esc_html( $attendee['status'] ?? '' ); + $checked = ! empty( $attendee['checked_in'] ) ? esc_html__( 'Yes', 'eb4tec' ) : esc_html__( 'No', 'eb4tec' ); + $tc_name = esc_html( $attendee['ticket_class'] ['name'] ?? '' ); + $created = esc_html( $attendee['created'] ?? '' ); + $eb_att_id = $attendee['id'] ?? ''; + $order_num = isset( $order_map[ $eb_att_id ] ) ? '#' . $order_map[ $eb_att_id ] : '—'; + + echo ""; + } + + echo '
' . esc_html( $h ) . '
{$name}{$email}{$order_num}{$tc_name}{$status}{$checked}{$created}
'; + } + + private function render_pagination( int $current, int $total_pages, int $post_id ): void { + if ( $total_pages <= 1 ) { + return; + } + + $base_url = add_query_arg( 'event_post_id', $post_id, admin_url( 'edit.php?post_type=tribe_events&page=eb4tec-attendees' ) ); + + echo '
'; + for ( $p = 1; $p <= $total_pages; $p++ ) { + $url = esc_url( add_query_arg( 'paged', $p, $base_url ) ); + $class = ( $p === $current ) ? 'button button-primary' : 'button'; + echo "{$p} "; + } + echo '
'; + } + + // --------------------------------------------------------------------------- + // CSV export + // --------------------------------------------------------------------------- + + public function handle_csv_export(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'Insufficient permissions.', 'eb4tec' ) ); + } + + $eb_event_id = sanitize_text_field( $_POST['eb_event_id'] ?? '' ); + $post_id = (int) ( $_POST['event_post_id'] ?? 0 ); + + if ( ! $eb_event_id ) { + wp_die( esc_html__( 'No event ID provided.', 'eb4tec' ) ); + } + + check_admin_referer( "eb4tec_export_attendees_{$eb_event_id}", '_eb4tec_export_nonce' ); + + $order_map = $this->build_order_map( $post_id ); + $attendees = $this->fetch_all_attendees( $eb_event_id ); + + if ( is_wp_error( $attendees ) ) { + wp_die( esc_html( $attendees->get_error_message() ) ); + } + + $event_title = $post_id ? sanitize_file_name( get_the_title( $post_id ) ) : $eb_event_id; + $filename = "attendees-{$event_title}-" . date( 'Y-m-d' ) . '.csv'; + + header( 'Content-Type: text/csv; charset=utf-8' ); + header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); + header( 'Pragma: no-cache' ); + header( 'Expires: 0' ); + + $output = fopen( 'php://output', 'w' ); + + fputcsv( $output, [ + __( 'Name', 'eb4tec' ), + __( 'Email', 'eb4tec' ), + __( 'Order Number', 'eb4tec' ), + __( 'Ticket Class', 'eb4tec' ), + __( 'Status', 'eb4tec' ), + __( 'Checked In', 'eb4tec' ), + __( 'Registration Date', 'eb4tec' ), + ] ); + + foreach ( $attendees as $attendee ) { + $profile = $attendee['profile'] ?? []; + $eb_att_id = $attendee['id'] ?? ''; + $order_num = $order_map[ $eb_att_id ] ?? ''; + + fputcsv( $output, [ + $profile['name'] ?? '', + $profile['email'] ?? '', + $order_num, + $attendee['ticket_class']['name'] ?? '', + $attendee['status'] ?? '', + ! empty( $attendee['checked_in'] ) ? 'Yes' : 'No', + $attendee['created'] ?? '', + ] ); + } + + fclose( $output ); + exit; + } + + private function fetch_all_attendees( string $eb_event_id ): array|WP_Error { + $page = 1; + $attendees = []; + + do { + $response = $this->api->get_attendees( $eb_event_id, $page ); + if ( is_wp_error( $response ) ) { + return $response; + } + $attendees = array_merge( $attendees, $response['attendees'] ?? [] ); + $has_more = ! empty( $response['pagination']['has_more_items'] ); + $page++; + } while ( $has_more ); + + return $attendees; + } + + /** + * Build a map of Eventbrite attendee ID → WooCommerce order number. + * Searches orders that have this event's post ID stored in meta. + */ + private function build_order_map( int $post_id ): array { + if ( ! $post_id ) { + return []; + } + + $orders = wc_get_orders( [ + 'meta_key' => '_eb4tec_event_post_id', + 'meta_value' => $post_id, + 'limit' => -1, + 'return' => 'ids', + ] ); + + $map = []; + foreach ( $orders as $order_id ) { + $attendee_ids = json_decode( get_post_meta( $order_id, '_eb4tec_attendee_ids', true ) ?: '[]', true ); + foreach ( $attendee_ids as $att_id ) { + $map[ $att_id ] = $order_id; + } + } + + return $map; + } +} diff --git a/includes/class-eb4tec-cron.php b/includes/class-eb4tec-cron.php new file mode 100644 index 0000000..726db44 --- /dev/null +++ b/includes/class-eb4tec-cron.php @@ -0,0 +1,120 @@ + HOUR_IN_SECONDS, + 'twicedaily' => 12 * HOUR_IN_SECONDS, + 'daily' => DAY_IN_SECONDS, + ]; + + $seconds = $map[ $interval_key ] ?? HOUR_IN_SECONDS; + + $schedules[ self::SCHEDULE ] = [ + 'interval' => $seconds, + 'display' => __( 'Eventbrite Sync', 'eb4tec' ), + ]; + + return $schedules; + } + + // --------------------------------------------------------------------------- + // Sync execution + // --------------------------------------------------------------------------- + + public function run_sync(): void { + $result = $this->event_sync->run_full_sync(); + + set_transient( 'eb4tec_last_sync_result', $result, DAY_IN_SECONDS ); + update_option( 'eb4tec_last_sync_timestamp', time() ); + } + + public function handle_sync_now(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'Insufficient permissions.', 'eb4tec' ) ); + } + + check_admin_referer( 'eb4tec_sync_now', '_eb4tec_sync_nonce' ); + + $this->run_sync(); + + $user_id = get_current_user_id(); + set_transient( "eb4tec_sync_now_result_{$user_id}", get_transient( 'eb4tec_last_sync_result' ), 60 ); + + $redirect = wp_get_referer() ?: admin_url( 'edit.php?post_type=tribe_events&page=eb4tec-settings' ); + wp_safe_redirect( $redirect ); + exit; + } + + // --------------------------------------------------------------------------- + // Admin notices (sync result — shown on all admin screens) + // --------------------------------------------------------------------------- + + public function admin_notices(): void { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + $screen = get_current_screen(); + // Only show on TEC / EB4TEC admin screens. + if ( ! $screen || ! str_contains( $screen->id ?? '', 'tribe_events' ) ) { + return; + } + + $user_id = get_current_user_id(); + $sync_result = get_transient( "eb4tec_sync_now_result_{$user_id}" ); + if ( ! is_array( $sync_result ) ) { + return; + } + + delete_transient( "eb4tec_sync_now_result_{$user_id}" ); + + $msg = sprintf( + __( 'Eventbrite sync complete — %d pulled, %d pushed, %d errors.', 'eb4tec' ), + (int) ( $sync_result['pulled'] ?? 0 ), + (int) ( $sync_result['pushed'] ?? 0 ), + (int) ( $sync_result['errors'] ?? 0 ) + ); + $class = ( ( $sync_result['errors'] ?? 0 ) > 0 ) ? 'notice-warning' : 'notice-success'; + + echo '

' . esc_html( $msg ) . '

'; + } +} diff --git a/includes/class-eb4tec-event-sync.php b/includes/class-eb4tec-event-sync.php new file mode 100644 index 0000000..65d3234 --- /dev/null +++ b/includes/class-eb4tec-event-sync.php @@ -0,0 +1,483 @@ +pull_from_eventbrite(); + if ( is_wp_error( $pull_result ) ) { + $errors[] = $pull_result->get_error_message(); + } else { + $pulled = $pull_result; + } + } + + if ( in_array( $direction, [ 'both', 'tec_to_eb' ], true ) ) { + $push_result = $this->push_to_eventbrite(); + if ( is_wp_error( $push_result ) ) { + $errors[] = $push_result->get_error_message(); + } else { + $pushed = $push_result; + } + } + + return [ 'pulled' => $pulled, 'pushed' => $pushed, 'errors' => count( $errors ) ]; + } + + // --------------------------------------------------------------------------- + // Pull: Eventbrite → TEC + // --------------------------------------------------------------------------- + + public function pull_from_eventbrite(): int|WP_Error { + $org_id = (string) get_option( 'eb4tec_org_id', '' ); + if ( empty( $org_id ) ) { + return new WP_Error( 'eb4tec_no_org', __( 'Eventbrite organization ID not configured.', 'eb4tec' ) ); + } + + $page = 1; + $pulled = 0; + + do { + $response = $this->api->get_organization_events( $org_id, $page ); + if ( is_wp_error( $response ) ) { + return $response; + } + + $events = $response['events'] ?? []; + $pagination = $response['pagination'] ?? []; + + foreach ( $events as $eb_event ) { + $result = $this->pull_event_from_data( $eb_event ); + if ( ! is_wp_error( $result ) && $result > 0 ) { + $pulled++; + } + } + + $has_more = ! empty( $pagination['has_more_items'] ); + $page++; + } while ( $has_more ); + + return $pulled; + } + + public function pull_event( string $eb_event_id ): int|WP_Error { + $data = $this->api->get_event( $eb_event_id ); + if ( is_wp_error( $data ) ) { + return $data; + } + return $this->pull_event_from_data( $data ); + } + + private function pull_event_from_data( array $eb_event ): int|WP_Error { + $eb_id = $eb_event['id'] ?? ''; + if ( empty( $eb_id ) ) { + return new WP_Error( 'eb4tec_no_id', __( 'Eventbrite event has no ID.', 'eb4tec' ) ); + } + + $existing_id = $this->find_tec_event( $eb_id ); + + // Skip events the user has marked to exclude from sync. + if ( $existing_id && get_post_meta( $existing_id, '_eb4tec_exclude_sync', true ) ) { + return $existing_id; + } + + $post_args = $this->eb_event_to_tec_args( $eb_event ); + + if ( $existing_id ) { + $post_args['ID'] = $existing_id; + // Remove status from update args — don't override draft if admin changed it. + unset( $post_args['post_status'] ); + $post_id = wp_update_post( $post_args, true ); + } else { + $post_id = wp_insert_post( $post_args, true ); + } + + if ( is_wp_error( $post_id ) ) { + return $post_id; + } + + $this->update_tec_from_eb( $post_id, $eb_event ); + + // Sync venue. + if ( get_option( 'eb4tec_sync_venues' ) && ! empty( $eb_event['venue_id'] ) ) { + $venue_post_id = $this->venue_sync->pull_venue( $eb_event['venue_id'] ); + if ( ! is_wp_error( $venue_post_id ) && $venue_post_id > 0 ) { + update_post_meta( $post_id, '_EventVenueID', $venue_post_id ); + } + } + + // Sync organizer. + if ( get_option( 'eb4tec_sync_organizers' ) && ! empty( $eb_event['organizer_id'] ) ) { + $org_post_id = $this->organizer_sync->pull_organizer( $eb_event['organizer_id'] ); + if ( ! is_wp_error( $org_post_id ) && $org_post_id > 0 ) { + update_post_meta( $post_id, '_EventOrganizerID', $org_post_id ); + } + } + + // Ensure WooCommerce product exists. + $this->woocommerce->sync_event_product( $post_id ); + + // Ensure hidden ticket class exists on Eventbrite. + $capacity = (int) get_post_meta( $post_id, '_eb4tec_capacity', true ); + if ( $capacity > 0 ) { + $this->ticket_manager->ensure_wp_ticket_class( $eb_id, $post_id, $capacity ); + } + + return $post_id; + } + + // --------------------------------------------------------------------------- + // Push: TEC → Eventbrite + // --------------------------------------------------------------------------- + + public function push_to_eventbrite(): int|WP_Error { + // Find events that need pushing: no EB ID yet, or flagged for push on last save. + $query = new WP_Query( [ + 'post_type' => 'tribe_events', + 'post_status' => [ 'publish', 'draft' ], + 'posts_per_page' => 100, + 'fields' => 'ids', + 'meta_query' => [ + 'relation' => 'OR', + [ + 'key' => '_eb4tec_event_id', + 'compare' => 'NOT EXISTS', + ], + [ + 'key' => '_eb4tec_push_on_save', + 'value' => '1', + ], + ], + ] ); + + $pushed = 0; + foreach ( $query->posts as $post_id ) { + if ( get_post_meta( $post_id, '_eb4tec_exclude_sync', true ) ) { + continue; + } + $result = $this->push_event( $post_id ); + if ( ! is_wp_error( $result ) ) { + $pushed++; + } + // Clear the flag after processing. + delete_post_meta( $post_id, '_eb4tec_push_on_save' ); + } + + return $pushed; + } + + public function push_event( int $post_id ): string|WP_Error { + $org_id = (string) get_option( 'eb4tec_org_id', '' ); + if ( empty( $org_id ) ) { + return new WP_Error( 'eb4tec_no_org', __( 'Eventbrite organization ID not configured.', 'eb4tec' ) ); + } + + $eb_event_id = (string) get_post_meta( $post_id, '_eb4tec_event_id', true ); + $body = $this->tec_post_to_eb_body( $post_id ); + + if ( $eb_event_id ) { + $result = $this->api->update_event( $eb_event_id, $body ); + } else { + $result = $this->api->create_event( $body ); + } + + if ( is_wp_error( $result ) ) { + return $result; + } + + $new_eb_id = $result['id'] ?? ''; + if ( empty( $new_eb_id ) ) { + return new WP_Error( 'eb4tec_no_event_id', __( 'Eventbrite did not return an event ID.', 'eb4tec' ) ); + } + + update_post_meta( $post_id, '_eb4tec_event_id', sanitize_text_field( $new_eb_id ) ); + update_post_meta( $post_id, '_eb4tec_eb_url', esc_url_raw( $result['url'] ?? '' ) ); + update_post_meta( $post_id, '_eb4tec_last_synced', time() ); + + // Auto-publish if configured. + if ( get_option( 'eb4tec_auto_publish' ) && get_post_status( $post_id ) === 'publish' ) { + $this->api->publish_event( $new_eb_id ); + } + + // Push venue. + if ( get_option( 'eb4tec_sync_venues' ) ) { + $venue_id = (int) get_post_meta( $post_id, '_EventVenueID', true ); + if ( $venue_id ) { + $this->venue_sync->push_venue( $venue_id ); + } + } + + // Push organizer. + if ( get_option( 'eb4tec_sync_organizers' ) ) { + $org_post_id = (int) get_post_meta( $post_id, '_EventOrganizerID', true ); + if ( $org_post_id ) { + $this->organizer_sync->push_organizer( $org_post_id ); + } + } + + // Create WC product if not already linked. + $this->woocommerce->sync_event_product( $post_id ); + + // Create hidden ticket class on Eventbrite. + $capacity = (int) get_post_meta( $post_id, '_eb4tec_capacity', true ); + if ( $capacity > 0 ) { + $this->ticket_manager->ensure_wp_ticket_class( $new_eb_id, $post_id, $capacity ); + } + + return $new_eb_id; + } + + // --------------------------------------------------------------------------- + // Meta box + // --------------------------------------------------------------------------- + + public function add_meta_box(): void { + add_meta_box( + 'eb4tec_event_meta', + __( 'Eventbrite Sync', 'eb4tec' ), + [ $this, 'render_meta_box' ], + 'tribe_events', + 'side', + 'default' + ); + } + + public function render_meta_box( WP_Post $post ): void { + wp_nonce_field( 'eb4tec_event_meta_save', '_eb4tec_event_meta_nonce' ); + + $eb_id = esc_attr( get_post_meta( $post->ID, '_eb4tec_event_id', true ) ); + $eb_url = esc_url( get_post_meta( $post->ID, '_eb4tec_eb_url', true ) ); + $last_synced = (int) get_post_meta( $post->ID, '_eb4tec_last_synced', true ); + $eb_status = esc_html( get_post_meta( $post->ID, '_eb4tec_eb_status', true ) ); + $push_on_save = (bool) get_post_meta( $post->ID, '_eb4tec_push_on_save', true ); + $exclude = (bool) get_post_meta( $post->ID, '_eb4tec_exclude_sync', true ); + $capacity = (int) get_post_meta( $post->ID, '_eb4tec_capacity', true ); + ?> +

+
+ +

+ + +

+ + + +

+ + + | + +

+ + +

+
+ + +

+ +

+ +

+

+ +

+ push_event( $post_id ); + add_action( 'save_post_tribe_events', [ $this, 'on_save_post' ], 20, 3 ); + } + } + + // --------------------------------------------------------------------------- + // Helper: find TEC post by EB event ID + // --------------------------------------------------------------------------- + + public function find_tec_event( string $eb_event_id ): int { + $query = new WP_Query( [ + 'post_type' => 'tribe_events', + 'post_status' => [ 'publish', 'draft', 'private' ], + 'posts_per_page' => 1, + 'fields' => 'ids', + 'meta_query' => [ [ + 'key' => '_eb4tec_event_id', + 'value' => $eb_event_id, + ] ], + ] ); + + return $query->posts[0] ?? 0; + } + + // --------------------------------------------------------------------------- + // Field mapping + // --------------------------------------------------------------------------- + + private function eb_event_to_tec_args( array $eb_event ): array { + $status = $eb_event['status'] ?? 'draft'; + $post_status = in_array( $status, [ 'live', 'started', 'ended', 'completed' ], true ) ? 'publish' : 'draft'; + + return [ + 'post_title' => sanitize_text_field( $eb_event['name']['text'] ?? __( 'Untitled Event', 'eb4tec' ) ), + 'post_content' => wp_kses_post( $eb_event['description']['html'] ?? '' ), + 'post_type' => 'tribe_events', + 'post_status' => $post_status, + ]; + } + + private function update_tec_from_eb( int $post_id, array $eb_event ): void { + update_post_meta( $post_id, '_eb4tec_event_id', sanitize_text_field( $eb_event['id'] ) ); + update_post_meta( $post_id, '_eb4tec_eb_url', esc_url_raw( $eb_event['url'] ?? '' ) ); + update_post_meta( $post_id, '_eb4tec_eb_status', sanitize_text_field( $eb_event['status'] ?? '' ) ); + update_post_meta( $post_id, '_eb4tec_last_synced', time() ); + + // Dates — stored in local time in TEC. + $start_local = sanitize_text_field( $eb_event['start']['local'] ?? '' ); + $end_local = sanitize_text_field( $eb_event['end']['local'] ?? '' ); + $timezone = sanitize_text_field( $eb_event['start']['timezone'] ?? '' ); + + if ( $start_local ) { + update_post_meta( $post_id, '_EventStartDate', $start_local ); + } + if ( $end_local ) { + update_post_meta( $post_id, '_EventEndDate', $end_local ); + } + if ( $timezone ) { + update_post_meta( $post_id, '_EventTimezone', $timezone ); + } + + // Capacity. + $capacity = (int) ( $eb_event['capacity'] ?? 0 ); + if ( $capacity > 0 ) { + update_post_meta( $post_id, '_eb4tec_capacity', $capacity ); + update_post_meta( $post_id, '_EventCapacity', $capacity ); + } + + // Cost — try to get from ticket classes. + $ticket_classes = $eb_event['ticket_classes'] ?? []; + $min_cost = null; + foreach ( $ticket_classes as $tc ) { + if ( ! empty( $tc['free'] ) ) { + continue; + } + $cost = (float) ( $tc['cost']['major_value'] ?? 0 ); + if ( null === $min_cost || $cost < $min_cost ) { + $min_cost = $cost; + } + } + if ( null !== $min_cost ) { + update_post_meta( $post_id, '_EventCost', (string) $min_cost ); + update_post_meta( $post_id, '_EventCurrencySymbol', sanitize_text_field( $ticket_classes[0]['cost']['currency'] ?? 'GBP' ) ); + } + + // Featured image. + if ( ! has_post_thumbnail( $post_id ) && ! empty( $eb_event['logo']['url'] ) ) { + $this->sideload_image( $eb_event['logo']['url'], $post_id, get_the_title( $post_id ) ); + } + } + + private function tec_post_to_eb_body( int $post_id ): array { + $post = get_post( $post_id ); + $start = (string) get_post_meta( $post_id, '_EventStartDate', true ); + $end = (string) get_post_meta( $post_id, '_EventEndDate', true ); + $timezone = (string) get_post_meta( $post_id, '_EventTimezone', true ) ?: wp_timezone_string(); + $currency = (string) get_post_meta( $post_id, '_EventCurrencySymbol', true ) ?: 'GBP'; + + // Convert local dates to UTC for the EB API. + $tz_obj = new DateTimeZone( $timezone ); + $start_dt = new DateTime( $start, $tz_obj ); + $end_dt = new DateTime( $end, $tz_obj ); + $start_dt->setTimezone( new DateTimeZone( 'UTC' ) ); + $end_dt->setTimezone( new DateTimeZone( 'UTC' ) ); + + return [ + 'event' => [ + 'name' => [ 'html' => $post->post_title ], + 'description' => [ 'html' => $post->post_content ], + 'start' => [ + 'timezone' => $timezone, + 'utc' => $start_dt->format( 'Y-m-d\TH:i:s\Z' ), + ], + 'end' => [ + 'timezone' => $timezone, + 'utc' => $end_dt->format( 'Y-m-d\TH:i:s\Z' ), + ], + 'currency' => $currency, + 'online_event' => false, + 'listed' => true, + 'shareable' => true, + ], + ]; + } + + private function sideload_image( string $url, int $post_id, string $title ): void { + if ( ! function_exists( 'media_sideload_image' ) ) { + require_once ABSPATH . 'wp-admin/includes/media.php'; + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/image.php'; + } + + $attachment_id = media_sideload_image( $url, $post_id, $title, 'id' ); + if ( ! is_wp_error( $attachment_id ) ) { + set_post_thumbnail( $post_id, $attachment_id ); + } + } +} diff --git a/includes/class-eb4tec-frontend.php b/includes/class-eb4tec-frontend.php new file mode 100644 index 0000000..b680181 --- /dev/null +++ b/includes/class-eb4tec-frontend.php @@ -0,0 +1,196 @@ + $end_dt; + } catch ( \Exception ) { + // Keep $has_ended = false. + } + } + + echo '
'; + + if ( $product_id && ! $has_ended ) { + $this->render_capacity_bar( $post_id ); + $this->render_buy_button( $post_id, $product_id ); + } elseif ( $has_ended ) { + echo '

' . esc_html__( 'This event has ended.', 'eb4tec' ) . '

'; + } + + if ( $eb_url ) { + echo ''; + } + + echo '
'; + } + + private function render_capacity_bar( int $post_id ): void { + $total = (int) get_post_meta( $post_id, '_eb4tec_capacity', true ); + $available = $this->ticket_manager->get_available_capacity( $post_id ); + + if ( $total <= 0 ) { + return; + } + + $sold = max( 0, $total - $available ); + $percent = (int) round( ( $sold / $total ) * 100 ); + + echo '
'; + printf( + '
', + $sold, + $total, + $percent + ); + + if ( $available > 0 ) { + echo '

' . esc_html( sprintf( + /* translators: 1: available spots, 2: total */ + _n( '%1$d of %2$d spot remaining', '%1$d of %2$d spots remaining', $available, 'eb4tec' ), + $available, + $total + ) ) . '

'; + } else { + echo '

' . esc_html__( 'Sold out', 'eb4tec' ) . '

'; + } + + echo '
'; + } + + private function render_buy_button( int $post_id, int $product_id ): void { + $available = $this->ticket_manager->get_available_capacity( $post_id ); + $button_html = ''; + + if ( $available > 0 ) { + $product_url = get_permalink( $product_id ); + $button_html = sprintf( + '%s', + esc_url( $product_url ), + esc_html__( 'Buy Tickets', 'eb4tec' ) + ); + } else { + $button_html = '' . esc_html__( 'Sold Out', 'eb4tec' ) . ''; + } + + echo apply_filters( 'eb4tec_ticket_button_html', $button_html, $post_id ); + } + + public function enqueue_styles(): void { + if ( ! is_singular( 'tribe_events' ) ) { + return; + } + + wp_register_style( 'eb4tec-frontend', false ); + wp_enqueue_style( 'eb4tec-frontend' ); + wp_add_inline_style( 'eb4tec-frontend', $this->get_inline_css() ); + } + + private function get_inline_css(): string { + return ' +.eb4tec-ticket-section { + margin: 20px 0; + padding: 16px 0; + border-top: 1px solid #e5e5e5; +} +.eb4tec-capacity-wrap { + margin-bottom: 12px; +} +.eb4tec-capacity-bar { + background: #e5e5e5; + border-radius: 4px; + height: 8px; + overflow: hidden; + max-width: 300px; +} +.eb4tec-capacity-bar__fill { + background: #2271b1; + height: 100%; + transition: width 0.3s ease; +} +.eb4tec-capacity-label { + font-size: 0.875em; + color: #555; + margin: 4px 0 0; +} +.eb4tec-capacity-label--sold-out { + color: #d63638; + font-weight: 600; +} +.eb4tec-buy-button { + display: inline-block; + padding: 10px 20px; + background: #2271b1; + color: #fff !important; + text-decoration: none; + border-radius: 4px; + font-weight: 600; + margin-right: 8px; +} +.eb4tec-buy-button:hover { + background: #135e96; +} +.eb4tec-sold-out-badge { + display: inline-block; + padding: 8px 16px; + background: #d63638; + color: #fff; + border-radius: 4px; + font-weight: 600; +} +.eb4tec-event-ended { + color: #555; + font-style: italic; +} +.eb4tec-eb-link { + margin-top: 8px; + font-size: 0.875em; +} +.eb4tec-ticket-classes { + list-style: none; + padding: 0; + margin: 0 0 12px; +} +.eb4tec-ticket-class { + padding: 6px 0; + border-bottom: 1px solid #f0f0f0; +} +.eb4tec-ticket-sold-out { + color: #d63638; + font-size: 0.85em; +} +'; + } +} diff --git a/includes/class-eb4tec-loader.php b/includes/class-eb4tec-loader.php new file mode 100644 index 0000000..b56536f --- /dev/null +++ b/includes/class-eb4tec-loader.php @@ -0,0 +1,52 @@ +

' . + wp_kses( + __( 'Eventbrite for The Events Calendar requires The Events Calendar to be installed and active.', 'eb4tec' ), + [ 'strong' => [], 'a' => [ 'href' => [], 'target' => [] ] ] + ) . + '

'; + } + + public function notice_woo_missing(): void { + echo '

' . + wp_kses( + __( 'Eventbrite for The Events Calendar requires WooCommerce to be installed and active.', 'eb4tec' ), + [ 'strong' => [], 'a' => [ 'href' => [], 'target' => [] ] ] + ) . + '

'; + } +} diff --git a/includes/class-eb4tec-organizer-sync.php b/includes/class-eb4tec-organizer-sync.php new file mode 100644 index 0000000..b812ddf --- /dev/null +++ b/includes/class-eb4tec-organizer-sync.php @@ -0,0 +1,129 @@ +api->get_organizer( $eb_organizer_id ); + if ( is_wp_error( $result ) ) { + return $result; + } + + $existing_id = $this->find_tec_organizer( $eb_organizer_id ); + + $name = sanitize_text_field( $result['name'] ?? '' ); + + $post_args = [ + 'post_title' => $name ?: __( 'Untitled Organizer', 'eb4tec' ), + 'post_content' => wp_kses_post( $result['description']['html'] ?? '' ), + 'post_type' => 'tribe_organizer', + 'post_status' => 'publish', + ]; + + if ( $existing_id ) { + $post_args['ID'] = $existing_id; + $post_id = wp_update_post( $post_args, true ); + } else { + $post_id = wp_insert_post( $post_args, true ); + } + + if ( is_wp_error( $post_id ) ) { + return $post_id; + } + + update_post_meta( $post_id, '_eb4tec_organizer_id', sanitize_text_field( $eb_organizer_id ) ); + + if ( ! empty( $result['website'] ) ) { + update_post_meta( $post_id, '_OrganizerWebsite', esc_url_raw( $result['website'] ) ); + } + + if ( ! empty( $result['twitter'] ) ) { + update_post_meta( $post_id, '_OrganizerTwitter', sanitize_text_field( $result['twitter'] ) ); + } + + // Sideload logo if not already set. + if ( ! has_post_thumbnail( $post_id ) && ! empty( $result['logo']['url'] ) ) { + $this->sideload_image( $result['logo']['url'], $post_id, $name ); + } + + return $post_id; + } + + public function push_organizer( int $post_id ): string|WP_Error { + $org_id = (string) get_option( 'eb4tec_org_id', '' ); + if ( empty( $org_id ) ) { + return new WP_Error( 'eb4tec_no_org', __( 'Eventbrite organization ID not configured.', 'eb4tec' ) ); + } + + $eb_organizer_id = get_post_meta( $post_id, '_eb4tec_organizer_id', true ); + $post = get_post( $post_id ); + + if ( ! $post ) { + return new WP_Error( 'eb4tec_no_post', __( 'Organizer post not found.', 'eb4tec' ) ); + } + + $website = (string) get_post_meta( $post_id, '_OrganizerWebsite', true ); + $twitter = (string) get_post_meta( $post_id, '_OrganizerTwitter', true ); + + $body = [ + 'organizer' => [ + 'name' => $post->post_title, + 'website' => $website, + 'twitter' => $twitter, + ], + ]; + + if ( $eb_organizer_id ) { + $result = $this->api->update_organizer( $eb_organizer_id, $body ); + } else { + $result = $this->api->create_organizer( $org_id, $body ); + } + + if ( is_wp_error( $result ) ) { + return $result; + } + + $new_id = $result['id'] ?? ''; + if ( $new_id ) { + update_post_meta( $post_id, '_eb4tec_organizer_id', sanitize_text_field( $new_id ) ); + } + + return $new_id ?: new WP_Error( 'eb4tec_no_organizer_id', __( 'Eventbrite did not return an organizer ID.', 'eb4tec' ) ); + } + + public function find_tec_organizer( string $eb_organizer_id ): int { + $query = new WP_Query( [ + 'post_type' => 'tribe_organizer', + 'post_status' => 'publish', + 'posts_per_page' => 1, + 'fields' => 'ids', + 'meta_query' => [ [ + 'key' => '_eb4tec_organizer_id', + 'value' => $eb_organizer_id, + ] ], + ] ); + + return $query->posts[0] ?? 0; + } + + private function sideload_image( string $url, int $post_id, string $title ): void { + if ( ! function_exists( 'media_sideload_image' ) ) { + require_once ABSPATH . 'wp-admin/includes/media.php'; + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/image.php'; + } + + $attachment_id = media_sideload_image( $url, $post_id, $title, 'id' ); + if ( ! is_wp_error( $attachment_id ) ) { + set_post_thumbnail( $post_id, $attachment_id ); + } + } +} diff --git a/includes/class-eb4tec-qr-code.php b/includes/class-eb4tec-qr-code.php new file mode 100644 index 0000000..4f4de84 --- /dev/null +++ b/includes/class-eb4tec-qr-code.php @@ -0,0 +1,77 @@ + 'qr', + 'chs' => "{$size}x{$size}", + 'chl' => rawurlencode( $data ), + 'choe' => 'UTF-8', + ], 'https://chart.googleapis.com/chart' ); + + $response = wp_remote_get( $api_url, [ 'timeout' => 15 ] ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $code = wp_remote_retrieve_response_code( $response ); + if ( $code !== 200 ) { + return new WP_Error( 'eb4tec_qr_fetch', __( 'QR code generation failed.', 'eb4tec' ), [ 'status' => $code ] ); + } + + $body = wp_remote_retrieve_body( $response ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + if ( false === file_put_contents( $file, $body ) ) { + return new WP_Error( 'eb4tec_qr_write', __( 'Could not save QR code image.', 'eb4tec' ) ); + } + + return $url; + } + + public function get_attendee_qr_data( string $attendee_id, string $order_id, string $event_id ): string { + return "EB4TEC:{$attendee_id}:{$order_id}:{$event_id}"; + } + + public function render_qr_html( string $url, string $attendee_id = '', int $size = 0 ): string { + if ( $size <= 0 ) { + $size = (int) get_option( 'eb4tec_qr_size', 200 ); + } + + $alt = $attendee_id + ? sprintf( __( 'Ticket QR code for attendee %s', 'eb4tec' ), esc_attr( $attendee_id ) ) + : __( 'Ticket QR code', 'eb4tec' ); + + return sprintf( + '%s', + esc_url( $url ), + esc_attr( $alt ), + $size, + $size + ); + } +} diff --git a/includes/class-eb4tec-settings.php b/includes/class-eb4tec-settings.php new file mode 100644 index 0000000..9cc6899 --- /dev/null +++ b/includes/class-eb4tec-settings.php @@ -0,0 +1,547 @@ +'; + echo '

' . esc_html__( 'Eventbrite for The Events Calendar', 'eb4tec' ) . '

'; + + // Sync status bar. + if ( $last_sync ) { + $sync_time = human_time_diff( $last_sync ); + echo '

'; + printf( + esc_html__( 'Last sync: %s ago', 'eb4tec' ), + esc_html( $sync_time ) + ); + if ( is_array( $sync_result ) ) { + printf( + ' — ' . esc_html__( '%d pulled, %d pushed, %d errors.', 'eb4tec' ), + (int) ( $sync_result['pulled'] ?? 0 ), + (int) ( $sync_result['pushed'] ?? 0 ), + (int) ( $sync_result['errors'] ?? 0 ) + ); + } + echo '

'; + } + + // Sync Now button. + echo '
'; + wp_nonce_field( 'eb4tec_sync_now', '_eb4tec_sync_nonce' ); + echo ''; + submit_button( __( 'Sync Now', 'eb4tec' ), 'secondary', 'submit', false ); + echo '
'; + + // Tabs. + $tabs = [ + 'api' => __( 'API & Credentials', 'eb4tec' ), + 'sync' => __( 'Sync Settings', 'eb4tec' ), + 'woocommerce' => __( 'WooCommerce', 'eb4tec' ), + ]; + + echo ''; + + echo '
'; + wp_nonce_field( 'eb4tec_save_settings', '_eb4tec_settings_nonce' ); + echo ''; + echo ''; + + echo ''; + + match ( $active_tab ) { + 'api' => $this->render_tab_api( $org_name ), + 'sync' => $this->render_tab_sync(), + 'woocommerce' => $this->render_tab_woocommerce(), + default => $this->render_tab_api( $org_name ), + }; + + echo ''; + + submit_button(); + echo '
'; + echo ''; + } + + private function render_tab_api( string $org_name ): void { + $token = get_option( 'eb4tec_api_token', '' ); + $token_display = $token ? str_repeat( '•', 20 ) : ''; + $org_id = esc_attr( get_option( 'eb4tec_org_id', '' ) ); + + ?> + + + + +

+ +

+ + + + + + + + + +

+ + + + + + + +

+ + + +

+ + + __( 'Hourly', 'eb4tec' ), + 'twicedaily' => __( 'Twice Daily', 'eb4tec' ), + 'daily' => __( 'Daily', 'eb4tec' ), + ]; + $directions = [ + 'both' => __( 'Both directions', 'eb4tec' ), + 'eb_to_tec' => __( 'Eventbrite → WordPress only', 'eb4tec' ), + 'tec_to_eb' => __( 'WordPress → Eventbrite only', 'eb4tec' ), + ]; + ?> + + + + + + + + + + + + + + + + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + 'product_cat', + 'hide_empty' => false, + ] ); + ?> + + + + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + 'eb4tec-settings', + 'tab' => $tab, + 'updated' => '1', + ], admin_url( 'edit.php?post_type=tribe_events' ) ); + + wp_safe_redirect( $redirect ); + exit; + } + + public function ajax_validate_token(): void { + check_ajax_referer( 'eb4tec_admin_ajax', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'eb4tec' ) ] ); + } + + $token = sanitize_text_field( $_POST['token'] ?? '' ); + if ( empty( $token ) ) { + wp_send_json_error( [ 'message' => __( 'No token provided.', 'eb4tec' ) ] ); + } + + // Temporarily update the token so the API client picks it up. + $old_token = get_option( 'eb4tec_api_token', '' ); + update_option( 'eb4tec_api_token', $token, false ); + + $api = new EB4TEC_API_Client(); + $result = $api->get_user_me(); + + if ( is_wp_error( $result ) ) { + update_option( 'eb4tec_api_token', $old_token, false ); + wp_send_json_error( [ 'message' => $result->get_error_message() ] ); + } + + // Fetch organization ID. + $orgs = isset( $result['organizations'] ) ? $result['organizations'] : []; + if ( empty( $orgs ) ) { + // Try getting org from user profile. + $org_id = ''; + $org_name = $result['name'] ?? ''; + } else { + $org = reset( $orgs ); + $org_id = $org['id'] ?? ''; + $org_name = $org['name'] ?? ''; + } + + update_option( 'eb4tec_org_id', sanitize_text_field( $org_id ) ); + update_option( 'eb4tec_org_name', sanitize_text_field( $org_name ) ); + + wp_send_json_success( [ + 'message' => sprintf( __( 'Connected as %s (Org: %s)', 'eb4tec' ), esc_html( $result['name'] ?? '' ), esc_html( $org_name ) ), + 'org_id' => $org_id, + 'org_name' => $org_name, + ] ); + } + + public function ajax_register_webhook(): void { + check_ajax_referer( 'eb4tec_admin_ajax', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'eb4tec' ) ] ); + } + + $secret = get_option( 'eb4tec_webhook_secret', '' ); + if ( empty( $secret ) ) { + $secret = wp_generate_password( 32, false ); + update_option( 'eb4tec_webhook_secret', $secret ); + } + + $endpoint_url = add_query_arg( 'eb4tec_webhook', '1', home_url( '/' ) ); + $api = new EB4TEC_API_Client(); + + $result = $api->create_webhook( [ + 'webhook' => [ + 'endpoint_url' => $endpoint_url, + 'actions' => 'event.created,event.published,event.updated,event.unpublished,attendee.updated', + ], + ] ); + + if ( is_wp_error( $result ) ) { + wp_send_json_error( [ 'message' => $result->get_error_message() ] ); + } + + update_option( 'eb4tec_webhook_id', sanitize_text_field( $result['id'] ?? '' ) ); + wp_send_json_success( [ 'message' => __( 'Webhook registered successfully.', 'eb4tec' ) ] ); + } + + public function ajax_delete_webhook(): void { + check_ajax_referer( 'eb4tec_admin_ajax', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'eb4tec' ) ] ); + } + + $webhook_id = get_option( 'eb4tec_webhook_id', '' ); + if ( empty( $webhook_id ) ) { + wp_send_json_error( [ 'message' => __( 'No webhook ID stored.', 'eb4tec' ) ] ); + } + + $api = new EB4TEC_API_Client(); + $result = $api->delete_webhook( $webhook_id ); + + if ( is_wp_error( $result ) ) { + wp_send_json_error( [ 'message' => $result->get_error_message() ] ); + } + + delete_option( 'eb4tec_webhook_id' ); + wp_send_json_success( [ 'message' => __( 'Webhook removed.', 'eb4tec' ) ] ); + } + + public function admin_enqueue_scripts( string $hook ): void { + $screen = get_current_screen(); + if ( ! $screen || ! str_contains( $screen->id ?? '', 'eb4tec' ) ) { + return; + } + + $nonce = wp_create_nonce( 'eb4tec_admin_ajax' ); + wp_add_inline_script( + 'jquery', + "(function($){ const nonce = '{$nonce}'; + $('#eb4tec-validate-token').on('click', function(){ + var token = $('#eb4tec_api_token').val(); + $.post(ajaxurl, {action:'eb4tec_validate_token',nonce:nonce,token:token}, function(r){ + var el = $('#eb4tec-validate-result'); + if(r.success){ el.css('color','green').text(r.data.message); $('#eb4tec_org_id').val(r.data.org_id); } + else { el.css('color','red').text(r.data.message); } + }); + }); + $('#eb4tec-register-webhook').on('click', function(){ + $.post(ajaxurl, {action:'eb4tec_register_webhook',nonce:nonce}, function(r){ + var el = $('#eb4tec-webhook-result'); + if(r.success){ el.css('color','green').text(r.data.message); } + else { el.css('color','red').text(r.data.message); } + }); + }); + $('#eb4tec-delete-webhook').on('click', function(){ + $.post(ajaxurl, {action:'eb4tec_delete_webhook',nonce:nonce}, function(r){ + var el = $('#eb4tec-webhook-result'); + if(r.success){ el.css('color','green').text(r.data.message); } + else { el.css('color','red').text(r.data.message); } + }); + }); + })(jQuery);" + ); + } + + public function admin_notices(): void { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + $screen = get_current_screen(); + if ( ! $screen || ! str_contains( $screen->id ?? '', 'eb4tec' ) ) { + return; + } + + if ( ! empty( $_GET['updated'] ) ) { + echo '

' . + esc_html__( 'Settings saved.', 'eb4tec' ) . + '

'; + } + + if ( get_transient( 'eb4tec_rate_limit_warning' ) ) { + echo '

' . + esc_html__( 'Eventbrite API rate limit is nearly exhausted. Sync operations may be delayed.', 'eb4tec' ) . + '

'; + } + + // Show "Sync Now" result. + $user_id = get_current_user_id(); + $sync_result = get_transient( "eb4tec_sync_now_result_{$user_id}" ); + if ( is_array( $sync_result ) ) { + delete_transient( "eb4tec_sync_now_result_{$user_id}" ); + $msg = sprintf( + __( 'Sync complete — %d pulled from Eventbrite, %d pushed to Eventbrite, %d errors.', 'eb4tec' ), + (int) ( $sync_result['pulled'] ?? 0 ), + (int) ( $sync_result['pushed'] ?? 0 ), + (int) ( $sync_result['errors'] ?? 0 ) + ); + $class = ( ( $sync_result['errors'] ?? 0 ) > 0 ) ? 'notice-warning' : 'notice-success'; + echo '

' . esc_html( $msg ) . '

'; + } + } +} diff --git a/includes/class-eb4tec-shortcodes.php b/includes/class-eb4tec-shortcodes.php new file mode 100644 index 0000000..908ecc2 --- /dev/null +++ b/includes/class-eb4tec-shortcodes.php @@ -0,0 +1,162 @@ +resolve_post_id( $atts ); + if ( ! $post_id ) { + return ''; + } + + $total = (int) get_post_meta( $post_id, '_eb4tec_capacity', true ); + $available = $this->ticket_manager->get_available_capacity( $post_id ); + + if ( $total <= 0 ) { + return ''; + } + + if ( $available <= 0 ) { + return '' . + esc_html__( 'Sold out', 'eb4tec' ) . + ''; + } + + return sprintf( + '%s', + esc_html( sprintf( + /* translators: 1: available spots, 2: total capacity */ + _n( '%1$d of %2$d spot remaining', '%1$d of %2$d spots remaining', $available, 'eb4tec' ), + $available, + $total + ) ) + ); + } + + /** + * [eb4tec_tickets event_id="123" post_id="456"] + * Renders a list of public Eventbrite ticket classes with a Buy button. + */ + public function tickets_shortcode( array $atts ): string { + $post_id = $this->resolve_post_id( $atts ); + if ( ! $post_id ) { + return ''; + } + + $eb_event_id = (string) get_post_meta( $post_id, '_eb4tec_event_id', true ); + if ( ! $eb_event_id ) { + return ''; + } + + $result = $this->api->get_ticket_classes( $eb_event_id ); + if ( is_wp_error( $result ) ) { + return ''; + } + + $ticket_classes = $result['ticket_classes'] ?? []; + $wp_label = strtolower( (string) get_option( 'eb4tec_wp_ticket_label', 'WordPress Purchase' ) ); + $product_id = (int) get_post_meta( $post_id, '_eb4tec_wp_product_id', true ); + $buy_url = $product_id ? get_permalink( $product_id ) : ''; + + // Filter out the hidden WordPress Purchase class. + $public_classes = array_filter( $ticket_classes, function( array $tc ) use ( $wp_label ): bool { + return strtolower( $tc['name'] ?? '' ) !== $wp_label; + } ); + + if ( empty( $public_classes ) ) { + return ''; + } + + $output = ''; + + // Add Buy Tickets button if a WC product exists. + if ( $buy_url ) { + $available = $this->ticket_manager->get_available_capacity( $post_id ); + if ( $available > 0 ) { + $output .= sprintf( + '%s', + esc_url( $buy_url ), + esc_html__( 'Buy Tickets', 'eb4tec' ) + ); + } else { + $output .= '' . esc_html__( 'Sold Out', 'eb4tec' ) . ''; + } + } + + return $output; + } + + private function resolve_post_id( array $atts ): int { + $atts = shortcode_atts( [ + 'post_id' => 0, + 'event_id' => '', + ], $atts ); + + if ( ! empty( $atts['post_id'] ) ) { + return (int) $atts['post_id']; + } + + if ( ! empty( $atts['event_id'] ) ) { + // Look up TEC post by Eventbrite event ID. + $query = new WP_Query( [ + 'post_type' => 'tribe_events', + 'post_status' => [ 'publish', 'draft' ], + 'posts_per_page' => 1, + 'fields' => 'ids', + 'meta_query' => [ [ + 'key' => '_eb4tec_event_id', + 'value' => sanitize_text_field( $atts['event_id'] ), + ] ], + ] ); + return $query->posts[0] ?? 0; + } + + // Default to the current event in the loop. + return get_the_ID() ?: 0; + } +} diff --git a/includes/class-eb4tec-ticket-manager.php b/includes/class-eb4tec-ticket-manager.php new file mode 100644 index 0000000..98a0763 --- /dev/null +++ b/includes/class-eb4tec-ticket-manager.php @@ -0,0 +1,113 @@ +api->update_ticket_class( $eb_event_id, $existing_class_id, $this->build_ticket_class_body( $capacity, $eb_event_id ) ); + if ( is_wp_error( $result ) ) { + // Stale ID — try creating a fresh one. + delete_post_meta( $post_id, '_eb4tec_wp_ticket_class_id' ); + return $this->create_wp_ticket_class( $eb_event_id, $post_id, $capacity ); + } + return $existing_class_id; + } + + return $this->create_wp_ticket_class( $eb_event_id, $post_id, $capacity ); + } + + private function create_wp_ticket_class( string $eb_event_id, int $post_id, int $capacity ): string|WP_Error { + $result = $this->api->create_ticket_class( $eb_event_id, $this->build_ticket_class_body( $capacity, $eb_event_id ) ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $class_id = $result['id'] ?? ''; + if ( empty( $class_id ) ) { + return new WP_Error( 'eb4tec_no_class_id', __( 'Eventbrite did not return a ticket class ID.', 'eb4tec' ) ); + } + + update_post_meta( $post_id, '_eb4tec_wp_ticket_class_id', sanitize_text_field( $class_id ) ); + return $class_id; + } + + /** + * Sync capacity to both the Eventbrite ticket class and the WooCommerce product stock. + */ + public function sync_capacity( int $post_id, int $new_capacity ): bool { + update_post_meta( $post_id, '_eb4tec_capacity', $new_capacity ); + update_post_meta( $post_id, '_EventCapacity', $new_capacity ); + + // Update WC product stock. + $product_id = (int) get_post_meta( $post_id, '_eb4tec_wp_product_id', true ); + if ( $product_id ) { + $product = wc_get_product( $product_id ); + if ( $product ) { + $product->set_stock_quantity( $new_capacity ); + $product->save(); + } + } + + // Update EB ticket class. + $eb_event_id = (string) get_post_meta( $post_id, '_eb4tec_event_id', true ); + $class_id = (string) get_post_meta( $post_id, '_eb4tec_wp_ticket_class_id', true ); + if ( $eb_event_id && $class_id ) { + $result = $this->api->update_ticket_class( $eb_event_id, $class_id, $this->build_ticket_class_body( $new_capacity, $eb_event_id ) ); + return ! is_wp_error( $result ); + } + + return true; + } + + /** + * Remaining available spots based on WooCommerce stock. + */ + public function get_available_capacity( int $post_id ): int { + $product_id = (int) get_post_meta( $post_id, '_eb4tec_wp_product_id', true ); + if ( ! $product_id ) { + return (int) get_post_meta( $post_id, '_eb4tec_capacity', true ); + } + + $product = wc_get_product( $product_id ); + if ( ! $product || ! $product->managing_stock() ) { + return (int) get_post_meta( $post_id, '_eb4tec_capacity', true ); + } + + return max( 0, (int) $product->get_stock_quantity() ); + } + + private function build_ticket_class_body( int $capacity, string $eb_event_id ): array { + $label = (string) get_option( 'eb4tec_wp_ticket_label', 'WordPress Purchase' ); + $hidden = get_option( 'eb4tec_wp_ticket_visible', 'hidden' ) === 'hidden'; + + $body = [ + 'ticket_class' => [ + 'name' => $label, + 'free' => true, + 'minimum_quantity' => 1, + 'maximum_quantity' => 10, + 'hidden' => $hidden, + ], + ]; + + if ( $capacity > 0 ) { + $body['ticket_class']['quantity_total'] = $capacity; + } + + return $body; + } +} diff --git a/includes/class-eb4tec-venue-sync.php b/includes/class-eb4tec-venue-sync.php new file mode 100644 index 0000000..fb1e228 --- /dev/null +++ b/includes/class-eb4tec-venue-sync.php @@ -0,0 +1,125 @@ +api->get_venue( $eb_venue_id ); + if ( is_wp_error( $result ) ) { + return $result; + } + + $existing_id = $this->find_tec_venue( $eb_venue_id ); + + $name = sanitize_text_field( $result['name'] ?? '' ); + $address = $result['address'] ?? []; + + $post_args = [ + 'post_title' => $name ?: __( 'Untitled Venue', 'eb4tec' ), + 'post_type' => 'tribe_venue', + 'post_status' => 'publish', + ]; + + if ( $existing_id ) { + $post_args['ID'] = $existing_id; + $post_id = wp_update_post( $post_args, true ); + } else { + $post_id = wp_insert_post( $post_args, true ); + } + + if ( is_wp_error( $post_id ) ) { + return $post_id; + } + + $this->save_venue_meta( $post_id, $eb_venue_id, $address, $result ); + + return $post_id; + } + + public function push_venue( int $post_id ): string|WP_Error { + $org_id = (string) get_option( 'eb4tec_org_id', '' ); + if ( empty( $org_id ) ) { + return new WP_Error( 'eb4tec_no_org', __( 'Eventbrite organization ID not configured.', 'eb4tec' ) ); + } + + $eb_venue_id = get_post_meta( $post_id, '_eb4tec_venue_id', true ); + $post = get_post( $post_id ); + + if ( ! $post ) { + return new WP_Error( 'eb4tec_no_post', __( 'Venue post not found.', 'eb4tec' ) ); + } + + $body = [ + 'venue' => [ + 'name' => $post->post_title, + 'address' => [ + 'address_1' => (string) get_post_meta( $post_id, '_VenueAddress', true ), + 'city' => (string) get_post_meta( $post_id, '_VenueCity', true ), + 'region' => (string) get_post_meta( $post_id, '_VenueStateProvince', true ), + 'postal_code' => (string) get_post_meta( $post_id, '_VenueZip', true ), + 'country' => (string) get_post_meta( $post_id, '_VenueCountry', true ), + ], + ], + ]; + + if ( $eb_venue_id ) { + $result = $this->api->update_venue( $eb_venue_id, $body ); + } else { + $result = $this->api->create_venue( $org_id, $body ); + } + + if ( is_wp_error( $result ) ) { + return $result; + } + + $new_id = $result['id'] ?? ''; + if ( $new_id ) { + update_post_meta( $post_id, '_eb4tec_venue_id', sanitize_text_field( $new_id ) ); + } + + return $new_id ?: new WP_Error( 'eb4tec_no_venue_id', __( 'Eventbrite did not return a venue ID.', 'eb4tec' ) ); + } + + public function find_tec_venue( string $eb_venue_id ): int { + $query = new WP_Query( [ + 'post_type' => 'tribe_venue', + 'post_status' => 'publish', + 'posts_per_page' => 1, + 'fields' => 'ids', + 'meta_query' => [ [ + 'key' => '_eb4tec_venue_id', + 'value' => $eb_venue_id, + ] ], + ] ); + + return $query->posts[0] ?? 0; + } + + private function save_venue_meta( int $post_id, string $eb_venue_id, array $address, array $result ): void { + update_post_meta( $post_id, '_eb4tec_venue_id', sanitize_text_field( $eb_venue_id ) ); + update_post_meta( $post_id, '_eb4tec_venue_last_synced', time() ); + + update_post_meta( $post_id, '_VenueAddress', sanitize_text_field( $address['address_1'] ?? '' ) ); + update_post_meta( $post_id, '_VenueCity', sanitize_text_field( $address['city'] ?? '' ) ); + update_post_meta( $post_id, '_VenueStateProvince', sanitize_text_field( $address['region'] ?? '' ) ); + update_post_meta( $post_id, '_VenueZip', sanitize_text_field( $address['postal_code'] ?? '' ) ); + update_post_meta( $post_id, '_VenueCountry', sanitize_text_field( $address['country'] ?? '' ) ); + + $lat = $address['latitude'] ?? ''; + $lng = $address['longitude'] ?? ''; + if ( $lat ) { + update_post_meta( $post_id, '_VenueGeoLat', sanitize_text_field( $lat ) ); + } + if ( $lng ) { + update_post_meta( $post_id, '_VenueGeoLng', sanitize_text_field( $lng ) ); + } + } +} diff --git a/includes/class-eb4tec-webhook.php b/includes/class-eb4tec-webhook.php new file mode 100644 index 0000000..5b2a3ae --- /dev/null +++ b/includes/class-eb4tec-webhook.php @@ -0,0 +1,149 @@ +verify_signature( $payload, $signature ) ) { + wp_die( 'Forbidden', 'Forbidden', [ 'response' => 403 ] ); + } + + $data = json_decode( $payload, true ); + if ( ! is_array( $data ) ) { + wp_send_json( [ 'status' => 'error', 'message' => 'Invalid JSON' ], 400 ); + } + + $action = $data['config']['action'] ?? ''; + $api_url = $data['api_url'] ?? ''; + + // Extract EB event ID from the API URL (e.g. .../events/123456789/). + $eb_event_id = $this->extract_event_id_from_url( $api_url ); + + switch ( $action ) { + case 'event.published': + case 'event.updated': + if ( $eb_event_id ) { + $this->event_sync->pull_event( $eb_event_id ); + } + break; + + case 'event.unpublished': + if ( $eb_event_id ) { + $this->handle_event_unpublished( $eb_event_id ); + } + break; + + case 'attendee.updated': + $this->handle_attendee_updated( $data ); + break; + + default: + // Unknown action — acknowledge without processing. + break; + } + + wp_send_json( [ 'status' => 'ok' ], 200 ); + } + + private function verify_signature( string $payload, string $signature ): bool { + $secret = (string) get_option( 'eb4tec_webhook_secret', '' ); + + // If no secret is configured, skip verification (allows initial setup). + if ( empty( $secret ) ) { + return true; + } + + if ( empty( $signature ) ) { + return false; + } + + $expected = hash_hmac( 'sha256', $payload, $secret ); + return hash_equals( $expected, $signature ); + } + + private function handle_event_unpublished( string $eb_event_id ): void { + $query = new WP_Query( [ + 'post_type' => 'tribe_events', + 'post_status' => [ 'publish', 'draft' ], + 'posts_per_page' => 1, + 'fields' => 'ids', + 'meta_query' => [ [ + 'key' => '_eb4tec_event_id', + 'value' => $eb_event_id, + ] ], + ] ); + + if ( ! empty( $query->posts ) ) { + $post_id = $query->posts[0]; + wp_update_post( [ + 'ID' => $post_id, + 'post_status' => 'draft', + ] ); + update_post_meta( $post_id, '_eb4tec_eb_status', 'unpublished' ); + } + } + + private function handle_attendee_updated( array $data ): void { + // Extract attendee ID from the API URL if present. + $api_url = $data['api_url'] ?? ''; + $attendee_id = $this->extract_id_from_url( $api_url, 'attendees' ); + + if ( ! $attendee_id ) { + return; + } + + // Find any WC order that has this attendee and update its note. + global $wpdb; + $order_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_eb4tec_attendee_ids' AND meta_value LIKE %s", + '%' . $wpdb->esc_like( $attendee_id ) . '%' + ) + ); + + foreach ( $order_ids as $order_id ) { + $order = wc_get_order( $order_id ); + if ( $order ) { + $order->add_order_note( + sprintf( __( 'EB4TEC: Eventbrite attendee %s was updated.', 'eb4tec' ), esc_html( $attendee_id ) ), + false, + false + ); + } + } + } + + private function extract_event_id_from_url( string $url ): string { + return $this->extract_id_from_url( $url, 'events' ); + } + + private function extract_id_from_url( string $url, string $resource ): string { + // URLs look like: https://www.eventbriteapi.com/v3/events/123456789/ + if ( preg_match( '#/' . preg_quote( $resource, '#' ) . '/(\d+)/#', $url, $matches ) ) { + return $matches[1]; + } + return ''; + } +} diff --git a/includes/class-eb4tec-woocommerce.php b/includes/class-eb4tec-woocommerce.php new file mode 100644 index 0000000..f4be882 --- /dev/null +++ b/includes/class-eb4tec-woocommerce.php @@ -0,0 +1,395 @@ +update_event_product( $existing_product_id, $post_id ); + return $existing_product_id; + } + + return $this->create_event_product( $post_id ); + } + + private function create_event_product( int $post_id ): int|WP_Error { + $event = get_post( $post_id ); + $title = $event ? $event->post_title . ' — ' . __( 'Tickets', 'eb4tec' ) : __( 'Event Tickets', 'eb4tec' ); + $cost = (float) get_post_meta( $post_id, '_EventCost', true ); + $capacity = (int) get_post_meta( $post_id, '_eb4tec_capacity', true ); + + $product = new WC_Product_Simple(); + $product->set_name( $title ); + $product->set_status( 'publish' ); + $product->set_regular_price( (string) $cost ); + $product->set_price( (string) $cost ); + $product->set_virtual( true ); + $product->set_sold_individually( false ); + + if ( $capacity > 0 ) { + $product->set_manage_stock( true ); + $product->set_stock_quantity( $capacity ); + $product->set_stock_status( 'instock' ); + $product->set_backorders( 'no' ); + } + + // Assign ticket category if configured. + $cat_id = (int) get_option( 'eb4tec_woo_category_id', 0 ); + if ( $cat_id ) { + $product->set_category_ids( [ $cat_id ] ); + } + + $product_id = $product->save(); + + if ( ! $product_id ) { + return new WP_Error( 'eb4tec_wc_product', __( 'Failed to create WooCommerce product.', 'eb4tec' ) ); + } + + update_post_meta( $product_id, '_eb4tec_event_post_id', $post_id ); + update_post_meta( $product_id, '_eb4tec_is_event_ticket', 'yes' ); + update_post_meta( $post_id, '_eb4tec_wp_product_id', $product_id ); + + // Transfer featured image. + $thumbnail_id = get_post_thumbnail_id( $post_id ); + if ( $thumbnail_id ) { + set_post_thumbnail( $product_id, $thumbnail_id ); + } + + return $product_id; + } + + private function update_event_product( int $product_id, int $post_id ): void { + $product = wc_get_product( $product_id ); + if ( ! $product ) { + return; + } + + $event = get_post( $post_id ); + $title = $event ? $event->post_title . ' — ' . __( 'Tickets', 'eb4tec' ) : $product->get_name(); + $cost = (float) get_post_meta( $post_id, '_EventCost', true ); + $capacity = (int) get_post_meta( $post_id, '_eb4tec_capacity', true ); + + $product->set_name( $title ); + $product->set_regular_price( (string) $cost ); + $product->set_price( (string) $cost ); + + if ( $capacity > 0 ) { + $product->set_manage_stock( true ); + $product->set_stock_quantity( $capacity ); + } + + $product->save(); + } + + // --------------------------------------------------------------------------- + // Order completed: register attendees in Eventbrite + // --------------------------------------------------------------------------- + + public function on_order_completed( int $order_id ): void { + $order = wc_get_order( $order_id ); + if ( ! $order ) { + return; + } + + foreach ( $order->get_items() as $item_id => $item ) { + $product_id = $item->get_product_id(); + if ( get_post_meta( $product_id, '_eb4tec_is_event_ticket', true ) !== 'yes' ) { + continue; + } + + $post_id = (int) get_post_meta( $product_id, '_eb4tec_event_post_id', true ); + $eb_event_id = (string) get_post_meta( $post_id, '_eb4tec_event_id', true ); + + if ( ! $post_id || ! $eb_event_id ) { + continue; + } + + $capacity = (int) get_post_meta( $post_id, '_eb4tec_capacity', true ); + $class_id_or_error = $this->ticket_manager->ensure_wp_ticket_class( $eb_event_id, $post_id, $capacity ); + if ( is_wp_error( $class_id_or_error ) ) { + $order->add_order_note( sprintf( + __( 'EB4TEC: Failed to get ticket class for event %d: %s', 'eb4tec' ), + $post_id, + $class_id_or_error->get_error_message() + ) ); + continue; + } + + $qty = (int) $item->get_quantity(); + $attendee_ids = []; + $qr_codes = []; + + for ( $i = 0; $i < $qty; $i++ ) { + $attendee_result = $this->register_attendee( $order_id, $order, $eb_event_id, $class_id_or_error, $i ); + + if ( is_wp_error( $attendee_result ) ) { + $order->add_order_note( sprintf( + __( 'EB4TEC: Failed to register attendee %d for event %d: %s', 'eb4tec' ), + $i + 1, + $post_id, + $attendee_result->get_error_message() + ) ); + continue; + } + + $attendee_ids[] = $attendee_result; + + // Generate QR code for this attendee. + $qr_data = $this->qr->get_attendee_qr_data( $attendee_result, (string) $order_id, $eb_event_id ); + $qr_url = $this->qr->generate( $qr_data ); + if ( ! is_wp_error( $qr_url ) ) { + $qr_codes[] = [ + 'attendee_id' => $attendee_result, + 'qr_url' => $qr_url, + ]; + } + } + + // Persist to order meta (append to any already registered for other items). + $existing_ids = json_decode( get_post_meta( $order_id, '_eb4tec_attendee_ids', true ) ?: '[]', true ); + $existing_codes = json_decode( get_post_meta( $order_id, '_eb4tec_qr_codes', true ) ?: '[]', true ); + + update_post_meta( $order_id, '_eb4tec_attendee_ids', wp_json_encode( array_merge( $existing_ids, $attendee_ids ) ) ); + update_post_meta( $order_id, '_eb4tec_qr_codes', wp_json_encode( array_merge( $existing_codes, $qr_codes ) ) ); + update_post_meta( $order_id, '_eb4tec_event_post_id', $post_id ); + + if ( ! empty( $attendee_ids ) ) { + $order->add_order_note( sprintf( + __( 'EB4TEC: Registered %d attendee(s) in Eventbrite (IDs: %s).', 'eb4tec' ), + count( $attendee_ids ), + implode( ', ', $attendee_ids ) + ) ); + } + } + } + + public function on_order_refunded( int $order_id ): void { + $attendee_ids = json_decode( get_post_meta( $order_id, '_eb4tec_attendee_ids', true ) ?: '[]', true ); + if ( empty( $attendee_ids ) ) { + return; + } + + // Eventbrite's API does not support cancelling free-class attendees via API. + // Log a note for manual follow-up. + $order = wc_get_order( $order_id ); + if ( $order ) { + $order->add_order_note( sprintf( + /* translators: list of Eventbrite attendee IDs */ + __( 'EB4TEC: Order refunded/cancelled. Please manually cancel these Eventbrite attendees: %s', 'eb4tec' ), + implode( ', ', array_map( 'esc_html', $attendee_ids ) ) + ) ); + } + } + + private function register_attendee( int $order_id, WC_Order $order, string $eb_event_id, string $ticket_class_id, int $index ): string|WP_Error { + $first_name = sanitize_text_field( $order->get_billing_first_name() ); + $last_name = sanitize_text_field( $order->get_billing_last_name() ); + $email = sanitize_email( $order->get_billing_email() ); + + // Append ticket number if ordering multiple. + $name = trim( "$first_name $last_name" ); + if ( $index > 0 ) { + $name .= ' (' . ( $index + 1 ) . ')'; + } + + $result = $this->api->create_attendee( $eb_event_id, [ + 'attendee' => [ + 'ticket_class_id' => $ticket_class_id, + 'profile' => [ + 'name' => $name, + 'email' => $email, + ], + 'answers' => [], + ], + ] ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $attendee_id = $result['id'] ?? ''; + return $attendee_id ?: new WP_Error( 'eb4tec_no_attendee_id', __( 'Eventbrite did not return an attendee ID.', 'eb4tec' ) ); + } + + // --------------------------------------------------------------------------- + // Meta boxes + // --------------------------------------------------------------------------- + + public function add_meta_boxes(): void { + // Order meta box. + add_meta_box( + 'eb4tec_order_meta', + __( 'Eventbrite Attendees', 'eb4tec' ), + [ $this, 'render_order_meta_box' ], + wc_get_page_screen_id( 'shop-order' ), + 'side', + 'default' + ); + + // Product meta box. + add_meta_box( + 'eb4tec_product_meta', + __( 'Linked Eventbrite Event', 'eb4tec' ), + [ $this, 'render_product_meta_box' ], + 'product', + 'side', + 'default' + ); + } + + public function render_order_meta_box( WP_Post $post ): void { + $attendee_ids = json_decode( get_post_meta( $post->ID, '_eb4tec_attendee_ids', true ) ?: '[]', true ); + $post_id = (int) get_post_meta( $post->ID, '_eb4tec_event_post_id', true ); + $eb_event_id = $post_id ? (string) get_post_meta( $post_id, '_eb4tec_event_id', true ) : ''; + + if ( empty( $attendee_ids ) ) { + echo '

' . esc_html__( 'No Eventbrite attendees registered for this order.', 'eb4tec' ) . '

'; + return; + } + + echo ''; + } + + public function render_product_meta_box( WP_Post $post ): void { + wp_nonce_field( 'eb4tec_product_meta_save', '_eb4tec_product_meta_nonce' ); + + $event_post_id = (int) get_post_meta( $post->ID, '_eb4tec_event_post_id', true ); + $is_ticket = get_post_meta( $post->ID, '_eb4tec_is_event_ticket', true ) === 'yes'; + + if ( $event_post_id ) { + $event_title = get_the_title( $event_post_id ); + $edit_url = get_edit_post_link( $event_post_id ); + echo '

'; + printf( + esc_html__( 'Ticket for: %s', 'eb4tec' ) . ' %s', + esc_html( $event_title ), + esc_url( $edit_url ), + esc_html__( 'Edit event', 'eb4tec' ) + ); + echo '

'; + } else { + echo '

' . esc_html__( 'Not linked to a TEC event.', 'eb4tec' ) . '

'; + } + + echo '

'; + } + + public function save_product_meta_box( int $post_id ): void { + if ( get_post_type( $post_id ) !== 'product' ) { + return; + } + + if ( ! wp_verify_nonce( $_POST['_eb4tec_product_meta_nonce'] ?? '', 'eb4tec_product_meta_save' ) ) { + return; + } + + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return; + } + + $is_ticket = ( $_POST['eb4tec_is_event_ticket'] ?? '' ) === 'yes' ? 'yes' : 'no'; + update_post_meta( $post_id, '_eb4tec_is_event_ticket', $is_ticket ); + } + + // --------------------------------------------------------------------------- + // QR display: thank-you page and email + // --------------------------------------------------------------------------- + + public function show_qr_on_thankyou( int $order_id ): void { + if ( ! get_option( 'eb4tec_qr_on_order_page' ) ) { + return; + } + + $qr_codes = json_decode( get_post_meta( $order_id, '_eb4tec_qr_codes', true ) ?: '[]', true ); + if ( empty( $qr_codes ) ) { + return; + } + + echo '
'; + echo '

' . esc_html__( 'Your Event Tickets', 'eb4tec' ) . '

'; + echo '

' . esc_html__( 'Present these QR codes at the event for check-in.', 'eb4tec' ) . '

'; + + foreach ( $qr_codes as $index => $entry ) { + $attendee_id = $entry['attendee_id'] ?? ''; + $qr_url = $entry['qr_url'] ?? ''; + if ( ! $qr_url ) { + continue; + } + echo '
'; + echo $this->qr->render_qr_html( $qr_url, $attendee_id ); + echo '' . sprintf( esc_html__( 'Ticket %d', 'eb4tec' ), $index + 1 ) . ''; + echo '
'; + } + + echo '
'; + } + + public function inject_qr_into_email( WC_Order $order, bool $sent_to_admin, bool $plain_text, WC_Email $email ): void { + if ( $sent_to_admin || $plain_text ) { + return; + } + + if ( ! get_option( 'eb4tec_qr_in_email' ) ) { + return; + } + + $qr_codes = json_decode( get_post_meta( $order->get_id(), '_eb4tec_qr_codes', true ) ?: '[]', true ); + if ( empty( $qr_codes ) ) { + return; + } + + echo '

' . esc_html__( 'Your Event Tickets', 'eb4tec' ) . '

'; + echo '

' . esc_html__( 'Present these QR codes at the event for check-in.', 'eb4tec' ) . '

'; + + foreach ( $qr_codes as $index => $entry ) { + $qr_url = $entry['qr_url'] ?? ''; + $attendee_id = $entry['attendee_id'] ?? ''; + if ( ! $qr_url ) { + continue; + } + echo '
'; + echo '' . sprintf( esc_html__( 'Ticket %d', 'eb4tec' ), $index + 1 ) . '
'; + echo $this->qr->render_qr_html( $qr_url, $attendee_id ); + echo '
'; + } + } +}