PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
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 2.0.1 All 84 releases
← All changes | app/Services/SettingsService.php +509 -101 3.0.2.7 → 3.0.16 View file →
@@ -19,8 +19,13 @@
19 19 */
20 20 private static ?array $settings = null;
21 21
22 22 /**
23 + * Cached {@see self::getPermalinkBases()} per request (after {@see 'yatra_permalink_bases'} filter).
24 + */
25 + private static ?array $permalinkBasesCache = null;
26 +
27 + /**
23 28 * Settings option prefix in database
24 29 * Each setting is stored as yatra_{key}
25 30 */
26 31 private const OPTION_PREFIX = 'yatra_';
@@ -36,9 +41,15 @@
36 41 'company_address' => '',
37 42 'timezone' => 'UTC',
38 43 'date_format' => 'Y-m-d',
39 44 'time_format' => 'H:i',
40 -
45 + /** Primary brand color (hex) for trip/booking/listing frontend — see FrontendThemeCss */
46 + 'frontend_primary_color' => '#3b82f6',
47 + /** Max width for Yatra trip/booking/listing containers (CSS length). Empty = theme.json / content width / filter. */
48 + 'frontend_container_max_width' => '',
49 + /** Trip listing card density: 'standard' | 'compact_mobile' | 'compact_all'. */
50 + 'frontend_listing_card_layout' => 'standard',
51 +
41 52 // Booking
42 53 'booking_base' => 'book',
43 54 'use_booking_page' => false,
44 55 'booking_page_id' => 0,
@@ -46,18 +57,44 @@
46 57 'privacy_policy_page_id' => 0,
47 58 'enable_guest_booking' => true,
48 59 'booking_confirmation' => true,
49 60 'auto_confirm_bookings' => false,
61 + // Auto-confirm mode: none | online | all. Default 'online' (successful
62 + // online payment => confirmed). Behaviour is resolved via
63 + // yatra_get_auto_confirm_mode(), which uses the stored mode when set and
64 + // otherwise derives from the legacy auto_confirm_bookings boolean.
65 + 'auto_confirm_mode' => 'online',
50 66 'require_login' => false,
51 67 'allow_guest_checkout' => true,
52 - 'cancellation_policy' => 'full_refund',
53 - 'cancellation_days' => 7,
54 - 'refund_policy' => '',
68 + // Hold guest bookings in `pending_verification` status until
69 + // the customer clicks a magic link sent to the email they
70 + // gave. Defends against typo'd email addresses (a booking
71 + // with the wrong email is unreachable forever) and against
72 + // form-spam bots that submit junk emails. Only applies when
73 + // `allow_guest_checkout` is true and the customer is not
74 + // logged in.
75 + 'require_guest_email_verification' => false,
76 + // cancellation_policy / cancellation_days / refund_policy
77 + // removed — see SettingsController::$default_settings for
78 + // the rationale. Leaving the keys out of this defaults map
79 + // means SettingsService::get() returns null for legacy
80 + // callers, and email/template render paths handle absence
81 + // gracefully by skipping the cancellation paragraph or
82 + // falling back to the per-trip cancellation_policy.
55 83 'booking_expiry_hours' => 24,
56 84 'booking_reminder_days' => 3,
85 + // Storefront booking horizon in months. 12 is the value that was
86 + // hard-coded before it became configurable, so a site that never
87 + // touches it behaves exactly as before. See yatra_get_availability_horizon_months().
88 + 'availability_horizon_months' => 12,
57 89 'allow_waitlist' => true,
58 90 'waitlist_auto_confirm' => false,
59 -
91 + // Pro: when enabled, the single-trip date_specific mode renders a
92 + // <select> of available departure dates instead of the flatpickr
93 + // calendar (desktop sidebar and mobile sticky bar). Renders no-op
94 + // for free installs — see Settings UI + FrontendAssetsProvider gate.
95 + 'date_picker_as_dropdown' => false,
96 +
60 97 // Payment
61 98 'currency' => 'USD',
62 99 'payment_test_mode' => true,
63 100 'currency_position' => 'before',
@@ -78,16 +115,8 @@
78 115 'payment_methods' => [],
79 116 'gateway_configs' => [],
80 117 'gateway_order' => [],
81 118
82 - // Scheduled/Recurring Payments - Pro feature
83 - // These defaults are overridden by Pro's FlexiblePaymentsModule when active
84 - 'enable_scheduled_payments' => false,
85 - 'scheduled_payment_type' => 'single',
86 - 'scheduled_payment_days' => 15,
87 - 'scheduled_payment_installments' => 1,
88 - 'scheduled_payment_interval' => 30,
89 - 'scheduled_payment_reminder_days' => 3,
90 119 'allow_save_payment_methods' => false,
91 120
92 121 // Email
93 122 'email_from_name' => '',
@@ -94,8 +123,66 @@
94 123 'email_from_address' => '',
95 124 'admin_email' => '',
96 125 'enable_admin_notifications' => true,
97 126 'enable_customer_notifications' => true,
127 + // Blind copy of every outgoing Yatra email, for archiving or monitoring.
128 + // Empty (the default) means no copy is sent, so existing sites are
129 + // unaffected. Accepts several comma-separated addresses.
130 + 'email_always_bcc' => '',
131 +
132 + // Email template enable flags.
133 + //
134 + // These mirror SettingsController::$default_settings + the
135 + // entries InstallerService seeds on activation. They're
136 + // duplicated here because SettingsService::isEnabled() falls
137 + // back to THIS array when the wp_option doesn't exist — and
138 + // there are two installation paths where the option is
139 + // missing in production:
140 + // 1. Sites that upgraded from a Yatra version that didn't
141 + // seed the flag (InstallerService runs only on initial
142 + // activation, not on update).
143 + // 2. Sites whose operator never opened Settings → never
144 + // hit the REST save endpoint that would write defaults.
145 + // Without this fallback, the verification email + booking
146 + // confirmation + every transactional email silently no-ops
147 + // on those installs (sendIfEnabled gates on the flag).
148 + 'email_template_booking' => true,
149 + 'email_template_confirmation' => true,
150 + // Separate "part payment received" email. Off by default: existing sites
151 + // keep sending the single payment-received template for every payment,
152 + // exactly as before. Only meaningful when deposits / partial payments
153 + // are enabled.
154 + 'email_template_partial_payment' => false,
155 + 'email_template_cancellation' => true,
156 + 'email_template_reminder' => true,
157 + 'email_template_admin_new_booking' => true,
158 + 'email_template_admin_payment' => true,
159 + 'email_template_admin_cancellation' => true,
160 + 'email_template_trip_consent' => true,
161 + 'email_template_customer_verification' => true,
162 + 'email_template_guest_verification' => true,
163 + 'email_template_account_email_change' => true,
164 + 'email_template_account_email_changed' => true,
165 + 'email_template_booking_completed' => true,
166 + 'email_template_booking_expired_customer' => true,
167 + 'email_template_admin_booking_expired' => true,
168 + 'email_template_scheduled_payment_reminder' => true,
169 + 'email_template_scheduled_payment_succeeded' => true,
170 + 'email_template_scheduled_payment_failed' => true,
171 + 'email_template_admin_scheduled_payment_failed' => true,
172 + 'email_template_enquiry_received' => true,
173 + 'email_template_enquiry_admin' => true,
174 + 'email_template_enquiry_response' => true,
175 + 'email_template_review_request' => true,
176 + 'email_template_abandoned_booking_recovery_first' => true,
177 + 'email_template_abandoned_booking_recovery_second' => true,
178 + 'email_template_abandoned_booking_recovery_final' => true,
179 + // Customer-registration gate (AuthController::register reads
180 + // this exact key). Mismatched name vs InstallerService's
181 + // `enable_customer_registration` seed — keeping both names
182 + // here so register() works regardless of which key was
183 + // saved on prior installs.
184 + 'customer_registration' => true,
98 185
99 186 // Trip
100 187 'trip_base' => 'trip',
101 188 'trips_per_page' => 12,
@@ -101,9 +188,27 @@
101 188 'trips_per_page' => 12,
102 189 'enable_wishlist' => false,
103 190 'enable_comparison' => false,
104 191 'show_sold_out' => true,
105 -
192 +
193 + // Search & Listing storefront UX.
194 + // Search-bar field visibility — default true so the bar renders every
195 + // field exactly as before for existing free/pro installs. Owners can
196 + // hide individual fields from Settings → Search & Listing.
197 + 'search_show_keyword' => true,
198 + 'search_show_destination' => true,
199 + 'search_show_activities' => true,
200 + 'search_show_duration' => true,
201 + 'search_show_budget' => true,
202 + // Opt-in (default false): show a date field that filters trips to those
203 + // with a departure on the selected date. Off by default so existing
204 + // search bars are unchanged on update.
205 + 'search_show_date' => false,
206 + // Collapse the listing filter sidebar sections on mobile. Default false
207 + // = today's behaviour (all sections expanded on every viewport), so an
208 + // existing site sees no change on update until the owner opts in.
209 + 'collapse_filters_on_mobile' => false,
210 +
106 211 // Customer
107 212 'enable_customer_accounts' => true,
108 213 'enable_customer_registration' => true,
109 214 'customer_account_page' => 0,
@@ -137,8 +242,18 @@
137 242 'destination_base' => 'destination',
138 243 'activity_base' => 'activity',
139 244 'trip_category_base' => 'trip-category',
140 245
246 + // SEO
247 + 'enable_sitemap' => true,
248 + // Which Yatra content types appear in /yatra-sitemap.xml. Defaults to
249 + // every type, so a site that never touches this keeps today's sitemap.
250 + 'sitemap_types' => ['archive', 'trip', 'destination', 'activity', 'category'],
251 + // Opt-in, and deliberately separate from the list above: dropping a type
252 + // from the sitemap is housekeeping, while noindex de-indexes pages that
253 + // may currently rank. That should never happen as a side effect.
254 + 'sitemap_noindex_excluded' => false,
255 +
141 256 // Advanced
142 257 'enable_debug_mode' => false,
143 258 'delete_data_on_uninstall' => false,
144 259
@@ -152,63 +267,70 @@
152 267 * @return array
153 268 */
154 269 public static function getDefaultBookingFormConfig(): array
155 270 {
271 + // User-facing strings (titles, descriptions, labels, placeholders,
272 + // option labels) are wrapped in __() so they are (a) extracted into the
273 + // .pot for Loco Translate and (b) translated to the active locale when
274 + // the config is built — e.g. on a Dutch storefront the default booking
275 + // form renders in Dutch. Structural values (id/type/order/width/etc.)
276 + // stay literal. Saved/custom labels are additionally translated at
277 + // render time (see yatra_translate_form_string()).
156 278 return [
157 279 'contact_form' => [
158 - 'title' => 'Lead Traveler / Contact Information',
159 - 'description' => 'Primary contact person for this booking',
280 + 'title' => __('Lead Traveler / Contact Information', 'yatra'),
281 + 'description' => __('Primary contact person for this booking', 'yatra'),
160 282 'fields' => [
161 - ['id' => 'first_name', 'type' => 'text', 'label' => 'First Name', 'placeholder' => 'Enter first name', 'required' => true, 'enabled' => true, 'order' => 1, 'width' => 'half', 'locked' => true],
162 - ['id' => 'last_name', 'type' => 'text', 'label' => 'Last Name', 'placeholder' => 'Enter last name', 'required' => true, 'enabled' => true, 'order' => 2, 'width' => 'half', 'locked' => true],
163 - ['id' => 'email', 'type' => 'email', 'label' => 'Email Address', 'placeholder' => '[email protected]', 'required' => true, 'enabled' => true, 'order' => 3, 'width' => 'half', 'locked' => true],
164 - ['id' => 'phone', 'type' => 'tel', 'label' => 'Phone Number', 'placeholder' => '+1 234 567 8900', 'required' => true, 'enabled' => true, 'order' => 4, 'width' => 'half', 'locked' => true],
165 - ['id' => 'country', 'type' => 'country', 'label' => 'Country', 'placeholder' => 'Select Country', 'required' => true, 'enabled' => true, 'order' => 5, 'width' => 'half', 'locked' => true],
166 - ['id' => 'nationality', 'type' => 'country', 'label' => 'Nationality', 'placeholder' => 'Select Nationality', 'required' => false, 'enabled' => true, 'order' => 6, 'width' => 'half'],
167 - ['id' => 'address', 'type' => 'text', 'label' => 'Address', 'placeholder' => 'Street address (optional)', 'required' => false, 'enabled' => true, 'order' => 7, 'width' => 'full'],
283 + ['id' => 'first_name', 'type' => 'text', 'label' => __('First Name', 'yatra'), 'placeholder' => __('Enter first name', 'yatra'), 'required' => true, 'enabled' => true, 'order' => 1, 'width' => 'half', 'locked' => true],
284 + ['id' => 'last_name', 'type' => 'text', 'label' => __('Last Name', 'yatra'), 'placeholder' => __('Enter last name', 'yatra'), 'required' => true, 'enabled' => true, 'order' => 2, 'width' => 'half', 'locked' => true],
285 + ['id' => 'email', 'type' => 'email', 'label' => __('Email Address', 'yatra'), 'placeholder' => '[email protected]', 'required' => true, 'enabled' => true, 'order' => 3, 'width' => 'half', 'locked' => true],
286 + ['id' => 'phone', 'type' => 'tel', 'label' => __('Phone Number', 'yatra'), 'placeholder' => '+1 234 567 8900', 'required' => true, 'enabled' => true, 'order' => 4, 'width' => 'half', 'locked' => true],
287 + ['id' => 'country', 'type' => 'country', 'label' => __('Country', 'yatra'), 'placeholder' => __('Select Country', 'yatra'), 'required' => true, 'enabled' => true, 'order' => 5, 'width' => 'half', 'locked' => true],
288 + ['id' => 'nationality', 'type' => 'country', 'label' => __('Nationality', 'yatra'), 'placeholder' => __('Select Nationality', 'yatra'), 'required' => false, 'enabled' => true, 'order' => 6, 'width' => 'half'],
289 + ['id' => 'address', 'type' => 'text', 'label' => __('Address', 'yatra'), 'placeholder' => __('Street address (optional)', 'yatra'), 'required' => false, 'enabled' => true, 'order' => 7, 'width' => 'full'],
168 290 ],
169 291 ],
170 292 'emergency_contact_form' => [
171 - 'title' => 'Emergency Contact',
172 - 'description' => 'Person to contact in case of emergency',
293 + 'title' => __('Emergency Contact', 'yatra'),
294 + 'description' => __('Person to contact in case of emergency', 'yatra'),
173 295 'enabled' => true,
174 296 'fields' => [
175 - ['id' => 'name', 'type' => 'text', 'label' => 'Contact Name', 'placeholder' => 'Full name', 'required' => true, 'enabled' => true, 'order' => 1, 'width' => 'half'],
176 - ['id' => 'phone', 'type' => 'tel', 'label' => 'Contact Phone', 'placeholder' => '+1 234 567 8900', 'required' => true, 'enabled' => true, 'order' => 2, 'width' => 'half'],
177 - ['id' => 'relationship', 'type' => 'select', 'label' => 'Relationship', 'placeholder' => 'Select Relationship', 'required' => false, 'enabled' => true, 'order' => 3, 'width' => 'full', 'options' => [
178 - ['value' => 'spouse', 'label' => 'Spouse/Partner'],
179 - ['value' => 'parent', 'label' => 'Parent'],
180 - ['value' => 'sibling', 'label' => 'Sibling'],
181 - ['value' => 'child', 'label' => 'Child'],
182 - ['value' => 'friend', 'label' => 'Friend'],
183 - ['value' => 'other', 'label' => 'Other'],
297 + ['id' => 'name', 'type' => 'text', 'label' => __('Contact Name', 'yatra'), 'placeholder' => __('Full name', 'yatra'), 'required' => true, 'enabled' => true, 'order' => 1, 'width' => 'half'],
298 + ['id' => 'phone', 'type' => 'tel', 'label' => __('Contact Phone', 'yatra'), 'placeholder' => '+1 234 567 8900', 'required' => true, 'enabled' => true, 'order' => 2, 'width' => 'half'],
299 + ['id' => 'relationship', 'type' => 'select', 'label' => __('Relationship', 'yatra'), 'placeholder' => __('Select Relationship', 'yatra'), 'required' => false, 'enabled' => true, 'order' => 3, 'width' => 'full', 'options' => [
300 + ['value' => 'spouse', 'label' => __('Spouse/Partner', 'yatra')],
301 + ['value' => 'parent', 'label' => __('Parent', 'yatra')],
302 + ['value' => 'sibling', 'label' => __('Sibling', 'yatra')],
303 + ['value' => 'child', 'label' => __('Child', 'yatra')],
304 + ['value' => 'friend', 'label' => __('Friend', 'yatra')],
305 + ['value' => 'other', 'label' => __('Other', 'yatra')],
184 306 ]],
185 307 ],
186 308 ],
187 309 'traveler_form' => [
188 - 'title' => 'Traveler Information',
189 - 'description' => 'Please provide details for each traveler',
310 + 'title' => __('Traveler Information', 'yatra'),
311 + 'description' => __('Please provide details for each traveler', 'yatra'),
190 312 'fields' => [
191 - ['id' => 'first_name', 'type' => 'text', 'label' => 'First Name', 'placeholder' => 'Legal first name', 'required' => true, 'enabled' => true, 'order' => 1, 'width' => 'half'],
192 - ['id' => 'last_name', 'type' => 'text', 'label' => 'Last Name', 'placeholder' => 'Legal last name', 'required' => true, 'enabled' => true, 'order' => 2, 'width' => 'half'],
193 - ['id' => 'date_of_birth', 'type' => 'date', 'label' => 'Date of Birth', 'placeholder' => '', 'required' => true, 'enabled' => true, 'order' => 3, 'width' => 'half'],
194 - ['id' => 'gender', 'type' => 'select', 'label' => 'Gender', 'placeholder' => 'Select Gender', 'required' => true, 'enabled' => true, 'order' => 4, 'width' => 'half', 'options' => [
195 - ['value' => 'male', 'label' => 'Male'],
196 - ['value' => 'female', 'label' => 'Female'],
197 - ['value' => 'other', 'label' => 'Other'],
313 + ['id' => 'first_name', 'type' => 'text', 'label' => __('First Name', 'yatra'), 'placeholder' => __('Legal first name', 'yatra'), 'required' => true, 'enabled' => true, 'order' => 1, 'width' => 'half'],
314 + ['id' => 'last_name', 'type' => 'text', 'label' => __('Last Name', 'yatra'), 'placeholder' => __('Legal last name', 'yatra'), 'required' => true, 'enabled' => true, 'order' => 2, 'width' => 'half'],
315 + ['id' => 'date_of_birth', 'type' => 'date', 'label' => __('Date of Birth', 'yatra'), 'placeholder' => '', 'required' => true, 'enabled' => true, 'order' => 3, 'width' => 'half'],
316 + ['id' => 'gender', 'type' => 'select', 'label' => __('Gender', 'yatra'), 'placeholder' => __('Select Gender', 'yatra'), 'required' => true, 'enabled' => true, 'order' => 4, 'width' => 'half', 'options' => [
317 + ['value' => 'male', 'label' => __('Male', 'yatra')],
318 + ['value' => 'female', 'label' => __('Female', 'yatra')],
319 + ['value' => 'other', 'label' => __('Other', 'yatra')],
198 320 ]],
199 - ['id' => 'nationality', 'type' => 'country', 'label' => 'Nationality', 'placeholder' => 'Select Nationality', 'required' => true, 'enabled' => true, 'order' => 5, 'width' => 'full'],
200 - ['id' => 'dietary', 'type' => 'select', 'label' => 'Dietary Requirements', 'placeholder' => 'Select', 'required' => false, 'enabled' => true, 'order' => 6, 'width' => 'half', 'section' => 'dietary_medical', 'options' => [
201 - ['value' => 'none', 'label' => 'No special requirements'],
202 - ['value' => 'vegetarian', 'label' => 'Vegetarian'],
203 - ['value' => 'vegan', 'label' => 'Vegan'],
204 - ['value' => 'halal', 'label' => 'Halal'],
205 - ['value' => 'kosher', 'label' => 'Kosher'],
206 - ['value' => 'gluten_free', 'label' => 'Gluten Free'],
207 - ['value' => 'lactose_free', 'label' => 'Lactose Free'],
208 - ['value' => 'other', 'label' => 'Other (specify in notes)'],
321 + ['id' => 'nationality', 'type' => 'country', 'label' => __('Nationality', 'yatra'), 'placeholder' => __('Select Nationality', 'yatra'), 'required' => true, 'enabled' => true, 'order' => 5, 'width' => 'full'],
322 + ['id' => 'dietary', 'type' => 'select', 'label' => __('Dietary Requirements', 'yatra'), 'placeholder' => __('Select', 'yatra'), 'required' => false, 'enabled' => true, 'order' => 6, 'width' => 'half', 'section' => 'dietary_medical', 'options' => [
323 + ['value' => 'none', 'label' => __('No special requirements', 'yatra')],
324 + ['value' => 'vegetarian', 'label' => __('Vegetarian', 'yatra')],
325 + ['value' => 'vegan', 'label' => __('Vegan', 'yatra')],
326 + ['value' => 'halal', 'label' => __('Halal', 'yatra')],
327 + ['value' => 'kosher', 'label' => __('Kosher', 'yatra')],
328 + ['value' => 'gluten_free', 'label' => __('Gluten Free', 'yatra')],
329 + ['value' => 'lactose_free', 'label' => __('Lactose Free', 'yatra')],
330 + ['value' => 'other', 'label' => __('Other (specify in notes)', 'yatra')],
209 331 ]],
210 - ['id' => 'medical', 'type' => 'text', 'label' => 'Medical Conditions / Allergies', 'placeholder' => 'Any allergies or conditions we should know', 'required' => false, 'enabled' => true, 'order' => 7, 'width' => 'half', 'section' => 'dietary_medical'],
332 + ['id' => 'medical', 'type' => 'text', 'label' => __('Medical Conditions / Allergies', 'yatra'), 'placeholder' => __('Any allergies or conditions we should know', 'yatra'), 'required' => false, 'enabled' => true, 'order' => 7, 'width' => 'half', 'section' => 'dietary_medical'],
211 333 ],
212 334 ],
213 335 ];
214 336 }
@@ -214,12 +336,17 @@
214 336 }
215 337
216 338 /**
217 339 * Get booking form configuration (merged with defaults)
218 - *
340 + *
341 + * @param int|null $tripId Trip being booked. When given, Pro's Dynamic Form
342 + * Field module resolves each section's per-trip
343 + * conditions for that trip (title, description and
344 + * field list); without it the full config is
345 + * returned, conditions included (the Settings editor).
219 346 * @return array
220 347 */
221 - public static function getBookingFormConfig(): array
348 + public static function getBookingFormConfig(?int $tripId = null): array
222 349 {
223 350 $saved_config = self::get('booking_form_config', []);
224 351 $default_config = self::getDefaultBookingFormConfig();
225 352
@@ -224,40 +351,101 @@
224 351 $default_config = self::getDefaultBookingFormConfig();
225 352
226 353 // If no saved config, return defaults (Pro may filter)
227 354 if (empty($saved_config)) {
228 - return apply_filters('yatra_booking_form_config', $default_config);
355 + return apply_filters('yatra_booking_form_config', $default_config, $tripId);
229 356 }
230 -
231 - // Build a map of locked field IDs from defaults
232 - $locked_fields = [];
233 - foreach ($default_config as $form_type => $form_config) {
234 - if (!empty($form_config['fields'])) {
235 - foreach ($form_config['fields'] as $field) {
236 - if (!empty($field['locked'])) {
237 - $locked_fields[$form_type][$field['id']] = true;
357 +
358 + // Merge saved over defaults. IMPORTANT: `fields` is a positional list,
359 + // so a naive array_replace_recursive() merges field-by-INDEX — which
360 + // resurrects a deleted default field (saved list is shorter, the tail
361 + // default leaks back) and duplicates fields after a middle deletion.
362 + // We therefore merge each section's fields BY `id`, treating the saved
363 + // config as the authoritative list (order, props, and deletions), while
364 + // guaranteeing that locked core fields always exist and stay
365 + // locked+required.
366 + $merged = [];
367 + foreach ($default_config as $form_type => $default_section) {
368 + $saved_section = is_array($saved_config[$form_type] ?? null)
369 + ? $saved_config[$form_type]
370 + : null;
371 +
372 + if ($saved_section === null) {
373 + // Section absent from saved config → use the default verbatim.
374 + $merged[$form_type] = $default_section;
375 + continue;
376 + }
377 +
378 + // Section-level scalars (title/description/enabled) come from saved,
379 + // falling back to default.
380 + $section = array_merge($default_section, $saved_section);
381 +
382 + // Index default fields by id + collect the locked ids for this section.
383 + $default_fields_by_id = [];
384 + $locked_ids = [];
385 + foreach (($default_section['fields'] ?? []) as $df) {
386 + if (empty($df['id'])) {
387 + continue;
388 + }
389 + $default_fields_by_id[$df['id']] = $df;
390 + if (!empty($df['locked'])) {
391 + $locked_ids[$df['id']] = true;
392 + }
393 + }
394 +
395 + // Rebuild the field list from the saved order, de-duplicated by id.
396 + $result_fields = [];
397 + $seen = [];
398 + $saved_fields = is_array($saved_section['fields'] ?? null)
399 + ? $saved_section['fields']
400 + : ($default_section['fields'] ?? []);
401 + foreach ($saved_fields as $sf) {
402 + $id = is_array($sf) ? ($sf['id'] ?? '') : '';
403 + if ($id === '' || isset($seen[$id])) {
404 + continue; // drop malformed / duplicate field entries
405 + }
406 + $seen[$id] = true;
407 + // Known default field → default props as the base, saved wins.
408 + $field = isset($default_fields_by_id[$id])
409 + ? array_merge($default_fields_by_id[$id], $sf)
410 + : $sf;
411 + if (isset($locked_ids[$id])) {
412 + $field['locked'] = true;
413 + $field['required'] = true;
414 + // Locked core fields must keep their original input type — a
415 + // saved config can't repurpose them (e.g. to a display-only
416 + // text_block), which would drop the real input from checkout.
417 + if (isset($default_fields_by_id[$id]['type'])) {
418 + $field['type'] = $default_fields_by_id[$id]['type'];
238 419 }
239 420 }
421 + $result_fields[] = $field;
240 422 }
423 +
424 + // Locked core fields can never be legitimately removed — re-add any
425 + // that the saved config dropped, so checkout/admin always have them.
426 + foreach ($locked_ids as $id => $_) {
427 + if (!isset($seen[$id])) {
428 + $field = $default_fields_by_id[$id];
429 + $field['locked'] = true;
430 + $field['required'] = true;
431 + $result_fields[] = $field;
432 + }
433 + }
434 +
435 + $section['fields'] = $result_fields;
436 + $merged[$form_type] = $section;
241 437 }
242 -
243 - // Merge saved with defaults
244 - $merged = array_replace_recursive($default_config, $saved_config);
245 -
246 - // Ensure locked status is preserved from defaults (locked cannot be overridden)
247 - foreach ($merged as $form_type => &$form_config) {
248 - if (!empty($form_config['fields']) && is_array($form_config['fields'])) {
249 - foreach ($form_config['fields'] as &$field) {
250 - // If this field ID is in the locked list, force locked=true and required=true
251 - if (isset($locked_fields[$form_type][$field['id']])) {
252 - $field['locked'] = true;
253 - $field['required'] = true;
254 - }
255 - }
438 +
439 + // Preserve any saved sections that aren't part of the defaults
440 + // (future-proofing for Pro-introduced sections).
441 + foreach ($saved_config as $form_type => $saved_section) {
442 + if (!isset($merged[$form_type])) {
443 + $merged[$form_type] = $saved_section;
256 444 }
257 445 }
258 -
259 - return apply_filters('yatra_booking_form_config', $merged);
446 +
447 + return apply_filters('yatra_booking_form_config', $merged, $tripId);
260 448 }
261 449
262 450 private static function isEmailIdentityKey(string $key): bool
263 451 {
@@ -317,8 +505,18 @@
317 505 if (self::$settings === null) {
318 506 self::load();
319 507 }
320 508
509 + if (self::isScheduledPaymentSetting($key)) {
510 + $scheduledDefaults = self::scheduledPaymentDefaults();
511 +
512 + return apply_filters(
513 + 'yatra_scheduled_payment_setting',
514 + $default ?? ($scheduledDefaults[$key] ?? null),
515 + $key
516 + );
517 + }
518 +
321 519 // Support dot notation for nested access (future use)
322 520 if (strpos($key, '.') !== false) {
323 521 $keys = explode('.', $key);
324 522 $value = self::$settings;
@@ -382,8 +580,16 @@
382 580 if (self::isFlexiblePaymentSetting($key)) {
383 581 $value = apply_filters('yatra_flexible_payment_setting', false, $key);
384 582 return filter_var($value, FILTER_VALIDATE_BOOLEAN);
385 583 }
584 +
585 + if (self::isScheduledPaymentSetting($key)) {
586 + $defaults = self::scheduledPaymentDefaults();
587 + $base = $defaults[$key] ?? false;
588 + $value = apply_filters('yatra_scheduled_payment_setting', $base, $key);
589 +
590 + return filter_var($value, FILTER_VALIDATE_BOOLEAN);
591 + }
386 592
387 593 $value = self::get($key, false);
388 594 return filter_var($value, FILTER_VALIDATE_BOOLEAN);
389 595 }
@@ -400,8 +606,15 @@
400 606 // Flexible payment settings require Pro module
401 607 if (self::isFlexiblePaymentSetting($key)) {
402 608 return (int) apply_filters('yatra_flexible_payment_setting', $default, $key);
403 609 }
610 +
611 + if (self::isScheduledPaymentSetting($key)) {
612 + $defaults = self::scheduledPaymentDefaults();
613 + $base = $defaults[$key] ?? $default;
614 +
615 + return (int) apply_filters('yatra_scheduled_payment_setting', $base, $key);
616 + }
404 617
405 618 return (int) self::get($key, $default);
406 619 }
407 620
@@ -477,8 +690,9 @@
477 690 */
478 691 public static function reload(): void
479 692 {
480 693 self::$settings = null;
694 + self::$permalinkBasesCache = null;
481 695 self::load();
482 696 }
483 697
484 698 /**
@@ -566,14 +780,181 @@
566 780 return self::getString('currency_position', 'before');
567 781 }
568 782
569 783 /**
784 + * Single source of truth for the number of decimals shown in prices.
785 + *
786 + * Historically two unsynced options existed:
787 + * - `currency_decimals` — the admin "Number of decimals" field, also handed
788 + * to the frontend JS as `decimalPlaces`. Written only when settings are saved.
789 + * - `decimal_places` — legacy, written by the installer (default 2) and the
790 + * Setup Wizard, and read by {@see yatra_format_price()}.
791 + *
792 + * They drifted, so PHP-rendered prices (single trip, showcase, listings) and
793 + * JS-rendered prices could disagree, and the admin field had no effect on PHP.
794 + * This resolver collapses both into ONE value that every reader uses:
795 + * 1. the admin field when it has been changed from the default (authoritative);
796 + * 2. otherwise a non-default legacy value (preserves Setup-Wizard choices);
797 + * 3. otherwise whichever is present, else the default.
798 + *
799 + * Result is clamped to 0–4. It can never silently regress a site that was
800 + * already showing the correct decimals — it only aligns the two readers.
801 + */
802 + public static function getPriceDecimals(): int
803 + {
804 + $default = 2;
805 +
806 + $cdRaw = get_option('yatra_currency_decimals', null); // admin field + JS
807 + $dpRaw = get_option('yatra_decimal_places', null); // legacy / yatra_format_price
808 +
809 + $cd = ($cdRaw === null || $cdRaw === '') ? null : (int) $cdRaw;
810 + $dp = ($dpRaw === null || $dpRaw === '') ? null : (int) $dpRaw;
811 +
812 + if ($cd !== null && $cd !== $default) {
813 + $value = $cd; // admin explicitly changed → wins
814 + } elseif ($dp !== null && $dp !== $default) {
815 + $value = $dp; // legacy Setup-Wizard value → preserved
816 + } elseif ($cd !== null) {
817 + $value = $cd; // admin field present at default
818 + } elseif ($dp !== null) {
819 + $value = $dp;
820 + } else {
821 + $value = $default;
822 + }
823 +
824 + return max(0, min(4, $value));
825 + }
826 +
827 + /**
828 + * Sanitize a single URL path segment used in Yatra rewrites (alphanumeric, underscore, hyphen).
829 + */
830 + private static function sanitizePermalinkSlug(string $value, string $fallback): string
831 + {
832 + $v = preg_replace('/[^a-z0-9_-]/i', '', $value);
833 +
834 + return ($v !== '' && is_string($v)) ? $v : $fallback;
835 + }
836 +
837 + /**
838 + * Default account path slug (before {@see 'yatra_permalink_bases'}).
839 + */
840 + private static function resolveDefaultAccountBaseSlug(): string
841 + {
842 + $customerPath = get_option('yatra_customer_account_page', '');
843 + if (is_string($customerPath) && $customerPath !== '' && $customerPath !== '0') {
844 + $slug = self::slugFromAccountPathString($customerPath);
845 + if ($slug !== '') {
846 + return self::sanitizePermalinkSlug($slug, 'account');
847 + }
848 + }
849 +
850 + $base = self::getString('account_base', '');
851 + $base = self::sanitizePermalinkSlug($base, '');
852 +
853 + return $base !== '' ? $base : 'account';
854 + }
855 +
856 + /**
857 + * Raw permalink configuration from options (not yet filtered).
858 + *
859 + * @return array<string, string>
860 + */
861 + private static function defaultPermalinkBases(): array
862 + {
863 + $trip = self::sanitizePermalinkSlug(self::getString('trip_base', 'trip'), 'trip');
864 + $booking = self::sanitizePermalinkSlug(self::getString('booking_base', 'booking'), 'booking');
865 + $account = self::resolveDefaultAccountBaseSlug();
866 + $destination = self::sanitizePermalinkSlug(self::getString('destination_base', 'destination'), 'destination');
867 + $activity = self::sanitizePermalinkSlug(self::getString('activity_base', 'activity'), 'activity');
868 + $tripCategory = self::sanitizePermalinkSlug(self::getString('trip_category_base', 'trip-category'), 'trip-category');
869 +
870 + return [
871 + 'trip_base' => $trip,
872 + 'booking_base' => $booking,
873 + 'account_base' => $account,
874 + 'destination_base' => $destination,
875 + 'activity_base' => $activity,
876 + 'trip_category_base' => $tripCategory,
877 + /** Path segment after booking base for confirmation URLs, e.g. /{booking_base}/confirmation/{ref}/ */
878 + 'booking_flow_confirmation_segment' => 'confirmation',
879 + /** Legacy pageless path /{prefix}/{reference}/ (default kept for old links). */
880 + 'legacy_booking_confirmation_prefix' => 'booking-confirmation',
881 + /** Pageless remaining balance checkout /{prefix}/{token}/ */
882 + 'remaining_checkout_prefix' => 'remaining-checkout',
883 + /** Email verification pretty path /{prefix}/{token}/ */
884 + 'email_verification_prefix' => 'yatra-verify-email',
885 + ];
886 + }
887 +
888 + /**
889 + * All path segments and prefixes used by Yatra rewrites, routing, and URL helpers.
890 + *
891 + * Third-party plugins can change slugs in one place via:
892 + *
893 + * `add_filter( 'yatra_permalink_bases', function ( array $bases ) { $bases['trip_base'] = 'tours'; return $bases; } );`
894 + *
895 + * **Full URLs (different from bases only):**
896 + *
897 + * - Outbound links: `yatra_destination_permalink`, `yatra_activity_permalink`, `yatra_category_permalink`, `yatra_trip_permalink`
898 + * ({@see yatra_get_destination_permalink()} and siblings in `includes/helpers.php`).
899 + * - Inbound path mapping (pretty URLs): {@see \Yatra\Core\Routing\UrlParser::getCleanRequestPath()} filter `yatra_frontend_request_path`.
900 + * - Inbound overrides: `yatra_pretty_route_match`, `yatra_plain_route_match` ({@see \Yatra\Core\Routing\PrettyRouteMatcher}, {@see \Yatra\Core\Routing\PlainPageMatcher}).
901 + *
902 + * After changing bases at runtime you must flush rewrite rules (or bump `yatra_rewrite_rules_version`
903 + * in development). Use the {@see 'yatra_register_rewrite_rules'} action to register extra rules that
904 + * depend on these bases.
905 + *
906 + * @return array<string, string>
907 + */
908 + public static function getPermalinkBases(): array
909 + {
910 + if (self::$permalinkBasesCache !== null) {
911 + return self::$permalinkBasesCache;
912 + }
913 +
914 + $defaults = self::defaultPermalinkBases();
915 + $filtered = apply_filters('yatra_permalink_bases', $defaults);
916 + if (!is_array($filtered)) {
917 + $filtered = $defaults;
918 + }
919 +
920 + $merged = array_merge($defaults, $filtered);
921 + $out = [
922 + 'trip_base' => self::sanitizePermalinkSlug((string) ($merged['trip_base'] ?? ''), $defaults['trip_base']),
923 + 'booking_base' => self::sanitizePermalinkSlug((string) ($merged['booking_base'] ?? ''), $defaults['booking_base']),
924 + 'account_base' => self::sanitizePermalinkSlug((string) ($merged['account_base'] ?? ''), $defaults['account_base']),
925 + 'destination_base' => self::sanitizePermalinkSlug((string) ($merged['destination_base'] ?? ''), $defaults['destination_base']),
926 + 'activity_base' => self::sanitizePermalinkSlug((string) ($merged['activity_base'] ?? ''), $defaults['activity_base']),
927 + 'trip_category_base' => self::sanitizePermalinkSlug((string) ($merged['trip_category_base'] ?? ''), $defaults['trip_category_base']),
928 + 'booking_flow_confirmation_segment' => self::sanitizePermalinkSlug(
929 + (string) ($merged['booking_flow_confirmation_segment'] ?? ''),
930 + $defaults['booking_flow_confirmation_segment']
931 + ),
932 + 'legacy_booking_confirmation_prefix' => self::sanitizePermalinkSlug(
933 + (string) ($merged['legacy_booking_confirmation_prefix'] ?? ''),
934 + $defaults['legacy_booking_confirmation_prefix']
935 + ),
936 + 'remaining_checkout_prefix' => self::sanitizePermalinkSlug(
937 + (string) ($merged['remaining_checkout_prefix'] ?? ''),
938 + $defaults['remaining_checkout_prefix']
939 + ),
940 + 'email_verification_prefix' => self::sanitizePermalinkSlug(
941 + (string) ($merged['email_verification_prefix'] ?? ''),
942 + $defaults['email_verification_prefix']
943 + ),
944 + ];
945 +
946 + self::$permalinkBasesCache = $out;
947 +
948 + return self::$permalinkBasesCache;
949 + }
950 +
951 + /**
570 952 * Get trip base slug
571 953 */
572 954 public static function getTripBase(): string
573 955 {
574 - $base = self::getString('trip_base', 'trip');
575 - return preg_replace('/[^a-z0-9_-]/i', '', $base) ?: 'trip';
956 + return self::getPermalinkBases()['trip_base'];
576 957 }
577 958
578 959 /**
579 960 * Get booking base slug
@@ -579,10 +960,9 @@
579 960 * Get booking base slug
580 961 */
581 962 public static function getBookingBase(): string
582 963 {
583 - $base = self::getString('booking_base', 'booking');
584 - return preg_replace('/[^a-z0-9_-]/i', '', $base) ?: 'booking';
964 + return self::getPermalinkBases()['booking_base'];
585 965 }
586 966
587 967 /**
588 968 * URL slug for the customer account area (Settings → Customer → account path).
@@ -590,22 +970,26 @@
590 970 * even when yatra_account_base was never saved or is out of sync.
591 971 */
592 972 public static function getAccountBase(): string
593 973 {
594 - $customerPath = get_option('yatra_customer_account_page', '');
595 - if (is_string($customerPath) && $customerPath !== '' && $customerPath !== '0') {
596 - $slug = self::slugFromAccountPathString($customerPath);
597 - if ($slug !== '') {
598 - return $slug;
599 - }
600 - }
974 + return self::getPermalinkBases()['account_base'];
975 + }
601 976
602 - $base = self::getString('account_base', '');
603 - $base = preg_replace('/[^a-z0-9_-]/i', '', $base) ?: '';
977 + public static function getDestinationBase(): string
978 + {
979 + return self::getPermalinkBases()['destination_base'];
980 + }
604 981
605 - return $base !== '' ? $base : 'account';
982 + public static function getActivityBase(): string
983 + {
984 + return self::getPermalinkBases()['activity_base'];
606 985 }
607 986
987 + public static function getTripCategoryBase(): string
988 + {
989 + return self::getPermalinkBases()['trip_category_base'];
990 + }
991 +
608 992 private static function slugFromAccountPathString(string $path): string
609 993 {
610 994 $path = trim(str_replace('\\', '/', $path), '/');
611 995 $parts = array_values(array_filter(explode('/', $path), static fn ($p) => $p !== ''));
@@ -676,18 +1060,42 @@
676 1060 'deposit_percentage',
677 1061 'partial_payment',
678 1062 'partial_payment_percentage',
679 1063 'enable_deposit',
680 - 'enable_scheduled_payments',
681 - 'scheduled_payment_type',
682 - 'scheduled_payment_days',
683 - 'scheduled_payment_installments',
684 - 'scheduled_payment_interval',
685 - 'scheduled_payment_reminder_days',
686 1064 'allow_save_payment_methods',
687 1065 ];
688 1066
689 1067 return in_array($key, $flexiblePaymentSettings, true);
1068 + }
1069 +
1070 + /**
1071 + * Settings owned by Yatra Pro "Scheduled Payments" module (not core options).
1072 + *
1073 + * @return array<string, mixed>
1074 + */
1075 + private static function scheduledPaymentDefaults(): array
1076 + {
1077 + return [
1078 + 'enable_scheduled_payments' => false,
1079 + 'scheduled_payment_type' => 'single',
1080 + 'scheduled_payment_days' => 15,
1081 + 'scheduled_payment_installments' => 1,
1082 + 'scheduled_payment_interval' => 30,
1083 + 'scheduled_payment_reminder_days' => 3,
1084 + // Anchor for the remaining-balance schedule:
1085 + // 'booking' (default, backward-compatible) → balance charged
1086 + // scheduled_payment_days after the deposit.
1087 + // 'tour' → balance charged/collected balance_due_days BEFORE the
1088 + // tour start date, and bookings made within that window
1089 + // must pay in full up front.
1090 + 'balance_anchor' => 'booking',
1091 + 'balance_due_days' => 14,
1092 + ];
1093 + }
1094 +
1095 + private static function isScheduledPaymentSetting(string $key): bool
1096 + {
1097 + return array_key_exists($key, self::scheduledPaymentDefaults());
690 1098 }
691 1099
692 1100 /**
693 1101 * Check if flexible payments module is available (Pro active + module enabled)