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 +333 -89 3.0.13 → 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,
@@ -136,9 +151,18 @@
136 151 'email_template_admin_scheduled_payment_failed' => true,
137 152 'email_template_enquiry_received' => true,
138 153 'email_template_enquiry_admin' => true,
139 154 'email_template_enquiry_response' => true,
140 - '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,
141 165 'email_template_abandoned_booking_recovery_first' => true,
142 166 'email_template_abandoned_booking_recovery_second' => true,
143 167 'email_template_abandoned_booking_recovery_final' => true,
144 168 'smtp_enabled' => false,
@@ -167,9 +191,13 @@
167 191 'auto_approve_reviews' => false,
168 192 'review_moderation' => true,
169 193 'min_rating' => 1,
170 194 'allow_anonymous_reviews' => false,
171 - '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,
172 200
173 201 // Tax Settings
174 202 'enable_tax' => false,
175 203 'tax_name' => __('Tax', 'yatra'),
@@ -208,8 +236,14 @@
208 236 'recaptcha_protect_enquiry' => false,
209 237 'recaptcha_protect_booking' => false,
210 238 'recaptcha_protect_registration' => false,
211 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 +
212 246 // Permalink Settings
213 247 'trip_base' => 'trip',
214 248 'destination_base' => 'destination',
215 249 'activity_base' => 'activity',
@@ -231,8 +265,12 @@
231 265 'search_show_destination' => true,
232 266 'search_show_activities' => true,
233 267 'search_show_duration' => true,
234 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,
235 273 'collapse_filters_on_mobile' => false,
236 274
237 275 // Booking Page Settings
238 276 'use_booking_page' => false,
@@ -247,8 +285,15 @@
247 285 'seo_trip_meta_description' => '',
248 286 'seo_trip_meta_keywords' => '',
249 287 'seo_trip_meta_image' => 0,
250 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,
251 296
252 297 // Advanced Settings
253 298 'debug_mode' => false,
254 299 'enable_logging' => false,
@@ -296,8 +341,26 @@
296 341 'permission_callback' => [$this, 'check_permission'],
297 342 ],
298 343 ]);
299 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 +
300 363 // Get WordPress pages for booking page selection
301 364 register_rest_route($namespace, '/' . $base . '/pages', [
302 365 [
303 366 'methods' => \WP_REST_Server::READABLE,
@@ -382,8 +445,43 @@
382 445 return current_user_can('yatra_manage_settings');
383 446 }
384 447
385 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 + /**
386 484 * Get all settings
387 485 */
388 486 public function get_settings(WP_REST_Request $request)
389 487 {
@@ -389,18 +487,36 @@
389 487 {
390 488 try {
391 489 $settings = [];
392 490
393 - // 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";
394 500 foreach ($this->default_settings as $key => $default_value) {
395 501 $option_name = 'yatra_' . $key;
396 - $value = get_option($option_name, false);
397 -
398 - // Only use default if option doesn't exist (wasn't set by InstallerService)
399 - 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) {
400 506 $value = $default_value;
401 507 }
402 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 +
403 519 // Stored empty string should behave like "unset" for delivery identity (matches installer / backfill).
404 520 if (($key === 'admin_email' || $key === 'from_email') && is_string($value) && trim($value) === '') {
405 521 $wp = (string) get_option('admin_email', '');
406 522 $value = $wp !== '' ? $wp : $value;
@@ -418,9 +534,18 @@
418 534 // Ensure arrays are returned as arrays (not objects)
419 535 if (is_array($default_value) && !is_array($value)) {
420 536 $value = [];
421 537 }
422 -
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 +
423 548 $settings[$key] = $value;
424 549 }
425 550
426 551 // Special handling for booking_form_config - always use getBookingFormConfig which handles locked fields
@@ -445,8 +570,10 @@
445 570 'scheduled_payment_days',
446 571 'scheduled_payment_installments',
447 572 'scheduled_payment_interval',
448 573 'scheduled_payment_reminder_days',
574 + 'balance_anchor',
575 + 'balance_due_days',
449 576 ] as $sk
450 577 ) {
451 578 if (array_key_exists($sk, $this->default_settings)) {
452 579 $settings[$sk] = \Yatra\Services\SettingsService::get(
@@ -505,8 +632,10 @@
505 632 'scheduled_payment_days',
506 633 'scheduled_payment_installments',
507 634 'scheduled_payment_interval',
508 635 'scheduled_payment_reminder_days',
636 + 'balance_anchor',
637 + 'balance_due_days',
509 638 ];
510 639
511 640 // Collect flexible payment settings to delegate to Pro
512 641 $flexible_payment_settings = [];
@@ -717,8 +846,19 @@
717 846 if ($filtered_value !== null) {
718 847 return $filtered_value;
719 848 }
720 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 +
721 861 // Handle null values - use default
722 862 if ($value === null) {
723 863 return $default;
724 864 }
@@ -767,8 +907,14 @@
767 907 // Validate ranges for specific fields
768 908 if ($key === 'booking_expiry_hours' && $int_value < 0) {
769 909 return null;
770 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 + }
771 917 if ($key === 'partial_payment_percentage' && ($int_value < 0 || $int_value > 100)) {
772 918 return null;
773 919 }
774 920 if ($key === 'deposit_percentage' && ($int_value < 0 || $int_value > 100)) {
@@ -851,8 +997,18 @@
851 997 return \Yatra\Utils\FrontendThemeCss::sanitizeContainerMaxWidthSetting(
852 998 is_string($value) ? $value : ''
853 999 );
854 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 + }
855 1011 if (is_string($key) && strpos($key, 'email_tpl_') === 0 && substr($key, -5) === '_body') {
856 1012 return wp_kses_post((string) $value);
857 1013 }
858 1014 if (is_string($key) && strpos($key, 'email_tpl_') === 0 && substr($key, -8) === '_subject') {
@@ -868,15 +1024,8 @@
868 1024 return $this->sanitize_gateway_configs($value);
869 1025 }
870 1026 return [];
871 1027 }
872 - if ($key === 'booking_form_config') {
873 - // Handle nested array structure for booking form config
874 - if (is_array($value)) {
875 - return $this->sanitize_booking_form_config($value);
876 - }
877 - return [];
878 - }
879 1028 if ($key === 'tax_rates') {
880 1029 // Handle nested array structure for tax rates
881 1030 if (is_array($value)) {
882 1031 return $this->sanitize_tax_rates($value);
@@ -1102,99 +1251,194 @@
1102 1251 private function sanitize_booking_form_config(array $config): array
1103 1252 {
1104 1253 $sanitized = [];
1105 1254 $allowed_form_types = ['contact_form', 'emergency_contact_form', 'traveler_form'];
1106 - $allowed_field_types = ['text', 'email', 'tel', 'date', 'select', 'country', 'textarea', 'checkbox', 'number', 'text_block'];
1107 - $allowed_widths = ['full', 'half', 'third'];
1108 -
1255 +
1109 1256 foreach ($config as $form_type => $form_config) {
1110 1257 if (!in_array($form_type, $allowed_form_types, true)) {
1111 1258 continue;
1112 1259 }
1113 -
1260 +
1114 1261 $sanitized[$form_type] = [
1115 1262 'title' => isset($form_config['title']) ? sanitize_text_field($form_config['title']) : '',
1116 1263 'description' => isset($form_config['description']) ? sanitize_text_field($form_config['description']) : '',
1117 1264 'enabled' => isset($form_config['enabled']) ? (bool) $form_config['enabled'] : true,
1118 - 'fields' => [],
1265 + 'fields' => $this->sanitize_booking_form_fields($form_config['fields'] ?? null, $form_type),
1119 1266 ];
1120 -
1121 - if (!empty($form_config['fields']) && is_array($form_config['fields'])) {
1122 - foreach ($form_config['fields'] as $field) {
1123 - if (!is_array($field) || empty($field['id'])) {
1124 - 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 + ];
1125 1345 }
1126 -
1127 - $sanitized_field = [
1128 - 'id' => sanitize_key($field['id']),
1129 - 'type' => in_array($field['type'] ?? 'text', $allowed_field_types, true) ? $field['type'] : 'text',
1130 - 'label' => isset($field['label']) ? sanitize_text_field($field['label']) : '',
1131 - 'placeholder' => isset($field['placeholder']) ? sanitize_text_field($field['placeholder']) : '',
1132 - 'required' => isset($field['required']) ? (bool) $field['required'] : false,
1133 - 'enabled' => isset($field['enabled']) ? (bool) $field['enabled'] : true,
1134 - 'order' => isset($field['order']) ? (int) $field['order'] : 0,
1135 - 'width' => in_array($field['width'] ?? 'full', $allowed_widths, true) ? ($field['width'] ?? 'full') : 'full',
1136 - 'locked' => isset($field['locked']) ? (bool) $field['locked'] : false,
1137 - ];
1138 -
1139 - // Handle optional section
1140 - if (!empty($field['section'])) {
1141 - $sanitized_field['section'] = sanitize_key($field['section']);
1142 - }
1346 + }
1347 + }
1143 1348
1144 - // Per-traveler targeting — Traveler section only. Whitelist
1145 - // the allowed values; only persist the non-default "lead" so
1146 - // other sections and existing configs stay byte-identical.
1147 - if (
1148 - $form_type === 'traveler_form'
1149 - && ($field['applies_to'] ?? 'all') === 'lead'
1150 - ) {
1151 - $sanitized_field['applies_to'] = 'lead';
1152 - }
1153 -
1154 - // Handle options for select fields
1155 - if ($sanitized_field['type'] === 'select' && !empty($field['options']) && is_array($field['options'])) {
1156 - $sanitized_field['options'] = [];
1157 - foreach ($field['options'] as $option) {
1158 - if (is_array($option) && isset($option['value'])) {
1159 - $sanitized_field['options'][] = [
1160 - 'value' => sanitize_key($option['value']),
1161 - 'label' => isset($option['label']) ? sanitize_text_field($option['label']) : $option['value'],
1162 - ];
1163 - }
1164 - }
1165 - }
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 + }
1166 1355
1167 - // A text block is display-only content placed between fields:
1168 - // keep its (safe-HTML) content, and it can never be required.
1169 - if ($sanitized_field['type'] === 'text_block') {
1170 - $sanitized_field['content'] = isset($field['content']) ? wp_kses_post($field['content']) : '';
1171 - $sanitized_field['required'] = false;
1172 - }
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 + }
1173 1367
1174 - // Phone fields: the country-code selector is ON by default.
1175 - // Only persist the non-default `false`, so existing configs
1176 - // (which never carried this key) stay byte-identical and read
1177 - // back as ON.
1178 - if (
1179 - $sanitized_field['type'] === 'tel'
1180 - && array_key_exists('show_country_code', $field)
1181 - && !$field['show_country_code']
1182 - ) {
1183 - $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;
1184 1409 }
1185 -
1186 - $sanitized[$form_type]['fields'][] = $sanitized_field;
1410 + )));
1411 + if ($ids !== []) {
1412 + $targets[$selector] = $ids;
1187 1413 }
1188 -
1189 - // Sort fields by order
1190 - usort($sanitized[$form_type]['fields'], function($a, $b) {
1191 - return ($a['order'] ?? 0) - ($b['order'] ?? 0);
1192 - });
1193 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 + ];
1194 1438 }
1195 -
1196 - return apply_filters('yatra_save_booking_form_config', $sanitized, $config);
1439 +
1440 + return $sanitized;
1197 1441 }
1198 1442
1199 1443 /**
1200 1444 * Flush rewrite rules