*/ private const SETTINGS_KEYS = [ 'require_phone', 'marketing_opt_in', 'cart_enabled', ]; /** * Allowed radius token values. * * @var list */ private const RADIUS_VALUES = [ 'var(--r-sm)', 'var(--r-md)', 'var(--r-lg)', 'var(--r-full)', ]; /** * Allowed font token values. * * @var list */ private const FONT_VALUES = [ 'var(--font-sans)', 'var(--font-mono)', ]; /** * Allowed density values. * * @var list */ private const DENSITY_VALUES = [ 'comfortable', 'compact' ]; /** * Allowed colour-scheme values. * * @var list */ private const SCHEME_VALUES = [ 'light', 'dark', 'auto' ]; /** * Allowed slot-style values. * * @var list */ private const SLOT_STYLE_VALUES = [ 'bordered', 'pill' ]; /** * Steps that every form must include (docs/06 §2). * * @var list */ private const REQUIRED_STEPS = [ 'datetime', 'details', 'confirmation' ]; /** * Constructor. * * @param BookingFormRepository $forms Form port. * @param Clock $clock Clock. * @param NamedFeatureGate|null $named Paid-plan features; open when omitted. */ public function __construct( private readonly BookingFormRepository $forms, private readonly Clock $clock, private readonly ?NamedFeatureGate $named = null, ) {} /** * Create a booking form. * * @param array $data Input. * * @throws DomainException When invalid. */ public function create( array $data ): BookingForm { $now = $this->clock->now(); $name = trim( (string) ( $data['name'] ?? '' ) ); if ( '' === $name ) { throw new DomainException( 'booking_form.name_empty', 'Form name must not be empty.' ); } $form = new BookingForm( null, Uuid::generate(), $name, $this->unique_slug( $this->resolve_slug( $data, $name ), null ), $this->sanitize_steps( $data['steps'] ?? FormConfiguration::DEFAULT_STEP_NAMES ), $this->sanitize_id_list( $data['service_ids'] ?? null ), $this->sanitize_id_list( $data['agent_ids'] ?? null ), $this->sanitize_id_list( $data['location_ids'] ?? null ), $this->sanitize_id_list( $data['category_ids'] ?? null ), $this->sanitize_settings( $data['settings'] ?? [] ), $this->sanitize_theme( $data['theme'] ?? [] ), $this->sanitize_status( $data['status'] ?? 'active' ), $now, $now, ); $this->forms->save( $form ); return $form; } /** * Update a booking form. * * @param int $id Form id. * @param array $data Input. * * @throws DomainException When invalid. */ public function update( int $id, array $data ): BookingForm { $form = $this->forms->find_by_id( $id ); if ( null === $form ) { throw new DomainException( 'booking_form.not_found', 'Booking form not found.' ); } $updated = $this->merge_patch( $form, $data, true ); $this->forms->save( $updated ); return $updated; } /** * Merge a partial patch onto an existing form without persisting. * * Used by the live-preview session handler. * * @param BookingForm $form Existing form. * @param array $data Patch fields. * @param bool $touch_updated_at Whether to bump updated_at. * * @throws DomainException When the patched name is empty. */ public function merge_patch( BookingForm $form, array $data, bool $touch_updated_at ): BookingForm { $name = array_key_exists( 'name', $data ) ? trim( (string) $data['name'] ) : $form->name(); if ( '' === $name ) { throw new DomainException( 'booking_form.name_empty', 'Form name must not be empty.' ); } return new BookingForm( $form->id(), $form->uuid(), $name, array_key_exists( 'slug', $data ) ? $this->unique_slug( $this->sanitized_slug( (string) $data['slug'], $name ), $form->id() ) : $form->slug(), array_key_exists( 'steps', $data ) ? $this->sanitize_steps( $data['steps'] ) : $form->steps(), array_key_exists( 'service_ids', $data ) ? $this->sanitize_id_list( $data['service_ids'] ) : $form->service_ids(), array_key_exists( 'agent_ids', $data ) ? $this->sanitize_id_list( $data['agent_ids'] ) : $form->agent_ids(), array_key_exists( 'location_ids', $data ) ? $this->sanitize_id_list( $data['location_ids'] ) : $form->location_ids(), array_key_exists( 'category_ids', $data ) ? $this->sanitize_id_list( $data['category_ids'] ) : $form->category_ids(), array_key_exists( 'settings', $data ) ? $this->sanitize_settings( $data['settings'] ) : $form->settings(), array_key_exists( 'theme', $data ) ? $this->sanitize_theme( $data['theme'] ) : $form->theme(), array_key_exists( 'status', $data ) ? $this->sanitize_status( $data['status'] ) : $form->status(), $form->created_at(), $touch_updated_at ? $this->clock->now() : $form->updated_at(), ); } /** * Delete a booking form. * * @param int $id Form id. * * @throws DomainException When not found. */ public function delete( int $id ): void { $form = $this->forms->find_by_id( $id ); if ( null === $form ) { throw new DomainException( 'booking_form.not_found', 'Booking form not found.' ); } $this->forms->delete( $id ); } /** * Resolve slug from input. * * @param array $data Input. * @param string $name Fallback name. */ private function resolve_slug( array $data, string $name ): string { if ( isset( $data['slug'] ) ) { return $this->sanitized_slug( (string) $data['slug'], $name ); } return $this->sanitized_slug( '', $name ); } /** * Sanitise a slug, falling back to the form name. * * @param string $slug Candidate slug. * @param string $name Fallback name. */ private function sanitized_slug( string $slug, string $name ): string { $from_input = sanitize_title( trim( $slug ) ); if ( '' !== $from_input ) { return $from_input; } $from_name = sanitize_title( $name ); return '' !== $from_name ? $from_name : 'booking-form'; } /** * Ensure the slug is unique, appending -2, -3, … when needed. * * @param string $slug Desired slug. * @param int|null $ignore_id Form id to ignore (the current row). */ private function unique_slug( string $slug, ?int $ignore_id ): string { $base = '' !== $slug ? $slug : 'booking-form'; $candidate = $base; $n = 2; while ( $n < 100 ) { $existing = $this->forms->find_by_slug( $candidate ); if ( null === $existing || $existing->id() === $ignore_id ) { return $candidate; } $candidate = $base . '-' . $n; ++$n; } return $base . '-' . bin2hex( random_bytes( 3 ) ); } /** * Sanitise ordered step names. * * @param mixed $raw Steps. * @return list * * @throws DomainException When invalid. */ private function sanitize_steps( mixed $raw ): array { if ( ! is_array( $raw ) ) { throw new DomainException( 'booking_form.steps_invalid', 'Steps must be a list of step names.' ); } $allowed = array_flip( FormConfiguration::DEFAULT_STEP_NAMES ); $steps = []; foreach ( $raw as $step ) { if ( ! is_string( $step ) || ! isset( $allowed[ $step ] ) ) { throw new DomainException( 'booking_form.step_unknown', 'Unknown step name in form configuration.' ); } if ( ! in_array( $step, $steps, true ) ) { $steps[] = $step; } } if ( [] === $steps ) { throw new DomainException( 'booking_form.steps_empty', 'At least one step is required.' ); } $this->assert_required_steps( $steps ); return $steps; } /** * Ensure mandatory journey steps are present. * * @param list $steps Sanitised step names. * * @throws DomainException When a required step is missing. */ private function assert_required_steps( array $steps ): void { foreach ( self::REQUIRED_STEPS as $required ) { if ( ! in_array( $required, $steps, true ) ) { throw new DomainException( 'booking_form.steps_missing_required', sprintf( 'The %s step is required and cannot be removed.', esc_html( $required ) ) ); } } } /** * Sanitise an optional id list. * * @param mixed $raw Ids. * @return list|null */ private function sanitize_id_list( mixed $raw ): ?array { if ( null === $raw ) { return null; } if ( ! is_array( $raw ) ) { return null; } $ids = []; foreach ( $raw as $id ) { $int = (int) $id; if ( $int > 0 ) { $ids[] = $int; } } return [] === $ids ? null : array_values( array_unique( $ids ) ); } /** * Sanitise settings allow-list. * * @param mixed $raw Settings. * @return array */ private function sanitize_settings( mixed $raw ): array { if ( ! is_array( $raw ) ) { return []; } $out = []; foreach ( self::SETTINGS_KEYS as $key ) { if ( array_key_exists( $key, $raw ) ) { $out[ $key ] = (bool) $raw[ $key ]; } } if ( array_key_exists( 'custom_fields', $raw ) ) { $fields = CustomFields::sanitise( $raw['custom_fields'] ); if ( [] !== $fields ) { $this->named_features()->assert_allows( NamedFeature::CustomFields ); } $out['custom_fields'] = $fields; } return $out; } /** * Plan map, or the open directory gate. */ private function named_features(): NamedFeatureGate { return $this->named ?? new OpenNamedFeatureGate(); } /** * Sanitise theme token allow-list (docs/22 §6). * * Accent accepts a CSS custom property or a six-digit hex. The other * five knobs are closed enumerations so a free-text field cannot break * the widget layout. * * @param mixed $raw Theme. * @return array */ private function sanitize_theme( mixed $raw ): array { $theme = BookingForm::default_theme(); if ( ! is_array( $raw ) ) { return $theme; } if ( isset( $raw['accent'] ) && is_string( $raw['accent'] ) ) { $theme['accent'] = $this->sanitize_accent( $raw['accent'] ); } if ( isset( $raw['radius'] ) && is_string( $raw['radius'] ) && in_array( $raw['radius'], self::RADIUS_VALUES, true ) ) { $theme['radius'] = $raw['radius']; } if ( isset( $raw['font'] ) && is_string( $raw['font'] ) && in_array( $raw['font'], self::FONT_VALUES, true ) ) { $theme['font'] = $raw['font']; } if ( isset( $raw['density'] ) && is_string( $raw['density'] ) && in_array( $raw['density'], self::DENSITY_VALUES, true ) ) { $theme['density'] = $raw['density']; } if ( isset( $raw['scheme'] ) && is_string( $raw['scheme'] ) && in_array( $raw['scheme'], self::SCHEME_VALUES, true ) ) { $theme['scheme'] = $raw['scheme']; } if ( isset( $raw['slot_style'] ) && is_string( $raw['slot_style'] ) && in_array( $raw['slot_style'], self::SLOT_STYLE_VALUES, true ) ) { $theme['slot_style'] = $raw['slot_style']; } return $theme; } /** * Sanitise the accent colour token. * * @param string $raw Raw accent. */ private function sanitize_accent( string $raw ): string { $value = trim( $raw ); if ( 1 === preg_match( '/^var\(--[a-z0-9-]+\)$/', $value ) ) { return $value; } if ( 1 === preg_match( '/^#[0-9A-Fa-f]{6}$/', $value ) ) { return strtolower( $value ); } if ( 1 === preg_match( '/^#[0-9A-Fa-f]{3}$/', $value ) ) { $r = $value[1]; $g = $value[2]; $b = $value[3]; return strtolower( '#' . $r . $r . $g . $g . $b . $b ); } return BookingForm::default_theme()['accent']; } /** * Sanitise publication status. * * @param mixed $raw Status value. */ private function sanitize_status( mixed $raw ): BookingFormStatus { $value = is_string( $raw ) ? $raw : 'active'; return BookingFormStatus::from( $value ); } }