PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 All 85 releases
← All changes | app/Controllers/SettingsController.php +354 -90 3.0.9 → 3.0.17 View file →
@@ -46,12 +46,24 @@
46 46 'date_format' => 'Y-m-d',
47 47 'time_format' => 'H:i',
48 48 'frontend_primary_color' => '#3b82f6',
49 49 'frontend_container_max_width' => '',
50 + // Trip listing card density. 'standard' = the current comfortable card;
51 + // 'compact_mobile' = compact card on phones/tablets only (desktop grid
52 + // unchanged); 'compact_all' = compact card at every screen size.
53 + 'frontend_listing_card_layout' => 'standard',
50 54
51 55 // Booking Settings
52 56 'booking_confirmation' => true,
57 + // Legacy boolean, kept for backward compatibility. Superseded by
58 + // 'auto_confirm_mode' below; the mode is authoritative once stored.
53 59 'auto_confirm_bookings' => false,
60 + // Auto-confirm mode: 'none' (never), 'online' (only successful online
61 + // gateway payments), or 'all' (confirm every booking at checkout).
62 + // Default 'online' (payment complete => confirmed). Existing sites with
63 + // no stored mode resolve on the fly via yatra_get_auto_confirm_mode()
64 + // (legacy true->all, false->online), preserving their prior behaviour.
65 + 'auto_confirm_mode' => 'online',
54 66 'auto_confirm_pay_later' => true,
55 67 'require_login' => false,
56 68 'allow_guest_checkout' => true,
57 69 // cancellation_policy / cancellation_days / refund_policy were
@@ -65,8 +77,9 @@
65 77 // accepts them, and the email template skips the cancellation
66 78 // paragraph when the global setting is absent.
67 79 'booking_expiry_hours' => 24,
68 80 'booking_reminder_days' => 3,
81 + 'availability_horizon_months' => 12,
69 82 'allow_waitlist' => true,
70 83 'waitlist_auto_confirm' => false,
71 84 // Pro: render available departure dates as a <select> instead of a
72 85 // flatpickr calendar on the single-trip sidebar (desktop + mobile).
@@ -102,8 +115,10 @@
102 115 'scheduled_payment_days' => 15, // Days until first scheduled payment
103 116 'scheduled_payment_installments' => 1, // Number of installments (if type is installments)
104 117 'scheduled_payment_interval' => 30, // Days between installments
105 118 'scheduled_payment_reminder_days' => 3, // Days before to send reminder
119 + 'balance_anchor' => 'booking', // 'booking' (BC default) | 'tour' (relative to tour date)
120 + 'balance_due_days' => 14, // When anchor=tour: balance due this many days before the tour
106 121 'allow_save_payment_methods' => false,
107 122
108 123 // Email Settings (WordPress site defaults when Yatra options are missing)
109 124 'admin_email' => $wpAdminEmail,
@@ -108,10 +123,16 @@
108 123 // Email Settings (WordPress site defaults when Yatra options are missing)
109 124 'admin_email' => $wpAdminEmail,
110 125 'from_email' => $wpAdminEmail,
111 126 'from_name' => $wpSiteName,
127 + // Blind copy of every outgoing Yatra email, for archiving/monitoring.
128 + // Empty means no copy is sent; accepts several comma-separated addresses.
129 + 'email_always_bcc' => '',
112 130 'email_template_booking' => true,
113 131 'email_template_confirmation' => true,
132 + // Separate part-payment email. Off by default so existing sites keep
133 + // sending the single payment template for every payment.
134 + 'email_template_partial_payment' => false,
114 135 'email_template_cancellation' => true,
115 136 'email_template_reminder' => true,
116 137 'email_template_admin_new_booking' => true,
117 138 'email_template_admin_payment' => true,
@@ -118,8 +139,10 @@
118 139 'email_template_admin_cancellation' => true,
119 140 'email_template_trip_consent' => true,
120 141 'email_template_customer_verification' => true,
121 142 'email_template_guest_verification' => true,
143 + 'email_template_account_email_change' => true,
144 + 'email_template_account_email_changed' => true,
122 145 'email_template_booking_completed' => true,
123 146 'email_template_booking_expired_customer' => true,
124 147 'email_template_admin_booking_expired' => true,
125 148 'email_template_scheduled_payment_reminder' => true,
@@ -128,9 +151,18 @@
128 151 'email_template_admin_scheduled_payment_failed' => true,
129 152 'email_template_enquiry_received' => true,
130 153 'email_template_enquiry_admin' => true,
131 154 'email_template_enquiry_response' => true,
132 - 'email_template_review_request' => true,
155 + // Off by default, unlike the other templates. A review request is the
156 + // one transactional email that is not a response to something the
157 + // customer just did, and several jurisdictions treat it as advertising
158 + // rather than service mail — in Germany the BGH (VI ZR 225/17) holds it
159 + // needs prior consent, and the existing-customer exemption in
160 + // §7 Abs. 3 UWG does not cover it. Shipping it on would have every new
161 + // site mailing customers for consent it has not collected, so the
162 + // operator turns it on once they have decided how they collect it.
163 + // Sites that already have it on are untouched.
164 + 'email_template_review_request' => false,
133 165 'email_template_abandoned_booking_recovery_first' => true,
134 166 'email_template_abandoned_booking_recovery_second' => true,
135 167 'email_template_abandoned_booking_recovery_final' => true,
136 168 'smtp_enabled' => false,
@@ -159,9 +191,13 @@
159 191 'auto_approve_reviews' => false,
160 192 'review_moderation' => true,
161 193 'min_rating' => 1,
162 194 'allow_anonymous_reviews' => false,
163 - 'review_reminder_days' => 7,
195 + 'review_reminder_days' => 3,
196 + // Never ask about a trip that ended longer ago than this. The
197 + // reminder is anchored to the tour's end date, so a backlog of
198 + // bookings completed late is skipped rather than mailed in bulk.
199 + 'review_reminder_max_age_days' => 14,
164 200
165 201 // Tax Settings
166 202 'enable_tax' => false,
167 203 'tax_name' => __('Tax', 'yatra'),
@@ -192,9 +228,22 @@
192 228 'facebook_pixel' => '',
193 229 'recaptcha_enabled' => false,
194 230 'recaptcha_site_key' => '',
195 231 'recaptcha_secret_key' => '',
196 -
232 + // reCAPTCHA v3: score threshold (0.0-1.0) + per-form protection toggles.
233 + // All off by default so enabling reCAPTCHA alone changes nothing until
234 + // the operator picks which forms to protect.
235 + 'recaptcha_score_threshold' => 0.5,
236 + 'recaptcha_protect_enquiry' => false,
237 + 'recaptcha_protect_booking' => false,
238 + 'recaptcha_protect_registration' => false,
239 +
240 + // Uninstall. Off by default and deliberately never defaulted to true:
241 + // turning this on means deleting the operator's bookings, customers and
242 + // payment history when the plugin is removed, so it has to be a decision
243 + // somebody makes on purpose.
244 + 'delete_data_on_uninstall' => false,
245 +
197 246 // Permalink Settings
198 247 'trip_base' => 'trip',
199 248 'destination_base' => 'destination',
200 249 'activity_base' => 'activity',
@@ -201,8 +250,13 @@
201 250 'trip_category_base' => 'trip-category',
202 251 'booking_base' => 'book',
203 252 // Wishlist (Pro) — stored in free options; active only when Pro + setting on
204 253 'enable_wishlist' => false,
254 + // Sold-out date visibility on the storefront. Default true keeps the
255 + // existing behaviour (sold-out dates stay visible, badged "sold out" and
256 + // able to drive the waitlist); owners can switch it off to hide them the
257 + // same way blocked dates are hidden.
258 + 'show_sold_out' => true,
205 259
206 260 // Search & Listing storefront UX. Defaults preserve current behaviour:
207 261 // every search field shown (true) and mobile filters expanded (false),
208 262 // so existing installs are unchanged until the owner opts in. Booleans
@@ -211,8 +265,12 @@
211 265 'search_show_destination' => true,
212 266 'search_show_activities' => true,
213 267 'search_show_duration' => true,
214 268 'search_show_budget' => true,
269 + // Date field is opt-in (default false) so updating the plugin never
270 + // changes an existing site's search bar. Operators enable it to let
271 + // customers find trips with a departure on a specific date.
272 + 'search_show_date' => false,
215 273 'collapse_filters_on_mobile' => false,
216 274
217 275 // Booking Page Settings
218 276 'use_booking_page' => false,
@@ -227,8 +285,15 @@
227 285 'seo_trip_meta_description' => '',
228 286 'seo_trip_meta_keywords' => '',
229 287 'seo_trip_meta_image' => 0,
230 288 'enable_sitemap' => true,
289 + // Which Yatra content types appear in /yatra-sitemap.xml. Defaults to
290 + // every type, so a site that never touches this keeps today's sitemap.
291 + 'sitemap_types' => ['archive', 'trip', 'destination', 'activity', 'category'],
292 + // Opt-in, and deliberately separate from the list above: dropping a type
293 + // from the sitemap is housekeeping, while noindex de-indexes pages that
294 + // may currently rank. That should never happen as a side effect.
295 + 'sitemap_noindex_excluded' => false,
231 296
232 297 // Advanced Settings
233 298 'debug_mode' => false,
234 299 'enable_logging' => false,
@@ -276,8 +341,26 @@
276 341 'permission_callback' => [$this, 'check_permission'],
277 342 ],
278 343 ]);
279 344
345 + // Booking form config, optionally resolved for one trip (Pro form
346 + // conditions). Readable by anyone who can view bookings, so the
347 + // booking detail screen can label the fields a trip actually asked.
348 + register_rest_route($namespace, '/' . $base . '/booking-form', [
349 + [
350 + 'methods' => \WP_REST_Server::READABLE,
351 + 'callback' => [$this, 'get_booking_form_config'],
352 + 'permission_callback' => [$this, 'check_booking_form_permission'],
353 + 'args' => [
354 + 'trip_id' => [
355 + 'type' => 'integer',
356 + 'required' => false,
357 + 'sanitize_callback' => 'absint',
358 + ],
359 + ],
360 + ],
361 + ]);
362 +
280 363 // Get WordPress pages for booking page selection
281 364 register_rest_route($namespace, '/' . $base . '/pages', [
282 365 [
283 366 'methods' => \WP_REST_Server::READABLE,
@@ -362,8 +445,43 @@
362 445 return current_user_can('yatra_manage_settings');
363 446 }
364 447
365 448 /**
449 + * The booking form config is needed to label booking data, so it is
450 + * readable by booking staff, not only settings managers.
451 + */
452 + public function check_booking_form_permission(?WP_REST_Request $request = null): bool
453 + {
454 + if (!is_user_logged_in()) {
455 + return false;
456 + }
457 + return current_user_can('yatra_manage_settings')
458 + || current_user_can('yatra_view_bookings')
459 + || current_user_can('yatra_edit_bookings');
460 + }
461 +
462 + /**
463 + * GET /settings/booking-form[?trip_id=N]
464 + *
465 + * Without trip_id: the full config exactly as the Settings screen sees it.
466 + * With trip_id: the config as that trip's checkout renders it — Pro form
467 + * conditions resolved (no Pro / no conditions → identical to the global).
468 + */
469 + public function get_booking_form_config(WP_REST_Request $request)
470 + {
471 + try {
472 + $trip_id = (int) $request->get_param('trip_id');
473 +
474 + return $this->success_response([
475 + 'booking_form_config' => \Yatra\Services\SettingsService::getBookingFormConfig($trip_id > 0 ? $trip_id : null),
476 + 'trip_id' => $trip_id > 0 ? $trip_id : null,
477 + ]);
478 + } catch (\Exception $e) {
479 + return $this->error_response($e->getMessage(), 500);
480 + }
481 + }
482 +
483 + /**
366 484 * Get all settings
367 485 */
368 486 public function get_settings(WP_REST_Request $request)
369 487 {
@@ -369,18 +487,36 @@
369 487 {
370 488 try {
371 489 $settings = [];
372 490
373 - // Get all settings from WordPress options table with yatra_ prefix
491 + // Get all settings from WordPress options table with yatra_ prefix.
492 + // A sentinel default is essential here: get_option() returns boolean
493 + // false for a stored-false option just as it does for a missing one,
494 + // so checking `=== false` would reset every saved-off boolean back to
495 + // its default. That is exactly the "Show sold-out dates" bug — the
496 + // storefront honoured the saved value (isEnabled coerces '' -> false)
497 + // while the admin checkbox re-appeared enabled because this endpoint
498 + // handed React the default (true) instead of the saved false.
499 + $unset_sentinel = "\0__yatra_option_unset__\0";
374 500 foreach ($this->default_settings as $key => $default_value) {
375 501 $option_name = 'yatra_' . $key;
376 - $value = get_option($option_name, false);
377 -
378 - // Only use default if option doesn't exist (wasn't set by InstallerService)
379 - if ($value === false) {
502 + $value = get_option($option_name, $unset_sentinel);
503 +
504 + // Only use default when the option truly does not exist.
505 + if ($value === $unset_sentinel) {
380 506 $value = $default_value;
381 507 }
382 508
509 + // Auto-Confirm mode has no stored default — it is resolved on
510 + // the fly. Return the effective mode so the admin shows the
511 + // site's real behaviour: a stored choice if the operator made
512 + // one, otherwise derived from the legacy boolean
513 + // (true -> 'all', false -> 'online'). Prevents an existing
514 + // "confirm all" site from displaying (and re-saving) as 'online'.
515 + if ($key === 'auto_confirm_mode' && function_exists('yatra_get_auto_confirm_mode')) {
516 + $value = yatra_get_auto_confirm_mode();
517 + }
518 +
383 519 // Stored empty string should behave like "unset" for delivery identity (matches installer / backfill).
384 520 if (($key === 'admin_email' || $key === 'from_email') && is_string($value) && trim($value) === '') {
385 521 $wp = (string) get_option('admin_email', '');
386 522 $value = $wp !== '' ? $wp : $value;
@@ -398,9 +534,18 @@
398 534 // Ensure arrays are returned as arrays (not objects)
399 535 if (is_array($default_value) && !is_array($value)) {
400 536 $value = [];
401 537 }
402 -
538 +
539 + // Boolean settings must round-trip to the admin as real booleans.
540 + // update_option() stores false as '' and the object cache can
541 + // return boolean false, so without this a disabled toggle would
542 + // reach React as '' / false and the checkbox (checked unless the
543 + // value is strictly !== false) would render enabled again.
544 + if (is_bool($default_value)) {
545 + $value = filter_var($value, FILTER_VALIDATE_BOOLEAN);
546 + }
547 +
403 548 $settings[$key] = $value;
404 549 }
405 550
406 551 // Special handling for booking_form_config - always use getBookingFormConfig which handles locked fields
@@ -425,8 +570,10 @@
425 570 'scheduled_payment_days',
426 571 'scheduled_payment_installments',
427 572 'scheduled_payment_interval',
428 573 'scheduled_payment_reminder_days',
574 + 'balance_anchor',
575 + 'balance_due_days',
429 576 ] as $sk
430 577 ) {
431 578 if (array_key_exists($sk, $this->default_settings)) {
432 579 $settings[$sk] = \Yatra\Services\SettingsService::get(
@@ -485,8 +632,10 @@
485 632 'scheduled_payment_days',
486 633 'scheduled_payment_installments',
487 634 'scheduled_payment_interval',
488 635 'scheduled_payment_reminder_days',
636 + 'balance_anchor',
637 + 'balance_due_days',
489 638 ];
490 639
491 640 // Collect flexible payment settings to delegate to Pro
492 641 $flexible_payment_settings = [];
@@ -697,8 +846,19 @@
697 846 if ($filtered_value !== null) {
698 847 return $filtered_value;
699 848 }
700 849
850 + // The booking-form config has its own structured sanitiser (field type
851 + // and width whitelists, locked core fields, text-block content, per-trip
852 + // conditions). It must run BEFORE the generic
853 + // is_array($default) branch below: that branch only text-sanitises
854 + // values and was catching this key first — because its default is [] —
855 + // so the structured sanitiser further down was never reached and any
856 + // shape at all was stored.
857 + if ($key === 'booking_form_config') {
858 + return is_array($value) ? $this->sanitize_booking_form_config($value) : [];
859 + }
860 +
701 861 // Handle null values - use default
702 862 if ($value === null) {
703 863 return $default;
704 864 }
@@ -747,8 +907,14 @@
747 907 // Validate ranges for specific fields
748 908 if ($key === 'booking_expiry_hours' && $int_value < 0) {
749 909 return null;
750 910 }
911 + // Storefront booking horizon: 1–36 months. Out of range is rejected
912 + // (not clamped) so a bad write can never blank the calendar — the
913 + // previously stored value, or the 12-month default, stays in force.
914 + if ($key === 'availability_horizon_months' && ($int_value < 1 || $int_value > 36)) {
915 + return null;
916 + }
751 917 if ($key === 'partial_payment_percentage' && ($int_value < 0 || $int_value > 100)) {
752 918 return null;
753 919 }
754 920 if ($key === 'deposit_percentage' && ($int_value < 0 || $int_value > 100)) {
@@ -831,8 +997,18 @@
831 997 return \Yatra\Utils\FrontendThemeCss::sanitizeContainerMaxWidthSetting(
832 998 is_string($value) ? $value : ''
833 999 );
834 1000 }
1001 + if ($key === 'frontend_listing_card_layout') {
1002 + $allowed = ['standard', 'compact_mobile', 'compact_all'];
1003 + $v = is_string($value) ? strtolower(trim($value)) : '';
1004 + return in_array($v, $allowed, true) ? $v : 'standard';
1005 + }
1006 + if ($key === 'auto_confirm_mode') {
1007 + $allowed = ['none', 'online', 'all'];
1008 + $v = is_string($value) ? strtolower(trim($value)) : '';
1009 + return in_array($v, $allowed, true) ? $v : 'online';
1010 + }
835 1011 if (is_string($key) && strpos($key, 'email_tpl_') === 0 && substr($key, -5) === '_body') {
836 1012 return wp_kses_post((string) $value);
837 1013 }
838 1014 if (is_string($key) && strpos($key, 'email_tpl_') === 0 && substr($key, -8) === '_subject') {
@@ -848,15 +1024,8 @@
848 1024 return $this->sanitize_gateway_configs($value);
849 1025 }
850 1026 return [];
851 1027 }
852 - if ($key === 'booking_form_config') {
853 - // Handle nested array structure for booking form config
854 - if (is_array($value)) {
855 - return $this->sanitize_booking_form_config($value);
856 - }
857 - return [];
858 - }
859 1028 if ($key === 'tax_rates') {
860 1029 // Handle nested array structure for tax rates
861 1030 if (is_array($value)) {
862 1031 return $this->sanitize_tax_rates($value);
@@ -1082,99 +1251,194 @@
1082 1251 private function sanitize_booking_form_config(array $config): array
1083 1252 {
1084 1253 $sanitized = [];
1085 1254 $allowed_form_types = ['contact_form', 'emergency_contact_form', 'traveler_form'];
1086 - $allowed_field_types = ['text', 'email', 'tel', 'date', 'select', 'country', 'textarea', 'checkbox', 'number', 'text_block'];
1087 - $allowed_widths = ['full', 'half', 'third'];
1088 -
1255 +
1089 1256 foreach ($config as $form_type => $form_config) {
1090 1257 if (!in_array($form_type, $allowed_form_types, true)) {
1091 1258 continue;
1092 1259 }
1093 -
1260 +
1094 1261 $sanitized[$form_type] = [
1095 1262 'title' => isset($form_config['title']) ? sanitize_text_field($form_config['title']) : '',
1096 1263 'description' => isset($form_config['description']) ? sanitize_text_field($form_config['description']) : '',
1097 1264 'enabled' => isset($form_config['enabled']) ? (bool) $form_config['enabled'] : true,
1098 - 'fields' => [],
1265 + 'fields' => $this->sanitize_booking_form_fields($form_config['fields'] ?? null, $form_type),
1099 1266 ];
1100 -
1101 - if (!empty($form_config['fields']) && is_array($form_config['fields'])) {
1102 - foreach ($form_config['fields'] as $field) {
1103 - if (!is_array($field) || empty($field['id'])) {
1104 - continue;
1267 +
1268 + // Per-trip form conditions (Pro Dynamic Form Field): each condition
1269 + // is a complete alternative version of this section — its own
1270 + // title, description and field list — used on the trips it names.
1271 + // Only persisted when there is at least one, so configs saved
1272 + // without the feature stay byte-identical.
1273 + $conditions = $this->sanitize_booking_form_conditions($form_config['conditions'] ?? null, $form_type);
1274 + if ($conditions !== []) {
1275 + $sanitized[$form_type]['conditions'] = $conditions;
1276 + }
1277 + }
1278 +
1279 + return apply_filters('yatra_save_booking_form_config', $sanitized, $config);
1280 + }
1281 +
1282 + /**
1283 + * Sanitise one section's field list (global fields or a condition's fields).
1284 + *
1285 + * @param mixed $fields
1286 + * @return array<int, array<string, mixed>>
1287 + */
1288 + private function sanitize_booking_form_fields($fields, string $form_type): array
1289 + {
1290 + $allowed_field_types = ['text', 'email', 'tel', 'date', 'select', 'country', 'textarea', 'checkbox', 'number', 'text_block'];
1291 + $allowed_widths = ['full', 'half', 'third'];
1292 + $sanitized = [];
1293 +
1294 + if (empty($fields) || !is_array($fields)) {
1295 + return $sanitized;
1296 + }
1297 +
1298 + foreach ($fields as $field) {
1299 + if (!is_array($field) || empty($field['id'])) {
1300 + continue;
1301 + }
1302 +
1303 + $sanitized_field = [
1304 + 'id' => sanitize_key($field['id']),
1305 + 'type' => in_array($field['type'] ?? 'text', $allowed_field_types, true) ? $field['type'] : 'text',
1306 + 'label' => isset($field['label']) ? sanitize_text_field($field['label']) : '',
1307 + 'placeholder' => isset($field['placeholder']) ? sanitize_text_field($field['placeholder']) : '',
1308 + 'required' => isset($field['required']) ? (bool) $field['required'] : false,
1309 + 'enabled' => isset($field['enabled']) ? (bool) $field['enabled'] : true,
1310 + 'order' => isset($field['order']) ? (int) $field['order'] : 0,
1311 + 'width' => in_array($field['width'] ?? 'full', $allowed_widths, true) ? ($field['width'] ?? 'full') : 'full',
1312 + ];
1313 +
1314 + // Only persist `locked` when set: every reader treats a missing key
1315 + // as unlocked, and configs saved before this sanitiser ran never
1316 + // carried a `locked => false`, so they stay byte-identical.
1317 + if (!empty($field['locked'])) {
1318 + $sanitized_field['locked'] = true;
1319 + }
1320 +
1321 + // Handle optional section
1322 + if (!empty($field['section'])) {
1323 + $sanitized_field['section'] = sanitize_key($field['section']);
1324 + }
1325 +
1326 + // Per-traveler targeting — Traveler section only. Whitelist
1327 + // the allowed values; only persist the non-default "lead" so
1328 + // other sections and existing configs stay byte-identical.
1329 + if (
1330 + $form_type === 'traveler_form'
1331 + && ($field['applies_to'] ?? 'all') === 'lead'
1332 + ) {
1333 + $sanitized_field['applies_to'] = 'lead';
1334 + }
1335 +
1336 + // Handle options for select fields
1337 + if ($sanitized_field['type'] === 'select' && !empty($field['options']) && is_array($field['options'])) {
1338 + $sanitized_field['options'] = [];
1339 + foreach ($field['options'] as $option) {
1340 + if (is_array($option) && isset($option['value'])) {
1341 + $sanitized_field['options'][] = [
1342 + 'value' => sanitize_key($option['value']),
1343 + 'label' => isset($option['label']) ? sanitize_text_field($option['label']) : $option['value'],
1344 + ];
1105 1345 }
1106 -
1107 - $sanitized_field = [
1108 - 'id' => sanitize_key($field['id']),
1109 - 'type' => in_array($field['type'] ?? 'text', $allowed_field_types, true) ? $field['type'] : 'text',
1110 - 'label' => isset($field['label']) ? sanitize_text_field($field['label']) : '',
1111 - 'placeholder' => isset($field['placeholder']) ? sanitize_text_field($field['placeholder']) : '',
1112 - 'required' => isset($field['required']) ? (bool) $field['required'] : false,
1113 - 'enabled' => isset($field['enabled']) ? (bool) $field['enabled'] : true,
1114 - 'order' => isset($field['order']) ? (int) $field['order'] : 0,
1115 - 'width' => in_array($field['width'] ?? 'full', $allowed_widths, true) ? ($field['width'] ?? 'full') : 'full',
1116 - 'locked' => isset($field['locked']) ? (bool) $field['locked'] : false,
1117 - ];
1118 -
1119 - // Handle optional section
1120 - if (!empty($field['section'])) {
1121 - $sanitized_field['section'] = sanitize_key($field['section']);
1122 - }
1346 + }
1347 + }
1123 1348
1124 - // Per-traveler targeting — Traveler section only. Whitelist
1125 - // the allowed values; only persist the non-default "lead" so
1126 - // other sections and existing configs stay byte-identical.
1127 - if (
1128 - $form_type === 'traveler_form'
1129 - && ($field['applies_to'] ?? 'all') === 'lead'
1130 - ) {
1131 - $sanitized_field['applies_to'] = 'lead';
1132 - }
1133 -
1134 - // Handle options for select fields
1135 - if ($sanitized_field['type'] === 'select' && !empty($field['options']) && is_array($field['options'])) {
1136 - $sanitized_field['options'] = [];
1137 - foreach ($field['options'] as $option) {
1138 - if (is_array($option) && isset($option['value'])) {
1139 - $sanitized_field['options'][] = [
1140 - 'value' => sanitize_key($option['value']),
1141 - 'label' => isset($option['label']) ? sanitize_text_field($option['label']) : $option['value'],
1142 - ];
1143 - }
1144 - }
1145 - }
1349 + // A text block is display-only content placed between fields:
1350 + // keep its (safe-HTML) content, and it can never be required.
1351 + if ($sanitized_field['type'] === 'text_block') {
1352 + $sanitized_field['content'] = isset($field['content']) ? wp_kses_post($field['content']) : '';
1353 + $sanitized_field['required'] = false;
1354 + }
1146 1355
1147 - // A text block is display-only content placed between fields:
1148 - // keep its (safe-HTML) content, and it can never be required.
1149 - if ($sanitized_field['type'] === 'text_block') {
1150 - $sanitized_field['content'] = isset($field['content']) ? wp_kses_post($field['content']) : '';
1151 - $sanitized_field['required'] = false;
1152 - }
1356 + // Phone fields: the country-code selector is ON by default.
1357 + // Only persist the non-default `false`, so existing configs
1358 + // (which never carried this key) stay byte-identical and read
1359 + // back as ON.
1360 + if (
1361 + $sanitized_field['type'] === 'tel'
1362 + && array_key_exists('show_country_code', $field)
1363 + && !$field['show_country_code']
1364 + ) {
1365 + $sanitized_field['show_country_code'] = false;
1366 + }
1153 1367
1154 - // Phone fields: the country-code selector is ON by default.
1155 - // Only persist the non-default `false`, so existing configs
1156 - // (which never carried this key) stay byte-identical and read
1157 - // back as ON.
1158 - if (
1159 - $sanitized_field['type'] === 'tel'
1160 - && array_key_exists('show_country_code', $field)
1161 - && !$field['show_country_code']
1162 - ) {
1163 - $sanitized_field['show_country_code'] = false;
1368 + $sanitized[] = $sanitized_field;
1369 + }
1370 +
1371 + // Sort fields by order
1372 + usort($sanitized, function ($a, $b) {
1373 + return ($a['order'] ?? 0) - ($b['order'] ?? 0);
1374 + });
1375 +
1376 + return $sanitized;
1377 + }
1378 +
1379 + /**
1380 + * Sanitise a section's per-trip conditions. A condition without any
1381 + * target (trip, category or trip type) can never match and is dropped.
1382 + *
1383 + * @param mixed $conditions
1384 + * @return array<int, array<string, mixed>>
1385 + */
1386 + private function sanitize_booking_form_conditions($conditions, string $form_type): array
1387 + {
1388 + if (empty($conditions) || !is_array($conditions)) {
1389 + return [];
1390 + }
1391 +
1392 + $allowed_trip_types = ['single_day', 'multi_day', 'flexible'];
1393 + $sanitized = [];
1394 + $n = 0;
1395 +
1396 + foreach ($conditions as $condition) {
1397 + if (!is_array($condition)) {
1398 + continue;
1399 + }
1400 + $n++;
1401 +
1402 + $raw_targets = is_array($condition['targets'] ?? null) ? $condition['targets'] : [];
1403 + $targets = [];
1404 + foreach (['trips', 'categories'] as $selector) {
1405 + $ids = array_values(array_unique(array_filter(
1406 + array_map('intval', is_array($raw_targets[$selector] ?? null) ? $raw_targets[$selector] : []),
1407 + static function ($id) {
1408 + return $id > 0;
1164 1409 }
1165 -
1166 - $sanitized[$form_type]['fields'][] = $sanitized_field;
1410 + )));
1411 + if ($ids !== []) {
1412 + $targets[$selector] = $ids;
1167 1413 }
1168 -
1169 - // Sort fields by order
1170 - usort($sanitized[$form_type]['fields'], function($a, $b) {
1171 - return ($a['order'] ?? 0) - ($b['order'] ?? 0);
1172 - });
1173 1414 }
1415 + $types = array_values(array_unique(array_filter(
1416 + array_map(static function ($t) {
1417 + return sanitize_key((string) $t);
1418 + }, is_array($raw_targets['trip_types'] ?? null) ? $raw_targets['trip_types'] : []),
1419 + static function ($t) use ($allowed_trip_types) {
1420 + return in_array($t, $allowed_trip_types, true);
1421 + }
1422 + )));
1423 + if ($types !== []) {
1424 + $targets['trip_types'] = $types;
1425 + }
1426 + if ($targets === []) {
1427 + continue;
1428 + }
1429 +
1430 + $id = sanitize_key((string) ($condition['id'] ?? ''));
1431 + $sanitized[] = [
1432 + 'id' => $id !== '' ? $id : 'condition_' . $n,
1433 + 'targets' => $targets,
1434 + 'title' => isset($condition['title']) ? sanitize_text_field($condition['title']) : '',
1435 + 'description' => isset($condition['description']) ? sanitize_text_field($condition['description']) : '',
1436 + 'fields' => $this->sanitize_booking_form_fields($condition['fields'] ?? null, $form_type),
1437 + ];
1174 1438 }
1175 -
1176 - return apply_filters('yatra_save_booking_form_config', $sanitized, $config);
1439 +
1440 + return $sanitized;
1177 1441 }
1178 1442
1179 1443 /**
1180 1444 * Flush rewrite rules