last_ok = true; switch ( $type ) { case 'receipt': $this->send_confirmation_email( $booking_id ); break; case 'order_placed': $b = SEATT_DB::get_booking( $booking_id ); if ( $b && 'paid' === $b->status ) { return true; } $this->send_client_order_placed_email( $booking_id ); break; case 'admin_order': $this->send_admin_order_placed_email( $booking_id ); break; } return $this->last_ok; } /** @var bool result of the last send() (for the queue's retry). */ private $last_ok = true; /** * THE bug behind "paid, but no receipt email": every email here * called SEATT_DB::get_booking_with_seats() and then read * $booking->buyer_email — but that function returns an ARRAY * ( 'booking' => row, 'seats' => [...], 'zones' => [...] ), not an * object. Property access on an array is null, so the "no email on * file" guard returned early on every single booking and nothing * was ever sent — silently, since that early return is the normal * path for walk-up bookings without an email. This flattens it * into the object shape all three emails were written against. */ private function load_booking( $booking_id ) { $data = SEATT_DB::get_booking_with_seats( $booking_id ); if ( ! $data || empty( $data['booking'] ) ) { return null; } $booking = clone $data['booking']; $booking->seats = (array) $data['seats']; $booking->zones = (array) $data['zones']; return $booking; } /** wp_mail() fails quietly (wrong SMTP password, blocked port). This * puts the reason in the Activity Log next to the booking. */ public function log_mail_failure( $error ) { if ( ! $this->sending_for ) { return; } $booking = SEATT_DB::get_booking( $this->sending_for ); $msg = is_wp_error( $error ) ? $error->get_error_message() : 'unknown error'; SEATT_DB::log_activity( $booking ? $booking->event_id : 0, $this->sending_for, null, 'email_failed', $msg ); } /** wp_mail wrapper that records the outcome for this booking. */ private function send( $booking, $to, $subject, $body, $label, $headers = array() ) { $this->sending_for = intval( $booking->id ); // A mail/SMTP failure must never break the booking flow that // triggered it (it used to abort complimentary tickets half-way). try { $ok = wp_mail( $to, $subject, $body, array_merge( array( 'Content-Type: text/html; charset=UTF-8' ), $headers ) ); } catch ( \Throwable $e ) { $ok = false; SEATT_DB::log_activity( $booking->event_id ?? null, $booking->id ?? null, null, 'email_failed', $label . ': ' . $e->getMessage() ); } if ( ! $ok ) { $this->last_ok = false; } $this->sending_for = 0; if ( $ok ) { SEATT_DB::log_activity( $booking->event_id, $booking->id, null, 'email_sent', $label . ' → ' . $to ); } return $ok; } /** Shared by both "Order Placed" emails below — turns a booking's * seats/zones into a plain-text line list for the ORDER_DETAILS * placeholder, since both the client and admin version of this * email want the same summary. */ private function order_details_text( $booking ) { $lines = array(); foreach ( $booking->seats as $seat ) { $lines[] = seatt_seat_label( $seat->row_name, $seat->seat_label ) . ' — ' . seatt_format_price( $seat->price ); } foreach ( $booking->zones as $zone ) { $lines[] = $zone->zone_name . ' × ' . $zone->quantity . ' — ' . seatt_format_price( $zone->price_each * $zone->quantity ); } $answers = class_exists( 'SEATT_Checkout_Fields' ) ? SEATT_Checkout_Fields::answers_text( $booking->id ) : ''; if ( '' !== $answers ) { $lines[] = ''; $lines[] = $answers; } return implode( "\n", $lines ); } /** Replaces the Tickera-style placeholders in a custom email * template with real booking values — shared by both new email * types since they use the same placeholder vocabulary (with * ORDER_ADMIN_URL only meaningful for the admin version). */ private function fill_placeholders( $template, $booking, $event_name ) { return str_replace( array( '{ORDER_ID}', '{ORDER_TOTAL}', '{BUYER_NAME}', '{EVENT_NAME}', '{ORDER_DETAILS}', '{ORDER_ADMIN_URL}' ), array( $booking->id, seatt_format_price( $booking->total ), $booking->buyer_name, $event_name, $this->order_details_text( $booking ), admin_url( 'admin.php?page=seatt-bookings&event_id=' . $booking->event_id ), ), $template ); } /** * "Client Order Placed Email" (Settings → Email) — an immediate * acknowledgment to the BUYER the moment their booking is created, * regardless of payment status yet. Deliberately separate from * send_confirmation_email() above (which only fires once PAID) — * a still-pending order and a confirmed one are different moments * a site owner may want to word differently, or only send one of. */ public function send_client_order_placed_email( $booking_id ) { if ( ! get_option( 'seatt_client_order_placed_enabled' ) ) { return; } $booking = $this->load_booking( $booking_id ); if ( ! $booking || empty( $booking->buyer_email ) || ! is_email( $booking->buyer_email ) ) { return; } $event = get_post( $booking->event_id ); $event_name = $event ? $event->post_title : __( 'Your Event', 'seathive' ); $subject = $this->fill_placeholders( (string) get_option( 'seatt_client_order_placed_subject', __( 'Order Received', 'seathive' ) ), $booking, $event_name ); $content = trim( (string) get_option( 'seatt_client_order_placed_content' ) ); if ( '' === $content ) { $content = "Hello,\n\nWe've received your order ({ORDER_ID}) totalling {ORDER_TOTAL} for {EVENT_NAME}.\n\n{ORDER_DETAILS}"; } $body = nl2br( esc_html( $this->fill_placeholders( $content, $booking, $event_name ) ) ); $this->send( $booking, $booking->buyer_email, $subject, $body, 'Order placed (buyer)' ); } /** * "Admin Order Placed Email" (Settings → Email) — notifies the * SITE OWNER (not the buyer) that a new order came in, so a real * person doesn't have to keep the Bookings page open to notice new * sales as they happen. */ public function send_admin_order_placed_email( $booking_id ) { if ( ! get_option( 'seatt_admin_order_placed_enabled' ) ) { return; } $booking = $this->load_booking( $booking_id ); if ( ! $booking ) { return; } $to_email = trim( (string) get_option( 'seatt_admin_order_placed_to_email' ) ); if ( '' === $to_email || ! is_email( $to_email ) ) { $to_email = get_option( 'admin_email' ); // sensible default — a site that hasn't set a specific "To" yet still gets notified somewhere } $event = get_post( $booking->event_id ); $event_name = $event ? $event->post_title : __( 'Your Event', 'seathive' ); $subject = $this->fill_placeholders( (string) get_option( 'seatt_admin_order_placed_subject', __( 'New Order Placed', 'seathive' ) ), $booking, $event_name ); $content = trim( (string) get_option( 'seatt_admin_order_placed_content' ) ); if ( '' === $content ) { $content = "Hello,\n\nA new order ({ORDER_ID}) totalling {ORDER_TOTAL} has been placed by {BUYER_NAME} for {EVENT_NAME}.\n\n{ORDER_DETAILS}\n\nView it here: {ORDER_ADMIN_URL}"; } $body = nl2br( esc_html( $this->fill_placeholders( $content, $booking, $event_name ) ) ); $this->send( $booking, $to_email, $subject, $body, 'Order placed (admin)' ); } /** * Receipt email, sent once a booking is PAID: event image on top, * the entry QR code right under it, then the order summary and a * button to the full receipt page. * * The QR is generated on this server and embedded in the email * itself (inline CID attachment): Outlook and many mobile mail apps * block remote images by default — exactly the moment someone is * standing at the door trying to open their ticket. * * Returns true when wp_mail accepted the message. */ public function send_confirmation_email( $booking_id ) { $booking = $this->load_booking( $booking_id ); if ( ! $booking || empty( $booking->buyer_email ) || ! is_email( $booking->buyer_email ) ) { // No email on file (e.g. an usher-sold walk-up booking with only // a name/phone) — nothing to send, and that's an expected, // ordinary case rather than something to warn about. return false; } // The Email tab's "Send Receipt Email" toggle — defaults ON. if ( ! get_option( 'seatt_send_receipt_email', 1 ) ) { return false; } $event = get_post( $booking->event_id ); $event_name = $event ? $event->post_title : __( 'Your Event', 'seathive' ); $event_date = $event ? get_post_meta( $event->ID, '_seatt_event_date', true ) : ''; $banner_url = ''; $banner_h = 0; if ( $event && has_post_thumbnail( $event->ID ) ) { $img = wp_get_attachment_image_src( get_post_thumbnail_id( $event->ID ), 'large' ); if ( $img ) { $banner_url = $img[0]; // Explicit height for Outlook, which ignores height:auto. $banner_h = ( ! empty( $img[1] ) && ! empty( $img[2] ) ) ? (int) round( 520 * $img[2] / $img[1] ) : 0; } } $confirmation_url = $this->find_confirmation_page_url( $booking ); $page_base = $this->find_confirmation_page_base( $booking ); $qr_src = ''; $qr_png = ''; $qr_list = array(); // one QR per ticket: array( cid, png, ticket row ) if ( $page_base && 'paid' === $booking->status ) { // Drawn locally (SEATT_QR + GD) and embedded as inline // attachments. The check-in token is never sent to an outside // QR service. Without GD on the server there's simply no // image, and the "View my ticket" button shows the QRs instead. $qr_list = self::ticket_qrs( $booking, $page_base ); if ( ! $qr_list ) { try { $qr_png = SEATT_QR::png( self::checkin_url( $booking, $page_base ), 8 ); } catch ( \Exception $e ) { $qr_png = ''; } $qr_src = $qr_png ? 'cid:esm-qr-' . intval( $booking->id ) : ''; } } $subject = sprintf( /* translators: %s: value */ __( 'Your tickets for %s — booking confirmed', 'seathive' ), $event_name ); $is_comp = 'comp' === $booking->payment_method; $ev_time = $event ? (string) get_post_meta( $event->ID, '_seatt_event_time', true ) : ''; $ev_end = $event ? (string) get_post_meta( $event->ID, '_seatt_event_end_time', true ) : ''; $ev_venue = $event ? trim( (string) get_post_meta( $event->ID, '_seatt_venue_address', true ) ) : ''; $n_tix = $qr_list ? count( $qr_list ) : 0; $custom_text = trim( (string) get_option( 'seatt_receipt_email_text', '' ) ); if ( '' !== $custom_text ) { $intro_text = str_replace( array( '{name}', '{event}' ), array( $booking->buyer_name, $event_name ), $custom_text ); } else { $intro_text = $is_comp ? sprintf( /* translators: %s: value */ __( 'Hi %s, you have received complimentary tickets. Your booking is confirmed — everything you need for the day is below.', 'seathive' ), $booking->buyer_name ) : sprintf( __( 'Hi %s, thank you for your order! Your payment has been received and your booking is confirmed. Everything you need for the day is below.', 'seathive' ), $booking->buyer_name ); } ob_start(); ?>
<?php echo esc_attr( $event_name ); ?> height="" style="display:block;width:100%;max-width:520px;height:auto;border:0">

id ) ); ?> · :

1 ? sprintf( /* translators: %d: number of QR codes */ __( 'Your %d entry QR codes', 'seathive' ), count( $qr_list ) ) : __( 'Your entry QR code', 'seathive' ), count( $qr_list ) > 1 ? __( 'One QR per person — each holder can scan in on their own.', 'seathive' ) : '' ), 'post', array_merge( wp_allowed_protocols(), array( 'cid' ) ) ); ?>

<?php esc_attr_e( 'Entry QR code', 'seathive' ); ?>

:
:
:
event_id ) ) : ?>
: event_id ) ); ?>
:

📎

1 ) : ?>

:

seats as $seat ) : ?> zones as $zone ) : ?> ( (float) $booking->discount > 0 || (float) $booking->fee > 0 || (float) $booking->tax > 0 ) && null !== $booking->subtotal ? seatt_format_price( $booking->subtotal ) : '', ( $booking->promo_code ? sprintf( /* translators: %s: value */ __( 'Discount (%s)', 'seathive' ), $booking->promo_code ) : __( 'Discount', 'seathive' ) ) => (float) $booking->discount > 0 ? '− ' . seatt_format_price( $booking->discount ) : '', ( get_option( 'seatt_fee_label', '' ) ?: __( 'Service fee', 'seathive' ) ) => (float) $booking->fee > 0 ? seatt_format_price( $booking->fee ) : '', __( 'Tax', 'seathive' ) => (float) $booking->tax > 0 ? seatt_format_price( $booking->tax ) : '', ) ) as $lbl => $amt ) : ?>
row_name, $seat->seat_label ) ); ?>attendee_name ) ) : ?> — attendee_name ); ?> payment_method ? esc_html__( 'Complimentary', 'seathive' ) : esc_html( seatt_format_price( $seat->price ) ); ?>
zone_name . ' × ' . $zone->quantity ); ?> price_each * $zone->quantity ) ); ?>
total ) ); ?> ()
event_id, true ) ) : ''; ?>

addStringAttachment( $pdf, $pdf_name, 'base64', 'application/pdf' ); } catch ( \Exception $e ) { // Sent without the attachment; the QR codes are inline anyway. } } foreach ( $qr_list as $q ) { if ( ! $q['png'] ) { continue; } try { $phpmailer->addStringEmbeddedImage( $q['png'], $q['cid'], 'ticket-' . (int) $q['ticket']->ticket_no . '.png', 'base64', 'image/png' ); } catch ( \Exception $e ) { // The just won't resolve. } } }; add_action( 'phpmailer_init', $embed ); } elseif ( $qr_png ) { $cid = 'esm-qr-' . intval( $booking->id ); $embed = function ( $phpmailer ) use ( $qr_png, $cid ) { try { $phpmailer->addStringEmbeddedImage( $qr_png, $cid, 'ticket-qr.png', 'base64', 'image/png' ); } catch ( \Exception $e ) { // Falls through: the just won't resolve. } }; add_action( 'phpmailer_init', $embed ); } $ok = $this->send( $booking, $booking->buyer_email, $subject, $body, 'Receipt' ); if ( $embed ) { remove_action( 'phpmailer_init', $embed ); } return $ok; } /** PNG QR per ticket of a paid booking (empty without GD / tickets). */ public static function ticket_qrs( $booking, $page_base, $only_email = '' ) { if ( ! class_exists( 'SEATT_Ticket_Codes' ) ) { return array(); } $out = array(); foreach ( SEATT_Ticket_Codes::ensure( $booking->id ) as $t ) { if ( '' !== $only_email && 0 !== strcasecmp( (string) $t->holder_email, $only_email ) ) { continue; } try { $png = SEATT_QR::png( SEATT_Ticket_Codes::url( $t, $page_base ), 7 ); } catch ( \Exception $e ) { $png = ''; } // Kept even without an image (no GD): the PDF still carries the QR. $out[] = array( 'cid' => 'esm-qr-t' . (int) $t->id, 'png' => $png, 'ticket' => $t ); } return $out; } /** Email-safe block of QR cards (tables, inline styles). */ public static function qr_cards_html( $qr_list, $title, $note = '' ) { $h = '
'; $h .= '

' . esc_html( $title ) . '

'; if ( $note ) { $h .= '

' . esc_html( $note ) . '

'; } foreach ( $qr_list as $q ) { $t = $q['ticket']; $h .= '
'; $h .= '

' . esc_html( $t->holder_name ) . '

'; $h .= '

' . esc_html( sprintf( /* translators: %1$d: number, %2$s: value */ __( 'Ticket %1$d · %2$s', 'seathive' ), (int) $t->ticket_no, $t->label ) ) . '

'; if ( $q['png'] ) { $h .= '' . esc_attr__( 'Entry QR code', 'seathive' ) . ''; } else { $h .= '

' . esc_html__( 'QR code in the attached PDF', 'seathive' ) . '

'; } $h .= '
'; } $h .= '

' . esc_html__( 'Show this at the door — staff will scan it to confirm your arrival.', 'seathive' ) . '

'; return $h; } /** The door-scan URL a ticket's QR encodes — identical to what the * receipt page renders, so both QRs scan to the same screen. */ public static function checkin_url( $booking, $page_base ) { return add_query_arg( array( 'seatt_checkin_id' => intval( $booking->id ), 'seatt_checkin_token' => $booking->checkin_token ), $page_base ); } /** Permalink of the page carrying this event's booking shortcode. */ private function find_confirmation_page_base( $booking ) { return seatt_event_booking_url( $booking->event_id ); } /** * The confirmation page is whatever page has the [seatt_booking ...] * shortcode for THIS event — same lookup pattern the portals already * use to find their own page, just keyed by event_id instead of * just the shortcode's presence, since the booking shortcode takes * an event_id attribute that varies per event/page. */ private function find_confirmation_page_url( $booking ) { $base = seatt_event_booking_url( $booking->event_id ); if ( ! $base ) { return ''; } // IMPORTANT: seatt_booking_id, NOT seatt_checkin_id/seatt_checkin_token // — those two params trigger a COMPLETELY DIFFERENT screen // (SEATT_Public::render_checkin(), the staff-only "confirm this // guest's arrival" view a door usher sees after scanning a // ticket's QR code). A buyer clicking this link with the checkin // params instead sees "Staff Login Required", never their // receipt — a real bug that shipped in this email's very first // version and only surfaced once another feature (the "Complete // Payment" button, in class-esm-payment.php) copied the same // wrong pattern and made it visibly hit a dead end. $b = isset( $booking->checkin_token ) ? $booking : SEATT_DB::get_booking( $booking->id ); return add_query_arg( SEATT_DB::receipt_args( $b ), $base ); } }