transport_result; } private AppointmentsRepository $appointments; private LocationsRepository $locations; public function __construct() { $this->appointments = new AppointmentsRepository(); $this->locations = new LocationsRepository(); } /** * @return array */ public static function template_labels(): array { return array( 'customer_confirmation' => __( 'Customer confirmation', 'abientot' ), 'customer_confirmed' => __( 'Customer booking confirmed', 'abientot' ), 'admin_new_booking' => __( 'Admin new booking', 'abientot' ), 'customer_cancellation' => __( 'Customer cancellation', 'abientot' ), 'customer_rescheduling' => __( 'Customer rescheduling', 'abientot' ), 'customer_completed' => __( 'Customer completed booking', 'abientot' ), ); } /** * @return array */ public static function template_subject_defaults(): array { return array( 'customer_confirmation' => __( 'Your booking with {business_name}', 'abientot' ), 'customer_confirmed' => __( 'Your booking is confirmed', 'abientot' ), 'admin_new_booking' => __( 'New booking: {service_name} with {customer_name}', 'abientot' ), 'customer_cancellation' => __( 'Your booking has been cancelled', 'abientot' ), 'customer_rescheduling' => __( 'Your booking has been rescheduled', 'abientot' ), 'customer_completed' => __( 'Thank you for your booking', 'abientot' ), ); } /** * @return array */ public static function template_body_defaults(): array { return array( 'customer_confirmation' => self::html_template( __( 'Booking request received', 'abientot' ), '

' . __( 'Hello {customer_name},', 'abientot' ) . '

' . __( 'Thank you for your booking request. Here are the details we received:', 'abientot' ) . '

' . self::details_table( array( __( 'Service', 'abientot' ) => '{service_name}', __( 'When', 'abientot' ) => '{start}', __( 'Location', 'abientot' ) => '{location_name}', __( 'Status', 'abientot' ) => '{status}' ) ) . '

{cancellation_contact}

{rescheduling_contact}

' ), 'customer_confirmed' => self::html_template( __( 'Booking confirmed', 'abientot' ), '

' . __( 'Hello {customer_name},', 'abientot' ) . '

' . __( 'Your booking has been confirmed. We look forward to seeing you.', 'abientot' ) . '

' . self::details_table( array( __( 'Service', 'abientot' ) => '{service_name}', __( 'When', 'abientot' ) => '{start}', __( 'Location', 'abientot' ) => '{location_name}', __( 'Status', 'abientot' ) => '{status}' ) ) . '

{cancellation_contact}

{rescheduling_contact}

' ), 'admin_new_booking' => self::html_template( __( 'New booking received', 'abientot' ), '

' . __( 'A new booking has been created.', 'abientot' ) . '

' . self::details_table( array( __( 'Customer', 'abientot' ) => '{customer_name}', __( 'Email', 'abientot' ) => '{customer_email}', __( 'Phone', 'abientot' ) => '{customer_phone}', __( 'Service', 'abientot' ) => '{service_name}', __( 'When', 'abientot' ) => '{start}', __( 'Status', 'abientot' ) => '{status}', __( 'Payment', 'abientot' ) => '{payment_status}', __( 'Booking ID', 'abientot' ) => '{booking_id}' ) ) ), 'customer_cancellation' => self::html_template( __( 'Booking cancelled', 'abientot' ), '

' . __( 'Hello {customer_name},', 'abientot' ) . '

' . __( 'Your booking has been cancelled.', 'abientot' ) . '

' . self::details_table( array( __( 'Service', 'abientot' ) => '{service_name}', __( 'When', 'abientot' ) => '{start}', __( 'Location', 'abientot' ) => '{location_name}' ) ) ), 'customer_rescheduling' => self::html_template( __( 'Booking rescheduled', 'abientot' ), '

' . __( 'Hello {customer_name},', 'abientot' ) . '

' . __( 'Your booking has been rescheduled. Your updated details are below.', 'abientot' ) . '

' . self::details_table( array( __( 'Service', 'abientot' ) => '{service_name}', __( 'New time', 'abientot' ) => '{start}', __( 'Location', 'abientot' ) => '{location_name}' ) ) ), 'customer_completed' => self::html_template( __( 'Thank you for your visit', 'abientot' ), '

' . __( 'Hello {customer_name},', 'abientot' ) . '

' . __( 'Thank you for visiting {business_name}. We hope to see you again.', 'abientot' ) . '

' . self::details_table( array( __( 'Service', 'abientot' ) => '{service_name}', __( 'When', 'abientot' ) => '{start}' ) ) ), ); } /** * Previous plain-text defaults used to upgrade only untouched saved templates. * * @return array */ public static function legacy_template_body_defaults(): array { return array( 'customer_confirmation' => "Hello {customer_name},\n\nYour booking request has been received.\n\nService: {service_name}\nWhen: {start}\nLocation: {location_name}\n\n{cancellation_contact}\n{rescheduling_contact}\n\nThank you,\n{business_name}", 'customer_confirmed' => "Hello {customer_name},\n\nYour booking has been confirmed.\n\nService: {service_name}\nWhen: {start}\nLocation: {location_name}\n\n{cancellation_contact}\n{rescheduling_contact}\n\nThank you,\n{business_name}", 'admin_new_booking' => "A new booking has been created.\n\nCustomer: {customer_name}\nEmail: {customer_email}\nPhone: {customer_phone}\nService: {service_name}\nWhen: {start}\nStatus: {status}\nPayment: {payment_status}\n\nBooking ID: {booking_id}", 'customer_cancellation' => "Hello {customer_name},\n\nYour booking has been cancelled.\n\nService: {service_name}\nWhen: {start}\n\n{business_name}", 'customer_rescheduling' => "Hello {customer_name},\n\nYour booking has been rescheduled.\n\nService: {service_name}\nNew time: {start}\nLocation: {location_name}\n\n{business_name}", 'customer_completed' => "Hello {customer_name},\n\nThank you for visiting {business_name}.\n\nService: {service_name}\nWhen: {start}", ); } /** * True when two saved templates are the same markup. Saving the email settings unchanged * lets the editor reorder attributes, respace styles, add and line breaks; such a * template is still the plugin default. */ public static function same_template( string $a, string $b ): bool { if ( $a === $b ) { return true; } // The 1.0.x credit line is never sent as saved (see with_credit()), so it doesn't count. $canonical = self::canonical_template( self::without_saved_credit( $a ) ); return null !== $canonical && $canonical === self::canonical_template( self::without_saved_credit( $b ) ); } private static function canonical_template( string $html ): ?string { if ( ! class_exists( '\DOMDocument' ) ) { return null; } $document = new \DOMDocument(); $previous = libxml_use_internal_errors( true ); $loaded = $document->loadHTML( '
' . $html . '
', LIBXML_NONET ); libxml_clear_errors(); libxml_use_internal_errors( $previous ); $root = $loaded ? $document->getElementsByTagName( 'div' )->item( 0 ) : null; return $root ? self::canonical_node( $root ) : null; } private static function canonical_node( \DOMNode $node ): string { if ( XML_TEXT_NODE === $node->nodeType ) { return trim( (string) preg_replace( '/[\s\x{00A0}]+/u', ' ', (string) $node->nodeValue ) ); } if ( XML_ELEMENT_NODE !== $node->nodeType ) { return ''; } $children = ''; foreach ( $node->childNodes as $child ) { $part = self::canonical_node( $child ); $children .= '' === $part ? '' : $part . "\n"; } $tag = strtolower( $node->nodeName ); if ( 'tbody' === $tag ) { return rtrim( $children ); } $attributes = array(); foreach ( $node->attributes as $attribute ) { $name = strtolower( $attribute->name ); $attributes[ $name ] = 'style' === $name ? self::canonical_style( $attribute->value ) : trim( $attribute->value ); } ksort( $attributes ); return '<' . $tag . ' ' . wp_json_encode( $attributes ) . ">\n" . $children . ''; } private static function canonical_style( string $style ): string { $rules = array(); foreach ( explode( ';', $style ) as $rule ) { $pair = explode( ':', $rule, 2 ); if ( 2 === count( $pair ) && '' !== trim( $pair[0] ) ) { $rules[] = strtolower( trim( $pair[0] ) ) . ':' . preg_replace( '/\s+/', ' ', trim( $pair[1] ) ); } } return implode( ';', $rules ); } private static function html_template( string $heading, string $content ): string { return '
{business_name}

' . $heading . '

' . $content . '
'; } /** * @param array $rows Detail labels and values. */ private static function details_table( array $rows ): string { $html = ''; foreach ( $rows as $label => $value ) { $html .= ''; } return $html . '
' . $label . '' . $value . '
'; } /** * Sends customer and admin creation notifications for a booking. */ public function send_booking_created( int $appointment_id ): void { $this->send_template_for_booking( 'customer_confirmation', $appointment_id ); $this->send_template_for_booking( 'admin_new_booking', $appointment_id ); } /** * Sends the customer notification matching a booking lifecycle event. */ public function send_booking_event( int $appointment_id, string $event, string $body_override = '' ): void { $map = array( 'confirmed' => 'customer_confirmed', 'cancelled' => 'customer_cancellation', 'rescheduled' => 'customer_rescheduling', 'completed' => 'customer_completed', ); if ( isset( $map[ $event ] ) ) { $this->send_template_for_booking( $map[ $event ], $appointment_id, $body_override ); } } /** * Returns the customer notification preview for a booking event. * * @return array{subject:string,body:string} */ public function preview_booking_event( int $appointment_id, string $event, string $start_utc = '', string $end_utc = '', string $body_override = '' ): array { $map = array( 'confirmed' => 'customer_confirmed', 'cancelled' => 'customer_cancellation', 'rescheduled' => 'customer_rescheduling', 'completed' => 'customer_completed', ); if ( ! isset( $map[ $event ] ) ) { return array( 'subject' => '', 'body' => '' ); } $booking = $this->appointments->find_for_admin( $appointment_id ); if ( ! $booking ) { return array( 'subject' => '', 'body' => '' ); } if ( '' !== $start_utc ) { $booking['start_utc'] = $start_utc; } if ( '' !== $end_utc ) { $booking['end_utc'] = $end_utc; } $template_key = $map[ $event ]; $variables = $this->variables_for_booking( $booking ); $body = '' !== $body_override ? $body_override : (string) $this->setting( 'email_' . $template_key . '_body' ); return array( 'subject' => $this->render_template( $this->setting( 'email_' . $template_key . '_subject' ), $variables ), 'body' => (string) apply_filters( 'abientot_email_booking_body', $this->render_template( $body, $variables ), $booking, $template_key ), ); } /** * Returns a rendered preview for settings UI. * * @return array{subject:string,body:string} */ public function preview( string $template_key, int $appointment_id = 0 ): array { $booking = $appointment_id > 0 ? $this->appointments->find_for_admin( $appointment_id ) : null; $variables = $booking ? $this->variables_for_booking( $booking ) : $this->sample_variables(); return array( 'subject' => $this->render_template( $this->setting( 'email_' . $template_key . '_subject' ), $variables ), 'body' => $this->render_template( $this->setting( 'email_' . $template_key . '_body' ), $variables ), ); } /** * Sends a sample test email. */ public function send_test( string $template_key, string $recipient, int $appointment_id = 0, ?string $subject_override = null, ?string $body_override = null ) { if ( ! is_email( $recipient ) || ! isset( self::template_labels()[ $template_key ] ) ) { return new WP_Error( 'abientot_test_email_invalid', __( 'Choose a valid template and recipient.', 'abientot' ) ); } $preview = $this->preview( $template_key, $appointment_id ); if ( null !== $subject_override || null !== $body_override ) { $booking = $appointment_id > 0 ? $this->appointments->find_for_admin( $appointment_id ) : null; $variables = $booking ? $this->variables_for_booking( $booking ) : $this->sample_variables(); if ( null !== $subject_override ) { $preview['subject'] = $this->render_template( $subject_override, $variables ); } if ( null !== $body_override ) { $preview['body'] = $this->render_template( $body_override, $variables ); } } $sent = $this->send( $recipient, sprintf( /* translators: %s: email subject. */ __( '[Test] %s', 'abientot' ), $preview['subject'] ), $preview['body'], array( 'appointment_id' => $appointment_id, 'template' => 'test', 'recipient_role' => 'test' ) ); return $sent ? true : new WP_Error( 'abientot_test_email_failed', __( 'WordPress could not send the test email.', 'abientot' ) ); } /** * Returns safe sample values for live admin template previews. * * @return array */ public function sample_template_variables(): array { return $this->sample_variables(); } /** Sends an explicitly authorized custom customer template; transport exceptions remain observable. */ public function send_custom_booking( int $appointment_id, string $subject, string $body, array $context = array() ): bool { $this->transport_result = null; $booking = $this->appointments->find_for_admin( $appointment_id ); if ( ! $booking || ! is_email( (string) $booking['customer_email'] ) ) { return false; } // Customer messages use the language the visitor booked in (WPML/Polylang); the text itself is the // caller's, except the reminder template, which is translated like the booking emails. $reminder_defaults = 'reminder' === ( $context['template'] ?? '' ) ? \Abientot\Reminders\Settings::defaults() : null; return (bool) MultilingualText::for_booking( $booking, function () use ( $booking, $appointment_id, $subject, $body, $context, $reminder_defaults ): bool { $booking = $this->booking_in_language( $booking, $appointment_id ); $variables = $this->variables_for_booking( $booking ); $template = in_array( $context['template'] ?? '', array( 'custom', 'reminder', 'service_preparation', 'service_followup' ), true ) ? $context['template'] : 'custom'; $rendered = ( $context['rendered'] ?? false ) === true; if ( null !== $reminder_defaults && ! $rendered && MultilingualText::forced() ) { $localized = \Abientot\Reminders\Settings::defaults(); $subject = $subject === $reminder_defaults['subject'] ? $localized['subject'] : MultilingualText::translate( $subject, 'reminder-subject' ); $body = $body === $reminder_defaults['body'] ? $localized['body'] : MultilingualText::translate( $body, 'reminder-body' ); } $renderedBody = $rendered ? $body : $this->render_template( $body, $variables ); if ( $template === 'reminder' ) { $renderedBody = (string) apply_filters( 'abientot_email_booking_body', $renderedBody, $booking, $template ); } return $this->send( (string) $booking['customer_email'], $rendered ? $subject : $this->render_template( $subject, $variables ), $renderedBody, array( 'appointment_id' => $appointment_id, 'template' => $template, 'recipient_role' => 'customer' ) ); } ); } /** * Re-reads the booking while a booking language is forced, so the status name follows that * language; otherwise returns it unchanged. * * @param array $booking Booking row. * @return array */ private function booking_in_language( array $booking, int $appointment_id ): array { if ( ! MultilingualText::forced() ) { return $booking; } $fresh = $this->appointments->find_for_admin( $appointment_id ); $fresh = is_array( $fresh ) ? $fresh : $booking; if ( isset( $fresh['status_label'] ) ) { $fresh['status_label'] = MultilingualText::status_label( (string) ( $fresh['status_slug'] ?? '' ), (string) $fresh['status_label'] ); } return $fresh; } /** * Template text for a customer email in the booking's language: a text left at the plugin * default is the default in that language; a text the business wrote is its WPML/Polylang * translation (string "email-