booking
/
includes
/
page-setup-wizard
/
step-date-selection
/
class-wpbc-setup-wizard-date-selection-module.php
class-wpbc-setup-wizard-date-selection-module.php in Booking Calendar 11.9, at includes/page-setup-wizard/step-date-selection/class-wpbc-setup-wizard-date-selection-module.php
| 1 | <?php |
| 2 | /** |
| 3 | * Reusable module adapter for the Setup Wizard Date Selection editor. |
| 4 | * |
| 5 | * @package Booking Calendar |
| 6 | */ |
| 7 | |
| 8 | if ( ! defined( 'ABSPATH' ) ) { |
| 9 | exit; |
| 10 | } |
| 11 | |
| 12 | /** |
| 13 | * Expose canonical date-selection behavior through the setup-module contract. |
| 14 | */ |
| 15 | final class WPBC_Setup_Wizard_Date_Selection_Module implements WPBC_Setup_Wizard_Step_Module, WPBC_Setup_Wizard_Current_Values_Module, WPBC_Setup_Wizard_Step_Values_Validator, WPBC_Setup_Wizard_Contextual_Values_Validator { |
| 16 | |
| 17 | /** @var WPBC_Setup_Wizard_Date_Selection */ |
| 18 | private $date_selection; |
| 19 | |
| 20 | /** |
| 21 | * Build the module around the date-selection domain service. |
| 22 | * |
| 23 | * @param WPBC_Setup_Wizard_Date_Selection|null $date_selection Optional service for testing. |
| 24 | */ |
| 25 | public function __construct( $date_selection = null ) { |
| 26 | $this->date_selection = $date_selection instanceof WPBC_Setup_Wizard_Date_Selection |
| 27 | ? $date_selection |
| 28 | : new WPBC_Setup_Wizard_Date_Selection(); |
| 29 | } |
| 30 | |
| 31 | /** |
| 32 | * Return the stable step identifier. |
| 33 | * |
| 34 | * @return string Stable identifier. |
| 35 | */ |
| 36 | public function get_step_id() { |
| 37 | return 'date_selection'; |
| 38 | } |
| 39 | |
| 40 | /** |
| 41 | * Return rail and template metadata. |
| 42 | * |
| 43 | * @return array<string,string> Step definition. |
| 44 | */ |
| 45 | public function get_step_definition() { |
| 46 | return array( |
| 47 | 'id' => $this->get_step_id(), |
| 48 | 'label' => __( 'Date selection', 'booking' ), |
| 49 | 'template' => 'step-date-selection', |
| 50 | 'footer_note' => __( 'Save & continue applies these Date Selection settings immediately.', 'booking' ), |
| 51 | ); |
| 52 | } |
| 53 | |
| 54 | /** |
| 55 | * Return the domain-owned allow-listed template path. |
| 56 | * |
| 57 | * @return string Absolute template path. |
| 58 | */ |
| 59 | public function get_template_path() { |
| 60 | return __DIR__ . '/template.php'; |
| 61 | } |
| 62 | |
| 63 | /** |
| 64 | * Return the ordered Date Selection draft contract. |
| 65 | * |
| 66 | * @return string[] Field allow-list. |
| 67 | */ |
| 68 | public function get_field_names() { |
| 69 | $field_names = array( |
| 70 | 'date_selection_mode', |
| 71 | 'date_selection_fixed_days', |
| 72 | 'date_selection_fixed_weekdays', |
| 73 | 'date_selection_dynamic_min', |
| 74 | 'date_selection_dynamic_max', |
| 75 | 'date_selection_dynamic_weekdays', |
| 76 | 'date_selection_dynamic_specific', |
| 77 | 'date_selection_changeover_enabled', |
| 78 | 'date_selection_check_in_time', |
| 79 | 'date_selection_check_out_time', |
| 80 | 'date_selection_triangles', |
| 81 | 'date_selection_checkout_available', |
| 82 | 'date_selection_recurrent_time', |
| 83 | 'date_selection_legend_enabled', |
| 84 | 'date_selection_legend_show_numbers', |
| 85 | 'date_selection_legend_vertical', |
| 86 | ); |
| 87 | |
| 88 | foreach ( $this->date_selection->get_legend_items() as $legend_item ) { |
| 89 | $field_names[] = 'date_selection_legend_item_' . $legend_item['id']; |
| 90 | $field_names[] = 'date_selection_legend_text_' . $legend_item['id']; |
| 91 | } |
| 92 | |
| 93 | return $field_names; |
| 94 | } |
| 95 | |
| 96 | /** |
| 97 | * Require the complete policy so later apply logic never guesses values. |
| 98 | * |
| 99 | * @return string[] Required fields. |
| 100 | */ |
| 101 | public function get_required_fields() { |
| 102 | return $this->get_field_names(); |
| 103 | } |
| 104 | |
| 105 | /** |
| 106 | * Return current canonical settings as safe draft suggestions. |
| 107 | * |
| 108 | * @return array<string,string> Initial values. |
| 109 | */ |
| 110 | public function get_initial_values() { |
| 111 | return $this->date_selection->get_initial_values(); |
| 112 | } |
| 113 | |
| 114 | /** |
| 115 | * Refresh the complete Date Selection policy from canonical options. |
| 116 | * |
| 117 | * @return string[] Current-value field identifiers. |
| 118 | */ |
| 119 | public function get_current_value_field_names() { |
| 120 | return $this->get_field_names(); |
| 121 | } |
| 122 | |
| 123 | /** |
| 124 | * Return the Customer Journey dependency that owns compatible modes. |
| 125 | * |
| 126 | * @return string[] Dependency step identifiers. |
| 127 | */ |
| 128 | public function get_context_dependencies() { |
| 129 | return array( 'customer_journey' ); |
| 130 | } |
| 131 | |
| 132 | /** |
| 133 | * Build the data-only Date Selection context and real calendar preview. |
| 134 | * |
| 135 | * @param array<string,mixed> $field_values Validated module values. |
| 136 | * @param array<string,mixed> $consumer_context Read-only dependency values. |
| 137 | * |
| 138 | * @return array<string,mixed> Authorized template context. |
| 139 | */ |
| 140 | public function get_context( array $field_values, array $consumer_context = array() ) { |
| 141 | $customer_journey_id = $this->get_customer_journey_id( $consumer_context ); |
| 142 | if ( '' === $customer_journey_id ) { |
| 143 | $customer_journey_id = 'guided_appointment_flow'; |
| 144 | } |
| 145 | $has_saved_values = ! empty( $consumer_context['_step_state']['has_saved_values'] ); |
| 146 | $is_journey_transition = $this->has_pending_customer_journey_transition( |
| 147 | isset( $consumer_context['_step_results'] ) && is_array( $consumer_context['_step_results'] ) |
| 148 | ? $consumer_context['_step_results'] |
| 149 | : array() |
| 150 | ); |
| 151 | $journey_policy = $this->date_selection->get_customer_journey_policy( $customer_journey_id ); |
| 152 | $field_values = $this->date_selection->apply_customer_journey_defaults( $field_values, $customer_journey_id, $has_saved_values, $is_journey_transition ); |
| 153 | |
| 154 | $resource_id = $this->get_preview_resource_id(); |
| 155 | $preview_result = $this->render_calendar_preview( $resource_id, $field_values, $customer_journey_id ); |
| 156 | $time_options = $this->date_selection->get_time_options( $field_values['date_selection_check_in_time'] ); |
| 157 | $time_options = array_replace( |
| 158 | $time_options, |
| 159 | $this->date_selection->get_time_options( $field_values['date_selection_check_out_time'] ) |
| 160 | ); |
| 161 | ksort( $time_options ); |
| 162 | |
| 163 | return array( |
| 164 | 'values' => $field_values, |
| 165 | 'customer_journey_policy' => $journey_policy, |
| 166 | 'weekdays' => $this->date_selection->get_weekdays(), |
| 167 | 'specific_day_presets' => $this->date_selection->get_specific_day_presets(), |
| 168 | 'legend_items' => $this->date_selection->get_legend_items(), |
| 169 | 'legend_preview_items' => $this->render_legend_preview_items( $field_values, $resource_id ), |
| 170 | 'time_options' => $time_options, |
| 171 | 'supports_advanced_range_rules' => $this->date_selection->supports_advanced_range_rules(), |
| 172 | 'preview_resource_id' => $resource_id, |
| 173 | 'preview_html' => $preview_result['html'], |
| 174 | 'preview_error' => $preview_result['error'], |
| 175 | ); |
| 176 | } |
| 177 | |
| 178 | /** |
| 179 | * Determine whether Customer Journey was saved after Date Selection. |
| 180 | * |
| 181 | * Every successful progressive save records the checkpoint revision observed |
| 182 | * by that operation. A newer Customer Journey result means the current Date |
| 183 | * Selection page was reached through that forward transition. Comparing |
| 184 | * existing server-owned metadata keeps the recommendation stable on refresh |
| 185 | * without adding a browser-controlled query flag or changing live settings. |
| 186 | * |
| 187 | * @param array<string,mixed> $step_results Normalized progressive-save results. |
| 188 | * |
| 189 | * @return bool True when the Customer Journey transition is pending. |
| 190 | */ |
| 191 | public function has_pending_customer_journey_transition( array $step_results ) { |
| 192 | return WPBC_Setup_Wizard_Customer_Journey_Policy::has_pending_transition_for_step( $step_results, 'date_selection' ); |
| 193 | } |
| 194 | |
| 195 | /** |
| 196 | * Validate one module field through the domain service. |
| 197 | * |
| 198 | * @param string $field_id Stable field identifier. |
| 199 | * @param mixed $raw_value Untrusted submitted or stored value. |
| 200 | * @param bool $is_required Whether an empty value is invalid. |
| 201 | * |
| 202 | * @return string|WP_Error Normalized value or an error. |
| 203 | */ |
| 204 | public function validate_field( $field_id, $raw_value, $is_required ) { |
| 205 | return $this->date_selection->validate_field( $field_id, $raw_value, $is_required ); |
| 206 | } |
| 207 | |
| 208 | /** |
| 209 | * Validate relationships between normalized Date Selection fields. |
| 210 | * |
| 211 | * @param array<string,mixed> $validated_values Individually validated field values. |
| 212 | * |
| 213 | * @return array<string,mixed>|WP_Error Validated values or a field-addressable error. |
| 214 | */ |
| 215 | public function validate_values( array $validated_values ) { |
| 216 | if ( |
| 217 | isset( $validated_values['date_selection_dynamic_min'], $validated_values['date_selection_dynamic_max'] ) |
| 218 | && (int) $validated_values['date_selection_dynamic_max'] < (int) $validated_values['date_selection_dynamic_min'] |
| 219 | ) { |
| 220 | $message = __( 'The maximum flexible range must be greater than or equal to the minimum range.', 'booking' ); |
| 221 | |
| 222 | return new WP_Error( |
| 223 | 'wpbc_setup_wizard_date_selection_range_invalid', |
| 224 | $message, |
| 225 | array( |
| 226 | 'field_errors' => array( |
| 227 | 'date_selection_dynamic_max' => $message, |
| 228 | ), |
| 229 | ) |
| 230 | ); |
| 231 | } |
| 232 | |
| 233 | if ( ! empty( $validated_values['date_selection_dynamic_specific'] ) ) { |
| 234 | $specific_days = array_map( 'intval', explode( ',', $validated_values['date_selection_dynamic_specific'] ) ); |
| 235 | $minimum_days = isset( $validated_values['date_selection_dynamic_min'] ) ? (int) $validated_values['date_selection_dynamic_min'] : 1; |
| 236 | $maximum_days = isset( $validated_values['date_selection_dynamic_max'] ) ? (int) $validated_values['date_selection_dynamic_max'] : WPBC_Setup_Wizard_Date_Selection::MAX_DYNAMIC_DAYS; |
| 237 | |
| 238 | foreach ( $specific_days as $specific_day ) { |
| 239 | if ( $specific_day < $minimum_days || $specific_day > $maximum_days ) { |
| 240 | $message = __( 'Every specific day length must be within the minimum and maximum flexible range.', 'booking' ); |
| 241 | |
| 242 | return new WP_Error( |
| 243 | 'wpbc_setup_wizard_date_selection_specific_range_invalid', |
| 244 | $message, |
| 245 | array( |
| 246 | 'field_errors' => array( |
| 247 | 'date_selection_dynamic_specific' => $message, |
| 248 | ), |
| 249 | ) |
| 250 | ); |
| 251 | } |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | // A check-out date exists only for range selections. Normalize stale or |
| 256 | // tampered values before the request-local preview consumes the draft. |
| 257 | if ( |
| 258 | isset( $validated_values['date_selection_mode'] ) |
| 259 | && in_array( $validated_values['date_selection_mode'], array( 'single', 'multiple' ), true ) |
| 260 | ) { |
| 261 | $validated_values['date_selection_checkout_available'] = 'Off'; |
| 262 | } |
| 263 | |
| 264 | // Match the canonical Calendar settings: changeover processing conflicts |
| 265 | // with recurrent time and an independently available check-out date. |
| 266 | if ( isset( $validated_values['date_selection_changeover_enabled'] ) && 'On' === $validated_values['date_selection_changeover_enabled'] ) { |
| 267 | $validated_values['date_selection_checkout_available'] = 'Off'; |
| 268 | $validated_values['date_selection_recurrent_time'] = 'Off'; |
| 269 | } |
| 270 | |
| 271 | return $validated_values; |
| 272 | } |
| 273 | |
| 274 | /** |
| 275 | * Enforce the selected Customer Journey after normal field validation. |
| 276 | * |
| 277 | * @param array<string,mixed> $validated_values Date Selection values after module validation. |
| 278 | * @param array<string,mixed> $consumer_context Read-only Customer Journey values. |
| 279 | * |
| 280 | * @return array<string,mixed>|WP_Error Journey-compatible values or a field-addressable error. |
| 281 | */ |
| 282 | public function validate_values_with_context( array $validated_values, array $consumer_context ) { |
| 283 | $customer_journey_id = $this->get_customer_journey_id( $consumer_context ); |
| 284 | if ( '' === $customer_journey_id ) { |
| 285 | return $validated_values; |
| 286 | } |
| 287 | |
| 288 | return $this->date_selection->validate_values_for_customer_journey( |
| 289 | $validated_values, |
| 290 | $customer_journey_id |
| 291 | ); |
| 292 | } |
| 293 | |
| 294 | /** |
| 295 | * Enqueue compiled Date Selection assets and the real calendar runtime. |
| 296 | * |
| 297 | * @param string $module_url Setup Wizard module URL. |
| 298 | * @param string|false $asset_version Plugin asset version. |
| 299 | * @param string $shared_style_handle Shared wizard style handle. |
| 300 | * @param string $shared_script_handle Shared wizard script handle. |
| 301 | * |
| 302 | * @return void |
| 303 | */ |
| 304 | public function enqueue_assets( $module_url, $asset_version, $shared_style_handle, $shared_script_handle ) { |
| 305 | $module_url = trailingslashit( $module_url ); |
| 306 | |
| 307 | wp_enqueue_style( 'wpbc-calendar' ); |
| 308 | wp_enqueue_script( 'wpbc_all' ); |
| 309 | wp_enqueue_style( 'wpbc-setup-wizard-date-selection', $module_url . 'step-date-selection/_out/step-date-selection.css', array( $shared_style_handle, 'wpbc-calendar' ), $asset_version ); |
| 310 | wp_enqueue_script( 'wpbc-setup-wizard-date-selection', $module_url . 'step-date-selection/_out/step-date-selection.js', array( $shared_script_handle, 'wpbc_all' ), $asset_version, true ); |
| 311 | wp_localize_script( |
| 312 | 'wpbc-setup-wizard-date-selection', |
| 313 | 'wpbc_setup_wizard_date_selection', |
| 314 | array( |
| 315 | 'max_fixed_days' => WPBC_Setup_Wizard_Date_Selection::MAX_FIXED_DAYS, |
| 316 | 'max_dynamic_days' => WPBC_Setup_Wizard_Date_Selection::MAX_DYNAMIC_DAYS, |
| 317 | 'preview_nonce' => wp_create_nonce( WPBC_Setup_Wizard_Date_Selection::PREVIEW_NONCE_ACTION ), |
| 318 | 'i18n' => array( |
| 319 | 'mode_required' => __( 'Choose how customers select dates.', 'booking' ), |
| 320 | 'fixed_days_invalid' => __( 'Enter a fixed range between 1 and 180 days.', 'booking' ), |
| 321 | 'dynamic_days_invalid' => __( 'Enter a flexible range between 1 and 1095 days, with the maximum not less than the minimum.', 'booking' ), |
| 322 | 'specific_days_invalid' => __( 'Enter comma-separated day lengths within the flexible range, for example 7,14,21,28.', 'booking' ), |
| 323 | ), |
| 324 | ) |
| 325 | ); |
| 326 | } |
| 327 | |
| 328 | /** |
| 329 | * Resolve the current default booking resource through the canonical helper. |
| 330 | * |
| 331 | * @return int Positive resource ID. |
| 332 | */ |
| 333 | private function get_preview_resource_id() { |
| 334 | $resource_id = class_exists( 'WPBC_FE_Attr_Postprocessor' ) |
| 335 | ? absint( WPBC_FE_Attr_Postprocessor::get_default_booking_resource_id() ) |
| 336 | : 1; |
| 337 | |
| 338 | return $resource_id > 0 ? $resource_id : 1; |
| 339 | } |
| 340 | |
| 341 | /** |
| 342 | * Read the allow-listed Customer Journey identifier from consumer context. |
| 343 | * |
| 344 | * @param array<string,mixed> $consumer_context Read-only dependency values. |
| 345 | * |
| 346 | * @return string Stable Customer Journey identifier. |
| 347 | */ |
| 348 | private function get_customer_journey_id( array $consumer_context ) { |
| 349 | if ( |
| 350 | isset( $consumer_context['customer_journey']['customer_journey'] ) |
| 351 | && is_scalar( $consumer_context['customer_journey']['customer_journey'] ) |
| 352 | ) { |
| 353 | return sanitize_key( (string) $consumer_context['customer_journey']['customer_journey'] ); |
| 354 | } |
| 355 | |
| 356 | return ''; |
| 357 | } |
| 358 | |
| 359 | /** |
| 360 | * Render a real, selectable two-month Booking Calendar preview. |
| 361 | * |
| 362 | * The renderer loads actual availability and bookings for the authorized |
| 363 | * default resource. Browser code changes only request-local selection |
| 364 | * parameters and never submits a booking or writes canonical settings. |
| 365 | * |
| 366 | * @param int $resource_id Authorized preview resource ID. |
| 367 | * @param array<string,string> $field_values Validated Date Selection draft. |
| 368 | * @param string $customer_journey_id Stable Customer Journey identifier. |
| 369 | * |
| 370 | * @return array{html:string,error:string} Preview result. |
| 371 | */ |
| 372 | private function render_calendar_preview( $resource_id, array $field_values, $customer_journey_id ) { |
| 373 | if ( ! class_exists( 'WPBC_FE_Render' ) ) { |
| 374 | return array( |
| 375 | 'html' => '', |
| 376 | 'error' => __( 'The customer calendar preview is unavailable.', 'booking' ), |
| 377 | ); |
| 378 | } |
| 379 | |
| 380 | $preview_configuration = $this->date_selection->get_preview_configuration( $field_values, $customer_journey_id ); |
| 381 | $request_overrides = $preview_configuration['calendar_request_overrides']; |
| 382 | // This page renders its configurable legend below the calendar, so suppress |
| 383 | // only the calendar renderer's duplicate legend in this local preview. |
| 384 | $request_overrides['wpbc_settings_calendar_preview_show_legend'] = 'Off'; |
| 385 | |
| 386 | $preview_html = WPBC_FE_Render::render_calendar_only( |
| 387 | array( |
| 388 | 'resource_id' => $resource_id, |
| 389 | 'cal_count' => 2, |
| 390 | 'is_echo' => 0, |
| 391 | 'shortcode_param__options' => '{calendar months_num_in_row=2 width=1023px}', |
| 392 | 'calendar_request_overrides' => $request_overrides, |
| 393 | ) |
| 394 | ); |
| 395 | |
| 396 | if ( ! is_string( $preview_html ) || '' === trim( $preview_html ) ) { |
| 397 | return array( |
| 398 | 'html' => '', |
| 399 | 'error' => __( 'The customer calendar preview could not be rendered.', 'booking' ), |
| 400 | ); |
| 401 | } |
| 402 | |
| 403 | return array( |
| 404 | 'html' => $preview_html, |
| 405 | 'error' => '', |
| 406 | ); |
| 407 | } |
| 408 | |
| 409 | /** |
| 410 | * Render canonical calendar-legend samples for the request-local preview. |
| 411 | * |
| 412 | * Each item is rendered separately so browser code can show, hide, and |
| 413 | * relabel an existing safe node without constructing or evaluating HTML. |
| 414 | * The saved Booking Calendar options remain unchanged. |
| 415 | * |
| 416 | * @param array<string,string> $field_values Validated Date Selection draft. |
| 417 | * @param int $resource_id Authorized preview resource ID. |
| 418 | * |
| 419 | * @return array<string,string> Canonical sample HTML keyed by legend item ID. |
| 420 | */ |
| 421 | private function render_legend_preview_items( array $field_values, $resource_id ) { |
| 422 | $preview_items = array(); |
| 423 | if ( ! function_exists( 'wpbc_get_calendar_legend__content_html' ) ) { |
| 424 | return $preview_items; |
| 425 | } |
| 426 | |
| 427 | $show_numbers = isset( $field_values['date_selection_legend_show_numbers'] ) && 'On' === $field_values['date_selection_legend_show_numbers']; |
| 428 | $day_number = wp_date( 'd' ); |
| 429 | foreach ( $this->date_selection->get_legend_items() as $legend_item ) { |
| 430 | $legend_id = $legend_item['id']; |
| 431 | $title_key = 'date_selection_legend_text_' . $legend_id; |
| 432 | $legend_title = isset( $field_values[ $title_key ] ) && '' !== $field_values[ $title_key ] |
| 433 | ? $field_values[ $title_key ] |
| 434 | : $legend_item['placeholder']; |
| 435 | |
| 436 | $preview_items[ $legend_id ] = wpbc_get_calendar_legend__content_html( |
| 437 | array( |
| 438 | 'is_vertical' => false, |
| 439 | 'text_for_day_cell' => $show_numbers ? $day_number : '', |
| 440 | 'items' => array( $legend_id ), |
| 441 | 'resource_id' => absint( $resource_id ), |
| 442 | 'titles' => array( $legend_id => $legend_title ), |
| 443 | ) |
| 444 | ); |
| 445 | } |
| 446 | |
| 447 | return $preview_items; |
| 448 | } |
| 449 | } |
| 450 |