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 +272 -81 3.0.14.2trunk 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).
@@ -302,8 +315,26 @@
302 315 'permission_callback' => [$this, 'check_permission'],
303 316 ],
304 317 ]);
305 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 +
306 337 // Get WordPress pages for booking page selection
307 338 register_rest_route($namespace, '/' . $base . '/pages', [
308 339 [
309 340 'methods' => \WP_REST_Server::READABLE,
@@ -388,8 +419,43 @@
388 419 return current_user_can('yatra_manage_settings');
389 420 }
390 421
391 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 + /**
392 458 * Get all settings
393 459 */
394 460 public function get_settings(WP_REST_Request $request)
395 461 {
@@ -413,8 +479,18 @@
413 479 if ($value === $unset_sentinel) {
414 480 $value = $default_value;
415 481 }
416 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 +
417 493 // Stored empty string should behave like "unset" for delivery identity (matches installer / backfill).
418 494 if (($key === 'admin_email' || $key === 'from_email') && is_string($value) && trim($value) === '') {
419 495 $wp = (string) get_option('admin_email', '');
420 496 $value = $wp !== '' ? $wp : $value;
@@ -744,8 +820,19 @@
744 820 if ($filtered_value !== null) {
745 821 return $filtered_value;
746 822 }
747 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 +
748 835 // Handle null values - use default
749 836 if ($value === null) {
750 837 return $default;
751 838 }
@@ -794,8 +881,14 @@
794 881 // Validate ranges for specific fields
795 882 if ($key === 'booking_expiry_hours' && $int_value < 0) {
796 883 return null;
797 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 + }
798 891 if ($key === 'partial_payment_percentage' && ($int_value < 0 || $int_value > 100)) {
799 892 return null;
800 893 }
801 894 if ($key === 'deposit_percentage' && ($int_value < 0 || $int_value > 100)) {
@@ -878,8 +971,18 @@
878 971 return \Yatra\Utils\FrontendThemeCss::sanitizeContainerMaxWidthSetting(
879 972 is_string($value) ? $value : ''
880 973 );
881 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 + }
882 985 if (is_string($key) && strpos($key, 'email_tpl_') === 0 && substr($key, -5) === '_body') {
883 986 return wp_kses_post((string) $value);
884 987 }
885 988 if (is_string($key) && strpos($key, 'email_tpl_') === 0 && substr($key, -8) === '_subject') {
@@ -895,15 +998,8 @@
895 998 return $this->sanitize_gateway_configs($value);
896 999 }
897 1000 return [];
898 1001 }
899 - if ($key === 'booking_form_config') {
900 - // Handle nested array structure for booking form config
901 - if (is_array($value)) {
902 - return $this->sanitize_booking_form_config($value);
903 - }
904 - return [];
905 - }
906 1002 if ($key === 'tax_rates') {
907 1003 // Handle nested array structure for tax rates
908 1004 if (is_array($value)) {
909 1005 return $this->sanitize_tax_rates($value);
@@ -1129,99 +1225,194 @@
1129 1225 private function sanitize_booking_form_config(array $config): array
1130 1226 {
1131 1227 $sanitized = [];
1132 1228 $allowed_form_types = ['contact_form', 'emergency_contact_form', 'traveler_form'];
1133 - $allowed_field_types = ['text', 'email', 'tel', 'date', 'select', 'country', 'textarea', 'checkbox', 'number', 'text_block'];
1134 - $allowed_widths = ['full', 'half', 'third'];
1135 -
1229 +
1136 1230 foreach ($config as $form_type => $form_config) {
1137 1231 if (!in_array($form_type, $allowed_form_types, true)) {
1138 1232 continue;
1139 1233 }
1140 -
1234 +
1141 1235 $sanitized[$form_type] = [
1142 1236 'title' => isset($form_config['title']) ? sanitize_text_field($form_config['title']) : '',
1143 1237 'description' => isset($form_config['description']) ? sanitize_text_field($form_config['description']) : '',
1144 1238 'enabled' => isset($form_config['enabled']) ? (bool) $form_config['enabled'] : true,
1145 - 'fields' => [],
1239 + 'fields' => $this->sanitize_booking_form_fields($form_config['fields'] ?? null, $form_type),
1146 1240 ];
1147 -
1148 - if (!empty($form_config['fields']) && is_array($form_config['fields'])) {
1149 - foreach ($form_config['fields'] as $field) {
1150 - if (!is_array($field) || empty($field['id'])) {
1151 - 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 + ];
1152 1319 }
1153 -
1154 - $sanitized_field = [
1155 - 'id' => sanitize_key($field['id']),
1156 - 'type' => in_array($field['type'] ?? 'text', $allowed_field_types, true) ? $field['type'] : 'text',
1157 - 'label' => isset($field['label']) ? sanitize_text_field($field['label']) : '',
1158 - 'placeholder' => isset($field['placeholder']) ? sanitize_text_field($field['placeholder']) : '',
1159 - 'required' => isset($field['required']) ? (bool) $field['required'] : false,
1160 - 'enabled' => isset($field['enabled']) ? (bool) $field['enabled'] : true,
1161 - 'order' => isset($field['order']) ? (int) $field['order'] : 0,
1162 - 'width' => in_array($field['width'] ?? 'full', $allowed_widths, true) ? ($field['width'] ?? 'full') : 'full',
1163 - 'locked' => isset($field['locked']) ? (bool) $field['locked'] : false,
1164 - ];
1165 -
1166 - // Handle optional section
1167 - if (!empty($field['section'])) {
1168 - $sanitized_field['section'] = sanitize_key($field['section']);
1169 - }
1320 + }
1321 + }
1170 1322
1171 - // Per-traveler targeting — Traveler section only. Whitelist
1172 - // the allowed values; only persist the non-default "lead" so
1173 - // other sections and existing configs stay byte-identical.
1174 - if (
1175 - $form_type === 'traveler_form'
1176 - && ($field['applies_to'] ?? 'all') === 'lead'
1177 - ) {
1178 - $sanitized_field['applies_to'] = 'lead';
1179 - }
1180 -
1181 - // Handle options for select fields
1182 - if ($sanitized_field['type'] === 'select' && !empty($field['options']) && is_array($field['options'])) {
1183 - $sanitized_field['options'] = [];
1184 - foreach ($field['options'] as $option) {
1185 - if (is_array($option) && isset($option['value'])) {
1186 - $sanitized_field['options'][] = [
1187 - 'value' => sanitize_key($option['value']),
1188 - 'label' => isset($option['label']) ? sanitize_text_field($option['label']) : $option['value'],
1189 - ];
1190 - }
1191 - }
1192 - }
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 + }
1193 1329
1194 - // A text block is display-only content placed between fields:
1195 - // keep its (safe-HTML) content, and it can never be required.
1196 - if ($sanitized_field['type'] === 'text_block') {
1197 - $sanitized_field['content'] = isset($field['content']) ? wp_kses_post($field['content']) : '';
1198 - $sanitized_field['required'] = false;
1199 - }
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 + }
1200 1341
1201 - // Phone fields: the country-code selector is ON by default.
1202 - // Only persist the non-default `false`, so existing configs
1203 - // (which never carried this key) stay byte-identical and read
1204 - // back as ON.
1205 - if (
1206 - $sanitized_field['type'] === 'tel'
1207 - && array_key_exists('show_country_code', $field)
1208 - && !$field['show_country_code']
1209 - ) {
1210 - $sanitized_field['show_country_code'] = false;
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;
1211 1383 }
1212 -
1213 - $sanitized[$form_type]['fields'][] = $sanitized_field;
1384 + )));
1385 + if ($ids !== []) {
1386 + $targets[$selector] = $ids;
1214 1387 }
1215 -
1216 - // Sort fields by order
1217 - usort($sanitized[$form_type]['fields'], function($a, $b) {
1218 - return ($a['order'] ?? 0) - ($b['order'] ?? 0);
1219 - });
1220 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 + ];
1221 1412 }
1222 -
1223 - return apply_filters('yatra_save_booking_form_config', $sanitized, $config);
1413 +
1414 + return $sanitized;
1224 1415 }
1225 1416
1226 1417 /**
1227 1418 * Flush rewrite rules