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-publish-integration / class-wpbc-setup-wizard-publish-integration.php

class-wpbc-setup-wizard-publish-integration.php in Booking Calendar 11.9, at includes/page-setup-wizard/step-publish-integration/class-wpbc-setup-wizard-publish-integration.php

681 lines 25.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Publish and integration choices for the Setup Wizard.
4 *
5 * @package Booking Calendar
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 require_once dirname( __DIR__ ) . '/class-wpbc-setup-wizard-environment-policy.php';
13
14 /**
15 * Provide authorized destinations, WordPress pages, shortcode suggestions, and
16 * the bounded managed block used by the progressive publishing save boundary.
17 */
18 final class WPBC_Setup_Wizard_Publish_Integration {
19
20 /** Opening marker for the one Setup Wizard-owned block on a WordPress page. */
21 const MANAGED_BLOCK_START = '<!-- wpbc-setup-wizard-booking:start -->';
22
23 /** Closing marker for the one Setup Wizard-owned block on a WordPress page. */
24 const MANAGED_BLOCK_END = '<!-- wpbc-setup-wizard-booking:end -->';
25
26 /** Meta key containing the site- and Booking Calendar owner-scoped context. */
27 const META_CONTEXT = '_wpbc_setup_wizard_publish_context';
28
29 /** Meta key containing the operation that created a wizard-owned page. */
30 const META_OPERATION = '_wpbc_setup_wizard_publish_operation';
31
32 /** Meta key containing the last server-owned page-content choice. */
33 const META_CONTENT = '_wpbc_setup_wizard_publish_content';
34
35 const DESTINATION_CREATE_PAGE = 'create_page';
36 const DESTINATION_EXISTING_PAGE = 'existing_page';
37 const DESTINATION_MANUAL = 'manual';
38 const DESTINATION_LATER = 'later';
39
40 const CONTENT_RECOMMENDED = 'recommended';
41 const CONTENT_APPOINTMENT_FLOW = 'appointment_flow';
42 const CONTENT_RESOURCE_SELECTION = 'resource_selection';
43 const CONTENT_DIRECT_BOOKING_FORM = 'direct_booking_form';
44 const CONTENT_AVAILABILITY_CALENDAR = 'availability_calendar';
45
46 /** Canonical form identity produced by every Setup Wizard Booking Form route. */
47 const CANONICAL_FORM_SLUG = 'standard';
48
49 /** Backward-compatible alias for the former Guided-only constant name. */
50 const GUIDED_FORM_SLUG = 'standard';
51
52 /** Official configuration guide for the Appointment booking shortcode. */
53 const HELP_APPOINTMENT_SHORTCODE_URL = 'https://wpbookingcalendar.com/faq/shortcode-appointment-booking/';
54
55 /** Official configuration guide for the Booking Resource selector shortcode. */
56 const HELP_RESOURCE_SHORTCODE_URL = 'https://wpbookingcalendar.com/faq/shortcode-booking-resource-selector/';
57
58 /** Official configuration guide for the direct Booking Form shortcode. */
59 const HELP_BOOKING_FORM_SHORTCODE_URL = 'https://wpbookingcalendar.com/faq/shortcode-booking-form/';
60
61 /** Official configuration guide for the availability-only calendar shortcode. */
62 const HELP_AVAILABILITY_SHORTCODE_URL = 'https://wpbookingcalendar.com/faq/shortcode-availability-calendar/';
63
64 /** Official guide for inserting Booking Calendar shortcodes into WordPress content. */
65 const HELP_SHORTCODE_INSERTION_URL = 'https://wpbookingcalendar.com/faq/insert-booking-calendar-into-page/';
66
67 /**
68 * Determine whether this planning step may appear on the current site.
69 *
70 * The step is deliberately omitted when the shared environment policy blocks
71 * page publishing instead of merely disabling its controls. Public live demos
72 * are restricted; internal test installations keep the complete route.
73 *
74 * @return bool True when the step may be shown.
75 */
76 public function is_available() {
77 return WPBC_Setup_Wizard_Environment_Policy::allows_page_publishing();
78 }
79
80 /**
81 * Return the preferred initial destination for the current user.
82 *
83 * @return string Stable destination identifier.
84 */
85 public function get_initial_destination() {
86 return current_user_can( 'publish_pages' ) ? self::DESTINATION_CREATE_PAGE : self::DESTINATION_MANUAL;
87 }
88
89 /**
90 * Return the initial page title suggestion for one customer journey.
91 *
92 * Guided Appointment keeps its established appointment language. Every other
93 * journey publishes a direct Booking Form by default, so its suggested public
94 * page title must not imply the guided Service and Provider flow.
95 *
96 * @param string $customer_journey Selected customer journey identifier.
97 *
98 * @return string Translated page title.
99 */
100 public function get_initial_page_title( $customer_journey ) {
101 return 'guided_appointment_flow' === sanitize_key( (string) $customer_journey )
102 ? __( 'Book an appointment', 'booking' )
103 : __( 'Booking form', 'booking' );
104 }
105
106 /**
107 * Return ordered destination cards with capability-aware availability.
108 *
109 * @param array<int,array<string,mixed>> $publishable_pages Pages the current user may edit.
110 *
111 * @return array<int,array<string,mixed>> Destination presentation records.
112 */
113 public function get_destination_options( array $publishable_pages ) {
114 $can_create_page = current_user_can( 'publish_pages' );
115 $can_use_page = current_user_can( 'edit_pages' ) && ! empty( $publishable_pages );
116
117 return array(
118 array(
119 'id' => self::DESTINATION_CREATE_PAGE,
120 'label' => __( 'Create a new page', 'booking' ),
121 'description' => __( 'Create a dedicated WordPress page for your booking experience.', 'booking' ),
122 'icon' => 'wpbc_icn_note_add',
123 'badge' => __( 'Recommended', 'booking' ),
124 'is_enabled' => $can_create_page,
125 'disabled_reason' => $can_create_page ? '' : __( 'Your WordPress role cannot publish pages. Choose another destination.', 'booking' ),
126 ),
127 array(
128 'id' => self::DESTINATION_EXISTING_PAGE,
129 'label' => __( 'Use an existing page', 'booking' ),
130 'description' => __( 'Add the booking experience to a page already on your site.', 'booking' ),
131 'icon' => 'wpbc_icn_insert_drive_file',
132 'badge' => '',
133 'is_enabled' => $can_use_page,
134 'disabled_reason' => current_user_can( 'edit_pages' )
135 ? __( 'No editable WordPress pages are available.', 'booking' )
136 : __( 'Your WordPress role cannot edit pages. Choose another destination.', 'booking' ),
137 ),
138 array(
139 'id' => self::DESTINATION_MANUAL,
140 'label' => __( 'Add it manually', 'booking' ),
141 'description' => __( 'Copy the shortcode and place it wherever you prefer.', 'booking' ),
142 'icon' => 'wpbc_icn_code',
143 'badge' => '',
144 'is_enabled' => true,
145 'disabled_reason' => '',
146 ),
147 array(
148 'id' => self::DESTINATION_LATER,
149 'label' => __( 'Decide later', 'booking' ),
150 'description' => __( 'Finish setup now and integrate the booking experience later.', 'booking' ),
151 'icon' => 'wpbc_icn_schedule',
152 'badge' => '',
153 'is_enabled' => true,
154 'disabled_reason' => '',
155 ),
156 );
157 }
158
159 /**
160 * Return every editable WordPress page authorized for the current user.
161 *
162 * @return array<int,array{id:int,title:string,url:string}> Authorized page records.
163 */
164 public function get_publishable_pages() {
165 if ( ! current_user_can( 'edit_pages' ) || ! $this->is_available() ) {
166 return array();
167 }
168
169 $page_ids = get_posts(
170 array(
171 'post_type' => 'page',
172 'post_status' => array( 'draft', 'publish', 'private' ),
173 'posts_per_page' => -1,
174 'orderby' => 'title',
175 'order' => 'ASC',
176 'fields' => 'ids',
177 'no_found_rows' => true,
178 'suppress_filters' => false,
179 )
180 );
181 $publishable_pages = array();
182
183 foreach ( $page_ids as $page_id ) {
184 $page_id = absint( $page_id );
185 if ( ! $page_id || ! current_user_can( 'edit_post', $page_id ) ) {
186 continue;
187 }
188
189 $page_title = wp_strip_all_tags( get_the_title( $page_id ) );
190 $publishable_pages[] = array(
191 'id' => $page_id,
192 'title' => '' !== $page_title ? $page_title : __( '(no title)', 'booking' ),
193 'url' => $this->get_relative_url( get_permalink( $page_id ) ),
194 );
195 }
196
197 return $publishable_pages;
198 }
199
200 /**
201 * Return journey-aware page-content choices and exact shortcodes.
202 *
203 * @param string $customer_journey Selected customer journey identifier.
204 * @param string $form_slug Retained source-template slug. Publishing always targets the canonical Standard form.
205 *
206 * @return array<int,array<string,string>> Content option records with official help links.
207 */
208 public function get_content_options( $customer_journey, $form_slug ) {
209 $customer_journey = sanitize_key( (string) $customer_journey );
210 $form_slug = self::CANONICAL_FORM_SLUG;
211 $direct_shortcode = sprintf( '[booking form_type="%s"]', $form_slug );
212
213 $direct_booking_option = array(
214 'id' => self::CONTENT_DIRECT_BOOKING_FORM,
215 'label' => __( 'Direct booking form', 'booking' ),
216 'description' => __( 'Show the booking form immediately.', 'booking' ),
217 'shortcode' => $direct_shortcode,
218 'shortcode_help_url' => self::HELP_BOOKING_FORM_SHORTCODE_URL,
219 'shortcode_help_label' => __( 'Booking form shortcode', 'booking' ),
220 );
221 $resource_selection_option = array(
222 'id' => self::CONTENT_RESOURCE_SELECTION,
223 'label' => __( 'Resource selection', 'booking' ),
224 'description' => __( 'Let customers choose what they want to book before opening the booking form.', 'booking' ),
225 'shortcode' => '[booking_resource_selector]',
226 'shortcode_help_url' => self::HELP_RESOURCE_SHORTCODE_URL,
227 'shortcode_help_label' => __( 'Booking Resource selector shortcode', 'booking' ),
228 );
229
230 if ( 'guided_appointment_flow' === $customer_journey ) {
231 $primary_option = array(
232 'id' => self::CONTENT_APPOINTMENT_FLOW,
233 'label' => __( 'Guided appointment flow', 'booking' ),
234 'description' => __( 'Let customers choose a Service, Provider, date, and time.', 'booking' ),
235 'shortcode' => '' !== $form_slug
236 ? sprintf( '[booking_appointment form_type="%s"]', $form_slug )
237 : '[booking_appointment]',
238 'shortcode_help_url' => self::HELP_APPOINTMENT_SHORTCODE_URL,
239 'shortcode_help_label' => __( 'Appointment booking shortcode', 'booking' ),
240 );
241 $secondary_option = $direct_booking_option;
242 } else {
243 $primary_option = $direct_booking_option;
244 $secondary_option = $resource_selection_option;
245 }
246
247 return array(
248 $primary_option,
249 $secondary_option,
250 array(
251 'id' => self::CONTENT_AVAILABILITY_CALENDAR,
252 'label' => __( 'Availability calendar', 'booking' ),
253 'description' => __( 'Show the booking availability calendar without the booking form.', 'booking' ),
254 'shortcode' => '[bookingcalendar]',
255 'shortcode_help_url' => self::HELP_AVAILABILITY_SHORTCODE_URL,
256 'shortcode_help_label' => __( 'Availability calendar shortcode', 'booking' ),
257 ),
258 );
259 }
260
261 /**
262 * Return the official guide for placing Booking Calendar shortcodes.
263 *
264 * This guide is shared by every allow-listed page-content choice, while the
265 * shortcode-specific guide remains part of each content record.
266 *
267 * @return array{url:string,label:string} Official insertion-help record.
268 */
269 public function get_shortcode_insertion_help() {
270 return array(
271 'url' => self::HELP_SHORTCODE_INSERTION_URL,
272 'label' => __( 'Add a shortcode to a WordPress page', 'booking' ),
273 );
274 }
275
276 /**
277 * Return the recommended content choice for one journey.
278 *
279 * @param string $customer_journey Selected customer journey identifier.
280 *
281 * @return string Stable content identifier.
282 */
283 public function get_recommended_content_id( $customer_journey ) {
284 return 'guided_appointment_flow' === sanitize_key( (string) $customer_journey )
285 ? self::CONTENT_APPOINTMENT_FLOW
286 : self::CONTENT_DIRECT_BOOKING_FORM;
287 }
288
289 /**
290 * Return the current owner- and site-scoped publishing context fingerprint.
291 *
292 * Both the mutation handler and read-only Booking Listing discovery use this
293 * value. Keeping the calculation here prevents ownership drift between the
294 * page writer and the component that later presents the created page.
295 *
296 * @return string SHA-256 context fingerprint.
297 */
298 public function get_context_fingerprint() {
299 $storage_context = class_exists( 'WPBC_Setup_Wizard_Access' )
300 ? WPBC_Setup_Wizard_Access::get_storage_context()
301 : array(
302 'owner_user_id' => function_exists( 'wpbc_get_current_user_id' ) ? absint( wpbc_get_current_user_id() ) : absint( get_current_user_id() ),
303 'site_id' => absint( get_current_blog_id() ),
304 );
305
306 return hash(
307 'sha256',
308 wp_json_encode(
309 array(
310 'owner_user_id' => isset( $storage_context['owner_user_id'] ) ? absint( $storage_context['owner_user_id'] ) : 0,
311 'site_id' => isset( $storage_context['site_id'] ) ? absint( $storage_context['site_id'] ) : 0,
312 )
313 )
314 );
315 }
316
317 /**
318 * Find the most recently modified public page created by this Setup context.
319 *
320 * Existing pages edited by Setup Wizard intentionally have no creation marker
321 * and are excluded. The Booking Listing dialog therefore describes a page as
322 * Setup-created only when the publish handler actually created and still owns
323 * it for the current Booking Calendar owner and multisite site.
324 *
325 * @return WP_Post|object|null Owned published page, or null when unavailable.
326 */
327 public function get_latest_created_page() {
328 $context_fingerprint = $this->get_context_fingerprint();
329 $page_ids = get_posts(
330 array(
331 'post_type' => 'page',
332 'post_status' => 'publish',
333 'posts_per_page' => 1,
334 'orderby' => 'modified',
335 'order' => 'DESC',
336 'fields' => 'ids',
337 'no_found_rows' => true,
338 'suppress_filters' => false,
339 'meta_query' => array(
340 'relation' => 'AND',
341 array(
342 'key' => self::META_CONTEXT,
343 'value' => $context_fingerprint,
344 ),
345 array(
346 'key' => self::META_OPERATION,
347 'compare' => 'EXISTS',
348 ),
349 ),
350 )
351 );
352 $page_id = ! empty( $page_ids ) ? absint( reset( $page_ids ) ) : 0;
353 $page = $page_id ? get_post( $page_id ) : null;
354
355 if ( ! $page || 'page' !== $page->post_type || 'publish' !== $page->post_status ) {
356 return null;
357 }
358
359 $stored_context = (string) get_post_meta( $page_id, self::META_CONTEXT, true );
360 $operation_id = (string) get_post_meta( $page_id, self::META_OPERATION, true );
361
362 return '' !== $operation_id && hash_equals( $stored_context, $context_fingerprint ) ? $page : null;
363 }
364
365 /**
366 * Resolve one journey-aware, server-owned page-content record.
367 *
368 * The `recommended` alias is resolved before lookup so persistence never
369 * depends on a browser-provided shortcode or label.
370 *
371 * @param string $customer_journey Selected customer journey identifier.
372 * @param string $form_slug Retained source-template slug. Publishing resolves it to the canonical Standard form.
373 * @param string $content_id Proposed page-content identifier.
374 *
375 * @return array<string,string>|WP_Error Authorized content record or error.
376 */
377 public function resolve_content_option( $customer_journey, $form_slug, $content_id ) {
378 $content_id = sanitize_key( (string) $content_id );
379 if ( self::CONTENT_RECOMMENDED === $content_id ) {
380 $content_id = $this->get_recommended_content_id( $customer_journey );
381 }
382
383 foreach ( $this->get_content_options( $customer_journey, $form_slug ) as $content_option ) {
384 if ( isset( $content_option['id'] ) && $content_id === $content_option['id'] ) {
385 return $content_option;
386 }
387 }
388
389 return new WP_Error( 'wpbc_setup_wizard_publish_content_stale', __( 'The selected booking page content is no longer available. Review the publishing choice and try again.', 'booking' ) );
390 }
391
392 /**
393 * Build the exact block managed by the Setup Wizard on a WordPress page.
394 *
395 * @param string $shortcode Server-owned Booking Calendar shortcode.
396 *
397 * @return string WordPress Shortcode block wrapped in stable management markers.
398 */
399 public function build_managed_block( $shortcode ) {
400 $shortcode = trim( (string) $shortcode );
401
402 return self::MANAGED_BLOCK_START . "\n"
403 . '<!-- wp:shortcode -->' . "\n"
404 . $shortcode . "\n"
405 . '<!-- /wp:shortcode -->' . "\n"
406 . self::MANAGED_BLOCK_END;
407 }
408
409 /**
410 * Insert or replace the one Setup Wizard-managed booking block.
411 *
412 * Existing unmarked copies are claimed only when exactly one unambiguous
413 * Shortcode block or raw shortcode exists. Multiple marker regions or
414 * multiple unmarked copies fail closed to avoid deleting customer content.
415 *
416 * @param string $post_content Existing WordPress page content.
417 * @param string $shortcode Server-owned Booking Calendar shortcode.
418 *
419 * @return string|WP_Error Updated page content or a safe ambiguity error.
420 */
421 public function upsert_managed_block( $post_content, $shortcode ) {
422 $post_content = (string) $post_content;
423 $shortcode = trim( (string) $shortcode );
424 $managed_block = $this->build_managed_block( $shortcode );
425 $start_count = substr_count( $post_content, self::MANAGED_BLOCK_START );
426 $end_count = substr_count( $post_content, self::MANAGED_BLOCK_END );
427
428 if ( 1 < $start_count || 1 < $end_count || $start_count !== $end_count ) {
429 return new WP_Error( 'wpbc_setup_wizard_publish_block_ambiguous', __( 'This page contains an incomplete or duplicate Setup Wizard booking block. Review the page content before trying again.', 'booking' ) );
430 }
431
432 if ( 1 === $start_count ) {
433 $pattern = '/' . preg_quote( self::MANAGED_BLOCK_START, '/' ) . '.*?' . preg_quote( self::MANAGED_BLOCK_END, '/' ) . '/s';
434 if ( 1 !== preg_match_all( $pattern, $post_content ) ) {
435 return new WP_Error( 'wpbc_setup_wizard_publish_block_ambiguous', __( 'This page contains an incomplete or duplicate Setup Wizard booking block. Review the page content before trying again.', 'booking' ) );
436 }
437
438 $unmanaged_content = preg_replace( $pattern, '', $post_content, 1 );
439 if ( ! is_string( $unmanaged_content ) || false !== strpos( $unmanaged_content, $shortcode ) ) {
440 return new WP_Error( 'wpbc_setup_wizard_publish_shortcode_ambiguous', __( 'This page contains another copy of the selected shortcode outside the Setup Wizard booking block. Remove the duplicate before trying again.', 'booking' ) );
441 }
442
443 return preg_replace_callback(
444 $pattern,
445 static function () use ( $managed_block ) {
446 return $managed_block;
447 },
448 $post_content,
449 1
450 );
451 }
452
453 $shortcode_block_pattern = '/<!--\s+wp:shortcode\s*-->\s*' . preg_quote( $shortcode, '/' ) . '\s*<!--\s+\/wp:shortcode\s*-->/';
454 $block_count = preg_match_all( $shortcode_block_pattern, $post_content );
455 $raw_count = substr_count( $post_content, $shortcode );
456
457 if ( false === $block_count || 1 < $raw_count || 1 < $block_count ) {
458 return new WP_Error( 'wpbc_setup_wizard_publish_shortcode_ambiguous', __( 'This page already contains the selected shortcode more than once. Remove duplicate copies before asking the Setup Wizard to manage it.', 'booking' ) );
459 }
460
461 if ( 1 === $block_count ) {
462 return preg_replace_callback(
463 $shortcode_block_pattern,
464 static function () use ( $managed_block ) {
465 return $managed_block;
466 },
467 $post_content,
468 1
469 );
470 }
471 if ( 1 === $raw_count ) {
472 return preg_replace_callback(
473 '/' . preg_quote( $shortcode, '/' ) . '/',
474 static function () use ( $managed_block ) {
475 return $managed_block;
476 },
477 $post_content,
478 1
479 );
480 }
481
482 return '' === trim( $post_content ) ? $managed_block : rtrim( $post_content ) . "\n\n" . $managed_block;
483 }
484
485 /**
486 * Build a relative page URL preview without creating the page.
487 *
488 * @param string $page_title Proposed WordPress page title.
489 *
490 * @return string Relative URL preview.
491 */
492 public function get_new_page_url_preview( $page_title ) {
493 $page_slug = sanitize_title( (string) $page_title );
494 $page_slug = '' !== $page_slug ? $page_slug : 'booking';
495
496 return $this->get_relative_url( home_url( user_trailingslashit( $page_slug ) ) );
497 }
498
499 /**
500 * Return the WordPress home path used by the browser-side slug preview.
501 *
502 * @return string Root-relative, trailing-slashed path.
503 */
504 public function get_home_path() {
505 $home_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
506 $home_path = is_string( $home_path ) && '' !== $home_path ? $home_path : '/';
507
508 return trailingslashit( '/' . ltrim( $home_path, '/' ) );
509 }
510
511 /**
512 * Validate a destination identifier against current capabilities.
513 *
514 * @param mixed $raw_destination Untrusted destination identifier.
515 * @param bool $is_required Whether an empty destination is invalid.
516 *
517 * @return string|WP_Error Normalized identifier or validation error.
518 */
519 public function validate_destination( $raw_destination, $is_required ) {
520 if ( ! is_scalar( $raw_destination ) ) {
521 return new WP_Error( 'wpbc_setup_wizard_publish_destination_invalid', __( 'Choose a valid booking destination.', 'booking' ) );
522 }
523
524 $raw_destination = trim( (string) $raw_destination );
525 $destination = sanitize_key( $raw_destination );
526 if ( '' === $destination ) {
527 return $is_required
528 ? new WP_Error( 'wpbc_setup_wizard_publish_destination_required', __( 'Choose how customers will access booking.', 'booking' ) )
529 : '';
530 }
531
532 if ( $destination !== $raw_destination || ! in_array( $destination, $this->get_destination_ids(), true ) ) {
533 return new WP_Error( 'wpbc_setup_wizard_publish_destination_unknown', __( 'Choose a valid booking destination.', 'booking' ) );
534 }
535
536 if ( self::DESTINATION_CREATE_PAGE === $destination && ! current_user_can( 'publish_pages' ) ) {
537 return new WP_Error( 'wpbc_setup_wizard_publish_create_forbidden', __( 'Your WordPress role cannot publish pages.', 'booking' ) );
538 }
539
540 if ( self::DESTINATION_EXISTING_PAGE === $destination && ! current_user_can( 'edit_pages' ) ) {
541 return new WP_Error( 'wpbc_setup_wizard_publish_existing_forbidden', __( 'Your WordPress role cannot edit pages.', 'booking' ) );
542 }
543
544 return $destination;
545 }
546
547 /**
548 * Validate a bounded proposed page title.
549 *
550 * @param mixed $raw_title Untrusted page title.
551 * @param bool $is_required Whether an empty title is invalid.
552 *
553 * @return string|WP_Error Sanitized title or validation error.
554 */
555 public function validate_page_title( $raw_title, $is_required ) {
556 if ( ! is_scalar( $raw_title ) ) {
557 return new WP_Error( 'wpbc_setup_wizard_publish_title_invalid', __( 'Enter a valid page title.', 'booking' ) );
558 }
559
560 $page_title = sanitize_text_field( (string) $raw_title );
561 if ( '' === trim( $page_title ) ) {
562 return $is_required
563 ? new WP_Error( 'wpbc_setup_wizard_publish_title_required', __( 'Enter a page title.', 'booking' ) )
564 : '';
565 }
566
567 $length = function_exists( 'mb_strlen' ) ? mb_strlen( $page_title ) : strlen( $page_title );
568 if ( 200 < $length ) {
569 return new WP_Error( 'wpbc_setup_wizard_publish_title_too_long', __( 'Use 200 characters or fewer for the page title.', 'booking' ) );
570 }
571
572 return $page_title;
573 }
574
575 /**
576 * Validate a selected WordPress page ID without authorizing it yet.
577 *
578 * @param mixed $raw_page_id Untrusted page ID.
579 *
580 * @return int|WP_Error Non-negative page ID or validation error.
581 */
582 public function validate_page_id( $raw_page_id ) {
583 if ( ! is_scalar( $raw_page_id ) || ! preg_match( '/^\d+$/', (string) $raw_page_id ) ) {
584 return new WP_Error( 'wpbc_setup_wizard_publish_page_invalid', __( 'Choose a valid WordPress page.', 'booking' ) );
585 }
586
587 return absint( $raw_page_id );
588 }
589
590 /**
591 * Validate a content choice before journey-specific validation.
592 *
593 * @param mixed $raw_content Untrusted content identifier.
594 * @param bool $is_required Whether an empty choice is invalid.
595 *
596 * @return string|WP_Error Normalized identifier or validation error.
597 */
598 public function validate_content( $raw_content, $is_required ) {
599 if ( ! is_scalar( $raw_content ) ) {
600 return new WP_Error( 'wpbc_setup_wizard_publish_content_invalid', __( 'Choose valid booking page content.', 'booking' ) );
601 }
602
603 $raw_content = trim( (string) $raw_content );
604 $content = sanitize_key( $raw_content );
605 if ( '' === $content ) {
606 return $is_required
607 ? new WP_Error( 'wpbc_setup_wizard_publish_content_required', __( 'Choose the booking experience to display.', 'booking' ) )
608 : '';
609 }
610
611 $allowed_content = array(
612 self::CONTENT_RECOMMENDED,
613 self::CONTENT_APPOINTMENT_FLOW,
614 self::CONTENT_RESOURCE_SELECTION,
615 self::CONTENT_DIRECT_BOOKING_FORM,
616 self::CONTENT_AVAILABILITY_CALENDAR,
617 );
618 if ( $content !== $raw_content || ! in_array( $content, $allowed_content, true ) ) {
619 return new WP_Error( 'wpbc_setup_wizard_publish_content_unknown', __( 'Choose valid booking page content.', 'booking' ) );
620 }
621
622 return $content;
623 }
624
625 /**
626 * Confirm that one page belongs to the current authorized page list.
627 *
628 * @param int $page_id Proposed WordPress page ID.
629 * @param array<int,array<string,mixed>> $publishable_pages Authorized page records.
630 *
631 * @return bool True when the page remains authorized.
632 */
633 public function is_authorized_page( $page_id, array $publishable_pages ) {
634 $page_id = absint( $page_id );
635 foreach ( $publishable_pages as $publishable_page ) {
636 if ( isset( $publishable_page['id'] ) && $page_id === absint( $publishable_page['id'] ) ) {
637 return true;
638 }
639 }
640
641 return false;
642 }
643
644 /**
645 * Return all stable destination IDs.
646 *
647 * @return string[] Destination allow-list.
648 */
649 private function get_destination_ids() {
650 return array(
651 self::DESTINATION_CREATE_PAGE,
652 self::DESTINATION_EXISTING_PAGE,
653 self::DESTINATION_MANUAL,
654 self::DESTINATION_LATER,
655 );
656 }
657
658 /**
659 * Reduce an absolute site URL to a root-relative display URL.
660 *
661 * @param mixed $url Candidate page URL.
662 *
663 * @return string Root-relative URL, or an empty string.
664 */
665 private function get_relative_url( $url ) {
666 if ( ! is_scalar( $url ) || '' === (string) $url ) {
667 return '';
668 }
669
670 $url_parts = wp_parse_url( (string) $url );
671 if ( ! is_array( $url_parts ) ) {
672 return '';
673 }
674
675 $path = isset( $url_parts['path'] ) && '' !== $url_parts['path'] ? (string) $url_parts['path'] : '/';
676 $query = isset( $url_parts['query'] ) && '' !== $url_parts['query'] ? '?' . $url_parts['query'] : '';
677
678 return $path . $query;
679 }
680 }
681