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 +300 -83 3.0.14.2 → 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).
@@ -138,9 +151,18 @@
138 151 'email_template_admin_scheduled_payment_failed' => true,
139 152 'email_template_enquiry_received' => true,
140 153 'email_template_enquiry_admin' => true,
141 154 'email_template_enquiry_response' => true,
142 - '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,
143 165 'email_template_abandoned_booking_recovery_first' => true,
144 166 'email_template_abandoned_booking_recovery_second' => true,
145 167 'email_template_abandoned_booking_recovery_final' => true,
146 168 'smtp_enabled' => false,
@@ -169,9 +191,13 @@
169 191 'auto_approve_reviews' => false,
170 192 'review_moderation' => true,
171 193 'min_rating' => 1,
172 194 'allow_anonymous_reviews' => false,
173 - '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,
174 200
175 201 // Tax Settings
176 202 'enable_tax' => false,
177 203 'tax_name' => __('Tax', 'yatra'),
@@ -210,8 +236,14 @@
210 236 'recaptcha_protect_enquiry' => false,
211 237 'recaptcha_protect_booking' => false,
212 238 'recaptcha_protect_registration' => false,
213 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 +
214 246 // Permalink Settings
215 247 'trip_base' => 'trip',
216 248 'destination_base' => 'destination',
217 249 'activity_base' => 'activity',
@@ -253,8 +285,15 @@
253 285 'seo_trip_meta_description' => '',
254 286 'seo_trip_meta_keywords' => '',
255 287 'seo_trip_meta_image' => 0,
256 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,
257 296
258 297 // Advanced Settings
259 298 'debug_mode' => false,
260 299 'enable_logging' => false,
@@ -302,8 +341,26 @@
302 341 'permission_callback' => [$this, 'check_permission'],
303 342 ],
304 343 ]);
305 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 +
306 363 // Get WordPress pages for booking page selection
307 364 register_rest_route($namespace, '/' . $base . '/pages', [
308 365 [
309 366 'methods' => \WP_REST_Server::READABLE,
@@ -388,8 +445,43 @@
388 445 return current_user_can('yatra_manage_settings');
389 446 }
390 447
391 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 + /**
392 484 * Get all settings
393 485 */
394 486 public function get_settings(WP_REST_Request $request)
395 487 {
@@ -413,8 +505,18 @@
413 505 if ($value === $unset_sentinel) {
414 506 $value = $default_value;
415 507 }
416 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 +
417 519 // Stored empty string should behave like "unset" for delivery identity (matches installer / backfill).
418 520 if (($key === 'admin_email' || $key === 'from_email') && is_string($value) && trim($value) === '') {
419 521 $wp = (string) get_option('admin_email', '');
420 522 $value = $wp !== '' ? $wp : $value;
@@ -744,8 +846,19 @@
744 846 if ($filtered_value !== null) {
745 847 return $filtered_value;
746 848 }
747 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 +
748 861 // Handle null values - use default
749 862 if ($value === null) {
750 863 return $default;
751 864 }
@@ -794,8 +907,14 @@
794 907 // Validate ranges for specific fields
795 908 if ($key === 'booking_expiry_hours' && $int_value < 0) {
796 909 return null;
797 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 + }
798 917 if ($key === 'partial_payment_percentage' && ($int_value < 0 || $int_value > 100)) {
799 918 return null;
800 919 }
801 920 if ($key === 'deposit_percentage' && ($int_value < 0 || $int_value > 100)) {
@@ -878,8 +997,18 @@
878 997 return \Yatra\Utils\FrontendThemeCss::sanitizeContainerMaxWidthSetting(
879 998 is_string($value) ? $value : ''
880 999 );
881 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 + }
882 1011 if (is_string($key) && strpos($key, 'email_tpl_') === 0 && substr($key, -5) === '_body') {
883 1012 return wp_kses_post((string) $value);
884 1013 }
885 1014 if (is_string($key) && strpos($key, 'email_tpl_') === 0 && substr($key, -8) === '_subject') {
@@ -895,15 +1024,8 @@
895 1024 return $this->sanitize_gateway_configs($value);
896 1025 }
897 1026 return [];
898 1027 }
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 1028 if ($key === 'tax_rates') {
907 1029 // Handle nested array structure for tax rates
908 1030 if (is_array($value)) {
909 1031 return $this->sanitize_tax_rates($value);
@@ -1129,99 +1251,194 @@
1129 1251 private function sanitize_booking_form_config(array $config): array
1130 1252 {
1131 1253 $sanitized = [];
1132 1254 $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 -
1255 +
1136 1256 foreach ($config as $form_type => $form_config) {
1137 1257 if (!in_array($form_type, $allowed_form_types, true)) {
1138 1258 continue;
1139 1259 }
1140 -
1260 +
1141 1261 $sanitized[$form_type] = [
1142 1262 'title' => isset($form_config['title']) ? sanitize_text_field($form_config['title']) : '',
1143 1263 'description' => isset($form_config['description']) ? sanitize_text_field($form_config['description']) : '',
1144 1264 'enabled' => isset($form_config['enabled']) ? (bool) $form_config['enabled'] : true,
1145 - 'fields' => [],
1265 + 'fields' => $this->sanitize_booking_form_fields($form_config['fields'] ?? null, $form_type),
1146 1266 ];
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;
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 + ];
1152 1345 }
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 - }
1346 + }
1347 + }
1170 1348
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 - }
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 + }
1193 1355
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 - }
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 + }
1200 1367
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;
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;
1211 1409 }
1212 -
1213 - $sanitized[$form_type]['fields'][] = $sanitized_field;
1410 + )));
1411 + if ($ids !== []) {
1412 + $targets[$selector] = $ids;
1214 1413 }
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 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 + ];
1221 1438 }
1222 -
1223 - return apply_filters('yatra_save_booking_form_config', $sanitized, $config);
1439 +
1440 + return $sanitized;
1224 1441 }
1225 1442
1226 1443 /**
1227 1444 * Flush rewrite rules