'text', 'nonce' => 'text', 'abientot_form_id' => 'text', 'service_id' => 'text', 'staff_id' => 'text', 'location_id' => 'text', 'date' => 'text', 'date_from' => 'text', 'days_from' => 'text', 'calendar_month' => 'text', 'next_available' => 'text', 'timezone' => 'text', 'start_local' => 'text', 'discovery_choice' => 'text', 'booking_fields' => 'textarea', 'booking_phone_country' => 'text', 'policy_accepted' => 'text', 'policy_version' => 'text', 'resource_category' => 'text', 'resource_choice_id' => 'text', 'resource_quote_currency' => 'text', 'resource_quote_cents' => 'text', 'resource_quote_minutes' => 'text', 'party_size' => 'text', 'variant_id' => 'text', 'extras' => 'text', 'answers' => 'textarea', 'selection_config_digest' => 'text', 'selection_definition_version' => 'text', 'credit_token' => 'text', 'credit_grant_id' => 'text', 'credit_use_package' => 'text', 'credit_submission_uuid' => 'text', 'recurrence_count' => 'text', 'recurrence_interval_weeks' => 'text', 'recurrence_submission_uuid' => 'text', ); private AvailabilityService $availability; private AppointmentsRepository $appointments; private CustomersRepository $customers; private ServicesRepository $services; private StaffRepository $staff; private LocationsRepository $locations; private StatusesRepository $statuses; private PaymentRecordsRepository $payments; private ActivityLogRepository $activity; private EmailService $emails; private bool $owns_booking_lock = false; /** Availability session issued or verified during this request; later calls must see the same value. */ private static ?string $availability_session_value = null; /** Booking form of the request being handled (0 = main form). */ private int $form_id = 0; /** Fields of the booking request being handled, set after its nonce was verified. */ private ?Input $input = null; public function __construct() { $this->availability = new AvailabilityService(); $this->appointments = new AppointmentsRepository(); $this->customers = new CustomersRepository(); $this->services = new ServicesRepository(); $this->staff = new StaffRepository(); $this->locations = new LocationsRepository(); $this->statuses = new StatusesRepository(); $this->payments = new PaymentRecordsRepository(); $this->activity = new ActivityLogRepository(); $this->emails = new EmailService(); } public function register(): void { add_shortcode( 'abientot_booking_form', array( $this, 'shortcode' ) ); add_action( 'init', array( $this, 'register_assets' ) ); add_action( 'init', array( $this, 'register_block' ) ); add_action( 'wp_ajax_abientot_public_slots', array( $this, 'ajax_slots' ) ); add_action( 'wp_ajax_nopriv_abientot_public_slots', array( $this, 'ajax_slots' ) ); add_action( 'wp_ajax_abientot_public_book', array( $this, 'ajax_book' ) ); add_action( 'wp_ajax_nopriv_abientot_public_book', array( $this, 'ajax_book' ) ); add_action( 'wp_ajax_abientot_public_nonce', array( $this, 'ajax_nonce' ) ); add_action( 'wp_ajax_nopriv_abientot_public_nonce', array( $this, 'ajax_nonce' ) ); } public function register_assets(): void { $style_path = ABIENTOT_PLUGIN_DIR . 'assets/frontend/booking-form.css'; $script_path = ABIENTOT_PLUGIN_DIR . 'assets/frontend/booking-form.js'; $style_version = file_exists( $style_path ) ? (string) filemtime( $style_path ) : ABIENTOT_VERSION; $script_version = file_exists( $script_path ) ? (string) filemtime( $script_path ) : ABIENTOT_VERSION; wp_register_style( 'abientot-frontend', ABIENTOT_PLUGIN_URL . 'assets/frontend/booking-form.css', array(), $style_version ); wp_register_style( 'abientot-journey', ABIENTOT_PLUGIN_URL . 'assets/frontend/journey.css', array( 'abientot-frontend' ), (string) filemtime( ABIENTOT_PLUGIN_DIR . 'assets/frontend/journey.css' ) ); wp_register_style( 'abientot-compact', ABIENTOT_PLUGIN_URL . 'assets/frontend/compact.css', array( 'abientot-journey' ), (string) filemtime( ABIENTOT_PLUGIN_DIR . 'assets/frontend/compact.css' ) ); wp_register_script( 'abientot-contrast', ABIENTOT_PLUGIN_URL . 'assets/frontend/contrast.js', array(), (string) filemtime( ABIENTOT_PLUGIN_DIR . 'assets/frontend/contrast.js' ), true ); wp_register_script( 'abientot-frontend', ABIENTOT_PLUGIN_URL . 'assets/frontend/booking-form.js', array( 'abientot-localization', 'wp-i18n', 'abientot-contrast' ), $script_version, true ); wp_register_script( 'abientot-block', ABIENTOT_PLUGIN_URL . 'assets/block/booking-form-block.js', array( 'wp-blocks', 'wp-element', 'wp-i18n', 'wp-server-side-render', 'wp-block-editor', 'wp-components' ), (string) filemtime( ABIENTOT_PLUGIN_DIR . 'assets/block/booking-form-block.js' ), true ); if ( is_admin() ) { $block_forms = array(); foreach ( \Abientot\Support\BookingForms::names() as $id => $name ) { $block_forms[] = array( 'value' => $id, 'label' => $name ); } wp_add_inline_script( 'abientot-block', 'window.abientotBlockForms=' . wp_json_encode( array( 'forms' => $block_forms ) ) . ';', 'before' ); } wp_set_script_translations( 'abientot-frontend', 'abientot', \Abientot\Support\Translations::bundled_path() ); wp_set_script_translations( 'abientot-block', 'abientot', \Abientot\Support\Translations::bundled_path() ); } public function register_block(): void { if ( ! function_exists( 'register_block_type' ) ) { return; } register_block_type( 'abientot/booking-form', array( 'api_version' => 2, 'title' => __( 'Abientot Booking Form', 'abientot' ), 'category' => 'widgets', 'icon' => 'calendar-alt', 'description' => __( 'Display the public Abientot booking form.', 'abientot' ), 'editor_script' => 'abientot-block', 'attributes' => array( 'formId' => array( 'type' => 'number', 'default' => 0 ), 'mode' => array( 'type' => 'string', 'default' => '' ), ), 'render_callback' => array( $this, 'render_block' ), ) ); } /** * @param array $atts Shortcode attributes. */ public function shortcode( array $atts = array() ): string { $atts = shortcode_atts( array( 'mode' => '', 'form' => '', ), $atts, 'abientot_booking_form' ); return $this->render_form( sanitize_key( (string) $atts['mode'] ), \Abientot\Support\BookingForms::resolve( $atts['form'] ) ); } /** * @param array $attributes Block attributes. */ public function render_block( array $attributes = array() ): string { $mode = isset( $attributes['mode'] ) ? sanitize_key( (string) $attributes['mode'] ) : ''; return $this->render_form( $mode, \Abientot\Support\BookingForms::resolve( $attributes['formId'] ?? 0 ) ); } /** * Hands out a fresh public booking nonce, uncached, so a form served from an aged page * cache can still check availability and book. It reads no input and changes nothing: * the token is the same one any visitor gets by loading the page. */ public function ajax_nonce(): void { nocache_headers(); if ( ! $this->rate_limit( 'nonce', 30, MINUTE_IN_SECONDS ) ) { wp_send_json_error( array( 'message' => __( 'Too many availability requests. Please wait a minute, then retry.', 'abientot' ) ), 429 ); } wp_send_json_success( array( 'nonce' => wp_create_nonce( 'abientot_public_booking' ), 'time' => time(), ) ); } public function ajax_slots(): void { check_ajax_referer( 'abientot_public_booking', 'nonce' ); try { $this->checked_public_slots(); } catch ( \Exception $error ) { wp_send_json_error( array( 'message' => __( 'Availability is temporarily unavailable. Please try again.', 'abientot' ) ), 503 ); } } private function checked_public_slots(): void { check_ajax_referer( 'abientot_public_booking', 'nonce' ); $this->input = Input::post( 'abientot_public_booking', 'nonce' ); if ( ! $this->rate_limit( 'availability_browser', 60, MINUTE_IN_SECONDS, $this->availability_session() ) || ! $this->rate_limit( 'availability_network', 600, MINUTE_IN_SECONDS ) ) { wp_send_json_error( array( 'message' => __( 'Too many availability requests. Please wait a minute, then retry.', 'abientot' ) ), 429 ); } $service_id = $this->input()->int( 'service_id' ); $date = $this->input()->text( 'date' ); if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) || ! checkdate( (int) substr( $date, 5, 2 ), (int) substr( $date, 8, 2 ), (int) substr( $date, 0, 4 ) ) ) { wp_send_json_error( array( 'message' => __( 'Choose a valid date.', 'abientot' ) ), 400 ); } $service = $this->public_service( $service_id ); $service_id = (int) ( $service['id'] ?? 0 ); if ( ! $service ) { wp_send_json_error( array( 'message' => __( 'Choose a valid service.', 'abientot' ) ), 400 ); } $staff = \Abientot\Support\BookingStaff::resolve( 'public' ); $location = \Abientot\Support\BookingLocation::resolve( 'public' ); $records = apply_filters( 'abientot_public_booking_records', array( 'staff' => $staff, 'location' => $location ), $service, $this->input()->fields( self::REQUEST_FIELDS ) ); $staff = $records['staff']; $location = $records['location']; if ( (int) $location['id'] <= 0 ) { wp_send_json_error( array( 'message' => __( 'Choose an available location.', 'abientot' ) ), 400 ); } if ( (int) $staff['id'] <= 0 && ! $this->resource_only_service( $service ) ) { wp_send_json_error( array( 'message' => __( 'Choose an available staff member.', 'abientot' ) ), 400 ); } try { $quote_input = $this->input()->fields( self::REQUEST_FIELDS ); $quote_input['action'] = 'abientot_public_slots'; $service = \Abientot\Support\BookingQuote::service( $service, (int) $location['id'], $quote_input ); } catch ( \Throwable $error ) { wp_send_json_error( array( 'message' => __( 'The booking details could not be accepted.', 'abientot' ) ), 400 ); } if ( $this->input()->filled( 'calendar_month' ) ) { $first = ( new DateTimeImmutable( $date, DateTimeHelper::utc_timezone() ) )->modify( 'first day of this month' ); $last = $first->modify( 'last day of this month' ); $days = array(); for ( $day = $first; $day <= $last; $day = $day->modify( '+1 day' ) ) { $days[ $day->format( 'Y-m-d' ) ] = false; } $month_slots = $this->availability->generate_slots_or_throw( array( 'service_id' => $service_id, 'duration_minutes' => (int) $service['duration_minutes'], 'booking_quote' => $service['_booking_quote'] ?? null, 'booking_context' => $service['_booking_context'] ?? null, 'staff_id' => (int) $staff['id'], 'location_id' => (int) $location['id'], 'date_from' => $first->format( 'Y-m-d' ), 'date_to' => $last->format( 'Y-m-d' ), ) ); foreach ( $month_slots as $slot ) { $key = substr( (string) $slot['start_local'], 0, 10 ); if ( array_key_exists( $key, $days ) ) { $days[ $key ] = true; } } wp_send_json_success( array( 'days' => $days ) ); } $search_next = $this->input()->filled( 'next_available' ); $end_date = $search_next ? ( new DateTimeImmutable( $date, DateTimeHelper::utc_timezone() ) )->modify( '+30 days' )->format( 'Y-m-d' ) : $date; $slots = $this->availability->generate_slots_or_throw( array( 'service_id' => $service_id, 'duration_minutes' => (int) $service['duration_minutes'], 'booking_quote' => $service['_booking_quote'] ?? null, 'booking_context' => $service['_booking_context'] ?? null, 'staff_id' => (int) $staff['id'], 'location_id' => (int) $location['id'], 'date_from' => $date, 'date_to' => $end_date, 'first_available_day' => $search_next, ) ); // Return availability for the seven visible days, using the same rules as slots. $days = array(); $days_from = $this->input()->text( 'days_from' ); if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $days_from ) && checkdate( (int) substr( $days_from, 5, 2 ), (int) substr( $days_from, 8, 2 ), (int) substr( $days_from, 0, 4 ) ) ) { $first = new DateTimeImmutable( $days_from, DateTimeHelper::utc_timezone() ); $last = $first->modify( '+6 days' ); if ( $search_next && $slots ) { $found = new DateTimeImmutable( substr( (string) $slots[0]['start_local'], 0, 10 ), DateTimeHelper::utc_timezone() ); $week_start = \Abientot\Support\Locale::week_start_date( $found ); $first = $week_start > $first ? $week_start : $first; $last = $first->modify( '+6 days' ); } // A normal request may inspect only the week containing its requested date. if ( ( ! $search_next && $days_from <= $date && $date <= $last->format( 'Y-m-d' ) ) || ( $search_next && $slots ) ) { for ( $day = $first; $day <= $last; $day = $day->modify( '+1 day' ) ) { $days[ $day->format( 'Y-m-d' ) ] = false; } $week_slots = $this->availability->generate_slots_or_throw( array( 'service_id' => $service_id, 'duration_minutes' => (int) $service['duration_minutes'], 'booking_quote' => $service['_booking_quote'] ?? null, 'booking_context' => $service['_booking_context'] ?? null, 'staff_id' => (int) $staff['id'], 'location_id' => (int) $location['id'], 'date_from' => $first->format( 'Y-m-d' ), 'date_to' => $last->format( 'Y-m-d' ), ) ); foreach ( $week_slots as $slot ) { $day_key = substr( (string) $slot['start_local'], 0, 10 ); if ( array_key_exists( $day_key, $days ) ) { $days[ $day_key ] = true; } } } } $payload = array( 'date' => $slots ? substr( (string) $slots[0]['start_local'], 0, 10 ) : $date, 'searchedUntil' => $end_date, 'quote' => array( 'price_cents' => (int) $service['price_cents'], 'duration_minutes' => (int) $service['duration_minutes'], 'currency' => (string) $service['currency'] ), 'days' => $days, 'slots' => array_map( function ( array $slot ): array { return array( 'startLocal' => str_replace( ' ', 'T', substr( (string) $slot['start_local'], 0, 16 ) ), 'endLocal' => substr( (string) $slot['end_local'], 11, 5 ), 'label' => DateTimeHelper::format_local_time( (string) $slot['start_local'] ), 'quote' => $slot['quote'] ?? null, 'resourceId' => (int) ( $slot['resource_choice']['resource_id'] ?? 0 ), ); }, $slots ), ); wp_send_json_success( $payload ); } public function ajax_book(): void { check_ajax_referer( 'abientot_public_booking', 'nonce' ); $this->input = Input::post( 'abientot_public_booking', 'nonce' ); if ( ! $this->rate_limit( 'book', 10, 10 * MINUTE_IN_SECONDS ) ) { wp_send_json_error( array( 'message' => __( 'Too many booking attempts. Please wait a few minutes and try again.', 'abientot' ) ), 429 ); } if ( $this->input()->filled( 'website' ) ) { wp_send_json_error( array( 'message' => __( 'The booking could not be accepted.', 'abientot' ) ), 400 ); } // Read and validate configuration only after any concurrent save completes. $lock_name = BookingLock::name(); if ( ! $this->acquire_lock( $lock_name ) ) { wp_send_json_error( array( 'code' => 'slot_unavailable', 'message' => __( 'That time is being booked. Please choose another slot.', 'abientot' ) ), 409 ); } try { $this->book_under_lock( $lock_name ); } finally { $this->release_lock( $lock_name ); } } /** Validate current configuration and reserve while holding the shared mutex. */ private function book_under_lock( string $lock_name ): void { // The submitted form decides customer fields, consent and messages; unknown ids use the main form. $this->form_id = \Abientot\Support\BookingForms::resolve( $this->input()->has( 'abientot_form_id' ) ? $this->input()->key( 'abientot_form_id' ) : 0 ); $settings = \Abientot\Support\BookingForms::settings( $this->form_id ); $service_id = $this->input()->int( 'service_id' ); $start_local = $this->normalize_start_local( $this->input()->text( 'start_local' ) ); $service = $this->public_service( $service_id ); $service_id = (int) ( $service['id'] ?? 0 ); $staff = \Abientot\Support\BookingStaff::resolve( 'public' ); $location = \Abientot\Support\BookingLocation::resolve( 'public' ); $records = apply_filters( 'abientot_public_booking_records', array( 'staff' => $staff, 'location' => $location ), $service, $this->input()->fields( self::REQUEST_FIELDS ) ); $staff = $records['staff']; $location = $records['location']; $status = $this->statuses->find_by_slug( (string) $settings['default_booking_status'] ) ?: $this->statuses->find_by_slug( 'pending' ); $choice_instant = null; try { $choice = apply_filters( 'abientot_public_booking_choice', array( 'staff' => $staff, 'location' => $location, 'start_local' => $start_local ), $service_id ); if ( ! is_array( $choice ) || ! is_array( $choice['staff'] ?? null ) || ! is_array( $choice['location'] ?? null ) || ! is_string( $choice['start_local'] ?? null ) ) { throw new \DomainException( 'booking_choice' ); } $staff = $choice['staff']; $location = $choice['location']; $start_local = $this->normalize_start_local( $choice['start_local'] ); if ( isset( $choice['start_utc'] ) ) { if ( ! is_string( $choice['start_utc'] ) ) { throw new \DomainException( 'booking_choice' ); } $choice_instant = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $choice['start_utc'], DateTimeHelper::utc_timezone() ); if ( ! $choice_instant || $choice_instant->format( 'Y-m-d H:i:s' ) !== $choice['start_utc'] ) { throw new \DomainException( 'booking_choice' ); } } } catch ( \Throwable $error ) { wp_send_json_error( array( 'message' => __( 'Choose an available time.', 'abientot' ) ), 400 ); } if ( ! $service || ! $status || ( (int) $staff['id'] <= 0 && ! $this->resource_only_service( $service ) ) || (int) $location['id'] <= 0 ) { wp_send_json_error( array( 'message' => __( 'The booking form is not fully configured yet.', 'abientot' ) ), 400 ); } if ( '' === $start_local ) { wp_send_json_error( array( 'message' => __( 'Choose an available time.', 'abientot' ) ), 400 ); } $field_values = $this->submitted_field_values( $settings ); if ( is_wp_error( $field_values ) ) { wp_send_json_error( array( 'message' => $field_values->get_error_message() ), 400 ); } $customer = $this->customer_payload( $field_values ); if ( is_wp_error( $customer ) ) { wp_send_json_error( array( 'message' => $customer->get_error_message() ), 400 ); } if ( ! empty( $settings['require_policy_checkbox'] ) ) { if ( '1' !== $this->input()->key( 'policy_accepted' ) ) { wp_send_json_error( array( 'message' => __( 'Please accept the booking terms before booking.', 'abientot' ) ), 400 ); } $policy = Notice::policy( $settings ); if ( strlen( $policy['text'] ) > 32768 || strlen( $policy['label'] ) > 4096 ) { wp_send_json_error( array( 'message' => __( 'The booking terms could not be recorded. Please contact the business.', 'abientot' ) ), 400 ); } $version = $this->input()->text( 'policy_version' ); if ( ! hash_equals( $policy['version'], $version ) ) { wp_send_json_error( array( 'message' => __( 'The booking terms have changed. Reload the form and review them before booking.', 'abientot' ) ), 409 ); } } $validation = apply_filters( 'abientot_validate_booking_submission', true, array( 'service_id' => $service_id, 'start_local' => $start_local, 'customer' => $customer, ) ); if ( is_wp_error( $validation ) ) { wp_send_json_error( array( 'message' => $validation->get_error_message() ), 400 ); } if ( false === $validation ) { wp_send_json_error( array( 'message' => __( 'The booking details could not be accepted.', 'abientot' ) ), 400 ); } try { $quote_input = $this->input()->fields( self::REQUEST_FIELDS ); $quote_input['action'] = 'abientot_public_book'; $service = \Abientot\Support\BookingQuote::service( $service, (int) $location['id'], $quote_input ); } catch ( \Throwable $error ) { $this->release_lock( $lock_name ); wp_send_json_error( array( 'message' => __( 'The booking details could not be accepted.', 'abientot' ) ), 400 ); } try { $creditIdentity = apply_filters( 'abientot_public_booking_identity', array(), $this->input()->fields( self::REQUEST_FIELDS ) ); if ( ! is_array( $creditIdentity ) ) { throw new \DomainException( 'booking_identity' ); } if ( ! $creditIdentity && ( $this->filled( 'credit_grant_id' ) || $this->filled( 'credit_token' ) || $this->filled( 'credit_use_package' ) ) ) { throw new \DomainException( 'booking_credits_unavailable' ); } } catch ( \Throwable $error ) { $this->release_lock( $lock_name ); wp_send_json_error( array( 'message' => __( 'The booking details could not be accepted.', 'abientot' ) ), 400 ); } $service['_booking_credit_identity'] = $creditIdentity['_booking_credit_identity'] ?? array(); $replay_scope = array( 'service_id' => $service_id, 'staff_id' => (int) $staff['id'], 'location_id' => (int) $location['id'], 'start_local' => $start_local, 'timezone' => (string) ( ( $staff['timezone'] ?? '' ) ?: ( ( $location['timezone'] ?? '' ) ?: $settings['business_timezone'] ) ), 'price_cents' => (int) $service['price_cents'], 'duration_minutes' => (int) $service['duration_minutes'], 'currency' => (string) $service['currency'], 'customer_fields_json' => wp_json_encode( $this->field_snapshot( $field_values, $settings ) ) ); foreach ( array( '_booking_quote', '_booking_answers', '_booking_credit_identity' )as$key ) { if ( isset( $service[ $key ] ) ) { $replay_scope[ $key ] = $service[ $key ]; } } try { $replay = apply_filters( 'abientot_public_booking_replay', null, $replay_scope, $customer ); if ( $replay !== null ) { if ( ! is_array( $replay ) || empty( $replay['replayed'] ) || ! is_array( $replay['appointment_ids'] ?? null ) || count( $replay['appointment_ids'] ) < 2 || count( $replay['appointment_ids'] ) > 12 ) { throw new \DomainException( 'booking_replay' ); } $first = $this->appointments->find( (int) $replay['appointment_ids'][0] ); if ( ! $first ) { throw new \DomainException( 'booking_replay' ); } $response = $this->public_confirmation( $first, $settings ); $this->release_lock( $lock_name ); wp_send_json_success( $this->confirmation_extensions( $response, $replay['appointment_ids'], $replay ) ); } } catch ( \Exception $error ) { $this->release_lock( $lock_name ); wp_send_json_error( array( 'message' => __( 'The booking details could not be accepted.', 'abientot' ) ), 400 ); } // Check under the shared lock, including statuses that do not block time. try { $duplicate = $this->appointments->has_public_submission( $customer, $service_id, $start_local, $choice_instant ? $choice_instant->format( 'Y-m-d H:i:s' ) : null ); } catch ( \Throwable $exception ) { if ( $exception instanceof \Abientot\Premium\Recurrence\CustomerConflict ) { $dates = array_values( array_filter( $exception->conflicting_dates, static fn( $day )=>is_string( $day ) && preg_match( '/^\d{4}-\d{2}-\d{2}$/D', $day ) ) ); wp_send_json_error( array( 'code' => 'series_unavailable', 'message' => sprintf( /* translators: %s: comma-separated list of dates (YYYY-MM-DD). */ __( 'No appointments were saved. There is no availability on %s. Choose another time or day in this form and review the series again.', 'abientot' ), implode( ', ', $dates ) ), 'conflicting_dates' => $dates ), 409 ); } wp_send_json_error( array( 'message' => __( 'The booking could not be saved.', 'abientot' ) ), 500 ); } if ( $duplicate ) { $this->release_lock( $lock_name ); wp_send_json_error( array( 'message' => __( 'This booking was already submitted. Please check the confirmation message.', 'abientot' ) ), 409 ); } $transaction_started = false; try { $availability_args = array( 'service_id' => $service_id, 'staff_id' => (int) $staff['id'], 'location_id' => (int) $location['id'], 'duration_minutes' => (int) $service['duration_minutes'], 'booking_quote' => $service['_booking_quote'] ?? null, 'booking_context' => $service['_booking_context'] ?? null, 'start_local' => $start_local, ); $result = $choice_instant ? $this->availability->validate_instant( $availability_args, $choice_instant ) : $this->availability->validate_slot( $availability_args ); if ( empty( $result['available'] ) ) { $this->release_lock( $lock_name ); wp_send_json_error( array( 'code' => 'slot_unavailable', 'message' => __( 'That time is no longer available. Please choose another slot.', 'abientot' ) ), 409 ); } global $wpdb; \Abientot\Support\TransactionGuard::prepare_write(); if ( false === $wpdb->query( 'START TRANSACTION' ) ) { throw new Exception( 'Transaction could not start.' ); } $transaction_started = true; // Public knowledge of an email or phone must not authorize overwriting // the stored identity details of an existing customer. $customer_id = $this->customers->find_or_create_for_booking( $customer, false ); if ( $customer_id <= 0 ) { throw new Exception( 'Customer could not be saved.' ); } $now = DateTimeHelper::now_utc_sql(); // A resource without any price (e.g. a restaurant table, paid on what is consumed) is not free. $payment_status = (int) $service['price_cents'] === 0 && empty( $service['price_open'] ) ? 'not_required' : ( in_array( (string) $settings['default_payment_status'], array( 'not_required', 'unpaid' ), true ) ? (string) $settings['default_payment_status'] : 'unpaid' ); $appointment_data = array( 'customer_id' => $customer_id, 'service_id' => (int) $service['id'], 'staff_id' => (int) $staff['id'], 'location_id' => (int) $location['id'], 'status_id' => (int) $status['id'], 'start_utc' => $result['start']->setTimezone( DateTimeHelper::utc_timezone() )->format( 'Y-m-d H:i:s' ), 'end_utc' => $result['end']->setTimezone( DateTimeHelper::utc_timezone() )->format( 'Y-m-d H:i:s' ), 'timezone' => (string) $result['timezone'], 'duration_minutes' => max( 1, (int) $service['duration_minutes'] ), 'price_cents' => (int) $service['price_cents'], 'currency' => (string) $service['currency'], 'payment_status' => $payment_status, 'source' => 'frontend', 'customer_fields_json' => wp_json_encode( $this->field_snapshot( $field_values, $settings ) ), 'customer_note' => (string) ( $field_values['customer_note'] ?? '' ), 'internal_note' => '', 'cancelled_at' => null, 'created_at' => $now, 'updated_at' => $now, ); $saved_ids = array(); $persist = function ( array $row ) use ( $appointment_data, $customer, $service, $now, &$saved_ids, &$transaction_started ): int { if ( count( $saved_ids ) >= 12 ) { throw new \DomainException( 'booking_batch_limit' ); } foreach ( $appointment_data as$key => $value ) { if ( ! in_array( $key, array( 'start_utc', 'end_utc' ), true ) && ( ! array_key_exists( $key, $row ) || $row[ $key ] !== $value ) ) { throw new \DomainException( 'booking_batch_snapshot' ); } } $row = array_intersect_key( $row, $appointment_data ); $instant = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', (string) ( $row['start_utc'] ?? '' ), DateTimeHelper::utc_timezone() ); if ( ! $instant || $instant->format( 'Y-m-d H:i:s' ) !== $row['start_utc'] || $instant->modify( '+' . (int) $row['duration_minutes'] . ' minutes' )->format( 'Y-m-d H:i:s' ) !== ( $row['end_utc'] ?? '' ) ) { throw new \DomainException( 'booking_batch_time' ); } if ( $this->appointments->has_public_submission( $customer, (int) $row['service_id'], $instant->setTimezone( new \DateTimeZone( $row['timezone'] ) )->format( 'Y-m-d H:i:s' ), $row['start_utc'] ) ) { throw new \DomainException( 'booking_batch_duplicate' ); } $decision = $this->availability->validate_instant( array( 'service_id' => (int) $row['service_id'], 'staff_id' => (int) $row['staff_id'], 'location_id' => (int) $row['location_id'], 'duration_minutes' => (int) $row['duration_minutes'], 'booking_quote' => $service['_booking_quote'] ?? null, 'booking_context' => $service['_booking_context'] ?? null ), $instant ); if ( empty( $decision['available'] ) || $decision['timezone'] !== $row['timezone'] ) { throw new \DomainException( 'booking_batch_unavailable' ); } // Add acceptance time only to persisted evidence, after replay/batch comparison. $row['customer_fields_json'] = Notice::stamp_snapshot( (string) $row['customer_fields_json'], $now ); $id = $this->appointments->insert( $row ); if ( $id <= 0 ) { throw new Exception( 'Appointment could not be saved.' ); } do_action( 'abientot_booking_snapshot_persist', $this->appointments->find( $id ) ?: array(), $service, static function () use ( &$transaction_started ): bool { return $transaction_started && \Abientot\Repositories\BookingLock::is_held(); } ); \Abientot\Support\BookingConstraints::apply( $this->appointments->find( $id ) ?: array(), null, static function () use ( &$transaction_started ): bool { return $transaction_started && \Abientot\Repositories\BookingLock::is_held(); } ); if ( ! $this->activity->record_customer_booking_event( $id, 'booking_created', __( 'Booking created from the public booking form.', 'abientot' ) ) || ! $this->payments->mark_manual_status( $id, $row['payment_status'], (int) $row['price_cents'], $row['currency'] ) ) { throw new Exception( 'Booking history/payment could not be saved.' ); } if ( ! apply_filters( 'abientot_public_payment_available', (int) $row['price_cents'] === 0 || ! empty( Settings::values()['pay_on_site_enabled'] ), $id, $row ) ) { throw new \DomainException( 'public_payment_unavailable' ); } $saved_ids[] = $id; return $id; }; $batch_base = $appointment_data; foreach ( array( '_booking_quote', '_booking_answers', '_booking_credit_identity' )as$key ) { if ( isset( $service[ $key ] ) ) { $batch_base[ $key ] = $service[ $key ]; } } $batch_result = apply_filters( 'abientot_public_booking_batch', null, $batch_base, $customer, $persist ); if ( $batch_result === null ) { $id = $persist( $appointment_data ); } else { if ( ! is_array( $batch_result ) || ( $batch_result['appointment_ids'] ?? null ) !== $saved_ids || count( $saved_ids ) < 2 ) { throw new \DomainException( 'booking_batch_result' ); } $id = $saved_ids[0]; } if ( false === $wpdb->query( 'COMMIT' ) ) { throw new Exception( 'Transaction could not commit.' ); } $transaction_started = false; $this->release_lock( $lock_name ); } catch ( \Throwable $exception ) { if ( $transaction_started ) { $wpdb->query( 'ROLLBACK' ); } $this->release_lock( $lock_name ); if ( $exception instanceof \Abientot\Premium\Recurrence\CustomerConflict ) { $dates = array_values( array_filter( $exception->conflicting_dates, static fn( $day )=>is_string( $day ) && preg_match( '/^\d{4}-\d{2}-\d{2}$/D', $day ) ) ); wp_send_json_error( array( 'code' => 'series_unavailable', 'message' => sprintf( /* translators: %s: comma-separated list of dates (YYYY-MM-DD). */ __( 'No appointments were saved. There is no availability on %s. Choose another time or day in this form and review the series again.', 'abientot' ), implode( ', ', $dates ) ), 'conflicting_dates' => $dates ), 409 ); } wp_send_json_error( array( 'message' => $exception->getMessage() === 'public_payment_unavailable' ? __( 'Payments are temporarily unavailable. Please contact the business to book.', 'abientot' ) : __( 'The booking could not be saved.', 'abientot' ) ), 500 ); } // Each committed occurrence uses the ordinary lifecycle and mail path once. foreach ( $saved_ids as$saved_id ) { \Abientot\Support\BookingEvents::dispatch( 'created', $saved_id, null, $this->appointments->find( $saved_id ) ?: array() ); \Abientot\Support\BookingEvents::notify_after_unlock( fn()=>$this->emails->send_booking_created( $saved_id ) ); } $response = $this->public_confirmation( $this->appointments->find( $id ) ?: array(), $settings ); wp_send_json_success( $this->confirmation_extensions( $response, $saved_ids, $batch_result ) ); } private function confirmation_extensions( array $response, array $ids, ?array $batch ): array { try { $extended = apply_filters( 'abientot_public_booking_confirmation', $response, $ids, $batch ); return is_array( $extended ) ? $extended : $response; } catch ( \Throwable $error ) { return $response; } } private function public_confirmation( array $booking, array $settings ): array { $id = (int) $booking['id']; $settings = \Abientot\Support\MultilingualText::form_settings( $settings, $this->form_id ); $service = \Abientot\Support\MultilingualText::service( $this->services->find( (int) $booking['service_id'] ) ?: array() ); $staff = $this->staff->find( (int) $booking['staff_id'] ) ?: array(); $location = \Abientot\Support\MultilingualText::location( $this->locations->find( (int) $booking['location_id'] ) ?: array() ); $status = $this->statuses->find( (int) $booking['status_id'] ) ?: array(); $calendar = CalendarFile::render( array( 'id' => $id, 'start_utc' => $booking['start_utc'], 'end_utc' => $booking['end_utc'], 'service' => (string) ( $service['name'] ?? '' ), 'location' => (string) ( $location['name'] ?? '' ), 'status' => (string) ( $status['slug'] ?? '' ) ) ); $local = ( new DateTimeImmutable( $booking['start_utc'], DateTimeHelper::utc_timezone() ) )->setTimezone( new \DateTimeZone( $booking['timezone'] ) )->format( 'Y-m-d H:i' ); return array( 'message' => (string) $settings['confirmation_message'], 'booking' => array( 'id' => $id, 'status' => (string) ( $status['slug'] ?? '' ), 'location' => (string) ( $location['name'] ?? '' ), 'staff' => array( 'name' => (string) ( $staff['display_name'] ?? '' ), 'photo' => $this->staff_photo_url( $staff ) ), 'timezone' => (string) $booking['timezone'], 'calendar' => $calendar, 'service' => (string) ( $service['name'] ?? '' ), 'when' => $this->confirmation_time( $local, $booking['timezone'] ), 'priceCents' => (int) $booking['price_cents'], 'currency' => (string) $booking['currency'], 'paymentStatus' => $booking['payment_status'], 'paymentInstructions' => ! empty( $settings['pay_on_site_enabled'] ) && $booking['payment_status'] === 'unpaid' ? (string) $settings['payment_instructions'] : '' ) ); } private function render_form( string $mode = '', int $form_id = 0 ): string { $settings = \Abientot\Support\MultilingualText::form_settings( \Abientot\Support\BookingForms::settings( $form_id ), $form_id ); $mode = in_array( $mode, array( 'embedded', 'popup' ), true ) ? $mode : (string) $settings['form_display_mode']; $services = $this->public_services(); wp_enqueue_style( 'abientot-journey' ); wp_enqueue_style( 'abientot-compact' ); wp_enqueue_script( 'abientot-frontend' ); wp_localize_script( 'abientot-frontend', 'abientotBooking', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'abientot_public_booking' ), 'nonceTime' => time(), 'lang' => \Abientot\Support\MultilingualText::current_language(), 'displayFormats' => array( 'date' => DateTimeHelper::date_display_format(), 'time' => DateTimeHelper::time_display_format(), 'fullDate' => DateTimeHelper::full_date_format(), 'monthYear' => DateTimeHelper::month_year_format(), ), 'dateNames' => \Abientot\Support\Locale::date_names(), 'locale' => \Abientot\Support\Locale::config(), 'strings' => array( 'calendarPrevious' => __( 'Previous month', 'abientot' ), 'calendarNext' => __( 'Next month', 'abientot' ), 'calendarHelp' => __( 'Choose an available day. Gray days have no bookable times.', 'abientot' ), 'chooseDate' => __( 'Choose a date', 'abientot' ), 'close' => __( 'Close', 'abientot' ), 'loading' => __( 'Loading available times...', 'abientot' ), 'noSlots' => __( 'No available times for this date.', 'abientot' ), 'chooseService' => __( 'Choose a service before continuing.', 'abientot' ), 'chooseSlot' => __( 'Choose a time before continuing.', 'abientot' ), 'requestError' => __( 'The booking form could not complete that request.', 'abientot' ), 'service' => __( 'Service', 'abientot' ), 'staff' => __( 'Staff', 'abientot' ), 'time' => __( 'Time', 'abientot' ), 'customer' => __( 'Customer', 'abientot' ), 'email' => __( 'Email', 'abientot' ), 'yes' => __( 'Yes', 'abientot' ), 'payment' => __( 'Payment', 'abientot' ), 'bookingNumber' => /* translators: %s: booking reference. */ __( 'Booking #%s', 'abientot' ), 'confirmation' => __( 'Booking confirmation', 'abientot' ), 'date' => __( 'Date', 'abientot' ), 'price' => __( 'Price', 'abientot' ), 'total' => __( 'Total', 'abientot' ), 'perAppointment' => __( 'Price per appointment', 'abientot' ), 'minutes' => __( 'minutes', 'abientot' ), 'location' => __( 'Location', 'abientot' ), 'timezone' => __( 'Timezone', 'abientot' ), 'retry' => __( 'Try again', 'abientot' ), 'nextAvailable' => __( 'Find the next available time', 'abientot' ), 'searchingDates' => __( 'Looking for your next available appointment…', 'abientot' ), 'noFutureSlots' => __( 'No times are available within the booking window. Please contact the business.', 'abientot' ), 'submitting' => __( 'Saving your booking...', 'abientot' ), 'seriesUnavailable' => __( 'This appointment has no availability. Choose another time or day below, then review the series again.', 'abientot' ), 'changeSeriesTime' => __( 'Choose another time or day', 'abientot' ), 'seriesTotal' => __( 'Total for all appointments', 'abientot' ), 'seriesView' => __( 'View all appointments', 'abientot' ), 'seriesCancel' => __( 'Cancel appointments in this series', 'abientot' ), 'seriesMove' => __( 'Reschedule appointments in this series', 'abientot' ), 'addToCalendar' => __( 'Add to calendar', 'abientot' ), 'pending' => __( 'Your request is awaiting approval. Please wait for confirmation from the business.', 'abientot' ), 'confirmed' => __( 'Your appointment is confirmed.', 'abientot' ), ), 'paymentInstructions' => ! empty( $settings['pay_on_site_enabled'] ) && 'unpaid' === (string) $settings['default_payment_status'] ? (string) $settings['payment_instructions'] : '', ) ); wp_add_inline_style( 'abientot-frontend', $this->appearance_css( $settings, $form_id > 0 ? '.abientot-booking.abientot-booking--form-' . $form_id : '.abientot-booking' ) ); $title_id = wp_unique_id( 'abientot-booking-title-' ); $public_staff = apply_filters( 'abientot_public_form_staff_identity', $this->default_staff() ); ob_start(); ?>
data-staff-name="" data-staff-photo="staff_photo_url( $public_staff ) ); ?>">
> $services Public services. * @param array $settings Settings. */ private function render_steps( array $services, array $settings ): void { $staff = $this->default_staff(); $location = $this->default_location(); $timezone = DateTimeHelper::business_timezone(); foreach ( array( $staff['timezone'] ?? '', $location['timezone'] ?? '' ) as $candidate ) { if ( '' === $candidate ) { continue; } try { $timezone = new DateTimeZone( $candidate ); break; } catch ( Exception $exception ) { continue; } } $today = new DateTimeImmutable( 'now', $timezone ); $timezone = apply_filters( 'abientot_public_form_timezone', $timezone ); $today = new DateTimeImmutable( 'now', $timezone ); $last_day = ( new DateTimeImmutable( 'now', DateTimeHelper::utc_timezone() ) )->modify( '+' . (int) $settings['booking_window_days'] . ' days' )->setTimezone( $timezone ); ?>
5 ) : ?>
render_grouped_services( $services, $settings, (string) $staff['display_name'] ); } else { foreach ( $services as $service ) { $this->render_service_card( $service, $settings, (string) $staff['display_name'] ); } } ?>

> $services Public services. * @param array $settings Settings. * @param string $staff_name Public staff display name. */ private function render_grouped_services( array $services, array $settings, string $staff_name ): void { $groups = array(); foreach ( $services as $service ) { $key = ! empty( $service['category_id'] ) ? 'cat-' . (int) $service['category_id'] : 'uncategorized'; if ( empty( $groups[ $key ] ) ) { $groups[ $key ] = array( 'name' => ! empty( $service['category_name'] ) ? (string) $service['category_name'] : __( 'Other services', 'abientot' ), 'description' => (string) ( $service['category_description'] ?? '' ), 'image_id' => (int) ( $service['category_image_id'] ?? 0 ), 'services' => array(), ); } $groups[ $key ]['services'][] = $service; } foreach ( $groups as $group ) { $open = 'collapsed' !== (string) $settings['form_category_behavior']; ?>
> 0 ) : ?> 'abientot-booking__image' ) ) ); ?>
render_service_card( $service, $settings, $staff_name ); ?>
$service Service row. * @param array $settings Settings. * @param string $staff_name Public staff display name. */ private function render_service_card( array $service, array $settings, string $staff_name ): void { ?> $settings Settings. */ private function render_map( array $settings ): void { if ( empty( $settings['form_show_map'] ) ) { return; } $location = $this->default_location(); $map_url = isset( $location['map_url'] ) ? esc_url( (string) $location['map_url'] ) : ''; if ( '' === $map_url ) { return; } ?>

$settings Settings. */ private function render_detail_fields( array $settings ): void { foreach ( Settings::normalize_form_fields( $settings['form_fields'] ?? array() ) as $field ) { $key = (string) $field['key']; $label = (string) $field['label']; $type = (string) $field['type']; $required = ! empty( $field['required'] ); $name = 'booking_fields[' . $key . ']'; $autocomplete = $this->field_autocomplete( $key ); $field_id = wp_unique_id( 'abientot-field-' ); ?>
$field_id, 'required' => $required ), $label ); ?> > >
$settings Settings. */ public function appearance_css( array $settings, string $scope = '.abientot-booking' ): string { $primary = \Abientot\Support\BrandPalette::color( (string) sanitize_hex_color( (string) ( $settings['form_primary_color'] ?? '#46c8b2' ) ) ) ?: '#46c8b2'; $background = \Abientot\Support\BrandPalette::color( (string) sanitize_hex_color( (string) ( $settings['form_background_color'] ?? '#ffffff' ) ) ) ?: '#ffffff'; $text = \Abientot\Support\BrandPalette::foreground( (string) sanitize_hex_color( (string) ( $settings['form_text_color'] ?? '#1f2933' ) ), (string) ( $settings['form_background_color'] ?? '#ffffff' ) ) ?: '#1f2933'; $opacity = max( 0, min( 100, absint( $settings['form_background_opacity'] ?? 5 ) ) ); $font = $this->font_stack( (string) ( $settings['form_font_family'] ?? 'system' ) ); $form_border_color = \Abientot\Support\BrandPalette::color( (string) sanitize_hex_color( (string) ( $settings['form_border_color'] ?? '#d6d9dd' ) ) ) ?: '#d6d9dd'; $max_width = max( 360, min( 1400, absint( $settings['form_max_width'] ?? 760 ) ) ); $padding = max( 8, min( 64, absint( $settings['form_padding'] ?? 24 ) ) ); $font_size = max( 13, min( 19, absint( $settings['form_font_size'] ?? 15 ) ) ); $radius = max( 0, min( 32, absint( $settings['form_container_border_radius'] ?? 6 ) ) ); $service_radius = max( 0, min( 32, absint( $settings['form_border_radius'] ?? 6 ) ) ); $hours_radius = max( 0, min( 32, absint( $settings['form_hours_border_radius'] ?? 6 ) ) ); $border = max( 0, min( 8, absint( $settings['form_border_width'] ?? 1 ) ) ); $service_border = max( 0, min( 8, absint( $settings['form_service_border_width'] ?? 1 ) ) ); $service_border_color = \Abientot\Support\BrandPalette::color( (string) sanitize_hex_color( (string) ( $settings['form_service_border_color'] ?? '#d6d9dd' ) ) ) ?: '#d6d9dd'; $service_background_color = \Abientot\Support\BrandPalette::color( (string) sanitize_hex_color( (string) ( $settings['form_service_background_color'] ?? '#ffffff' ) ) ) ?: '#ffffff'; $category_background_color = \Abientot\Support\BrandPalette::color( (string) sanitize_hex_color( (string) ( $settings['form_category_background_color'] ?? '#ffffff' ) ) ) ?: '#ffffff'; $field_text_color = \Abientot\Support\BrandPalette::foreground( (string) sanitize_hex_color( (string) ( $settings['form_field_text_color'] ?? '#1f2933' ) ), (string) ( $settings['form_field_background_color'] ?? '#ffffff' ) ) ?: '#1f2933'; $field_background_color = \Abientot\Support\BrandPalette::color( (string) sanitize_hex_color( (string) ( $settings['form_field_background_color'] ?? '#ffffff' ) ) ) ?: '#ffffff'; $field_border_color = \Abientot\Support\BrandPalette::color( (string) sanitize_hex_color( (string) ( $settings['form_field_border_color'] ?? '#d6d9dd' ) ) ) ?: '#d6d9dd'; $field_radius = max( 0, min( 32, absint( $settings['form_field_border_radius'] ?? 4 ) ) ); $field_border = max( 0, min( 8, absint( $settings['form_field_border_width'] ?? 1 ) ) ); $image_size = max( 32, min( 240, absint( $settings['form_image_size'] ?? 72 ) ) ); $image_radius = max( 0, min( 48, absint( $settings['form_image_radius'] ?? 6 ) ) ); $image_border = max( 0, min( 8, absint( $settings['form_image_border_width'] ?? 1 ) ) ); $image_fit = in_array( (string) ( $settings['form_image_fit'] ?? 'cover' ), array( 'cover', 'contain' ), true ) ? (string) $settings['form_image_fit'] : 'cover'; $map_height = max( 120, min( 520, absint( $settings['form_map_height'] ?? 220 ) ) ); $css = sprintf( '.abientot-booking{--abientot-primary:%1$s;--abientot-text:%2$s;--abientot-surface-rgb:%3$s;--abientot-surface-alpha:%4$s;--abientot-form-border-color:%6$s;--abientot-form-max-width:%7$dpx;--abientot-form-padding:%8$dpx;--abientot-form-radius:%9$dpx;--abientot-form-border-width:%10$dpx;--abientot-service-radius:%11$dpx;--abientot-service-border-width:%12$dpx;--abientot-service-border-color:%13$s;--abientot-service-background-color:%19$s;--abientot-category-background-color:%20$s;--abientot-image-size:%14$dpx;--abientot-image-radius:%15$dpx;--abientot-image-border-width:%16$dpx;--abientot-image-fit:%17$s;--abientot-map-height:%18$dpx;--abientot-hours-radius:%21$dpx;font-family:%5$s;}', esc_attr( $primary ), esc_attr( $text ), esc_attr( $this->hex_to_rgb_string( $background ) ), esc_attr( number_format( $opacity / 100, 2, '.', '' ) ), $font, // Allowlisted constant from font_stack(); HTML escaping would break its quoted family names. esc_attr( $form_border_color ), $max_width, $padding, $radius, $border, $service_radius, $service_border, esc_attr( $service_border_color ), $image_size, $image_radius, $image_border, esc_attr( $image_fit ), $map_height, esc_attr( $service_background_color ), esc_attr( $category_background_color ), $hours_radius ) . sprintf( '.abientot-booking{--abientot-field-text-color:%1$s;--abientot-field-background-color:%2$s;--abientot-field-border-color:%3$s;--abientot-field-radius:%4$dpx;--abientot-field-border-width:%5$dpx;}', esc_attr( $field_text_color ), esc_attr( $field_background_color ), esc_attr( $field_border_color ), $field_radius, $field_border ) . sprintf( '.abientot-booking{--abientot-font-size:%dpx;--abientot-shadow:%s;}', $font_size, $this->shadow( (string) ( $settings['form_shadow'] ?? 'soft' ) ) ) . sprintf( '.abientot-booking{--abientot-on-primary:%1$s;--abientot-primary-text:%2$s;--abientot-border:%3$s;}', esc_attr( \Abientot\Support\BrandPalette::on_primary( $primary ) ), esc_attr( \Abientot\Support\BrandPalette::readable( $primary, $background ) ), esc_attr( \Abientot\Support\BrandPalette::mix( $primary, $background, .18 ) ) ) . $this->auto_text_css( $settings, $primary, $background, $service_background_color, $category_background_color, $field_background_color ); if ( '.abientot-booking' === $scope ) { return $css; } // Additional forms: the same rules, limited to that form's root so several forms can share a page. return str_replace( array( '.abientot-booking.abientot-booking--auto-text{', '.abientot-booking{' ), array( $scope . '.abientot-booking--auto-text{', $scope . '{' ), $css ); } /** * Automatic text color: the chosen text colors in the nearest shade that keeps a good contrast * on each background (7:1 for main text, 4.5:1 for secondary text). The form surface uses its * own color here; booking-form.js refines it for see-through forms from the page behind. */ private function auto_text_css( array $settings, string $primary, string $background, string $card, string $category, string $field ): string { if ( empty( $settings['form_auto_text_color'] ) ) { return ''; } $text = sanitize_hex_color( (string) ( $settings['form_text_color'] ?? '' ) ) ?: '#1f2933'; $input = sanitize_hex_color( (string) ( $settings['form_field_text_color'] ?? '' ) ) ?: $text; $panel = \Abientot\Support\BrandPalette::mix( $primary, $background, .06 ); $vars = array(); foreach ( array( '' => $background, 'auto-panel-' => $panel, 'auto-card-' => $card, 'auto-category-' => $category ) as $prefix => $surface ) { $main = \Abientot\Support\BrandPalette::readable( $text, $surface, 7 ); $vars[] = '--abientot-' . $prefix . 'text:' . $main; $vars[] = '--abientot-' . $prefix . 'muted:' . \Abientot\Support\BrandPalette::readable_muted( $main, $surface ); } $field_text = \Abientot\Support\BrandPalette::readable( $input, $field, 7 ); $vars[] = '--abientot-field-text-color:' . $field_text; $vars[] = '--abientot-auto-field-muted:' . \Abientot\Support\BrandPalette::readable_muted( $field_text, $field ); return '.abientot-booking.abientot-booking--auto-text{' . esc_attr( implode( ';', $vars ) ) . ';}'; } /** Depth of the form surface: none, soft or lifted. Mirrored in admin.js for the preview. */ private function shadow( string $shadow ): string { $shadows = array( 'none' => 'none', 'soft' => '0 8px 28px rgba(19,31,41,.07)', 'lifted' => '0 24px 60px rgba(15,23,42,.18),0 2px 6px rgba(15,23,42,.06)', ); return $shadows[ $shadow ] ?? $shadows['soft']; } /** Per-card automatic text when a card uses its own (service) background color. */ public function auto_text_card_style( array $settings, string $card_background ): string { if ( empty( $settings['form_auto_text_color'] ) || ! sanitize_hex_color( $card_background ) ) { return ''; } $text = sanitize_hex_color( (string) ( $settings['form_text_color'] ?? '' ) ) ?: '#1f2933'; $main = \Abientot\Support\BrandPalette::readable( $text, $card_background, 7 ); return ' --abientot-auto-card-text: ' . $main . '; --abientot-auto-card-muted: ' . \Abientot\Support\BrandPalette::readable_muted( $main, $card_background ) . ';'; } private function service_card_style( array $service, array $settings ): string { $service_color = \Abientot\Support\BrandPalette::color( (string) sanitize_hex_color( (string) ( $service['color'] ?? '' ) ) ); if ( ! $service_color ) { return ''; } $styles = array(); if ( 'service' === (string) ( $settings['form_service_border_color_mode'] ?? 'custom' ) ) { $styles[] = '--abientot-service-border-color: ' . $service_color; } if ( 'service' === (string) ( $settings['form_service_background_color_mode'] ?? 'custom' ) ) { $styles[] = '--abientot-service-background-color: ' . $service_color; $card_text = trim( $this->auto_text_card_style( $settings, $service_color ), ' ;' ); if ( '' !== $card_text ) { $styles[] = $card_text; } } return $styles ? implode( '; ', $styles ) . ';' : ''; } private function font_stack( string $font ): string { $stacks = array( 'system' => '-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', 'serif' => 'Georgia,"Times New Roman",serif', 'mono' => '"SFMono-Regular",Consolas,"Liberation Mono",monospace', 'rounded' => '"Trebuchet MS","Segoe UI",sans-serif', ); return $stacks[ $font ] ?? $stacks['system']; } private function hex_to_rgb_string( string $hex ): string { $hex = ltrim( $hex, '#' ); if ( 3 === strlen( $hex ) ) { $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; } return sprintf( '%d,%d,%d', hexdec( substr( $hex, 0, 2 ) ), hexdec( substr( $hex, 2, 2 ) ), hexdec( substr( $hex, 4, 2 ) ) ); } /** * @return array> */ private function public_services(): array { $rows = array_map( array( \Abientot\Support\MultilingualText::class, 'service' ), $this->services->list_for_admin() ); return array_values( array_filter( $rows, static function ( array $service ): bool { return 'active' === (string) $service['status'] && 'public' === (string) $service['visibility']; } ) ); } /** Fields of the current booking request; only the AJAX handlers set them, after verifying the nonce. */ private function input(): Input { if ( null === $this->input ) { wp_send_json_error( array( 'message' => __( 'The booking details could not be accepted.', 'abientot' ) ), 400 ); } return $this->input; } /** Whether a field was sent with a non-empty value (string or array). */ private function filled( string $key ): bool { return $this->input()->filled( $key ); } private function public_service( int $service_id ): ?array { $service = $this->services->find( $service_id ); if ( ! $service || 'active' !== (string) $service['status'] || 'public' !== (string) $service['visibility'] ) { $service = null; } $result = apply_filters( 'abientot_public_service_resolution', $service, $service_id, $this->input()->fields( self::REQUEST_FIELDS ) ); return is_array( $result ) ? $result : null; } /** * @param array $field_values Submitted form field values. */ private function customer_payload( array $field_values ) { $first_name = (string) ( $field_values['first_name'] ?? '' ); $last_name = (string) ( $field_values['last_name'] ?? '' ); $email = isset( $field_values['email'] ) ? sanitize_email( $field_values['email'] ) : ''; $phone = (string) ( $field_values['phone'] ?? '' ); $display_name = trim( $first_name . ' ' . $last_name ); if ( '' !== $email && ! is_email( $email ) ) { return new \WP_Error( 'abientot_email', __( 'Enter a valid email address.', 'abientot' ) ); } if ( '' === $display_name ) { $display_name = $email ?: ( $phone ?: __( 'Booking customer', 'abientot' ) ); } $payload = array( 'first_name' => $first_name, 'last_name' => $last_name, 'display_name' => $display_name, 'email' => $email, 'phone' => $phone, ); return apply_filters( 'abientot_booking_form_fields', $payload ); } /** * @param array $settings Settings. * @return array|\WP_Error */ private function submitted_field_values( array $settings ) { $fields = Settings::normalize_form_fields( $settings['form_fields'] ?? array() ); $values = array(); foreach ( $fields as $field ) { $key = (string) $field['key']; $type = (string) $field['type']; if ( 'checkbox' === $type ) { $value = $this->input()->item_filled( 'booking_fields', $key ) ? '1' : ''; } else { $value = (string) $this->input()->item( 'booking_fields', $key, 'textarea' === $type ? 'textarea' : 'text' ); } if ( ! empty( $field['required'] ) && '' === $value ) { return new \WP_Error( 'abientot_required_field', sprintf( /* translators: %s: customer field label. */ __( '%s is required.', 'abientot' ), (string) $field['label'] ) ); } if ( 'email' === $type && '' !== $value && ! is_email( $value ) ) { return new \WP_Error( 'abientot_email_field', sprintf( /* translators: %s: customer field label. */ __( '%s must be a valid email address.', 'abientot' ), (string) $field['label'] ) ); } if ( 'tel' === $type && '' !== $value ) { $country = strtoupper( (string) $this->input()->item( 'booking_phone_country', $key ) ); try { $value = \Abientot\Support\Phone::normalize( $value, $country ); } catch ( \DomainException $error ) { return new \WP_Error( 'abientot_phone_field', sprintf( /* translators: %s: phone field label. */__( '%s must be a valid phone number. Choose a country or include the international calling code.', 'abientot' ), $field['label'] ) ); } } $values[ $key ] = $value; } return $values; } /** * @param mixed $raw Raw submitted value. */ /** * @param array $values Submitted values. * @param array $settings Settings. * @return array> */ private function field_snapshot( array $values, array $settings ): array { $snapshot = array(); foreach ( Settings::normalize_form_fields( $settings['form_fields'] ?? array() ) as $field ) { $key = (string) $field['key']; $snapshot[] = array( 'key' => $key, 'label' => (string) $field['label'], 'type' => (string) $field['type'], 'required' => ! empty( $field['required'] ) ? '1' : '0', 'value' => (string) ( $values[ $key ] ?? '' ), ); } if ( ! empty( $settings['require_policy_checkbox'] ) ) { $snapshot[] = array( 'key' => '_abientot_policy', 'label' => __( 'Booking terms acceptance', 'abientot' ), 'type' => 'metadata', 'required' => '0', 'value' => wp_json_encode( Notice::policy( $settings ) ) ); } return $snapshot; } private function field_autocomplete( string $key ): string { $map = array( 'first_name' => 'given-name', 'last_name' => 'family-name', 'email' => 'email', 'phone' => 'tel', ); return $map[ $key ] ?? ''; } private function staff_photo_url( array $staff ): string { return esc_url_raw( (string) ( wp_get_attachment_image_url( (int) ( $staff['photo_id'] ?? 0 ), 'thumbnail' ) ?: '' ) ); } private function resource_only_service( ?array $service ): bool { return $service !== null && is_array( $service['_booking_context'] ?? null ) && (int) ( $service['_booking_context']['category_id'] ?? 0 ) > 0 && ( new \Abientot\Repositories\ResourceBookingRepository() )->internal_service( (int) $service['id'] ); } private function default_staff(): array { $staff = $this->staff->default_staff(); return is_array( $staff ) ? $staff : array( 'id' => 0, 'display_name' => '', 'timezone' => wp_timezone_string() ); } private function default_location(): array { $location = $this->locations->default_location(); return is_array( $location ) ? \Abientot\Support\MultilingualText::location( $location ) : array( 'id' => 0, 'name' => '', 'timezone' => wp_timezone_string() ); } /** A short-lived signed first-party cookie isolates availability browsing behind proxies. */ private function availability_session(): string { $name = 'abientot_availability_session'; if ( null !== self::$availability_session_value ) { return self::$availability_session_value; } $value = isset( $_COOKIE[ $name ] ) && is_string( $_COOKIE[ $name ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ $name ] ) ) : ''; $parts = explode( '.', $value ); if ( 3 === count( $parts ) && preg_match( '/^[a-f0-9]{32}$/', $parts[0] ) && ctype_digit( $parts[1] ) && (int) $parts[1] > time() && hash_equals( hash_hmac( 'sha256', $parts[0] . '.' . $parts[1], wp_salt( 'nonce' ) ), $parts[2] ) ) { return self::$availability_session_value = $parts[0]; } $id = bin2hex( random_bytes( 16 ) ); $expires = time() + HOUR_IN_SECONDS; $payload = $id . '.' . $expires; $value = $payload . '.' . hash_hmac( 'sha256', $payload, wp_salt( 'nonce' ) ); setcookie( $name, $value, array( 'expires' => $expires, 'path' => '/', 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax' ) ); return self::$availability_session_value = $id; } private function rate_limit( string $bucket, int $limit, int $window, ?string $identity = null ): bool { $key = 'abientot_rate_' . sanitize_key( $bucket ) . '_' . md5( $identity ?? $this->client_ip() ); $stored = get_transient( $key ); $now = time(); $state = is_array( $stored ) ? $stored : array( 'count' => (int) $stored, 'expires' => $now + $window ); if ( (int) $state['expires'] <= $now ) { $state = array( 'count' => 0, 'expires' => $now + $window ); } if ( (int) $state['count'] >= $limit ) { return false; } $state['count']++; set_transient( $key, $state, max( 1, (int) $state['expires'] - $now ) ); return true; } private function client_ip(): string { return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown'; } private function acquire_lock( string $lock_name ): bool { if ( BookingLock::is_held() ) { return true; } $this->owns_booking_lock = BookingLock::acquire(); return $this->owns_booking_lock; } private function release_lock( string $lock_name ): void { if ( $this->owns_booking_lock ) { BookingLock::release(); $this->owns_booking_lock = false; } } private function confirmation_time( string $start_local, string $timezone_name ): string { try { return DateTimeHelper::format_local_datetime( $this->normalize_start_local( $start_local ) . ':00', $timezone_name ); } catch ( Exception $exception ) { return $start_local; } } private function normalize_start_local( string $start_local ): string { $start_local = str_replace( 'T', ' ', trim( $start_local ) ); if ( preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $start_local ) ) { $start_local = substr( $start_local, 0, 16 ); } return preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $start_local ) ? $start_local : ''; } private function money( int $price_cents, string $currency ): string { return \Abientot\Support\Currency::format( $price_cents, $currency ); } }