settings = $settings ?? new Settings(); parent::__construct( 'eventin-ticket-action', 'Check one ticket for one event and build the ticket card. Call this when the visitor confirms which event and which ticket they want. This only CHECKS — the visitor must tap the button to continue.' ); } /** * Define tool properties * * @return array */ protected function properties(): array { return [ new ToolProperty( name: 'event_id', type: PropertyType::NUMBER, description: 'The event id from eventin-event-search.', required: true ), new ToolProperty( name: 'ticket_slug', type: PropertyType::STRING, description: 'The ticket slug, or the ticket type name (for example "VIP") the visitor chose.', required: true ), new ToolProperty( name: 'quantity', type: PropertyType::NUMBER, description: 'How many tickets. Default 1.', required: false ), ]; } /** * Execute the tool * * @param int|null $event_id Event post id. Nullable because NeuronAI * passes null (not a value) when the model * omits the property, and a non-nullable int * would TypeError outside this try/catch. * @param string|null $ticket_slug Ticket slug. * @param int|null $quantity Quantity. * @return array */ public function __invoke( ?int $event_id = null, ?string $ticket_slug = null, ?int $quantity = 1 ): array { if ( ! class_exists( 'Wpeventin' ) ) { return [ 'ok' => false, 'error' => 'Eventin is not active on this website.', ]; } if ( ! $event_id ) { return [ 'ok' => false, 'error' => 'No event_id was given. Call eventin-event-search first to get one, then try again.', ]; } if ( empty( $ticket_slug ) ) { return [ 'ok' => false, 'error' => 'No ticket_slug was given. Ask the visitor which ticket type they want, then try again with that slug from eventin-event-search.', ]; } // \Throwable, not \Exception: a bad meta shape can raise a PHP Error that // would otherwise crash the whole agent turn. try { $qty = max( 1, (int) ( $quantity ?? 1 ) ); // --- Check 1: the event exists and is published --------------------- $post = get_post( $event_id ); if ( ! $post || 'etn' !== ( $post->post_type ?? '' ) || 'publish' !== ( $post->post_status ?? '' ) ) { return [ 'ok' => false, 'error' => 'Event #' . $event_id . ' is not available. Call eventin-event-search again to get a current event id.', ]; } // --- Check 2: the event has not finished ---------------------------- if ( $this->event_has_finished( $event_id ) ) { return [ 'ok' => false, 'error' => 'That event has already finished. Offer the visitor an upcoming event instead.', ]; } // --- Check 3: the ticket belongs to this event ---------------------- $variations = get_post_meta( $event_id, 'etn_ticket_variations', true ); $variations = is_array( $variations ) ? $variations : []; $ticket = null; foreach ( $variations as $variation ) { if ( is_array( $variation ) && ( $variation['etn_ticket_slug'] ?? '' ) === $ticket_slug ) { $ticket = $variation; break; } } // The search result the model sees has no ticket slugs (they are // stripped to save tokens), so it often only knows the ticket name. if ( null === $ticket ) { $wanted = strtolower( trim( Eventin_Event_Search_Tool::decode_text( (string) $ticket_slug ) ) ); foreach ( $variations as $variation ) { $name = strtolower( trim( Eventin_Event_Search_Tool::decode_text( (string) ( is_array( $variation ) ? ( $variation['etn_ticket_name'] ?? '' ) : '' ) ) ) ); if ( '' !== $name && $name === $wanted ) { $ticket = $variation; break; } } } if ( null === $ticket ) { $names = []; foreach ( $variations as $variation ) { if ( is_array( $variation ) && ! empty( $variation['etn_ticket_name'] ) ) { $names[] = Eventin_Event_Search_Tool::decode_text( (string) $variation['etn_ticket_name'] ); } } return [ 'ok' => false, 'error' => 'There is no ticket type "' . $ticket_slug . '" for this event. The ticket types are: ' . implode( ', ', $names ) . '. Ask the visitor which one they want.', ]; } // Use the real slug from here on, even when the name matched. $ticket_slug = (string) ( $ticket['etn_ticket_slug'] ?? $ticket_slug ); // --- Check 4: the ticket is switched on ----------------------------- if ( false === ( $ticket['etn_enable_ticket'] ?? true ) ) { return [ 'ok' => false, 'error' => 'That ticket type is not on sale. Ask the visitor if another ticket type works.', ]; } // --- Check 5: the sale window is open ------------------------------- $sale = self::sale_window_state( $ticket, (string) get_post_meta( $event_id, 'event_timezone', true ) ); if ( 'open' !== $sale['state'] ) { return [ 'ok' => false, 'error' => $sale['message'], ]; } // --- Checks 6 and 7: min and max per order -------------------------- $min = isset( $ticket['etn_min_ticket'] ) ? (int) $ticket['etn_min_ticket'] : 0; $max = isset( $ticket['etn_max_ticket'] ) ? (int) $ticket['etn_max_ticket'] : 0; if ( $min > 0 && $qty < $min ) { return [ 'ok' => false, 'error' => 'This ticket has a minimum of ' . $min . ' per order. Ask the visitor if they want ' . $min . '.', ]; } if ( $max > 0 && $qty > $max ) { return [ 'ok' => false, 'error' => 'This ticket has a maximum of ' . $max . ' per order. Ask the visitor if ' . $max . ' works.', ]; } // --- Check 8: enough seats ------------------------------------------ $seats_left = $this->seats_for_ticket( $ticket ); if ( null !== $seats_left && $seats_left < $qty ) { if ( 0 === $seats_left ) { return [ 'ok' => false, 'error' => 'That ticket is sold out. Offer another ticket type or another event.', ]; } return [ 'ok' => false, 'error' => 'There are only ' . $seats_left . ' of that ticket left, and the visitor asked for ' . $qty . '. Ask if the smaller number works.', ]; } // --- Check 9: build the summaries ----------------------------------- $price = (float) ( $ticket['etn_ticket_price'] ?? 0 ); $permalink = (string) get_permalink( $event_id ); $event_summary = $this->event_summary( $event_id, (string) ( $post->post_title ?? '' ), $permalink ); $ticket_summary = [ 'slug' => (string) $ticket_slug, 'name' => Eventin_Event_Search_Tool::decode_text( (string) ( $ticket['etn_ticket_name'] ?? $ticket_slug ) ), 'price' => $price, 'currency' => function_exists( 'etn_currency' ) ? etn_currency() : 'USD', // Decoded: Eventin stores symbols HTML-encoded ("$"), and React // renders card text literally, so the raw entity would be shown. 'symbol' => Eventin_Event_Search_Tool::currency_symbol(), 'is_free' => 0.0 === $price, 'quantity' => $qty, 'seats_left' => $seats_left, 'total' => $price * $qty, ]; $card = $this->build_card( $event_id, $permalink, (string) $ticket_slug, $ticket, $qty, $price, $event_summary, $ticket_summary ); $card['button_label'] = self::button_label( (string) $card['mode'], (bool) $ticket_summary['is_free'] ); // Without this the model told visitors "you're all checked in" when // nothing had been booked. $card['next_step'] = sprintf( 'Nothing is booked yet. Tell the visitor to tap the "%s" button on the card to finish.', $card['button_label'] ); return $card; } catch ( \Throwable $e ) { // Log the real cause for the operator; the model gets a generic // non-retry message. Log::error( 'Eventin ticket action failed: ' . $e->getMessage() ); return [ 'ok' => false, 'error' => 'The ticket check failed. Tell the visitor to open the event page and pick the ticket there.', ]; } } /** * The label on the card button, so the model names the button the visitor * really sees. Keep in step with TicketActionCard. * * @param string $mode Card mode. * @param bool $is_free Whether the ticket is free. * @return string */ public static function button_label( string $mode, bool $is_free ): string { if ( 'event_page' === $mode ) { return __( 'View event', 'aisentic' ); } return $is_free ? __( 'Register free', 'aisentic' ) : __( 'Get tickets', 'aisentic' ); } /** * Pick the card mode and build its payload. The first matching rule wins. * * @param int $event_id Event post id. * @param string $permalink Event permalink. * @param string $ticket_slug Ticket slug. * @param array $ticket Ticket variation. * @param int $qty Quantity. * @param float $price Unit price. * @param array $event_summary Event summary. * @param array $ticket_summary Ticket summary. * @return array */ private function build_card( int $event_id, string $permalink, string $ticket_slug, array $ticket, int $qty, float $price, array $event_summary, array $ticket_summary ): array { $base = [ 'ok' => true, 'fallback_url' => $permalink, 'event_summary' => $event_summary, 'ticket_summary' => $ticket_summary, ]; // Rule 1: a seat plan needs the visitor to pick seats on a map, which the // chat cannot show. Send them to the event page. $seat_plan = (bool) get_post_meta( $event_id, 'enable_seatmap', true ); if ( $seat_plan ) { return array_merge( $base, [ 'mode' => 'event_page', 'action_card' => 'view_event', ] ); } // Rule 2: every payment method (local, WooCommerce, SureCart, FluentCart) // goes through Eventin's own checkout page, like the ticket form on the // event page does. Eventin sends WooCommerce payments on from there, so // tickets never use the WooCommerce product cart card. $checkout = $this->checkout_payload( $event_id, $permalink, $ticket_slug, $ticket, $qty, $price ); if ( null === $checkout ) { return array_merge( $base, [ 'mode' => 'event_page', 'action_card' => 'view_event', ] ); } return array_merge( $base, [ 'mode' => 'checkout', 'action_card' => 'get_tickets', 'checkout_payload' => $checkout, ] ); } /** * Build the payload the browser writes into Eventin's localStorage. * * Field for field, this mirrors useTicketPurchase.js in the Eventin plugin. * Returns null when a required field is missing — the caller then falls back * to the event page card rather than opening an empty checkout. * * @param int $event_id Event post id. * @param string $permalink Event permalink. * @param string $ticket_slug Ticket slug. * @param array $ticket Ticket variation. * @param int $qty Quantity. * @param float $price Unit price. * @return array|null */ private function checkout_payload( int $event_id, string $permalink, string $ticket_slug, array $ticket, int $qty, float $price ): ?array { $start_date = (string) get_post_meta( $event_id, 'etn_start_date', true ); $start_time = (string) get_post_meta( $event_id, 'etn_start_time', true ); $title = (string) get_the_title( $event_id ); // The checkout cannot render without these three. if ( '' === $start_date || '' === $title || '' === $permalink ) { return null; } $location_meta = get_post_meta( $event_id, 'etn_event_location', true ); $extra_fields = get_post_meta( $event_id, 'etn_extra_fields', true ); $event_information = [ 'id' => $event_id, 'title' => $title, 'extra_fields' => is_array( $extra_fields ) ? $extra_fields : [], 'location' => is_array( $location_meta ) ? $location_meta : [], 'timezone' => (string) get_post_meta( $event_id, 'event_timezone', true ), 'start_date' => $start_date, 'start_time' => $start_time, 'link' => $permalink, 'tax_status' => (string) get_post_meta( $event_id, '_tax_status', true ), ]; $ticket_counts = $this->ticket_counts( $event_id, $ticket_slug, $qty ); // With the booking timer on, Eventin's own button first holds the tickets // with a POST to orders/book-seats, and the checkout counts down that // hold. The browser sends the same body. Null means no hold is needed. $timer_on = 'on' === (string) ( function_exists( 'etn_get_option' ) ? etn_get_option( 'ticket_purchase_timer_enable', 'off' ) : 'off' ); $hold_seats = null; if ( $timer_on ) { $booked_tickets = []; foreach ( $ticket_counts as $slug => $count ) { $booked_tickets[] = [ 'ticket_slug' => (string) $slug, 'ticket_quantity' => (int) $count['quantity'], ]; } $hold_seats = [ 'event_id' => $event_id, 'booked_tickets' => $booked_tickets, ]; } return [ 'cart_info' => [ 'eventInformation' => $event_information, // Every ticket type, not just the chosen one. Eventin's own // initializeTicketCounts() seeds all of them and leaves the // unselected ones at quantity 0; the checkout expects that shape. 'ticketCounts' => $ticket_counts, 'totalPrice' => $price * $qty, ], 'hold_seats' => $hold_seats, 'event_data' => [ 'title' => $title, // The raw location array, not its address string. Eventin's own // saveEventData() stores the whole object and the success page // reads it back as one; a bare string renders wrong there. 'location' => is_array( $location_meta ) ? $location_meta : [], 'start_date' => $start_date, 'start_time' => $start_time, 'link' => $permalink, ], 'checkout_url' => trailingslashit( site_url() ) . 'eventin-purchase/checkout', ]; } /** * Build ticketCounts the way Eventin's ticket selector does: one entry per * ticket type, with 0 for every type the visitor did not choose. * * @param int $event_id Event post id. * @param string $chosen_slug The slug the visitor picked. * @param int $qty Quantity for the chosen slug. * @return array */ private function ticket_counts( int $event_id, string $chosen_slug, int $qty ): array { $variations = get_post_meta( $event_id, 'etn_ticket_variations', true ); $variations = is_array( $variations ) ? $variations : []; $counts = []; foreach ( $variations as $variation ) { if ( ! is_array( $variation ) ) { continue; } $slug = (string) ( $variation['etn_ticket_slug'] ?? '' ); if ( '' === $slug ) { continue; } $counts[ $slug ] = [ 'name' => (string) ( $variation['etn_ticket_name'] ?? $slug ), 'slug' => $slug, 'price' => (float) ( $variation['etn_ticket_price'] ?? 0 ), 'quantity' => $slug === $chosen_slug ? $qty : 0, ]; } return $counts; } /** * Has the event already finished? * * Prefers Eventin's own model. Falls back to the stored end datetime when * the model class is missing. * * @param int $event_id Event post id. * @return bool */ private function event_has_finished( int $event_id ): bool { // is_expaired() is Eventin's spelling. Do not correct it. if ( class_exists( '\Etn\Core\Event\Event_Model' ) ) { $model = new \Etn\Core\Event\Event_Model( $event_id ); if ( method_exists( $model, 'is_expaired' ) ) { return (bool) $model->is_expaired(); } } $end_date = (string) get_post_meta( $event_id, 'etn_end_date', true ); if ( '' === $end_date ) { return false; } $end_time = (string) get_post_meta( $event_id, 'etn_end_time', true ); $end = self::to_timestamp( $end_date, $end_time, (string) get_post_meta( $event_id, 'event_timezone', true ) ); return null !== $end && $end < time(); } /** * Is this ticket's sale window open? * * The rules come from Eventin's is-ticket-sellable.js. A missing date means * "no limit on that side". Both missing means sellable. * * @param array $ticket One ticket variation. * @param string $timezone Event timezone string. * @return array */ public static function sale_window_state( array $ticket, string $timezone ): array { $now = time(); $start = self::to_timestamp( (string) ( $ticket['start_date'] ?? '' ), (string) ( $ticket['start_time'] ?? '' ), $timezone ); $end = self::to_timestamp( (string) ( $ticket['end_date'] ?? '' ), (string) ( $ticket['end_time'] ?? '' ), $timezone ); if ( null !== $start && $now < $start ) { return [ 'state' => 'not_started', 'message' => 'The sale begins on ' . gmdate( 'j F Y', $start ) . '. Tell the visitor when to come back.', ]; } if ( null !== $end && $now > $end ) { return [ 'state' => 'ended', 'message' => 'The sale period for that ticket has ended. Offer another ticket type or another event.', ]; } return [ 'state' => 'open', 'message' => '', ]; } /** * Turn a stored date plus optional time into a timestamp in the event's zone. * * Two stored shapes must both work: a plain "2026-06-02" from the modern * editor, and a full ISO instant like "2026-06-01T18:00:00.000Z" from a * classic-editor save. Slicing the first ten characters off the ISO form * gives the wrong calendar day, so it is parsed and re-rendered instead. * * @param string $date Date string. * @param string $time Time string, may be empty. * @param string $timezone Timezone string, may be empty. * @return int|null */ private static function to_timestamp( string $date, string $time, string $timezone ): ?int { $date = trim( $date ); if ( '' === $date ) { return null; } // An ISO instant carries a time part. Render it back to a calendar date. if ( preg_match( '/[T:]/', $date ) ) { try { $parsed = new \DateTime( $date ); $date = $parsed->format( 'Y-m-d' ); } catch ( \Throwable $e ) { return null; } } $zone = null; if ( '' !== $timezone ) { try { $zone = new \DateTimeZone( $timezone ); } catch ( \Throwable $e ) { $zone = null; } } if ( null === $zone ) { $zone = function_exists( 'wp_timezone' ) ? wp_timezone() : new \DateTimeZone( 'UTC' ); } $time = trim( $time ); try { // Never strtotime() the raw stored string — it double-shifts the offset. $moment = new \DateTime( $date, $zone ); if ( '' !== $time ) { $parsed_time = date_parse( $time ); if ( false !== $parsed_time['hour'] && null !== $parsed_time['hour'] ) { $moment->setTime( (int) $parsed_time['hour'], (int) $parsed_time['minute'] ); } } return $moment->getTimestamp(); } catch ( \Throwable $e ) { return null; } } /** * Seats left for one ticket type, or null when unlimited. * * `etn_avaiilable_tickets` is Eventin's spelling — two i's. Correcting it * silently breaks every seat count. * * @param array $ticket One ticket variation. * @return int|null */ private function seats_for_ticket( array $ticket ): ?int { $total = $ticket['etn_avaiilable_tickets'] ?? null; if ( true === ( $ticket['etn_unlimited_tickets'] ?? false ) || null === $total || '' === $total || -1 === (int) $total ) { return null; } $sold = (int) ( $ticket['etn_sold_tickets'] ?? 0 ); $pending = (int) ( $ticket['pending'] ?? 0 ); return max( 0, (int) $total - ( $sold + $pending ) ); } /** * Compact event summary for the card. * * @param int $event_id Event post id. * @param string $title Event title. * @param string $permalink Event permalink. * @return array */ private function event_summary( int $event_id, string $title, string $permalink ): array { $location_meta = get_post_meta( $event_id, 'etn_event_location', true ); return [ 'id' => $event_id, 'title' => Eventin_Event_Search_Tool::decode_text( $title ), 'permalink' => $permalink, 'image_url' => (string) get_the_post_thumbnail_url( $event_id, 'medium' ), 'start_datetime' => trim( (string) get_post_meta( $event_id, 'etn_start_date', true ) . ' ' . (string) get_post_meta( $event_id, 'etn_start_time', true ) ), 'end_datetime' => trim( (string) get_post_meta( $event_id, 'etn_end_date', true ) . ' ' . (string) get_post_meta( $event_id, 'etn_end_time', true ) ), // The caller already refused a finished event, so started means on now. 'is_running' => Eventin_Event_Search_Tool::has_started( $event_id ), 'timezone' => (string) get_post_meta( $event_id, 'event_timezone', true ), 'location' => is_array( $location_meta ) ? Eventin_Event_Search_Tool::decode_text( (string) ( $location_meta['address'] ?? '' ) ) : '', ]; } }