PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
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 2.0.1 2.0.10 All 83 releases
← All changes | app/Controllers/SettingsController.php +327 -77 3.0.8trunk 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,
@@ -192,9 +215,16 @@
192 215 'facebook_pixel' => '',
193 216 'recaptcha_enabled' => false,
194 217 'recaptcha_site_key' => '',
195 218 'recaptcha_secret_key' => '',
196 -
219 + // reCAPTCHA v3: score threshold (0.0-1.0) + per-form protection toggles.
220 + // All off by default so enabling reCAPTCHA alone changes nothing until
221 + // the operator picks which forms to protect.
222 + 'recaptcha_score_threshold' => 0.5,
223 + 'recaptcha_protect_enquiry' => false,
224 + 'recaptcha_protect_booking' => false,
225 + 'recaptcha_protect_registration' => false,
226 +
197 227 // Permalink Settings
198 228 'trip_base' => 'trip',
199 229 'destination_base' => 'destination',
200 230 'activity_base' => 'activity',
@@ -201,8 +231,13 @@
201 231 'trip_category_base' => 'trip-category',
202 232 'booking_base' => 'book',
203 233 // Wishlist (Pro) — stored in free options; active only when Pro + setting on
204 234 'enable_wishlist' => false,
235 + // Sold-out date visibility on the storefront. Default true keeps the
236 + // existing behaviour (sold-out dates stay visible, badged "sold out" and
237 + // able to drive the waitlist); owners can switch it off to hide them the
238 + // same way blocked dates are hidden.
239 + 'show_sold_out' => true,
205 240
206 241 // Search & Listing storefront UX. Defaults preserve current behaviour:
207 242 // every search field shown (true) and mobile filters expanded (false),
208 243 // so existing installs are unchanged until the owner opts in. Booleans
@@ -211,8 +246,12 @@
211 246 'search_show_destination' => true,
212 247 'search_show_activities' => true,
213 248 'search_show_duration' => true,
214 249 'search_show_budget' => true,
250 + // Date field is opt-in (default false) so updating the plugin never
251 + // changes an existing site's search bar. Operators enable it to let
252 + // customers find trips with a departure on a specific date.
253 + 'search_show_date' => false,
215 254 'collapse_filters_on_mobile' => false,
216 255
217 256 // Booking Page Settings
218 257 'use_booking_page' => false,
@@ -276,8 +315,26 @@
276 315 'permission_callback' => [$this, 'check_permission'],
277 316 ],
278 317 ]);
279 318
319 + // Booking form config, optionally resolved for one trip (Pro form
320 + // conditions). Readable by anyone who can view bookings, so the
321 + // booking detail screen can label the fields a trip actually asked.
322 + register_rest_route($namespace, '/' . $base . '/booking-form', [
323 + [
324 + 'methods' => \WP_REST_Server::READABLE,
325 + 'callback' => [$this, 'get_booking_form_config'],
326 + 'permission_callback' => [$this, 'check_booking_form_permission'],
327 + 'args' => [
328 + 'trip_id' => [
329 + 'type' => 'integer',
330 + 'required' => false,
331 + 'sanitize_callback' => 'absint',
332 + ],
333 + ],
334 + ],
335 + ]);
336 +
280 337 // Get WordPress pages for booking page selection
281 338 register_rest_route($namespace, '/' . $base . '/pages', [
282 339 [
283 340 'methods' => \WP_REST_Server::READABLE,
@@ -362,8 +419,43 @@
362 419 return current_user_can('yatra_manage_settings');
363 420 }
364 421
365 422 /**
423 + * The booking form config is needed to label booking data, so it is
424 + * readable by booking staff, not only settings managers.
425 + */
426 + public function check_booking_form_permission(?WP_REST_Request $request = null): bool
427 + {
428 + if (!is_user_logged_in()) {
429 + return false;
430 + }
431 + return current_user_can('yatra_manage_settings')
432 + || current_user_can('yatra_view_bookings')
433 + || current_user_can('yatra_edit_bookings');
434 + }
435 +
436 + /**
437 + * GET /settings/booking-form[?trip_id=N]
438 + *
439 + * Without trip_id: the full config exactly as the Settings screen sees it.
440 + * With trip_id: the config as that trip's checkout renders it — Pro form
441 + * conditions resolved (no Pro / no conditions → identical to the global).
442 + */
443 + public function get_booking_form_config(WP_REST_Request $request)
444 + {
445 + try {
446 + $trip_id = (int) $request->get_param('trip_id');
447 +
448 + return $this->success_response([
449 + 'booking_form_config' => \Yatra\Services\SettingsService::getBookingFormConfig($trip_id > 0 ? $trip_id : null),
450 + 'trip_id' => $trip_id > 0 ? $trip_id : null,
451 + ]);
452 + } catch (\Exception $e) {
453 + return $this->error_response($e->getMessage(), 500);
454 + }
455 + }
456 +
457 + /**
366 458 * Get all settings
367 459 */
368 460 public function get_settings(WP_REST_Request $request)
369 461 {
@@ -369,18 +461,36 @@
369 461 {
370 462 try {
371 463 $settings = [];
372 464
373 - // Get all settings from WordPress options table with yatra_ prefix
465 + // Get all settings from WordPress options table with yatra_ prefix.
466 + // A sentinel default is essential here: get_option() returns boolean
467 + // false for a stored-false option just as it does for a missing one,
468 + // so checking `=== false` would reset every saved-off boolean back to
469 + // its default. That is exactly the "Show sold-out dates" bug — the
470 + // storefront honoured the saved value (isEnabled coerces '' -> false)
471 + // while the admin checkbox re-appeared enabled because this endpoint
472 + // handed React the default (true) instead of the saved false.
473 + $unset_sentinel = "\0__yatra_option_unset__\0";
374 474 foreach ($this->default_settings as $key => $default_value) {
375 475 $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) {
476 + $value = get_option($option_name, $unset_sentinel);
477 +
478 + // Only use default when the option truly does not exist.
479 + if ($value === $unset_sentinel) {
380 480 $value = $default_value;
381 481 }
382 482
483 + // Auto-Confirm mode has no stored default — it is resolved on
484 + // the fly. Return the effective mode so the admin shows the
485 + // site's real behaviour: a stored choice if the operator made
486 + // one, otherwise derived from the legacy boolean
487 + // (true -> 'all', false -> 'online'). Prevents an existing
488 + // "confirm all" site from displaying (and re-saving) as 'online'.
489 + if ($key === 'auto_confirm_mode' && function_exists('yatra_get_auto_confirm_mode')) {
490 + $value = yatra_get_auto_confirm_mode();
491 + }
492 +
383 493 // Stored empty string should behave like "unset" for delivery identity (matches installer / backfill).
384 494 if (($key === 'admin_email' || $key === 'from_email') && is_string($value) && trim($value) === '') {
385 495 $wp = (string) get_option('admin_email', '');
386 496 $value = $wp !== '' ? $wp : $value;
@@ -398,9 +508,18 @@
398 508 // Ensure arrays are returned as arrays (not objects)
399 509 if (is_array($default_value) && !is_array($value)) {
400 510 $value = [];
401 511 }
402 -
512 +
513 + // Boolean settings must round-trip to the admin as real booleans.
514 + // update_option() stores false as '' and the object cache can
515 + // return boolean false, so without this a disabled toggle would
516 + // reach React as '' / false and the checkbox (checked unless the
517 + // value is strictly !== false) would render enabled again.
518 + if (is_bool($default_value)) {
519 + $value = filter_var($value, FILTER_VALIDATE_BOOLEAN);
520 + }
521 +
403 522 $settings[$key] = $value;
404 523 }
405 524
406 525 // Special handling for booking_form_config - always use getBookingFormConfig which handles locked fields
@@ -425,8 +544,10 @@
425 544 'scheduled_payment_days',
426 545 'scheduled_payment_installments',
427 546 'scheduled_payment_interval',
428 547 'scheduled_payment_reminder_days',
548 + 'balance_anchor',
549 + 'balance_due_days',
429 550 ] as $sk
430 551 ) {
431 552 if (array_key_exists($sk, $this->default_settings)) {
432 553 $settings[$sk] = \Yatra\Services\SettingsService::get(
@@ -485,8 +606,10 @@
485 606 'scheduled_payment_days',
486 607 'scheduled_payment_installments',
487 608 'scheduled_payment_interval',
488 609 'scheduled_payment_reminder_days',
610 + 'balance_anchor',
611 + 'balance_due_days',
489 612 ];
490 613
491 614 // Collect flexible payment settings to delegate to Pro
492 615 $flexible_payment_settings = [];
@@ -697,8 +820,19 @@
697 820 if ($filtered_value !== null) {
698 821 return $filtered_value;
699 822 }
700 823
824 + // The booking-form config has its own structured sanitiser (field type
825 + // and width whitelists, locked core fields, text-block content, per-trip
826 + // conditions). It must run BEFORE the generic
827 + // is_array($default) branch below: that branch only text-sanitises
828 + // values and was catching this key first — because its default is [] —
829 + // so the structured sanitiser further down was never reached and any
830 + // shape at all was stored.
831 + if ($key === 'booking_form_config') {
832 + return is_array($value) ? $this->sanitize_booking_form_config($value) : [];
833 + }
834 +
701 835 // Handle null values - use default
702 836 if ($value === null) {
703 837 return $default;
704 838 }
@@ -747,8 +881,14 @@
747 881 // Validate ranges for specific fields
748 882 if ($key === 'booking_expiry_hours' && $int_value < 0) {
749 883 return null;
750 884 }
885 + // Storefront booking horizon: 1–36 months. Out of range is rejected
886 + // (not clamped) so a bad write can never blank the calendar — the
887 + // previously stored value, or the 12-month default, stays in force.
888 + if ($key === 'availability_horizon_months' && ($int_value < 1 || $int_value > 36)) {
889 + return null;
890 + }
751 891 if ($key === 'partial_payment_percentage' && ($int_value < 0 || $int_value > 100)) {
752 892 return null;
753 893 }
754 894 if ($key === 'deposit_percentage' && ($int_value < 0 || $int_value > 100)) {
@@ -831,8 +971,18 @@
831 971 return \Yatra\Utils\FrontendThemeCss::sanitizeContainerMaxWidthSetting(
832 972 is_string($value) ? $value : ''
833 973 );
834 974 }
975 + if ($key === 'frontend_listing_card_layout') {
976 + $allowed = ['standard', 'compact_mobile', 'compact_all'];
977 + $v = is_string($value) ? strtolower(trim($value)) : '';
978 + return in_array($v, $allowed, true) ? $v : 'standard';
979 + }
980 + if ($key === 'auto_confirm_mode') {
981 + $allowed = ['none', 'online', 'all'];
982 + $v = is_string($value) ? strtolower(trim($value)) : '';
983 + return in_array($v, $allowed, true) ? $v : 'online';
984 + }
835 985 if (is_string($key) && strpos($key, 'email_tpl_') === 0 && substr($key, -5) === '_body') {
836 986 return wp_kses_post((string) $value);
837 987 }
838 988 if (is_string($key) && strpos($key, 'email_tpl_') === 0 && substr($key, -8) === '_subject') {
@@ -848,15 +998,8 @@
848 998 return $this->sanitize_gateway_configs($value);
849 999 }
850 1000 return [];
851 1001 }
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 1002 if ($key === 'tax_rates') {
860 1003 // Handle nested array structure for tax rates
861 1004 if (is_array($value)) {
862 1005 return $this->sanitize_tax_rates($value);
@@ -1082,87 +1225,194 @@
1082 1225 private function sanitize_booking_form_config(array $config): array
1083 1226 {
1084 1227 $sanitized = [];
1085 1228 $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 -
1229 +
1089 1230 foreach ($config as $form_type => $form_config) {
1090 1231 if (!in_array($form_type, $allowed_form_types, true)) {
1091 1232 continue;
1092 1233 }
1093 -
1234 +
1094 1235 $sanitized[$form_type] = [
1095 1236 'title' => isset($form_config['title']) ? sanitize_text_field($form_config['title']) : '',
1096 1237 'description' => isset($form_config['description']) ? sanitize_text_field($form_config['description']) : '',
1097 1238 'enabled' => isset($form_config['enabled']) ? (bool) $form_config['enabled'] : true,
1098 - 'fields' => [],
1239 + 'fields' => $this->sanitize_booking_form_fields($form_config['fields'] ?? null, $form_type),
1099 1240 ];
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;
1241 +
1242 + // Per-trip form conditions (Pro Dynamic Form Field): each condition
1243 + // is a complete alternative version of this section — its own
1244 + // title, description and field list — used on the trips it names.
1245 + // Only persisted when there is at least one, so configs saved
1246 + // without the feature stay byte-identical.
1247 + $conditions = $this->sanitize_booking_form_conditions($form_config['conditions'] ?? null, $form_type);
1248 + if ($conditions !== []) {
1249 + $sanitized[$form_type]['conditions'] = $conditions;
1250 + }
1251 + }
1252 +
1253 + return apply_filters('yatra_save_booking_form_config', $sanitized, $config);
1254 + }
1255 +
1256 + /**
1257 + * Sanitise one section's field list (global fields or a condition's fields).
1258 + *
1259 + * @param mixed $fields
1260 + * @return array<int, array<string, mixed>>
1261 + */
1262 + private function sanitize_booking_form_fields($fields, string $form_type): array
1263 + {
1264 + $allowed_field_types = ['text', 'email', 'tel', 'date', 'select', 'country', 'textarea', 'checkbox', 'number', 'text_block'];
1265 + $allowed_widths = ['full', 'half', 'third'];
1266 + $sanitized = [];
1267 +
1268 + if (empty($fields) || !is_array($fields)) {
1269 + return $sanitized;
1270 + }
1271 +
1272 + foreach ($fields as $field) {
1273 + if (!is_array($field) || empty($field['id'])) {
1274 + continue;
1275 + }
1276 +
1277 + $sanitized_field = [
1278 + 'id' => sanitize_key($field['id']),
1279 + 'type' => in_array($field['type'] ?? 'text', $allowed_field_types, true) ? $field['type'] : 'text',
1280 + 'label' => isset($field['label']) ? sanitize_text_field($field['label']) : '',
1281 + 'placeholder' => isset($field['placeholder']) ? sanitize_text_field($field['placeholder']) : '',
1282 + 'required' => isset($field['required']) ? (bool) $field['required'] : false,
1283 + 'enabled' => isset($field['enabled']) ? (bool) $field['enabled'] : true,
1284 + 'order' => isset($field['order']) ? (int) $field['order'] : 0,
1285 + 'width' => in_array($field['width'] ?? 'full', $allowed_widths, true) ? ($field['width'] ?? 'full') : 'full',
1286 + ];
1287 +
1288 + // Only persist `locked` when set: every reader treats a missing key
1289 + // as unlocked, and configs saved before this sanitiser ran never
1290 + // carried a `locked => false`, so they stay byte-identical.
1291 + if (!empty($field['locked'])) {
1292 + $sanitized_field['locked'] = true;
1293 + }
1294 +
1295 + // Handle optional section
1296 + if (!empty($field['section'])) {
1297 + $sanitized_field['section'] = sanitize_key($field['section']);
1298 + }
1299 +
1300 + // Per-traveler targeting — Traveler section only. Whitelist
1301 + // the allowed values; only persist the non-default "lead" so
1302 + // other sections and existing configs stay byte-identical.
1303 + if (
1304 + $form_type === 'traveler_form'
1305 + && ($field['applies_to'] ?? 'all') === 'lead'
1306 + ) {
1307 + $sanitized_field['applies_to'] = 'lead';
1308 + }
1309 +
1310 + // Handle options for select fields
1311 + if ($sanitized_field['type'] === 'select' && !empty($field['options']) && is_array($field['options'])) {
1312 + $sanitized_field['options'] = [];
1313 + foreach ($field['options'] as $option) {
1314 + if (is_array($option) && isset($option['value'])) {
1315 + $sanitized_field['options'][] = [
1316 + 'value' => sanitize_key($option['value']),
1317 + 'label' => isset($option['label']) ? sanitize_text_field($option['label']) : $option['value'],
1318 + ];
1105 1319 }
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 - }
1320 + }
1321 + }
1123 1322
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 - }
1323 + // A text block is display-only content placed between fields:
1324 + // keep its (safe-HTML) content, and it can never be required.
1325 + if ($sanitized_field['type'] === 'text_block') {
1326 + $sanitized_field['content'] = isset($field['content']) ? wp_kses_post($field['content']) : '';
1327 + $sanitized_field['required'] = false;
1328 + }
1146 1329
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;
1330 + // Phone fields: the country-code selector is ON by default.
1331 + // Only persist the non-default `false`, so existing configs
1332 + // (which never carried this key) stay byte-identical and read
1333 + // back as ON.
1334 + if (
1335 + $sanitized_field['type'] === 'tel'
1336 + && array_key_exists('show_country_code', $field)
1337 + && !$field['show_country_code']
1338 + ) {
1339 + $sanitized_field['show_country_code'] = false;
1340 + }
1341 +
1342 + $sanitized[] = $sanitized_field;
1343 + }
1344 +
1345 + // Sort fields by order
1346 + usort($sanitized, function ($a, $b) {
1347 + return ($a['order'] ?? 0) - ($b['order'] ?? 0);
1348 + });
1349 +
1350 + return $sanitized;
1351 + }
1352 +
1353 + /**
1354 + * Sanitise a section's per-trip conditions. A condition without any
1355 + * target (trip, category or trip type) can never match and is dropped.
1356 + *
1357 + * @param mixed $conditions
1358 + * @return array<int, array<string, mixed>>
1359 + */
1360 + private function sanitize_booking_form_conditions($conditions, string $form_type): array
1361 + {
1362 + if (empty($conditions) || !is_array($conditions)) {
1363 + return [];
1364 + }
1365 +
1366 + $allowed_trip_types = ['single_day', 'multi_day', 'flexible'];
1367 + $sanitized = [];
1368 + $n = 0;
1369 +
1370 + foreach ($conditions as $condition) {
1371 + if (!is_array($condition)) {
1372 + continue;
1373 + }
1374 + $n++;
1375 +
1376 + $raw_targets = is_array($condition['targets'] ?? null) ? $condition['targets'] : [];
1377 + $targets = [];
1378 + foreach (['trips', 'categories'] as $selector) {
1379 + $ids = array_values(array_unique(array_filter(
1380 + array_map('intval', is_array($raw_targets[$selector] ?? null) ? $raw_targets[$selector] : []),
1381 + static function ($id) {
1382 + return $id > 0;
1152 1383 }
1153 -
1154 - $sanitized[$form_type]['fields'][] = $sanitized_field;
1384 + )));
1385 + if ($ids !== []) {
1386 + $targets[$selector] = $ids;
1155 1387 }
1156 -
1157 - // Sort fields by order
1158 - usort($sanitized[$form_type]['fields'], function($a, $b) {
1159 - return ($a['order'] ?? 0) - ($b['order'] ?? 0);
1160 - });
1161 1388 }
1389 + $types = array_values(array_unique(array_filter(
1390 + array_map(static function ($t) {
1391 + return sanitize_key((string) $t);
1392 + }, is_array($raw_targets['trip_types'] ?? null) ? $raw_targets['trip_types'] : []),
1393 + static function ($t) use ($allowed_trip_types) {
1394 + return in_array($t, $allowed_trip_types, true);
1395 + }
1396 + )));
1397 + if ($types !== []) {
1398 + $targets['trip_types'] = $types;
1399 + }
1400 + if ($targets === []) {
1401 + continue;
1402 + }
1403 +
1404 + $id = sanitize_key((string) ($condition['id'] ?? ''));
1405 + $sanitized[] = [
1406 + 'id' => $id !== '' ? $id : 'condition_' . $n,
1407 + 'targets' => $targets,
1408 + 'title' => isset($condition['title']) ? sanitize_text_field($condition['title']) : '',
1409 + 'description' => isset($condition['description']) ? sanitize_text_field($condition['description']) : '',
1410 + 'fields' => $this->sanitize_booking_form_fields($condition['fields'] ?? null, $form_type),
1411 + ];
1162 1412 }
1163 -
1164 - return apply_filters('yatra_save_booking_form_config', $sanitized, $config);
1413 +
1414 + return $sanitized;
1165 1415 }
1166 1416
1167 1417 /**
1168 1418 * Flush rewrite rules