PluginProbe
Booking Calendar / 11.9
Booking Calendar v11.9
11.9 11.8.4 11.8.3 11.8.2 11.8.1 11.8 11.7 11.6.1 11.6 11.5 11.4.3 11.4.2 11.4.1 11.4 11.3 11.2.1 11.2 11.1 11.0 10.15.7 10.15.6 10.1.3 10.10 10.10.1 10.10.2 All 205 releases
booking / includes / page-setup-wizard / step-booking-form-template / class-wpbc-setup-wizard-booking-form-templates.php

class-wpbc-setup-wizard-booking-form-templates.php in Booking Calendar 11.9, at includes/page-setup-wizard/step-booking-form-template/class-wpbc-setup-wizard-booking-form-templates.php

711 lines 28.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Read-only Form Builder template catalog for Setup Wizard Page 10.
4 *
5 * @package Booking Calendar
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 /**
13 * Adapt the bundled Form Builder registry to presentation and preview DTOs.
14 *
15 * Browser responses receive only catalog metadata, sanitized rendered preview
16 * HTML, or a short-lived preview URL. Executable template definitions stay
17 * server-side and are resolved from the current registry allow-list for every
18 * preview request.
19 */
20 final class WPBC_Setup_Wizard_Booking_Form_Templates {
21
22 /**
23 * Normalized template DTOs cached for the current request.
24 *
25 * The established Form Builder registry loads every template definition when
26 * it is called. Step initialization, rendering, and validation can all need
27 * the same catalog, so retaining the normalized records avoids repeating that
28 * work without persisting or sharing registry state between requests.
29 *
30 * @var array<string,array<int,array<string,mixed>>>
31 */
32 private $templates_cache = array();
33
34 /**
35 * Server-owned normalized registry records cached for this request only.
36 *
37 * @var array<string,array<string,mixed>>|null
38 */
39 private $registry_records_cache = null;
40
41 /**
42 * Return the presentation context for the template selector.
43 *
44 * @param array<string,mixed> $step_values Validated values for this module.
45 * @param string $customer_journey Selected customer journey ID.
46 *
47 * @return array<string,mixed> Data-only selector context.
48 */
49 public function get_context( array $step_values, $customer_journey ) {
50 $templates = $this->get_templates( $customer_journey );
51 $selected_slug = isset( $step_values['booking_form_template'] ) && is_scalar( $step_values['booking_form_template'] )
52 ? sanitize_title( (string) $step_values['booking_form_template'] )
53 : '';
54 $booking_form_usage = isset( $step_values['booking_form_usage'] )
55 ? $this->validate_booking_form_usage_for_journey( $step_values['booking_form_usage'], $customer_journey, false )
56 : $this->get_initial_booking_form_usage();
57 $available_slugs = wp_list_pluck( $templates, 'slug' );
58
59 if ( ! in_array( $selected_slug, $available_slugs, true ) ) {
60 $selected_slug = $this->get_recommended_template_slug( $customer_journey );
61 }
62 if ( is_wp_error( $booking_form_usage ) || '' === $booking_form_usage ) {
63 $booking_form_usage = $this->get_initial_booking_form_usage();
64 }
65
66 return array(
67 'values' => array(
68 'booking_form_template' => $selected_slug,
69 'booking_form_usage' => $booking_form_usage,
70 ),
71 'templates' => $templates,
72 'filters' => $this->get_filter_records( $templates ),
73 'usage_options' => $this->get_booking_form_usage_options( $customer_journey ),
74 'selected_template' => $this->find_template( $templates, $selected_slug ),
75 'customer_journey' => is_scalar( $customer_journey ) ? sanitize_key( (string) $customer_journey ) : '',
76 'preview_is_static' => false,
77 );
78 }
79
80 /**
81 * Build a normalized, non-persistent preview payload for one allow-listed template.
82 *
83 * The returned form definitions are passed directly to the shared Form Builder
84 * preview service, which applies the canonical source sanitizers before either
85 * request-local rendering or transient preview storage. They are never returned
86 * to the browser as raw source and are never saved as a Form Builder form record.
87 *
88 * @param mixed $raw_template_slug Untrusted or stored template slug.
89 *
90 * @return array<string,mixed>|WP_Error Preview payload or validation error.
91 */
92 public function get_preview_payload( $raw_template_slug ) {
93 $template_slug = $this->validate_template_slug( $raw_template_slug, true );
94 if ( is_wp_error( $template_slug ) ) {
95 return $template_slug;
96 }
97
98 $registry_records = $this->get_registry_records();
99 if ( ! isset( $registry_records[ $template_slug ]['record'] ) || ! is_array( $registry_records[ $template_slug ]['record'] ) ) {
100 return new WP_Error( 'wpbc_setup_wizard_booking_form_preview_missing', __( 'The selected booking form template is no longer available.', 'booking' ) );
101 }
102
103 $record = $registry_records[ $template_slug ]['record'];
104 $structure_json = isset( $record['structure_json'] ) && is_scalar( $record['structure_json'] ) ? (string) $record['structure_json'] : '';
105 $structure = json_decode( $structure_json, true );
106 $structure = is_array( $structure ) ? $structure : array();
107 $advanced_form = isset( $record['advanced_form'] ) && is_scalar( $record['advanced_form'] ) ? (string) $record['advanced_form'] : '';
108 $content_form = isset( $record['content_form'] ) && is_scalar( $record['content_form'] ) ? (string) $record['content_form'] : '';
109
110 if ( empty( $structure ) && '' === trim( $advanced_form ) && '' === trim( $content_form ) ) {
111 return new WP_Error( 'wpbc_setup_wizard_booking_form_preview_empty', __( 'This booking form template does not contain previewable form data.', 'booking' ) );
112 }
113
114 return array(
115 'template_slug' => $template_slug,
116 'form_name' => $template_slug,
117 'structure' => $structure,
118 'advanced_form' => $advanced_form,
119 'content_form' => $content_form,
120 );
121 }
122
123 /**
124 * Build the canonical Form Builder configuration for an allow-listed template.
125 *
126 * The registry remains the only source of executable form definitions. The
127 * browser supplies only the stable slug, which is revalidated before the
128 * server-owned record is converted into the canonical FormConfig contract.
129 * Ownership and publication scope are added by the progressive save handler.
130 *
131 * @param mixed $raw_template_slug Untrusted or stored template slug.
132 *
133 * @return array<string,mixed>|WP_Error Canonical save configuration or error.
134 */
135 public function get_save_form_config( $raw_template_slug ) {
136 $template_slug = $this->validate_template_slug( $raw_template_slug, true );
137 if ( is_wp_error( $template_slug ) ) {
138 return $template_slug;
139 }
140
141 $registry_records = $this->get_registry_records();
142 if ( ! isset( $registry_records[ $template_slug ]['record'] ) || ! is_array( $registry_records[ $template_slug ]['record'] ) ) {
143 return new WP_Error( 'wpbc_setup_wizard_booking_form_template_missing', __( 'The selected booking form template is no longer available.', 'booking' ) );
144 }
145
146 $record = $registry_records[ $template_slug ]['record'];
147 $structure_json = isset( $record['structure_json'] ) && is_scalar( $record['structure_json'] ) ? (string) $record['structure_json'] : '';
148 $structure = json_decode( $structure_json, true );
149 if ( ! is_array( $structure ) || empty( $structure ) ) {
150 return new WP_Error( 'wpbc_setup_wizard_booking_form_template_structure_invalid', __( 'The selected booking form template cannot be saved because its structure is invalid.', 'booking' ) );
151 }
152
153 $settings = array();
154 if ( isset( $record['settings_json'] ) && is_scalar( $record['settings_json'] ) && '' !== (string) $record['settings_json'] ) {
155 $decoded_settings = json_decode( (string) $record['settings_json'], true );
156 $settings = is_array( $decoded_settings ) ? $decoded_settings : array();
157 }
158
159 return array(
160 'form_name' => $template_slug,
161 'engine' => isset( $record['engine'] ) ? sanitize_key( (string) $record['engine'] ) : 'bfb',
162 'engine_version' => isset( $record['engine_version'] ) ? sanitize_text_field( (string) $record['engine_version'] ) : '1.0',
163 'title' => isset( $record['title'] ) ? sanitize_text_field( (string) $record['title'] ) : $template_slug,
164 'description' => isset( $record['description'] ) ? sanitize_textarea_field( (string) $record['description'] ) : '',
165 'structure_json' => wp_json_encode( $structure ),
166 'settings' => $settings,
167 'advanced_form' => isset( $record['advanced_form'] ) && is_scalar( $record['advanced_form'] ) ? (string) $record['advanced_form'] : '',
168 'content_form' => isset( $record['content_form'] ) && is_scalar( $record['content_form'] ) ? (string) $record['content_form'] : '',
169 'picture_url' => isset( $record['picture_url'] ) ? esc_url_raw( (string) $record['picture_url'] ) : '',
170 'is_default' => 0,
171 'booking_resource_id' => null,
172 'status' => 'published',
173 );
174 }
175
176 /**
177 * Build an inline-only preview payload for one allow-listed template.
178 *
179 * Request-local previews cannot run the complete hint-calculation lifecycle,
180 * so their dynamic hint shortcodes are replaced before rendering. Signed
181 * iframe and new-window previews continue to use {@see get_preview_payload()}
182 * and therefore retain the normal hint behavior.
183 *
184 * @param mixed $raw_template_slug Untrusted or stored template slug.
185 *
186 * @return array<string,mixed>|WP_Error Inline preview payload or validation error.
187 */
188 public function get_inline_preview_payload( $raw_template_slug ) {
189 $preview_payload = $this->get_preview_payload( $raw_template_slug );
190 if ( is_wp_error( $preview_payload ) ) {
191 return $preview_payload;
192 }
193
194 $preview_payload['advanced_form'] = $this->replace_dynamic_hints_for_preview( $preview_payload['advanced_form'] );
195
196 return $preview_payload;
197 }
198
199 /**
200 * Replace dynamic hint shortcodes with an explicit static preview value.
201 *
202 * Request-local Setup Wizard previews intentionally do not calculate date,
203 * time, capacity, or cost hints. Replacing the server-owned template
204 * shortcodes before the canonical renderer runs prevents it from creating
205 * `.wpbc_field_hint` elements, so the existing Free and Pro clients skip
206 * their legacy hint AJAX requests. Signed iframe and published booking forms
207 * retain the original shortcodes and their complete hint lifecycle.
208 *
209 * @param string $advanced_form Sanitized later by the shared preview service.
210 *
211 * @return string Preview form source with dynamic hints replaced by static text.
212 */
213 private function replace_dynamic_hints_for_preview( $advanced_form ) {
214 if ( '' === trim( $advanced_form ) ) {
215 return $advanced_form;
216 }
217
218 $dynamic_hint_shortcodes = array(
219 '[additional_cost_hint]',
220 '[balance_hint]',
221 '[cancel_date_hint]',
222 '[capacity_hint]',
223 '[check_in_date_hint]',
224 '[check_out_date_hint]',
225 '[check_out_plus1day_hint]',
226 '[cost_hint]',
227 '[coupon_discount_hint]',
228 '[days_number_hint]',
229 '[deposit_hint]',
230 '[end_time_hint]',
231 '[estimate_day_cost_hint]',
232 '[estimate_night_cost_hint]',
233 '[nights_number_hint]',
234 '[original_cost_hint]',
235 '[pre_checkin_date_hint]',
236 '[resource_title_hint]',
237 '[selected_dates_hint]',
238 '[selected_short_dates_hint]',
239 '[selected_short_timedates_hint]',
240 '[selected_timedates_hint]',
241 '[service_title_hint]',
242 '[start_time_hint]',
243 );
244 $preview_value = sprintf(
245 '<span class="wpbc_setup_wizard__preview-static-hint">%s</span>',
246 esc_html__( 'Preview only', 'booking' )
247 );
248
249 return str_replace(
250 $dynamic_hint_shortcodes,
251 array_fill( 0, count( $dynamic_hint_shortcodes ), $preview_value ),
252 $advanced_form
253 );
254 }
255
256 /**
257 * Return the preferred initial template slug without writing Form Builder data.
258 *
259 * @return string Stable template slug, or an empty string when unavailable.
260 */
261 public function get_initial_template_slug() {
262 return $this->get_default_template_slug( $this->get_templates( 'guided_appointment_flow' ) );
263 }
264
265 /**
266 * Return the first recommended template for one Customer Journey.
267 *
268 * This is a presentation default only. The consuming module preserves a
269 * saved choice during ordinary revisits and reapplies this recommendation
270 * only after Customer Journey is explicitly saved again.
271 *
272 * @param string $customer_journey Stable Customer Journey identifier.
273 *
274 * @return string Recommended template slug, or the general default.
275 */
276 public function get_recommended_template_slug( $customer_journey ) {
277 $templates = $this->get_templates( $customer_journey );
278 $recommended_slugs = $this->get_recommended_template_slugs( $customer_journey );
279
280 foreach ( $recommended_slugs as $recommended_slug ) {
281 foreach ( $templates as $template ) {
282 if ( $recommended_slug === ( isset( $template['slug'] ) ? (string) $template['slug'] : '' ) ) {
283 return $recommended_slug;
284 }
285 }
286 }
287
288 return $this->get_initial_template_slug();
289 }
290
291 /**
292 * Return the initial public entry point used by the selected template.
293 *
294 * Direct rendering is the safest initial preview because it shows the exact
295 * selected template without depending on already-published Services or
296 * Providers. Appointment flow remains an explicit customer choice.
297 *
298 * @return string Stable Booking Form usage identifier.
299 */
300 public function get_initial_booking_form_usage() {
301 return 'direct_booking_form';
302 }
303
304 /**
305 * Return the allow-listed Booking Form entry points exposed by this step.
306 *
307 * Appointment Flow is a Guided Appointment entry point, not a generic Form
308 * Builder presentation mode. Omitting the journey keeps the complete global
309 * allow-list for compatibility callers; any explicitly supplied context must
310 * name Guided appointment to receive Appointment Flow.
311 *
312 * @param string|null $customer_journey Optional Customer Journey identifier. Omit only for global compatibility validation.
313 *
314 * @return array<int,array{id:string,label:string,description:string}> Usage option DTOs.
315 */
316 public function get_booking_form_usage_options( $customer_journey = null ) {
317 $usage_options = array(
318 array(
319 'id' => 'direct_booking_form',
320 'label' => __( 'Direct booking form', 'booking' ),
321 'description' => __( 'Show the selected booking form immediately.', 'booking' ),
322 ),
323 array(
324 'id' => 'appointment_flow',
325 'label' => __( 'Appointment flow', 'booking' ),
326 'description' => __( 'Let customers choose a published Service and Provider before opening the selected booking form.', 'booking' ),
327 ),
328 );
329 $has_journey_context = null !== $customer_journey;
330 $customer_journey = sanitize_key( is_scalar( $customer_journey ) ? (string) $customer_journey : '' );
331
332 if ( $has_journey_context && 'guided_appointment_flow' !== $customer_journey ) {
333 return array( $usage_options[0] );
334 }
335
336 return $usage_options;
337 }
338
339 /**
340 * Validate the selected Booking Form entry point against the exact allow-list.
341 *
342 * @param mixed $raw_booking_form_usage Untrusted or stored usage identifier.
343 * @param bool $is_required Whether an empty choice must be rejected.
344 *
345 * @return string|WP_Error Normalized usage identifier or a validation error.
346 */
347 public function validate_booking_form_usage( $raw_booking_form_usage, $is_required ) {
348 if ( ! is_scalar( $raw_booking_form_usage ) ) {
349 return new WP_Error( 'wpbc_setup_wizard_booking_form_usage_invalid', __( 'Choose a valid booking form entry point.', 'booking' ) );
350 }
351
352 $raw_booking_form_usage = trim( (string) $raw_booking_form_usage );
353 $booking_form_usage = sanitize_key( $raw_booking_form_usage );
354
355 if ( '' === $booking_form_usage ) {
356 return $is_required
357 ? new WP_Error( 'wpbc_setup_wizard_booking_form_usage_required', __( 'Choose how customers will open the booking form.', 'booking' ) )
358 : '';
359 }
360
361 $allowed_usage_ids = wp_list_pluck( $this->get_booking_form_usage_options(), 'id' );
362 if ( $booking_form_usage !== $raw_booking_form_usage || ! in_array( $booking_form_usage, $allowed_usage_ids, true ) ) {
363 return new WP_Error( 'wpbc_setup_wizard_booking_form_usage_unknown', __( 'Choose a valid booking form entry point.', 'booking' ) );
364 }
365
366 return $booking_form_usage;
367 }
368
369 /**
370 * Validate a Booking Form entry point for the active Customer Journey.
371 *
372 * This contextual boundary prevents a stale checkpoint or a tampered request
373 * from enabling Appointment Flow after the route changes to a time-based
374 * journey. The global validator remains available for compatibility callers
375 * that do not own journey context.
376 *
377 * @param mixed $raw_booking_form_usage Untrusted or stored usage identifier.
378 * @param mixed $customer_journey Server-owned Customer Journey identifier.
379 * @param bool $is_required Whether an empty choice must be rejected.
380 *
381 * @return string|WP_Error Normalized usage identifier or a validation error.
382 */
383 public function validate_booking_form_usage_for_journey( $raw_booking_form_usage, $customer_journey, $is_required ) {
384 $booking_form_usage = $this->validate_booking_form_usage( $raw_booking_form_usage, $is_required );
385 if ( is_wp_error( $booking_form_usage ) || '' === $booking_form_usage ) {
386 return $booking_form_usage;
387 }
388
389 $customer_journey = sanitize_key( is_scalar( $customer_journey ) ? (string) $customer_journey : '' );
390 $allowed_usage_ids = wp_list_pluck( $this->get_booking_form_usage_options( $customer_journey ), 'id' );
391 if ( ! in_array( $booking_form_usage, $allowed_usage_ids, true ) ) {
392 return new WP_Error(
393 'wpbc_setup_wizard_booking_form_usage_journey_invalid',
394 __( 'Appointment flow is available only for the Guided appointment customer journey.', 'booking' )
395 );
396 }
397
398 return $booking_form_usage;
399 }
400
401 /**
402 * Map an allow-listed usage identifier to the shared preview renderer.
403 *
404 * @param mixed $raw_booking_form_usage Untrusted usage identifier.
405 * @param string|null $customer_journey Optional server-owned Customer Journey identifier. Omit only for global compatibility validation.
406 *
407 * @return string|WP_Error Either booking or appointment, or a validation error.
408 */
409 public function get_preview_render_mode( $raw_booking_form_usage, $customer_journey = null ) {
410 $booking_form_usage = null === $customer_journey
411 ? $this->validate_booking_form_usage( $raw_booking_form_usage, true )
412 : $this->validate_booking_form_usage_for_journey( $raw_booking_form_usage, $customer_journey, true );
413 if ( is_wp_error( $booking_form_usage ) ) {
414 return $booking_form_usage;
415 }
416
417 return 'appointment_flow' === $booking_form_usage ? 'appointment' : 'booking';
418 }
419
420 /**
421 * Validate a selected template against the current registry allow-list.
422 *
423 * @param mixed $raw_template_slug Untrusted or stored template slug.
424 * @param bool $is_required Whether an empty choice must be rejected.
425 *
426 * @return string|WP_Error Normalized slug or a validation error.
427 */
428 public function validate_template_slug( $raw_template_slug, $is_required ) {
429 if ( ! is_scalar( $raw_template_slug ) ) {
430 return new WP_Error( 'wpbc_setup_wizard_booking_form_template_invalid', __( 'Choose a valid booking form template.', 'booking' ) );
431 }
432
433 $raw_template_slug = trim( (string) $raw_template_slug );
434 $template_slug = sanitize_title( $raw_template_slug );
435
436 if ( '' === $template_slug ) {
437 return $is_required
438 ? new WP_Error( 'wpbc_setup_wizard_booking_form_template_required', __( 'Choose a booking form template.', 'booking' ) )
439 : '';
440 }
441
442 if ( $template_slug !== $raw_template_slug || ! in_array( $template_slug, $this->get_template_slugs(), true ) ) {
443 return new WP_Error( 'wpbc_setup_wizard_booking_form_template_unknown', __( 'The selected booking form template is no longer available.', 'booking' ) );
444 }
445
446 return $template_slug;
447 }
448
449 /**
450 * Return the exact template slugs available from the read-only registry.
451 *
452 * @return string[] Stable template slugs.
453 */
454 private function get_template_slugs() {
455 return wp_list_pluck( $this->get_templates( 'guided_appointment_flow' ), 'slug' );
456 }
457
458 /**
459 * Read and normalize Form Builder template registry records.
460 *
461 * Demo-only fixture records are excluded so customer setup never exposes
462 * private development examples. Registry filters remain supported because
463 * they are the established Form Builder extension point.
464 *
465 * @param string $customer_journey Selected customer journey ID.
466 *
467 * @return array<int,array<string,mixed>> Presentation-only template DTOs.
468 */
469 private function get_templates( $customer_journey ) {
470 $cache_key = is_scalar( $customer_journey ) ? sanitize_key( (string) $customer_journey ) : '';
471
472 if ( isset( $this->templates_cache[ $cache_key ] ) ) {
473 return $this->templates_cache[ $cache_key ];
474 }
475
476 $template_dtos = array();
477 $registry_records = $this->get_registry_records();
478 $recommended_template_slugs = $this->get_recommended_template_slugs( $customer_journey );
479
480 foreach ( $registry_records as $template_slug => $template_config ) {
481 $record = $template_config['record'];
482
483 $category = $this->get_template_category( $template_slug );
484 $is_recommended = in_array( $template_slug, $recommended_template_slugs, true );
485 $raw_picture_url = isset( $record['picture_url'] ) && is_scalar( $record['picture_url'] ) ? (string) $record['picture_url'] : '';
486 $picture_url = function_exists( 'wpbc_bfb_resolve_picture_url' )
487 ? wpbc_bfb_resolve_picture_url( $raw_picture_url )
488 : '';
489 $template_title = isset( $record['title'] ) && is_scalar( $record['title'] ) ? trim( (string) $record['title'] ) : '';
490 $template_description = isset( $record['description'] ) && is_scalar( $record['description'] )
491 ? (string) $record['description']
492 : '';
493
494 $template_dtos[] = array(
495 'slug' => $template_slug,
496 'title' => '' !== $template_title
497 ? sanitize_text_field( $template_title )
498 : sanitize_text_field( ucwords( str_replace( array( '-', '_' ), ' ', $template_slug ) ) ),
499 'description' => sanitize_textarea_field( $template_description ),
500 'picture_url' => esc_url_raw( (string) $picture_url ),
501 'category' => $category,
502 'is_recommended' => $is_recommended,
503 );
504 }
505
506 /*
507 * The Form Builder library lists template rows newest-first. Bundled
508 * registry records are intentionally appended in increasing display
509 * priority so the last seeded row becomes the first library card. Mirror
510 * that established order here instead of exposing the seed order in the
511 * Setup Wizard.
512 */
513 $template_dtos = array_reverse( $template_dtos );
514
515 $this->templates_cache[ $cache_key ] = $template_dtos;
516
517 return $this->templates_cache[ $cache_key ];
518 }
519
520 /**
521 * Return unique, non-demo template records from the established registry.
522 *
523 * Records remain server-side. This method establishes the single allow-list
524 * used by catalog rendering, field validation, and interactive previews.
525 *
526 * @return array<string,array<string,mixed>> Records keyed by stable form slug.
527 */
528 private function get_registry_records() {
529 if ( null !== $this->registry_records_cache ) {
530 return $this->registry_records_cache;
531 }
532
533 $this->registry_records_cache = array();
534 if ( ! function_exists( 'wpbc_bfb_activation__get_templates_registry' ) ) {
535 return $this->registry_records_cache;
536 }
537
538 foreach ( (array) wpbc_bfb_activation__get_templates_registry() as $template_config ) {
539 if ( ! is_array( $template_config ) || empty( $template_config['record'] ) || ! is_array( $template_config['record'] ) ) {
540 continue;
541 }
542
543 $template_key = isset( $template_config['template_key'] ) && is_scalar( $template_config['template_key'] )
544 ? sanitize_key( (string) $template_config['template_key'] )
545 : '';
546 $record = $template_config['record'];
547 $template_slug = isset( $record['form_slug'] ) && is_scalar( $record['form_slug'] )
548 ? sanitize_title( (string) $record['form_slug'] )
549 : '';
550
551 if ( '' === $template_slug || 0 === strpos( $template_key, 'demo_' ) || isset( $this->registry_records_cache[ $template_slug ] ) ) {
552 continue;
553 }
554
555 $this->registry_records_cache[ $template_slug ] = array(
556 'template_key' => $template_key,
557 'record' => $record,
558 );
559 }
560
561 return $this->registry_records_cache;
562 }
563
564 /**
565 * Map a stable Form Builder slug to a selector category.
566 *
567 * @param string $template_slug Stable template slug.
568 *
569 * @return string One of appointments, time_slots, full_days, or other.
570 */
571 private function get_template_category( $template_slug ) {
572 if ( 0 === strpos( $template_slug, 'appointments_services_' ) || 0 === strpos( $template_slug, 'time_appointments_' ) ) {
573 return 'appointments';
574 }
575
576 if ( 0 === strpos( $template_slug, 'time_slots_' ) ) {
577 return 'time_slots';
578 }
579
580 if ( 0 === strpos( $template_slug, 'dates_' ) ) {
581 return 'full_days';
582 }
583
584 return 'other';
585 }
586
587 /**
588 * Return the template recommendations owned by each supported journey.
589 *
590 * Recommendations are deliberately narrower than selector categories. The
591 * Guided appointment flow needs templates with a compatible appointment
592 * structure, while the Appointments tab must continue to expose the complete
593 * appointment-oriented library.
594 *
595 * @param string $customer_journey Selected customer journey ID.
596 *
597 * @return string[] Ordered stable template slugs for the current journey.
598 */
599 private function get_recommended_template_slugs( $customer_journey ) {
600 $customer_journey = is_scalar( $customer_journey ) ? sanitize_key( (string) $customer_journey ) : '';
601 $start_end_recommendations = array(
602 'time_slots_start_end_times_1_hour_selection',
603 );
604 $full_day_recommendations = array(
605 'dates_2_columns_hints_full_days',
606 'dates_vertical_hints_full_days',
607 'dates_form_with_inline_hints',
608 'dates_2_columns_sidebar_hints',
609 'dates_advanced_3_steps_review_with_hints',
610 'dates_form_with_vertical_layout',
611 );
612 $recommendations = array(
613 'guided_appointment_flow' => array(
614 'appointments_services_selection_summary',
615 'appointments_services_flow',
616 'time_appointments_2_steps_wizard',
617 ),
618 'start_end_time' => $start_end_recommendations,
619 'repeated_time_multiple_dates' => $start_end_recommendations,
620 'first_start_last_end' => $start_end_recommendations,
621 'start_time_duration' => array(
622 'time_slots_start_duration_times_selection',
623 ),
624 'fixed_time_slots' => array(
625 'time_slots_2_columns_hints',
626 'time_slots_20_min_3_steps_review_with_hints',
627 'time_slots_20_min_2_steps_wizard',
628 'time_slots_30_min_2_steps_wizard',
629 ),
630 'single_full_day' => $full_day_recommendations,
631 'multiple_independent_days' => $full_day_recommendations,
632 'flexible_date_range' => $full_day_recommendations,
633 'fixed_length_date_range' => $full_day_recommendations,
634 'check_in_out_changeover' => $full_day_recommendations,
635 );
636
637 return isset( $recommendations[ $customer_journey ] )
638 ? $recommendations[ $customer_journey ]
639 : array();
640 }
641
642 /**
643 * Build ordered selector filters and their visible counts.
644 *
645 * @param array<int,array<string,mixed>> $templates Template DTOs.
646 *
647 * @return array<int,array{id:string,label:string,count:int}> Filter records.
648 */
649 private function get_filter_records( array $templates ) {
650 $counts = array(
651 'recommended' => 0,
652 'all' => count( $templates ),
653 'appointments' => 0,
654 'time_slots' => 0,
655 'full_days' => 0,
656 );
657
658 foreach ( $templates as $template ) {
659 if ( ! empty( $template['is_recommended'] ) ) {
660 ++$counts['recommended'];
661 }
662 if ( isset( $counts[ $template['category'] ] ) ) {
663 ++$counts[ $template['category'] ];
664 }
665 }
666
667 return array(
668 array( 'id' => 'recommended', 'label' => __( 'Recommended', 'booking' ), 'count' => $counts['recommended'] ),
669 array( 'id' => 'all', 'label' => __( 'All templates', 'booking' ), 'count' => $counts['all'] ),
670 array( 'id' => 'appointments', 'label' => __( 'Appointments', 'booking' ), 'count' => $counts['appointments'] ),
671 array( 'id' => 'time_slots', 'label' => __( 'Time Slots', 'booking' ), 'count' => $counts['time_slots'] ),
672 array( 'id' => 'full_days', 'label' => __( 'Full Days', 'booking' ), 'count' => $counts['full_days'] ),
673 );
674 }
675
676 /**
677 * Return the preferred default from an already normalized catalog.
678 *
679 * @param array<int,array<string,mixed>> $templates Template DTOs.
680 *
681 * @return string Stable selected slug or an empty string.
682 */
683 private function get_default_template_slug( array $templates ) {
684 foreach ( $templates as $template ) {
685 if ( ! empty( $template['is_recommended'] ) ) {
686 return (string) $template['slug'];
687 }
688 }
689
690 return ! empty( $templates[0]['slug'] ) ? (string) $templates[0]['slug'] : '';
691 }
692
693 /**
694 * Find one normalized template by slug.
695 *
696 * @param array<int,array<string,mixed>> $templates Template DTOs.
697 * @param string $template_slug Stable template slug.
698 *
699 * @return array<string,mixed> Matching DTO or an empty array.
700 */
701 private function find_template( array $templates, $template_slug ) {
702 foreach ( $templates as $template ) {
703 if ( $template_slug === $template['slug'] ) {
704 return $template;
705 }
706 }
707
708 return array();
709 }
710 }
711