booking_repo = $booking_repo ?: new BookingRepository(); add_action( 'cleannetic_booking_created', [ $this, 'on_booking_created' ], 10, 2 ); add_action( 'cleannetic_booking_status_changed', [ $this, 'on_status_changed' ], 10, 3 ); } /** * Lets callers (e.g. the demo importer) mute booking emails. */ private function emails_enabled(): bool { return (bool) apply_filters( 'cleannetic_send_booking_emails', true ); } public function on_booking_created( int $booking_id, ?array $booking = null ): void { if ( ! $this->emails_enabled() ) { return; } $booking = $booking ?: $this->booking_repo->find( $booking_id ); if ( ! $booking ) { return; } if ( Settings::get( 'send_customer_email', true ) && ! empty( $booking['customer_email'] ) ) { $this->send_customer_confirmation( $booking ); } if ( Settings::get( 'send_admin_email', true ) ) { $this->send_admin_alert( $booking ); } } public function on_status_changed( int $booking_id, string $new_status, string $old_status ): void { if ( ! $this->emails_enabled() || ! Settings::get( 'send_status_emails', true ) ) { return; } $booking = $this->booking_repo->find( $booking_id ); if ( ! $booking || empty( $booking['customer_email'] ) ) { return; } $business_name = Settings::get( 'business_name', get_bloginfo( 'name' ) ); $status_label = BookingStatus::label( $new_status ); $subject = sprintf( /* translators: 1: status, 2: booking ref, 3: business name */ __( 'Booking %1$s — %2$s | %3$s', 'cleannetic' ), $status_label, $booking['booking_ref'], $business_name ); $body = $this->render_email_layout( /* translators: %s: booking status */ sprintf( __( 'Booking Status Updated: %s', 'cleannetic' ), $status_label ), sprintf( /* translators: 1: customer name, 2: booking ref, 3: service name, 4: booking status */ __( 'Hello %1$s, your booking %2$s for %3$s has been marked as %4$s.', 'cleannetic' ), esc_html( $booking['customer_name'] ), esc_html( $booking['booking_ref'] ), esc_html( $booking['service_name'] ), esc_html( $status_label ) ), $booking ); $this->send( $booking['customer_email'], $subject, $body ); } /** * Replaces the merge tags offered in Settings → Email Notifications. * * @param bool $for_html Escape values when the result is rendered as HTML. */ private function replace_merge_tags( string $template, array $booking, bool $for_html = false ): string { $address = implode( ', ', array_filter( [ $booking['address_data']['street'] ?? '', $booking['address_data']['city'] ?? '', $booking['address_data']['zip'] ?? '' ] ) ); $values = [ '{customer_name}' => (string) $booking['customer_name'], '{service_name}' => (string) $booking['service_name'], '{booking_ref}' => (string) $booking['booking_ref'], '{start_time}' => Formatter::datetime( $booking['start_datetime'] ), '{total_price}' => Formatter::price( (float) $booking['total_amount'] ), '{customer_address}' => $address, '{business_name}' => (string) Settings::get( 'business_name', get_bloginfo( 'name' ) ), ]; if ( $for_html ) { $values = array_map( 'esc_html', $values ); } return strtr( $template, $values ); } private function is_quote( array $booking ): bool { return BookingStatus::QUOTE_REQUESTED === $booking['status']; } public function send_customer_confirmation( array $booking ): void { if ( $this->is_quote( $booking ) ) { /* translators: %s: booking ref */ $subject = sprintf( __( 'We received your quote request — %s', 'cleannetic' ), $booking['booking_ref'] ); $heading = __( 'Quote request received', 'cleannetic' ); $intro = $this->replace_merge_tags( __( 'Thanks for contacting {business_name}. We will review your request and get back to you shortly with a final price.', 'cleannetic' ), $booking, true ); $this->send( $booking['customer_email'], $subject, $this->render_email_layout( $heading, $intro, $booking ) ); return; } $subject_tpl = (string) Settings::get( 'customer_email_subject', __( 'Your cleaning is booked — {booking_ref}', 'cleannetic' ) ); $subject = $this->replace_merge_tags( $subject_tpl, $booking ); $heading = $this->replace_merge_tags( (string) Settings::get( 'customer_email_heading', __( 'You are all set!', 'cleannetic' ) ), $booking ); $intro = (string) Settings::get( 'customer_email_intro', __( 'Thanks for booking with us. Here are your appointment details.', 'cleannetic' ) ); $intro = nl2br( $this->replace_merge_tags( esc_html( $intro ), $booking, true ) ); $body = $this->render_email_layout( $heading, $intro, $booking ); // Generate .ics attachment. $attachments = []; $ics_path = $this->generate_ics_file( $booking ); if ( $ics_path && file_exists( $ics_path ) ) { $attachments[] = $ics_path; } $this->send( $booking['customer_email'], $subject, $body, $attachments ); if ( $ics_path && file_exists( $ics_path ) ) { wp_delete_file( $ics_path ); } } public function send_admin_alert( array $booking ): void { $admin_email = (string) Settings::get( 'admin_notification_email', get_option( 'admin_email' ) ); if ( empty( $admin_email ) || ! is_email( $admin_email ) ) { return; } $subject_tpl = (string) Settings::get( 'admin_email_subject', __( 'New booking {booking_ref} — {service_name}', 'cleannetic' ) ); $subject = $this->replace_merge_tags( $subject_tpl, $booking ); if ( $this->is_quote( $booking ) ) { /* translators: %s: booking ref */ $heading = sprintf( __( 'New Quote Request (#%s)', 'cleannetic' ), $booking['booking_ref'] ); /* translators: %s: customer name */ $intro = sprintf( __( '%s has requested a quote. Review the details and reply with a price.', 'cleannetic' ), esc_html( $booking['customer_name'] ) ); } else { /* translators: %s: booking ref */ $heading = sprintf( __( 'New Booking Received (#%s)', 'cleannetic' ), $booking['booking_ref'] ); $intro = 'admin' === ( $booking['source'] ?? '' ) /* translators: %s: customer name */ ? sprintf( __( 'A new booking was created in the dashboard for %s.', 'cleannetic' ), esc_html( $booking['customer_name'] ) ) /* translators: %s: customer name */ : sprintf( __( 'A new cleaning booking has been placed online by %s.', 'cleannetic' ), esc_html( $booking['customer_name'] ) ); } $body = $this->render_email_layout( $heading, $intro, $booking, true ); $this->send( $admin_email, $subject, $body ); } private function render_email_layout( string $heading, string $intro, array $booking, bool $is_admin = false ): string { $business_name = (string) Settings::get( 'business_name', get_bloginfo( 'name' ) ); $accent_color = (string) Settings::get( 'accent_color', '#0D9488' ); $formatted_price = Formatter::price( (float) $booking['total_amount'] ); $formatted_date = Formatter::datetime( $booking['start_datetime'] ); $address_lines = []; if ( ! empty( $booking['address_data']['street'] ) ) { $address_lines[] = esc_html( $booking['address_data']['street'] ); } if ( ! empty( $booking['address_data']['city'] ) || ! empty( $booking['address_data']['zip'] ) ) { $address_lines[] = esc_html( trim( ( $booking['address_data']['city'] ?? '' ) . ' ' . ( $booking['address_data']['zip'] ?? '' ) ) ); } $address_html = ! empty( $address_lines ) ? implode( '
', $address_lines ) : esc_html__( 'Address on file', 'cleannetic' ); if ( ! empty( $booking['address_data']['gate_code'] ) ) { /* translators: %s: gate or access code. */ $address_html .= '
' . sprintf( esc_html__( 'Gate / Access Code: %s', 'cleannetic' ), esc_html( $booking['address_data']['gate_code'] ) ) . ''; } ob_start(); ?> <?php echo esc_html( $heading ); ?>

is_quote( $booking ) ? esc_html__( 'Preferred Date & Time', 'cleannetic' ) : esc_html__( 'Date & Time', 'cleannetic' ); ?> ()

©

escape_ics( $summary ) . "\r\n" . /* translators: 1: business name, 2: booking ref */ "DESCRIPTION:" . $this->escape_ics( sprintf( __( 'Cleaning appointment booked with %1$s. Reference: %2$s', 'cleannetic' ), $business_name, $booking['booking_ref'] ) ) . "\r\n" . "LOCATION:" . $this->escape_ics( $location ) . "\r\n" . 'STATUS:' . ( BookingStatus::PENDING === $booking['status'] ? 'TENTATIVE' : 'CONFIRMED' ) . "\r\n" . "END:VEVENT\r\n" . "END:VCALENDAR\r\n"; $temp_dir = wp_upload_dir()['basedir'] . '/cleannetic-temp'; if ( ! file_exists( $temp_dir ) ) { wp_mkdir_p( $temp_dir ); } $file_path = $temp_dir . '/' . sanitize_file_name( $booking['booking_ref'] . '.ics' ); file_put_contents( $file_path, $ics_content ); return $file_path; } private function escape_ics( string $text ): string { return str_replace( [ '\\', ',', ';', "\r\n", "\n" ], [ '\\\\', '\,', '\;', '\n', '\n' ], $text ); } private function send( string $to, string $subject, string $html_body, array $attachments = [] ): bool { $headers = [ 'Content-Type: text/html; charset=UTF-8', 'From: "' . str_replace( [ '"', "\r", "\n" ], '', (string) Settings::get( 'business_name', get_bloginfo( 'name' ) ) ) . '" <' . Settings::get( 'business_email', get_option( 'admin_email' ) ) . '>', ]; return (bool) wp_mail( $to, $subject, $html_body, $headers, $attachments ); } }