| @@ -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_'; |
| @@ -40,8 +45,10 @@ | ||
| 40 | 45 | /** Primary brand color (hex) for trip/booking/listing frontend — see FrontendThemeCss */ |
| 41 | 46 | 'frontend_primary_color' => '#3b82f6', |
| 42 | 47 | /** Max width for Yatra trip/booking/listing containers (CSS length). Empty = theme.json / content width / filter. */ |
| 43 | 48 | 'frontend_container_max_width' => '', |
| 49 | + /** Trip listing card density: 'standard' | 'compact_mobile' | 'compact_all'. */ | |
| 50 | + 'frontend_listing_card_layout' => 'standard', | |
| 44 | 51 | |
| 45 | 52 | // Booking |
| 46 | 53 | 'booking_base' => 'book', |
| 47 | 54 | 'use_booking_page' => false, |
| @@ -50,18 +57,44 @@ | ||
| 50 | 57 | 'privacy_policy_page_id' => 0, |
| 51 | 58 | 'enable_guest_booking' => true, |
| 52 | 59 | 'booking_confirmation' => true, |
| 53 | 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', | |
| 54 | 66 | 'require_login' => false, |
| 55 | 67 | 'allow_guest_checkout' => true, |
| 56 | - 'cancellation_policy' => 'full_refund', | |
| 57 | - 'cancellation_days' => 7, | |
| 58 | - '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. | |
| 59 | 83 | 'booking_expiry_hours' => 24, |
| 60 | 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, | |
| 61 | 89 | 'allow_waitlist' => true, |
| 62 | 90 | 'waitlist_auto_confirm' => false, |
| 63 | - | |
| 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 | + | |
| 64 | 97 | // Payment |
| 65 | 98 | 'currency' => 'USD', |
| 66 | 99 | 'payment_test_mode' => true, |
| 67 | 100 | 'currency_position' => 'before', |
| @@ -90,8 +123,66 @@ | ||
| 90 | 123 | 'email_from_address' => '', |
| 91 | 124 | 'admin_email' => '', |
| 92 | 125 | 'enable_admin_notifications' => true, |
| 93 | 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, | |
| 94 | 185 | |
| 95 | 186 | // Trip |
| 96 | 187 | 'trip_base' => 'trip', |
| 97 | 188 | 'trips_per_page' => 12, |
| @@ -97,9 +188,27 @@ | ||
| 97 | 188 | 'trips_per_page' => 12, |
| 98 | 189 | 'enable_wishlist' => false, |
| 99 | 190 | 'enable_comparison' => false, |
| 100 | 191 | 'show_sold_out' => true, |
| 101 | - | |
| 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 | + | |
| 102 | 211 | // Customer |
| 103 | 212 | 'enable_customer_accounts' => true, |
| 104 | 213 | 'enable_customer_registration' => true, |
| 105 | 214 | 'customer_account_page' => 0, |
| @@ -133,8 +242,18 @@ | ||
| 133 | 242 | 'destination_base' => 'destination', |
| 134 | 243 | 'activity_base' => 'activity', |
| 135 | 244 | 'trip_category_base' => 'trip-category', |
| 136 | 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 | + | |
| 137 | 256 | // Advanced |
| 138 | 257 | 'enable_debug_mode' => false, |
| 139 | 258 | 'delete_data_on_uninstall' => false, |
| 140 | 259 | |
| @@ -148,63 +267,70 @@ | ||
| 148 | 267 | * @return array |
| 149 | 268 | */ |
| 150 | 269 | public static function getDefaultBookingFormConfig(): array |
| 151 | 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()). | |
| 152 | 278 | return [ |
| 153 | 279 | 'contact_form' => [ |
| 154 | - 'title' => 'Lead Traveler / Contact Information', | |
| 155 | - 'description' => 'Primary contact person for this booking', | |
| 280 | + 'title' => __('Lead Traveler / Contact Information', 'yatra'), | |
| 281 | + 'description' => __('Primary contact person for this booking', 'yatra'), | |
| 156 | 282 | 'fields' => [ |
| 157 | - ['id' => 'first_name', 'type' => 'text', 'label' => 'First Name', 'placeholder' => 'Enter first name', 'required' => true, 'enabled' => true, 'order' => 1, 'width' => 'half', 'locked' => true], | |
| 158 | - ['id' => 'last_name', 'type' => 'text', 'label' => 'Last Name', 'placeholder' => 'Enter last name', 'required' => true, 'enabled' => true, 'order' => 2, 'width' => 'half', 'locked' => true], | |
| 159 | - ['id' => 'email', 'type' => 'email', 'label' => 'Email Address', 'placeholder' => '[email protected]', 'required' => true, 'enabled' => true, 'order' => 3, 'width' => 'half', 'locked' => true], | |
| 160 | - ['id' => 'phone', 'type' => 'tel', 'label' => 'Phone Number', 'placeholder' => '+1 234 567 8900', 'required' => true, 'enabled' => true, 'order' => 4, 'width' => 'half', 'locked' => true], | |
| 161 | - ['id' => 'country', 'type' => 'country', 'label' => 'Country', 'placeholder' => 'Select Country', 'required' => true, 'enabled' => true, 'order' => 5, 'width' => 'half', 'locked' => true], | |
| 162 | - ['id' => 'nationality', 'type' => 'country', 'label' => 'Nationality', 'placeholder' => 'Select Nationality', 'required' => false, 'enabled' => true, 'order' => 6, 'width' => 'half'], | |
| 163 | - ['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'], | |
| 164 | 290 | ], |
| 165 | 291 | ], |
| 166 | 292 | 'emergency_contact_form' => [ |
| 167 | - 'title' => 'Emergency Contact', | |
| 168 | - 'description' => 'Person to contact in case of emergency', | |
| 293 | + 'title' => __('Emergency Contact', 'yatra'), | |
| 294 | + 'description' => __('Person to contact in case of emergency', 'yatra'), | |
| 169 | 295 | 'enabled' => true, |
| 170 | 296 | 'fields' => [ |
| 171 | - ['id' => 'name', 'type' => 'text', 'label' => 'Contact Name', 'placeholder' => 'Full name', 'required' => true, 'enabled' => true, 'order' => 1, 'width' => 'half'], | |
| 172 | - ['id' => 'phone', 'type' => 'tel', 'label' => 'Contact Phone', 'placeholder' => '+1 234 567 8900', 'required' => true, 'enabled' => true, 'order' => 2, 'width' => 'half'], | |
| 173 | - ['id' => 'relationship', 'type' => 'select', 'label' => 'Relationship', 'placeholder' => 'Select Relationship', 'required' => false, 'enabled' => true, 'order' => 3, 'width' => 'full', 'options' => [ | |
| 174 | - ['value' => 'spouse', 'label' => 'Spouse/Partner'], | |
| 175 | - ['value' => 'parent', 'label' => 'Parent'], | |
| 176 | - ['value' => 'sibling', 'label' => 'Sibling'], | |
| 177 | - ['value' => 'child', 'label' => 'Child'], | |
| 178 | - ['value' => 'friend', 'label' => 'Friend'], | |
| 179 | - ['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')], | |
| 180 | 306 | ]], |
| 181 | 307 | ], |
| 182 | 308 | ], |
| 183 | 309 | 'traveler_form' => [ |
| 184 | - 'title' => 'Traveler Information', | |
| 185 | - 'description' => 'Please provide details for each traveler', | |
| 310 | + 'title' => __('Traveler Information', 'yatra'), | |
| 311 | + 'description' => __('Please provide details for each traveler', 'yatra'), | |
| 186 | 312 | 'fields' => [ |
| 187 | - ['id' => 'first_name', 'type' => 'text', 'label' => 'First Name', 'placeholder' => 'Legal first name', 'required' => true, 'enabled' => true, 'order' => 1, 'width' => 'half'], | |
| 188 | - ['id' => 'last_name', 'type' => 'text', 'label' => 'Last Name', 'placeholder' => 'Legal last name', 'required' => true, 'enabled' => true, 'order' => 2, 'width' => 'half'], | |
| 189 | - ['id' => 'date_of_birth', 'type' => 'date', 'label' => 'Date of Birth', 'placeholder' => '', 'required' => true, 'enabled' => true, 'order' => 3, 'width' => 'half'], | |
| 190 | - ['id' => 'gender', 'type' => 'select', 'label' => 'Gender', 'placeholder' => 'Select Gender', 'required' => true, 'enabled' => true, 'order' => 4, 'width' => 'half', 'options' => [ | |
| 191 | - ['value' => 'male', 'label' => 'Male'], | |
| 192 | - ['value' => 'female', 'label' => 'Female'], | |
| 193 | - ['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')], | |
| 194 | 320 | ]], |
| 195 | - ['id' => 'nationality', 'type' => 'country', 'label' => 'Nationality', 'placeholder' => 'Select Nationality', 'required' => true, 'enabled' => true, 'order' => 5, 'width' => 'full'], | |
| 196 | - ['id' => 'dietary', 'type' => 'select', 'label' => 'Dietary Requirements', 'placeholder' => 'Select', 'required' => false, 'enabled' => true, 'order' => 6, 'width' => 'half', 'section' => 'dietary_medical', 'options' => [ | |
| 197 | - ['value' => 'none', 'label' => 'No special requirements'], | |
| 198 | - ['value' => 'vegetarian', 'label' => 'Vegetarian'], | |
| 199 | - ['value' => 'vegan', 'label' => 'Vegan'], | |
| 200 | - ['value' => 'halal', 'label' => 'Halal'], | |
| 201 | - ['value' => 'kosher', 'label' => 'Kosher'], | |
| 202 | - ['value' => 'gluten_free', 'label' => 'Gluten Free'], | |
| 203 | - ['value' => 'lactose_free', 'label' => 'Lactose Free'], | |
| 204 | - ['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')], | |
| 205 | 331 | ]], |
| 206 | - ['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'], | |
| 207 | 333 | ], |
| 208 | 334 | ], |
| 209 | 335 | ]; |
| 210 | 336 | } |
| @@ -210,12 +336,17 @@ | ||
| 210 | 336 | } |
| 211 | 337 | |
| 212 | 338 | /** |
| 213 | 339 | * Get booking form configuration (merged with defaults) |
| 214 | - * | |
| 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). | |
| 215 | 346 | * @return array |
| 216 | 347 | */ |
| 217 | - public static function getBookingFormConfig(): array | |
| 348 | + public static function getBookingFormConfig(?int $tripId = null): array | |
| 218 | 349 | { |
| 219 | 350 | $saved_config = self::get('booking_form_config', []); |
| 220 | 351 | $default_config = self::getDefaultBookingFormConfig(); |
| 221 | 352 | |
| @@ -220,40 +351,101 @@ | ||
| 220 | 351 | $default_config = self::getDefaultBookingFormConfig(); |
| 221 | 352 | |
| 222 | 353 | // If no saved config, return defaults (Pro may filter) |
| 223 | 354 | if (empty($saved_config)) { |
| 224 | - return apply_filters('yatra_booking_form_config', $default_config); | |
| 355 | + return apply_filters('yatra_booking_form_config', $default_config, $tripId); | |
| 225 | 356 | } |
| 226 | - | |
| 227 | - // Build a map of locked field IDs from defaults | |
| 228 | - $locked_fields = []; | |
| 229 | - foreach ($default_config as $form_type => $form_config) { | |
| 230 | - if (!empty($form_config['fields'])) { | |
| 231 | - foreach ($form_config['fields'] as $field) { | |
| 232 | - if (!empty($field['locked'])) { | |
| 233 | - $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']; | |
| 234 | 419 | } |
| 235 | 420 | } |
| 421 | + $result_fields[] = $field; | |
| 236 | 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; | |
| 237 | 437 | } |
| 238 | - | |
| 239 | - // Merge saved with defaults | |
| 240 | - $merged = array_replace_recursive($default_config, $saved_config); | |
| 241 | - | |
| 242 | - // Ensure locked status is preserved from defaults (locked cannot be overridden) | |
| 243 | - foreach ($merged as $form_type => &$form_config) { | |
| 244 | - if (!empty($form_config['fields']) && is_array($form_config['fields'])) { | |
| 245 | - foreach ($form_config['fields'] as &$field) { | |
| 246 | - // If this field ID is in the locked list, force locked=true and required=true | |
| 247 | - if (isset($locked_fields[$form_type][$field['id']])) { | |
| 248 | - $field['locked'] = true; | |
| 249 | - $field['required'] = true; | |
| 250 | - } | |
| 251 | - } | |
| 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; | |
| 252 | 444 | } |
| 253 | 445 | } |
| 254 | - | |
| 255 | - return apply_filters('yatra_booking_form_config', $merged); | |
| 446 | + | |
| 447 | + return apply_filters('yatra_booking_form_config', $merged, $tripId); | |
| 256 | 448 | } |
| 257 | 449 | |
| 258 | 450 | private static function isEmailIdentityKey(string $key): bool |
| 259 | 451 | { |
| @@ -498,8 +690,9 @@ | ||
| 498 | 690 | */ |
| 499 | 691 | public static function reload(): void |
| 500 | 692 | { |
| 501 | 693 | self::$settings = null; |
| 694 | + self::$permalinkBasesCache = null; | |
| 502 | 695 | self::load(); |
| 503 | 696 | } |
| 504 | 697 | |
| 505 | 698 | /** |
| @@ -587,14 +780,181 @@ | ||
| 587 | 780 | return self::getString('currency_position', 'before'); |
| 588 | 781 | } |
| 589 | 782 | |
| 590 | 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 | + /** | |
| 591 | 952 | * Get trip base slug |
| 592 | 953 | */ |
| 593 | 954 | public static function getTripBase(): string |
| 594 | 955 | { |
| 595 | - $base = self::getString('trip_base', 'trip'); | |
| 596 | - return preg_replace('/[^a-z0-9_-]/i', '', $base) ?: 'trip'; | |
| 956 | + return self::getPermalinkBases()['trip_base']; | |
| 597 | 957 | } |
| 598 | 958 | |
| 599 | 959 | /** |
| 600 | 960 | * Get booking base slug |
| @@ -600,10 +960,9 @@ | ||
| 600 | 960 | * Get booking base slug |
| 601 | 961 | */ |
| 602 | 962 | public static function getBookingBase(): string |
| 603 | 963 | { |
| 604 | - $base = self::getString('booking_base', 'booking'); | |
| 605 | - return preg_replace('/[^a-z0-9_-]/i', '', $base) ?: 'booking'; | |
| 964 | + return self::getPermalinkBases()['booking_base']; | |
| 606 | 965 | } |
| 607 | 966 | |
| 608 | 967 | /** |
| 609 | 968 | * URL slug for the customer account area (Settings → Customer → account path). |
| @@ -611,22 +970,26 @@ | ||
| 611 | 970 | * even when yatra_account_base was never saved or is out of sync. |
| 612 | 971 | */ |
| 613 | 972 | public static function getAccountBase(): string |
| 614 | 973 | { |
| 615 | - $customerPath = get_option('yatra_customer_account_page', ''); | |
| 616 | - if (is_string($customerPath) && $customerPath !== '' && $customerPath !== '0') { | |
| 617 | - $slug = self::slugFromAccountPathString($customerPath); | |
| 618 | - if ($slug !== '') { | |
| 619 | - return $slug; | |
| 620 | - } | |
| 621 | - } | |
| 974 | + return self::getPermalinkBases()['account_base']; | |
| 975 | + } | |
| 622 | 976 | |
| 623 | - $base = self::getString('account_base', ''); | |
| 624 | - $base = preg_replace('/[^a-z0-9_-]/i', '', $base) ?: ''; | |
| 977 | + public static function getDestinationBase(): string | |
| 978 | + { | |
| 979 | + return self::getPermalinkBases()['destination_base']; | |
| 980 | + } | |
| 625 | 981 | |
| 626 | - return $base !== '' ? $base : 'account'; | |
| 982 | + public static function getActivityBase(): string | |
| 983 | + { | |
| 984 | + return self::getPermalinkBases()['activity_base']; | |
| 627 | 985 | } |
| 628 | 986 | |
| 987 | + public static function getTripCategoryBase(): string | |
| 988 | + { | |
| 989 | + return self::getPermalinkBases()['trip_category_base']; | |
| 990 | + } | |
| 991 | + | |
| 629 | 992 | private static function slugFromAccountPathString(string $path): string |
| 630 | 993 | { |
| 631 | 994 | $path = trim(str_replace('\\', '/', $path), '/'); |
| 632 | 995 | $parts = array_values(array_filter(explode('/', $path), static fn ($p) => $p !== '')); |
| @@ -717,8 +1080,16 @@ | ||
| 717 | 1080 | 'scheduled_payment_days' => 15, |
| 718 | 1081 | 'scheduled_payment_installments' => 1, |
| 719 | 1082 | 'scheduled_payment_interval' => 30, |
| 720 | 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, | |
| 721 | 1092 | ]; |
| 722 | 1093 | } |
| 723 | 1094 | |
| 724 | 1095 | private static function isScheduledPaymentSetting(string $key): bool |