*/ public function get_state(): array { $stored = $this->read_option(); $user = $this->current_user(); $page_id = (int) ( $stored['page_id'] ?? 0 ); $page_url = ''; if ( $page_id > 0 ) { $permalink = get_permalink( $page_id ); $page_url = is_string( $permalink ) ? $permalink : ''; } return [ 'current_step' => (int) ( $stored['current_step'] ?? 1 ), 'total_steps' => self::TOTAL_STEPS, 'completed' => (bool) ( $stored['completed'] ?? false ), 'business_name' => (string) $this->settings->get( 'general.business_name' ), 'timezone' => (string) $this->settings->get( 'general.timezone' ), 'currency' => (string) $this->settings->get( 'general.currency' ), 'week_start' => (int) $this->settings->get( 'general.week_start' ), 'time_format' => (string) $this->settings->get( 'general.time_format' ), 'service_name' => (string) ( $stored['service_name'] ?? 'Consultation' ), 'duration_min' => (int) ( $stored['duration_min'] ?? 60 ), 'price_minor' => (int) ( $stored['price_minor'] ?? 5000 ), 'hours' => $this->hours_from_stored( $stored ), 'agent_name' => (string) ( $stored['agent_name'] ?? $user['display_name'] ), 'agent_email' => (string) ( $stored['agent_email'] ?? $user['email'] ), 'payment_mode' => (string) ( $stored['payment_mode'] ?? 'on_site' ), 'service_id' => (int) ( $stored['service_id'] ?? 0 ), 'agent_id' => (int) ( $stored['agent_id'] ?? 0 ), 'form_id' => (int) ( $stored['form_id'] ?? 0 ), 'page_id' => $page_id, 'page_url' => $page_url, 'current_user' => $user, ]; } /** * Whether the wizard has produced a published booking page. */ public function is_complete(): bool { $stored = $this->read_option(); return (bool) ( $stored['completed'] ?? false ) && (int) ( $stored['page_id'] ?? 0 ) > 0; } /** * Dispatch a wizard step from REST. * * @param string $step Step key. * @param array $params Request body. * * @throws DomainException When the step or payload is invalid. */ public function run_step( string $step, array $params ): void { if ( ! empty( $params['skip'] ) ) { $this->skip( $step ); return; } match ( $step ) { 'business' => $this->save_business( $params ), 'service' => $this->save_service( $params ), 'hours' => $this->save_hours( $params ), 'you' => $this->save_you( $params ), 'payments' => $this->save_payments( $params ), 'booking_page' => $this->save_booking_page( $params ), default => throw new DomainException( 'wizard.unknown_step', 'Unknown wizard step.' ), }; } /** * Persist the business step. * * @param array $params Input. * * @throws DomainException When timezone or currency is invalid. */ public function save_business( array $params ): void { $name = isset( $params['business_name'] ) ? trim( (string) $params['business_name'] ) : ''; if ( '' === $name ) { throw new DomainException( 'wizard.business_name', 'Enter a business name.' ); } $timezone = isset( $params['timezone'] ) ? trim( (string) $params['timezone'] ) : 'Europe/London'; $this->assert_timezone( $timezone ); $currency = strtoupper( isset( $params['currency'] ) ? trim( (string) $params['currency'] ) : 'GBP' ); if ( 1 !== preg_match( '/^[A-Z]{3}$/', $currency ) ) { throw new DomainException( 'wizard.currency', 'Currency must be a three-letter code such as GBP.' ); } $this->settings->set( 'general.business_name', $name ); $this->settings->set( 'general.timezone', $timezone ); $this->settings->set( 'general.currency', $currency ); if ( array_key_exists( 'week_start', $params ) ) { $this->settings->set( 'general.week_start', $params['week_start'] ); } if ( array_key_exists( 'time_format', $params ) ) { $this->settings->set( 'general.time_format', $params['time_format'] ); } $this->advance_from( 1 ); } /** * Persist the first service. * * @param array $params Input. * * @throws DomainException When name, duration or price is invalid. */ public function save_service( array $params ): void { $name = isset( $params['service_name'] ) ? trim( (string) $params['service_name'] ) : ''; if ( '' === $name ) { throw new DomainException( 'wizard.service_name', 'Enter a service name.' ); } $duration_min = isset( $params['duration_min'] ) ? (int) $params['duration_min'] : 60; if ( $duration_min < 5 || $duration_min > 480 ) { throw new DomainException( 'wizard.duration', 'Duration must be between 5 and 480 minutes.' ); } $price_minor = isset( $params['price_minor'] ) ? (int) $params['price_minor'] : 0; if ( $price_minor < 0 ) { throw new DomainException( 'wizard.price', 'Price cannot be negative.' ); } $stored = $this->read_option(); $service_id = (int) ( $stored['service_id'] ?? 0 ); $payload = [ 'name' => $name, 'status' => 'active', ]; if ( $service_id > 0 ) { $service = $this->services->update( $service_id, $payload ); } else { $service = $this->services->create( $payload ); } $service_id = (int) $service->id(); $duration_id = (int) ( $stored['duration_id'] ?? 0 ); $duration = [ 'service_id' => $service_id, 'duration_min' => $duration_min, 'price_minor' => $price_minor, 'currency' => (string) $this->settings->get( 'general.currency' ), 'label' => $duration_min . ' min', 'is_default' => true, ]; if ( $duration_id > 0 ) { $this->durations->update( $duration_id, $duration ); } else { $created = $this->durations->create( $duration ); $duration_id = (int) $created->id(); } $stored['service_id'] = $service_id; $stored['duration_id'] = $duration_id; $stored['service_name'] = $name; $stored['duration_min'] = $duration_min; $stored['price_minor'] = $price_minor; $this->write_option( $stored ); $this->link_agent_to_service( $stored ); $this->advance_from( 2 ); } /** * Persist weekly hours (applied to the agent once it exists). * * @param array $params Input. * * @throws DomainException When a day row is invalid. */ public function save_hours( array $params ): void { $hours = $this->sanitise_hours( $params['hours'] ?? null ); $stored = $this->read_option(); $stored['hours'] = $hours; $this->write_option( $stored ); $agent_id = (int) ( $stored['agent_id'] ?? 0 ); if ( $agent_id > 0 ) { $this->apply_hours( $agent_id, $hours ); } $this->advance_from( 3 ); } /** * Persist the first agent, pre-filled from the current WordPress user. * * @param array $params Input. * * @throws DomainException When name or email is invalid. */ public function save_you( array $params ): void { $user = $this->current_user(); $name = isset( $params['agent_name'] ) ? trim( (string) $params['agent_name'] ) : $user['display_name']; $email = isset( $params['agent_email'] ) ? trim( (string) $params['agent_email'] ) : $user['email']; if ( '' === $name ) { $name = $user['display_name']; } if ( '' === $name ) { $name = __( 'Practice owner', 'buildler' ); } if ( '' === $email ) { $email = $user['email']; } if ( '' === $email ) { $admin = get_option( 'admin_email', '' ); $email = is_string( $admin ) ? trim( $admin ) : ''; } if ( '' === $email ) { throw new DomainException( 'wizard.agent_email', 'Enter an email address for the first agent.' ); } $stored = $this->read_option(); $agent_id = (int) ( $stored['agent_id'] ?? 0 ); $payload = [ 'display_name' => $name, 'email' => $email, 'timezone' => (string) $this->settings->get( 'general.timezone' ), 'status' => 'active', 'wp_user_id' => $user['id'] > 0 ? $user['id'] : null, ]; if ( $agent_id > 0 ) { $agent = $this->agents->update( $agent_id, $payload ); } else { $existing = $user['id'] > 0 ? $this->agent_port->find_by_wp_user_id( $user['id'] ) : null; if ( null !== $existing && null !== $existing->id() ) { $agent = $this->agents->update( (int) $existing->id(), $payload ); } else { $agent = $this->agents->create( $payload ); } } $agent_id = (int) $agent->id(); $stored['agent_id'] = $agent_id; $stored['agent_name'] = $name; $stored['agent_email'] = $email; $this->write_option( $stored ); $this->apply_hours( $agent_id, $this->hours_from_stored( $stored ) ); $this->link_agent_to_service( $stored ); $this->advance_from( 4 ); } /** * Persist the payments choice. On-site is the first-run default. * * Stripe Connect OAuth is not implemented; operators add keys later in Settings. * * @param array $params Input. */ public function save_payments( array $params ): void { $mode = isset( $params['payment_mode'] ) ? (string) $params['payment_mode'] : 'on_site'; if ( 'stripe_later' !== $mode ) { $mode = 'on_site'; } $this->settings->set( 'payments.pay_on_site_only', true ); $this->settings->set( 'payments.any_gateway_configured', false ); $this->settings->set( 'payments.test_mode', true ); $stored = $this->read_option(); $stored['payment_mode'] = $mode; $this->write_option( $stored ); $this->advance_from( 5 ); } /** * Create (or update) the booking form and a published WordPress page. * * @param array $params Input. * * @throws DomainException When the page cannot be published. */ public function save_booking_page( array $params ): void { $stored = $this->ensure_catalogue(); $service_id = (int) ( $stored['service_id'] ?? 0 ); $agent_id = (int) ( $stored['agent_id'] ?? 0 ); if ( $service_id < 1 || $agent_id < 1 ) { throw new DomainException( 'wizard.incomplete', 'Create a service and an agent before publishing the booking page.' ); } if ( ! empty( $params['seed_demo'] ) ) { $this->seed_second_service(); $stored = $this->read_option(); } $service_ids = [ $service_id ]; $demo_id = (int) ( $stored['demo_service_id'] ?? 0 ); if ( $demo_id > 0 && $demo_id !== $service_id ) { $service_ids[] = $demo_id; } $form_id = (int) ( $stored['form_id'] ?? 0 ); $business = (string) $this->settings->get( 'general.business_name' ); $form_name = '' !== $business ? $business . ' booking' : 'Booking form'; $form_data = [ 'name' => $form_name, 'steps' => [ 'service', 'datetime', 'details', 'confirmation' ], 'service_ids' => $service_ids, 'agent_ids' => [ $agent_id ], 'status' => 'active', 'settings' => [ 'require_phone' => false, 'marketing_opt_in' => false, 'cart_enabled' => false, ], ]; if ( $form_id > 0 ) { $form = $this->forms->update( $form_id, $form_data ); } else { $form = $this->forms->create( $form_data ); } $form_id = (int) $form->id(); $page_id = $this->publish_booking_page( (int) ( $stored['page_id'] ?? 0 ), $form_id, $business ); $stored['form_id'] = $form_id; $stored['page_id'] = $page_id; $stored['completed'] = true; $this->write_option( $stored ); $this->advance_from( 6, true ); } /** * Skip a step without writing domain data. The booking page cannot be skipped. * * @param string $step Step key. * * @throws DomainException When unknown, or when skipping the booking page. */ public function skip( string $step ): void { $index = $this->step_index( $step ); if ( 6 === $index ) { throw new DomainException( 'wizard.skip_page', 'Publish the booking page to finish setup.' ); } $this->advance_from( $index ); } /** * Create the first service and agent from defaults when earlier steps were skipped. * * @return array * * @throws DomainException When a service or agent cannot be created. */ private function ensure_catalogue(): array { $this->ensure_business_name(); $stored = $this->read_option(); if ( (int) ( $stored['service_id'] ?? 0 ) < 1 ) { $service_name = trim( (string) ( $stored['service_name'] ?? '' ) ); $duration_min = (int) ( $stored['duration_min'] ?? 60 ); $price_minor = (int) ( $stored['price_minor'] ?? 5000 ); $this->save_service( [ 'service_name' => '' !== $service_name ? $service_name : 'Consultation', 'duration_min' => $duration_min >= 5 ? $duration_min : 60, 'price_minor' => $price_minor >= 0 ? $price_minor : 5000, ] ); $stored = $this->read_option(); } if ( (int) ( $stored['agent_id'] ?? 0 ) < 1 ) { $this->save_you( [ 'agent_name' => (string) ( $stored['agent_name'] ?? '' ), 'agent_email' => (string) ( $stored['agent_email'] ?? '' ), ] ); $stored = $this->read_option(); } return $stored; } /** * Fill an empty business name from the WordPress site title. */ private function ensure_business_name(): void { $name = trim( (string) $this->settings->get( 'general.business_name' ) ); if ( '' !== $name ) { return; } $site = get_bloginfo( 'name' ); $fallback = is_string( $site ) ? trim( $site ) : ''; $this->settings->set( 'general.business_name', '' !== $fallback ? $fallback : 'My business' ); } /** * Default weekly hours: Monday–Friday 09:00–17:00. * * @return list */ public static function default_hours(): array { $hours = []; for ( $day = 0; $day <= 6; $day++ ) { $weekday = $day >= 1 && $day <= 5; $hours[] = [ 'day' => $day, 'start' => '09:00', 'end' => '17:00', 'enabled' => $weekday, ]; } return $hours; } /** * Advance if the stored cursor is at or before this step. * * @param int $from_step Completed step number (1–6). * @param bool $complete Mark the wizard finished. */ private function advance_from( int $from_step, bool $complete = false ): void { $stored = $this->read_option(); $current = (int) ( $stored['current_step'] ?? 1 ); $next = min( self::TOTAL_STEPS + 1, $from_step + 1 ); if ( $current <= $from_step ) { $stored['current_step'] = $next; } if ( $complete ) { $stored['completed'] = true; $stored['current_step'] = self::TOTAL_STEPS + 1; } $this->write_option( $stored ); } /** * Map a step key to its 1-based index. * * @param string $step Step key. * * @throws DomainException When unknown. */ private function step_index( string $step ): int { return match ( $step ) { 'business' => 1, 'service' => 2, 'hours' => 3, 'you' => 4, 'payments' => 5, 'booking_page' => 6, default => throw new DomainException( 'wizard.unknown_step', 'Unknown wizard step.' ), }; } /** * Reject unknown IANA timezones. * * @param string $timezone IANA name. * * @throws DomainException When unknown to PHP. */ private function assert_timezone( string $timezone ): void { try { new \DateTimeZone( $timezone ); } catch ( \Exception ) { throw new DomainException( 'wizard.timezone', 'Enter a valid IANA timezone such as Europe/London.' ); } } /** * Normalise a weekly-hours payload. * * @param mixed $raw Hours payload. * @return list * * @throws DomainException When a row is invalid. */ private function sanitise_hours( mixed $raw ): array { $by_day = []; foreach ( self::default_hours() as $row ) { $by_day[ $row['day'] ] = $row; } if ( ! is_array( $raw ) ) { return array_values( $by_day ); } foreach ( $raw as $row ) { if ( ! is_array( $row ) ) { continue; } $day = isset( $row['day'] ) ? (int) $row['day'] : -1; if ( $day < 0 || $day > 6 ) { throw new DomainException( 'wizard.hours_day', 'Weekday must be between 0 (Sunday) and 6 (Saturday).' ); } $start = $this->normalise_clock( isset( $row['start'] ) ? (string) $row['start'] : '09:00' ); $end = $this->normalise_clock( isset( $row['end'] ) ? (string) $row['end'] : '17:00' ); $by_day[ $day ] = [ 'day' => $day, 'start' => $start, 'end' => $end, 'enabled' => ! empty( $row['enabled'] ), ]; } ksort( $by_day ); return array_values( $by_day ); } /** * Accept HH:MM or HH:MM:SS and return HH:MM. * * @param string $clock HH:MM or HH:MM:SS. * * @throws DomainException When invalid. */ private function normalise_clock( string $clock ): string { $clock = trim( $clock ); if ( 1 === preg_match( '/^\d{2}:\d{2}$/', $clock ) ) { return $clock; } if ( 1 === preg_match( '/^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/', $clock ) ) { return substr( $clock, 0, 5 ); } throw new DomainException( 'wizard.hours_time', 'Times must be HH:MM.' ); } /** * Hours from stored state, or the weekday default. * * @param array $stored Option row. * @return list */ private function hours_from_stored( array $stored ): array { try { return $this->sanitise_hours( $stored['hours'] ?? null ); } catch ( DomainException ) { return self::default_hours(); } } /** * Replace an agent's weekly rows with the wizard hours. * * @param int $agent_id Agent id. * @param list $hours Hours. */ private function apply_hours( int $agent_id, array $hours ): void { foreach ( $this->schedules->find_by_owner( ScheduleOwnerType::Agent, $agent_id ) as $existing ) { $this->schedules->delete( $existing ); } foreach ( $hours as $row ) { if ( empty( $row['enabled'] ) ) { continue; } $this->schedules->save( new WeeklySchedule( null, ScheduleOwnerType::Agent, $agent_id, (int) $row['day'], (string) $row['start'], (string) $row['end'] ) ); } } /** * Attach the wizard agent to the wizard service when both exist. * * @param array $stored Option row. */ private function link_agent_to_service( array $stored ): void { $agent_id = (int) ( $stored['agent_id'] ?? 0 ); $service_id = (int) ( $stored['service_id'] ?? 0 ); if ( $agent_id < 1 || $service_id < 1 ) { return; } $this->agent_port->assign_service( $agent_id, $service_id ); } /** * Optional second example service so the picker is not a single card. */ private function seed_second_service(): void { $stored = $this->read_option(); if ( ! empty( $stored['demo_service_id'] ) ) { return; } $service = $this->services->create( [ 'name' => 'Follow-up', 'status' => 'active', ] ); $sid = (int) $service->id(); $this->durations->create( [ 'service_id' => $sid, 'duration_min' => 30, 'price_minor' => 2500, 'currency' => (string) $this->settings->get( 'general.currency' ), 'label' => '30 min', 'is_default' => true, ] ); $agent_id = (int) ( $stored['agent_id'] ?? 0 ); if ( $agent_id > 0 ) { $this->agent_port->assign_service( $agent_id, $sid ); } $stored['demo_service_id'] = $sid; $this->write_option( $stored ); } /** * Publish or update the customer-facing booking page. * * @param int $page_id Existing page id, or 0. * @param int $form_id Booking form id. * @param string $business Business name. * * @throws DomainException When WordPress rejects the write. */ private function publish_booking_page( int $page_id, int $form_id, string $business ): int { $title = '' !== $business ? sprintf( /* translators: %s: business name */ __( 'Book with %s', 'buildler' ), $business ) : __( 'Book a session', 'buildler' ); $intro = __( 'Choose a time that suits you. Confirmation arrives by email.', 'buildler' ); $content = sprintf( "\n

%s

\n\n\n", esc_html( $intro ), $form_id ); $payload = [ 'post_title' => $title, 'post_name' => 'book-online', 'post_status' => 'publish', 'post_type' => 'page', 'post_content' => $content, ]; $existing = $page_id > 0 ? get_post( $page_id ) : get_page_by_path( 'book-online' ); if ( $existing instanceof WP_Post ) { $payload['ID'] = $existing->ID; $result = wp_update_post( $payload, true ); } else { $result = wp_insert_post( $payload, true ); } if ( $result instanceof WP_Error || $result < 1 ) { $message = $result instanceof WP_Error ? $result->get_error_message() : 'Could not publish the booking page.'; throw new DomainException( 'wizard.page', esc_html( $message ) ); } return (int) $result; } /** * Current WordPress user, or empty placeholders. * * @return array{id: int, display_name: string, email: string} */ private function current_user(): array { $user = wp_get_current_user(); if ( ! $user instanceof WP_User || $user->ID < 1 ) { return [ 'id' => 0, 'display_name' => '', 'email' => '', ]; } return [ 'id' => (int) $user->ID, 'display_name' => (string) $user->display_name, 'email' => (string) $user->user_email, ]; } /** * Read the wizard option row. * * @return array */ private function read_option(): array { $state = get_option( self::OPTION_NAME, [] ); return is_array( $state ) ? $state : []; } /** * Persist the wizard option row. * * @param array $state Option row. */ private function write_option( array $state ): void { update_option( self::OPTION_NAME, $state, false ); } }