| @@ -45,8 +45,10 @@ | ||
| 45 | 45 | /** Primary brand color (hex) for trip/booking/listing frontend — see FrontendThemeCss */ |
| 46 | 46 | 'frontend_primary_color' => '#3b82f6', |
| 47 | 47 | /** Max width for Yatra trip/booking/listing containers (CSS length). Empty = theme.json / content width / filter. */ |
| 48 | 48 | 'frontend_container_max_width' => '', |
| 49 | + /** Trip listing card density: 'standard' | 'compact_mobile' | 'compact_all'. */ | |
| 50 | + 'frontend_listing_card_layout' => 'standard', | |
| 49 | 51 | |
| 50 | 52 | // Booking |
| 51 | 53 | 'booking_base' => 'book', |
| 52 | 54 | 'use_booking_page' => false, |
| @@ -55,15 +57,36 @@ | ||
| 55 | 57 | 'privacy_policy_page_id' => 0, |
| 56 | 58 | 'enable_guest_booking' => true, |
| 57 | 59 | 'booking_confirmation' => true, |
| 58 | 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', | |
| 59 | 66 | 'require_login' => false, |
| 60 | 67 | 'allow_guest_checkout' => true, |
| 61 | - 'cancellation_policy' => 'full_refund', | |
| 62 | - 'cancellation_days' => 7, | |
| 63 | - '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. | |
| 64 | 83 | 'booking_expiry_hours' => 24, |
| 65 | 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, | |
| 66 | 89 | 'allow_waitlist' => true, |
| 67 | 90 | 'waitlist_auto_confirm' => false, |
| 68 | 91 | // Pro: when enabled, the single-trip date_specific mode renders a |
| 69 | 92 | // <select> of available departure dates instead of the flatpickr |
| @@ -100,8 +123,66 @@ | ||
| 100 | 123 | 'email_from_address' => '', |
| 101 | 124 | 'admin_email' => '', |
| 102 | 125 | 'enable_admin_notifications' => true, |
| 103 | 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, | |
| 104 | 185 | |
| 105 | 186 | // Trip |
| 106 | 187 | 'trip_base' => 'trip', |
| 107 | 188 | 'trips_per_page' => 12, |
| @@ -107,9 +188,27 @@ | ||
| 107 | 188 | 'trips_per_page' => 12, |
| 108 | 189 | 'enable_wishlist' => false, |
| 109 | 190 | 'enable_comparison' => false, |
| 110 | 191 | 'show_sold_out' => true, |
| 111 | - | |
| 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 | + | |
| 112 | 211 | // Customer |
| 113 | 212 | 'enable_customer_accounts' => true, |
| 114 | 213 | 'enable_customer_registration' => true, |
| 115 | 214 | 'customer_account_page' => 0, |
| @@ -143,8 +242,18 @@ | ||
| 143 | 242 | 'destination_base' => 'destination', |
| 144 | 243 | 'activity_base' => 'activity', |
| 145 | 244 | 'trip_category_base' => 'trip-category', |
| 146 | 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 | + | |
| 147 | 256 | // Advanced |
| 148 | 257 | 'enable_debug_mode' => false, |
| 149 | 258 | 'delete_data_on_uninstall' => false, |
| 150 | 259 | |
| @@ -158,63 +267,70 @@ | ||
| 158 | 267 | * @return array |
| 159 | 268 | */ |
| 160 | 269 | public static function getDefaultBookingFormConfig(): array |
| 161 | 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()). | |
| 162 | 278 | return [ |
| 163 | 279 | 'contact_form' => [ |
| 164 | - 'title' => 'Lead Traveler / Contact Information', | |
| 165 | - 'description' => 'Primary contact person for this booking', | |
| 280 | + 'title' => __('Lead Traveler / Contact Information', 'yatra'), | |
| 281 | + 'description' => __('Primary contact person for this booking', 'yatra'), | |
| 166 | 282 | 'fields' => [ |
| 167 | - ['id' => 'first_name', 'type' => 'text', 'label' => 'First Name', 'placeholder' => 'Enter first name', 'required' => true, 'enabled' => true, 'order' => 1, 'width' => 'half', 'locked' => true], | |
| 168 | - ['id' => 'last_name', 'type' => 'text', 'label' => 'Last Name', 'placeholder' => 'Enter last name', 'required' => true, 'enabled' => true, 'order' => 2, 'width' => 'half', 'locked' => true], | |
| 169 | - ['id' => 'email', 'type' => 'email', 'label' => 'Email Address', 'placeholder' => '[email protected]', 'required' => true, 'enabled' => true, 'order' => 3, 'width' => 'half', 'locked' => true], | |
| 170 | - ['id' => 'phone', 'type' => 'tel', 'label' => 'Phone Number', 'placeholder' => '+1 234 567 8900', 'required' => true, 'enabled' => true, 'order' => 4, 'width' => 'half', 'locked' => true], | |
| 171 | - ['id' => 'country', 'type' => 'country', 'label' => 'Country', 'placeholder' => 'Select Country', 'required' => true, 'enabled' => true, 'order' => 5, 'width' => 'half', 'locked' => true], | |
| 172 | - ['id' => 'nationality', 'type' => 'country', 'label' => 'Nationality', 'placeholder' => 'Select Nationality', 'required' => false, 'enabled' => true, 'order' => 6, 'width' => 'half'], | |
| 173 | - ['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'], | |
| 174 | 290 | ], |
| 175 | 291 | ], |
| 176 | 292 | 'emergency_contact_form' => [ |
| 177 | - 'title' => 'Emergency Contact', | |
| 178 | - 'description' => 'Person to contact in case of emergency', | |
| 293 | + 'title' => __('Emergency Contact', 'yatra'), | |
| 294 | + 'description' => __('Person to contact in case of emergency', 'yatra'), | |
| 179 | 295 | 'enabled' => true, |
| 180 | 296 | 'fields' => [ |
| 181 | - ['id' => 'name', 'type' => 'text', 'label' => 'Contact Name', 'placeholder' => 'Full name', 'required' => true, 'enabled' => true, 'order' => 1, 'width' => 'half'], | |
| 182 | - ['id' => 'phone', 'type' => 'tel', 'label' => 'Contact Phone', 'placeholder' => '+1 234 567 8900', 'required' => true, 'enabled' => true, 'order' => 2, 'width' => 'half'], | |
| 183 | - ['id' => 'relationship', 'type' => 'select', 'label' => 'Relationship', 'placeholder' => 'Select Relationship', 'required' => false, 'enabled' => true, 'order' => 3, 'width' => 'full', 'options' => [ | |
| 184 | - ['value' => 'spouse', 'label' => 'Spouse/Partner'], | |
| 185 | - ['value' => 'parent', 'label' => 'Parent'], | |
| 186 | - ['value' => 'sibling', 'label' => 'Sibling'], | |
| 187 | - ['value' => 'child', 'label' => 'Child'], | |
| 188 | - ['value' => 'friend', 'label' => 'Friend'], | |
| 189 | - ['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')], | |
| 190 | 306 | ]], |
| 191 | 307 | ], |
| 192 | 308 | ], |
| 193 | 309 | 'traveler_form' => [ |
| 194 | - 'title' => 'Traveler Information', | |
| 195 | - 'description' => 'Please provide details for each traveler', | |
| 310 | + 'title' => __('Traveler Information', 'yatra'), | |
| 311 | + 'description' => __('Please provide details for each traveler', 'yatra'), | |
| 196 | 312 | 'fields' => [ |
| 197 | - ['id' => 'first_name', 'type' => 'text', 'label' => 'First Name', 'placeholder' => 'Legal first name', 'required' => true, 'enabled' => true, 'order' => 1, 'width' => 'half'], | |
| 198 | - ['id' => 'last_name', 'type' => 'text', 'label' => 'Last Name', 'placeholder' => 'Legal last name', 'required' => true, 'enabled' => true, 'order' => 2, 'width' => 'half'], | |
| 199 | - ['id' => 'date_of_birth', 'type' => 'date', 'label' => 'Date of Birth', 'placeholder' => '', 'required' => true, 'enabled' => true, 'order' => 3, 'width' => 'half'], | |
| 200 | - ['id' => 'gender', 'type' => 'select', 'label' => 'Gender', 'placeholder' => 'Select Gender', 'required' => true, 'enabled' => true, 'order' => 4, 'width' => 'half', 'options' => [ | |
| 201 | - ['value' => 'male', 'label' => 'Male'], | |
| 202 | - ['value' => 'female', 'label' => 'Female'], | |
| 203 | - ['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')], | |
| 204 | 320 | ]], |
| 205 | - ['id' => 'nationality', 'type' => 'country', 'label' => 'Nationality', 'placeholder' => 'Select Nationality', 'required' => true, 'enabled' => true, 'order' => 5, 'width' => 'full'], | |
| 206 | - ['id' => 'dietary', 'type' => 'select', 'label' => 'Dietary Requirements', 'placeholder' => 'Select', 'required' => false, 'enabled' => true, 'order' => 6, 'width' => 'half', 'section' => 'dietary_medical', 'options' => [ | |
| 207 | - ['value' => 'none', 'label' => 'No special requirements'], | |
| 208 | - ['value' => 'vegetarian', 'label' => 'Vegetarian'], | |
| 209 | - ['value' => 'vegan', 'label' => 'Vegan'], | |
| 210 | - ['value' => 'halal', 'label' => 'Halal'], | |
| 211 | - ['value' => 'kosher', 'label' => 'Kosher'], | |
| 212 | - ['value' => 'gluten_free', 'label' => 'Gluten Free'], | |
| 213 | - ['value' => 'lactose_free', 'label' => 'Lactose Free'], | |
| 214 | - ['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')], | |
| 215 | 331 | ]], |
| 216 | - ['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'], | |
| 217 | 333 | ], |
| 218 | 334 | ], |
| 219 | 335 | ]; |
| 220 | 336 | } |
| @@ -220,12 +336,17 @@ | ||
| 220 | 336 | } |
| 221 | 337 | |
| 222 | 338 | /** |
| 223 | 339 | * Get booking form configuration (merged with defaults) |
| 224 | - * | |
| 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). | |
| 225 | 346 | * @return array |
| 226 | 347 | */ |
| 227 | - public static function getBookingFormConfig(): array | |
| 348 | + public static function getBookingFormConfig(?int $tripId = null): array | |
| 228 | 349 | { |
| 229 | 350 | $saved_config = self::get('booking_form_config', []); |
| 230 | 351 | $default_config = self::getDefaultBookingFormConfig(); |
| 231 | 352 | |
| @@ -230,40 +351,101 @@ | ||
| 230 | 351 | $default_config = self::getDefaultBookingFormConfig(); |
| 231 | 352 | |
| 232 | 353 | // If no saved config, return defaults (Pro may filter) |
| 233 | 354 | if (empty($saved_config)) { |
| 234 | - return apply_filters('yatra_booking_form_config', $default_config); | |
| 355 | + return apply_filters('yatra_booking_form_config', $default_config, $tripId); | |
| 235 | 356 | } |
| 236 | - | |
| 237 | - // Build a map of locked field IDs from defaults | |
| 238 | - $locked_fields = []; | |
| 239 | - foreach ($default_config as $form_type => $form_config) { | |
| 240 | - if (!empty($form_config['fields'])) { | |
| 241 | - foreach ($form_config['fields'] as $field) { | |
| 242 | - if (!empty($field['locked'])) { | |
| 243 | - $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']; | |
| 244 | 419 | } |
| 245 | 420 | } |
| 421 | + $result_fields[] = $field; | |
| 246 | 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; | |
| 247 | 437 | } |
| 248 | - | |
| 249 | - // Merge saved with defaults | |
| 250 | - $merged = array_replace_recursive($default_config, $saved_config); | |
| 251 | - | |
| 252 | - // Ensure locked status is preserved from defaults (locked cannot be overridden) | |
| 253 | - foreach ($merged as $form_type => &$form_config) { | |
| 254 | - if (!empty($form_config['fields']) && is_array($form_config['fields'])) { | |
| 255 | - foreach ($form_config['fields'] as &$field) { | |
| 256 | - // If this field ID is in the locked list, force locked=true and required=true | |
| 257 | - if (isset($locked_fields[$form_type][$field['id']])) { | |
| 258 | - $field['locked'] = true; | |
| 259 | - $field['required'] = true; | |
| 260 | - } | |
| 261 | - } | |
| 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; | |
| 262 | 444 | } |
| 263 | 445 | } |
| 264 | - | |
| 265 | - return apply_filters('yatra_booking_form_config', $merged); | |
| 446 | + | |
| 447 | + return apply_filters('yatra_booking_form_config', $merged, $tripId); | |
| 266 | 448 | } |
| 267 | 449 | |
| 268 | 450 | private static function isEmailIdentityKey(string $key): bool |
| 269 | 451 | { |
| @@ -598,8 +780,52 @@ | ||
| 598 | 780 | return self::getString('currency_position', 'before'); |
| 599 | 781 | } |
| 600 | 782 | |
| 601 | 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 | + /** | |
| 602 | 828 | * Sanitize a single URL path segment used in Yatra rewrites (alphanumeric, underscore, hyphen). |
| 603 | 829 | */ |
| 604 | 830 | private static function sanitizePermalinkSlug(string $value, string $fallback): string |
| 605 | 831 | { |
| @@ -854,8 +1080,16 @@ | ||
| 854 | 1080 | 'scheduled_payment_days' => 15, |
| 855 | 1081 | 'scheduled_payment_installments' => 1, |
| 856 | 1082 | 'scheduled_payment_interval' => 30, |
| 857 | 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, | |
| 858 | 1092 | ]; |
| 859 | 1093 | } |
| 860 | 1094 | |
| 861 | 1095 | private static function isScheduledPaymentSetting(string $key): bool |