PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 All 85 releases
← All changes | resources/js/pages/Settings.tsx +1400 -978 3.0.6 → 3.0.17 View file →
@@ -14,8 +14,9 @@
14 14 Calendar,
15 15 DollarSign,
16 16 Mail,
17 17 MapPin,
18 + SlidersHorizontal,
18 19 Users,
19 20 Star,
20 21 Receipt,
21 22 Globe,
@@ -46,9 +47,9 @@
46 47 BarChart3,
47 48 Palette,
48 49 Percent,
49 50 } from "lucide-react";
50 -import { __ } from "../lib/i18n";
51 +import { __, sprintf } from "../lib/i18n";
51 52 import { prepareWordPressMediaFrameOpen } from "../lib/wp-media-open";
52 53 import { usePermissions } from "../hooks/usePermissions";
53 54 import { useToast } from "../components/ui/toast";
54 55 import { navigateMenu } from "../hooks/useNavigate";
@@ -91,8 +92,20 @@
91 92 } from "../lib/frontend-permalink-urls";
92 93 import { getCurrencyOptions } from "../data/currencies";
93 94 import { SearchableSelect } from "../components/ui/searchable-select";
94 95 import { MultiSelect, MultiSelectOption } from "../components/ui/multi-select";
96 +import type {
97 + BookingFormConfig,
98 + FormCondition,
99 + FormFieldConfig,
100 + FormSectionConfig,
101 +} from "../components/settings/booking-form-types";
102 +import { BookingFormFieldsEditor } from "../components/settings/BookingFormFieldsEditor";
103 +import {
104 + BookingFormConditionsModal,
105 + describeTargets,
106 + useConditionTargets,
107 +} from "../components/settings/BookingFormConditionsModal";
95 108
96 109 // Multiple Taxes Editor Component
97 110 const MultipleTaxesEditor = React.memo(
98 111 ({
@@ -226,8 +239,11 @@
226 239 }> = ({ formData, setFormData }) => {
227 240 const yatraAdmin = (window as any).yatraAdmin || {};
228 241 const gcSettings = yatraAdmin.googleCalendar || {};
229 242 const siteUrl = yatraAdmin.siteUrl || "";
243 + // Full URL to the dedicated Google Calendar dashboard subpage so the user can
244 + // reach it from the UI (not just via the OAuth redirect / a direct link).
245 + const gcDashboardUrl = `${yatraAdmin.adminUrl || "admin.php"}?page=yatra&subpage=google-calendar`;
230 246
231 247 // Use state from parent formData if available, otherwise use from yatraAdmin
232 248 const [clientId, setClientId] = useState(
233 249 formData.google_calendar_client_id || gcSettings.client_id || "",
@@ -245,8 +261,10 @@
245 261 formData.google_calendar_connected || gcSettings.connected || false,
246 262 );
247 263 const [connecting, setConnecting] = useState(false);
248 264 const [syncing, setSyncing] = useState(false);
265 + const [savingCalendar, setSavingCalendar] = useState(false);
266 + const { showToast } = useToast();
249 267
250 268 const redirectUri =
251 269 yatraAdmin.googleCalendarRedirectUri ||
252 270 gcSettings.redirect_uri ||
@@ -260,8 +278,13 @@
260 278 const allSettings = {
261 279 google_calendar_client_id: clientId,
262 280 google_calendar_client_secret: clientSecret,
263 281 google_calendar_enabled: true, // Enable by default when settings are provided
282 + // Persist the target calendar through the MAIN page Save too (the option
283 + // name matches what the sync path reads), so the user's natural action —
284 + // clicking the page's Save button — keeps the chosen calendar.
285 + google_calendar_calendar_id: calendarId,
286 + google_calendar_calendar_name: calendarName,
264 287 };
265 288
266 289 // Update formData with all settings
267 290 setFormData((prev) => {
@@ -275,10 +298,10 @@
275 298
276 299 // Update formData when any setting changes
277 300 useEffect(() => {
278 301 syncSettingsToFormData();
279 - // eslint-disable-next-line react-hooks/exhaustive-deps
280 - }, [clientId, clientSecret]);
302 + // eslint-disable-next-line react-hooks/exhaustive-deps
303 + }, [clientId, clientSecret, calendarId, calendarName]);
281 304
282 305 const handleConnect = async () => {
283 306 setConnecting(true);
284 307 try {
@@ -310,10 +333,14 @@
310 333 // Update settings state
311 334 setConnected(false);
312 335 setCalendarId("");
313 336 setCalendarName("");
337 + showToast(__("Disconnected from Google Calendar.", "yatra"), "success");
314 338 } catch (error) {
315 - console.error("Failed to disconnect:", error);
339 + showToast(
340 + __("Failed to disconnect. Please try again.", "yatra"),
341 + "error",
342 + );
316 343 }
317 344 };
318 345
319 346 const handleSync = async () => {
@@ -318,18 +345,52 @@
318 345
319 346 const handleSync = async () => {
320 347 setSyncing(true);
321 348 try {
322 - await apiClient.post("/google-calendar/sync-all");
323 - // No need to update lastSync state directly as it's derived from formData
324 - // The API response will be handled by the backend and reflected in the next load
349 + const res: any = await apiClient.post("/google-calendar/sync-all");
350 + if (res && res.success === false) {
351 + showToast(
352 + res.message || __("Failed to sync bookings.", "yatra"),
353 + "error",
354 + );
355 + } else {
356 + showToast(
357 + (res && res.message) ||
358 + __("Bookings synced to Google Calendar.", "yatra"),
359 + "success",
360 + );
361 + }
325 362 } catch (error) {
326 - console.error("Failed to sync:", error);
363 + showToast(
364 + __("Failed to sync bookings. Please try again.", "yatra"),
365 + "error",
366 + );
327 367 } finally {
328 368 setSyncing(false);
329 369 }
330 370 };
331 371
372 + // Persist the target calendar to the Google Calendar module endpoint (the same
373 + // store the sync path reads), so the value survives a page reload. A blank
374 + // Calendar ID is allowed and resolves to the primary calendar at sync time.
375 + const handleSaveCalendar = async () => {
376 + setSavingCalendar(true);
377 + try {
378 + await apiClient.post("/google-calendar/settings", {
379 + calendar_id: calendarId.trim(),
380 + calendar_name: calendarName.trim(),
381 + });
382 + showToast(__("Calendar settings saved.", "yatra"), "success");
383 + } catch (error) {
384 + showToast(
385 + __("Failed to save calendar settings. Please try again.", "yatra"),
386 + "error",
387 + );
388 + } finally {
389 + setSavingCalendar(false);
390 + }
391 + };
392 +
332 393 return (
333 394 <div className="space-y-6">
334 395 {/* Settings Card */}
335 396 <Card>
@@ -507,9 +568,9 @@
507 568 <h4 className="font-medium text-sm text-green-800 dark:text-green-400 flex items-center gap-2">
508 569 <CheckCircle className="w-4 h-4" />
509 570 {__("Connected Calendar", "yatra")}
510 571 </h4>
511 - <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
572 + <div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:items-end">
512 573 <FormField
513 574 id="gc_calendar_id"
514 575 label={__("Calendar ID", "yatra")}
515 576 description={__(
@@ -551,20 +612,42 @@
551 612 ) : (
552 613 __("Never synced", "yatra")
553 614 )}
554 615 </p>
555 - <Button
556 - variant="outline"
557 - size="sm"
558 - onClick={handleSync}
559 - disabled={syncing}
560 - className="flex items-center gap-2"
561 - >
562 - <RefreshCw
563 - className={`w-4 h-4 ${syncing ? "animate-spin" : ""}`}
564 - />
565 - {__("Sync Now", "yatra")}
566 - </Button>
616 + <div className="flex items-center gap-2">
617 + <a
618 + href={gcDashboardUrl}
619 + className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-green-300 dark:border-green-700 text-green-800 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/40"
620 + >
621 + <Calendar className="w-4 h-4" />
622 + {__("Open dashboard", "yatra")}
623 + </a>
624 + <Button
625 + size="sm"
626 + onClick={handleSaveCalendar}
627 + disabled={savingCalendar}
628 + className="flex items-center gap-2"
629 + >
630 + {savingCalendar ? (
631 + <RefreshCw className="w-4 h-4 animate-spin" />
632 + ) : (
633 + <CheckCircle className="w-4 h-4" />
634 + )}
635 + {__("Save Calendar", "yatra")}
636 + </Button>
637 + <Button
638 + variant="outline"
639 + size="sm"
640 + onClick={handleSync}
641 + disabled={syncing}
642 + className="flex items-center gap-2"
643 + >
644 + <RefreshCw
645 + className={`w-4 h-4 ${syncing ? "animate-spin" : ""}`}
646 + />
647 + {__("Sync Now", "yatra")}
648 + </Button>
649 + </div>
567 650 </div>
568 651 </div>
569 652 )}
570 653
@@ -570,8 +653,15 @@
570 653
571 654 <div className="flex flex-wrap justify-between items-center gap-3 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
572 655 <div className="flex flex-wrap gap-3">
573 656 <a
657 + href={gcDashboardUrl}
658 + className="flex items-center gap-1 text-blue-600 hover:text-blue-500 dark:text-blue-400 text-sm font-medium"
659 + >
660 + <Calendar className="w-3.5 h-3.5" />
661 + {__("Open Google Calendar dashboard", "yatra")}
662 + </a>
663 + <a
574 664 href="https://console.cloud.google.com/apis/credentials"
575 665 target="_blank"
576 666 rel="noopener noreferrer"
577 667 className="flex items-center gap-1 text-blue-600 hover:text-blue-500 dark:text-blue-400 text-sm"
@@ -636,8 +726,9 @@
636 726 | "booking_form"
637 727 | "payment"
638 728 | "pricing"
639 729 | "trip"
730 + | "search"
640 731 | "customer"
641 732 | "review"
642 733 | "tax"
643 734 | "currency"
@@ -643,47 +734,14 @@
643 734 | "currency"
644 735 | "integration"
645 736 | "permalink"
646 737 | "seo"
647 - | "advanced";
738 + | "advanced"
739 + | "uninstall";
648 740
649 741 // Form Builder Types
650 -interface FormFieldConfig {
651 - id: string;
652 - type:
653 - | "text"
654 - | "email"
655 - | "tel"
656 - | "date"
657 - | "select"
658 - | "country"
659 - | "textarea"
660 - | "checkbox"
661 - | "number";
662 - label: string;
663 - placeholder: string;
664 - required: boolean;
665 - enabled: boolean;
666 - order: number;
667 - width: "full" | "half" | "third";
668 - section?: string;
669 - options?: { value: string; label: string }[];
670 - locked?: boolean; // If true, field cannot be deleted and required cannot be changed
671 -}
672 -
673 -interface FormSectionConfig {
674 - title: string;
675 - description: string;
676 - enabled?: boolean;
677 - fields: FormFieldConfig[];
678 -}
679 -
680 -interface BookingFormConfig {
681 - contact_form: FormSectionConfig;
682 - emergency_contact_form: FormSectionConfig;
683 - traveler_form: FormSectionConfig;
684 -}
685 -
742 +// Booking form builder types live in components/settings/booking-form-types.ts
743 +// (shared with the per-trip Conditions popup).
686 744 interface PaymentGatewayConfig {
687 745 enabled: boolean;
688 746 icon?: string;
689 747 title?: string;
@@ -743,8 +801,10 @@
743 801 /** Hex brand color for public trip/booking/listing UI (buttons, links, accents) */
744 802 frontend_primary_color: string;
745 803 /** CSS max width for Yatra containers; empty = follow theme / theme.json */
746 804 frontend_container_max_width: string;
805 + /** Trip listing card density: "standard" | "compact_mobile" | "compact_all" */
806 + frontend_listing_card_layout: string;
747 807
748 808 // Google Calendar Settings
749 809 google_calendar_client_id?: string;
750 810 google_calendar_client_secret?: string;
@@ -753,11 +813,21 @@
753 813 google_calendar_connected?: boolean;
754 814 google_calendar_last_sync?: string | null;
755 815 google_calendar_enabled?: boolean;
756 816
817 + // Search & Listing storefront UX
818 + search_show_keyword: boolean;
819 + search_show_destination: boolean;
820 + search_show_activities: boolean;
821 + search_show_duration: boolean;
822 + search_show_budget: boolean;
823 + search_show_date: boolean;
824 + collapse_filters_on_mobile: boolean;
825 +
757 826 // Booking Settings
758 827 booking_confirmation: boolean;
759 828 auto_confirm_bookings: boolean;
829 + auto_confirm_mode: "none" | "online" | "all";
760 830 require_login: boolean;
761 831 allow_guest_checkout: boolean;
762 832 require_guest_email_verification: boolean;
763 833 booking_expiry_hours: number;
@@ -781,8 +851,10 @@
781 851 scheduled_payment_days: number;
782 852 scheduled_payment_installments: number;
783 853 scheduled_payment_interval: number;
784 854 scheduled_payment_reminder_days: number;
855 + balance_anchor: string;
856 + balance_due_days: number;
785 857 gateway_configs: Record<string, PaymentGatewayConfig>;
786 858 gateway_order?: string[];
787 859
788 860 // Pricing Settings
@@ -808,8 +880,9 @@
808 880 email_template_admin_payment: boolean;
809 881 email_template_admin_cancellation: boolean;
810 882 email_template_trip_consent: boolean;
811 883 email_template_customer_verification: boolean;
884 + email_template_guest_verification: boolean;
812 885 email_template_booking_completed: boolean;
813 886 email_template_booking_expired_customer: boolean;
814 887 email_template_admin_booking_expired: boolean;
815 888 email_template_scheduled_payment_reminder: boolean;
@@ -847,8 +920,10 @@
847 920 email_tpl_trip_consent_subject: string;
848 921 email_tpl_trip_consent_body: string;
849 922 email_tpl_customer_verification_subject: string;
850 923 email_tpl_customer_verification_body: string;
924 + email_tpl_guest_verification_subject: string;
925 + email_tpl_guest_verification_body: string;
851 926 email_tpl_booking_completed_subject: string;
852 927 email_tpl_booking_completed_body: string;
853 928 email_tpl_booking_expired_customer_subject: string;
854 929 email_tpl_booking_expired_customer_body: string;
@@ -885,8 +960,12 @@
885 960 allow_customer_reviews: boolean;
886 961 customer_dashboard_enabled: boolean;
887 962 /** Wishlist / saved trips (Yatra Pro); toggled in Customer settings */
888 963 enable_wishlist?: boolean;
964 + /** Keep sold-out departure dates visible on the storefront (default true) */
965 + show_sold_out?: boolean;
966 + /** How many months ahead customers can see and book (1–36, default 12) */
967 + availability_horizon_months?: number;
889 968
890 969 // Review Settings
891 970 enable_reviews: boolean;
892 971 require_booking: boolean;
@@ -894,8 +973,9 @@
894 973 review_moderation: boolean;
895 974 min_rating: number;
896 975 allow_anonymous_reviews: boolean;
897 976 review_reminder_days: number;
977 + review_reminder_max_age_days: number;
898 978
899 979 // Tax Settings
900 980 enable_tax: boolean;
901 981 tax_name: string;
@@ -929,8 +1009,12 @@
929 1009 facebook_pixel: string;
930 1010 recaptcha_enabled: boolean;
931 1011 recaptcha_site_key: string;
932 1012 recaptcha_secret_key: string;
1013 + recaptcha_score_threshold: number;
1014 + recaptcha_protect_enquiry: boolean;
1015 + recaptcha_protect_booking: boolean;
1016 + recaptcha_protect_registration: boolean;
933 1017
934 1018 // Mailchimp Integration (Pro)
935 1019 mailchimp_api_key?: string;
936 1020 mailchimp_list_id?: string;
@@ -991,8 +1075,9 @@
991 1075
992 1076 // Advanced Settings
993 1077 debug_mode: boolean;
994 1078 enable_logging: boolean;
1079 + delete_data_on_uninstall: boolean;
995 1080 cache_enabled: boolean;
996 1081 api_key: string;
997 1082 api_rate_limit: number;
998 1083 session_timeout: number;
@@ -1004,8 +1089,11 @@
1004 1089 seo_trip_meta_title: string;
1005 1090 seo_trip_meta_description: string;
1006 1091 seo_trip_meta_keywords: string;
1007 1092 seo_trip_meta_image: number;
1093 + enable_sitemap: boolean;
1094 + sitemap_types: string[];
1095 + sitemap_noindex_excluded: boolean;
1008 1096 }
1009 1097
1010 1098 // Form Builder Component
1011 1099 type BookingFormSubTab =
@@ -1040,11 +1128,17 @@
1040 1128 }) => {
1041 1129 const [activeFormTab, setActiveFormTab] =
1042 1130 useState<BookingFormSubTab>(getInitialFormSubTab);
1043 1131
1044 - // Check if Dynamic Form Field module is enabled via modules API
1132 + // Check if Dynamic Form Field module is enabled via modules API.
1133 + // NOTE: the key must differ from useModulesQuery()'s ["modules"] — this
1134 + // query caches the RAW { success, data } envelope, and the Modules page
1135 + // reads the same cache entry expecting a plain array. Sharing the key made
1136 + // Modules crash with "modules.forEach is not a function" after visiting
1137 + // Settings → Booking Form. The "modules" prefix keeps it invalidated by the
1138 + // module toggles.
1045 1139 const { data: modulesData } = useQuery({
1046 - queryKey: ["modules"],
1140 + queryKey: ["modules", "raw-envelope"],
1047 1141 queryFn: async () => {
1048 1142 const response = await apiService.getModules();
1049 1143 return response;
1050 1144 },
@@ -1066,8 +1160,27 @@
1066 1160 // Discount Stacking tab visibility earlier this session.
1067 1161 return !!window.yatraAdmin?.dynamicFormFieldEnabled;
1068 1162 }, [modulesData]);
1069 1163
1164 + // Per-trip "Conditions" popup for the active form. Needs the module on AND a
1165 + // Pro build that resolves conditions at checkout (older Pro builds set the
1166 + // module flag but not this one — offering the popup there would save rules
1167 + // that never apply). Target names (trips / categories) are only fetched when
1168 + // there is something to label.
1169 + const conditionsSupported =
1170 + isDynamicFormFieldEnabled &&
1171 + !!window.yatraAdmin?.bookingFormConditionsEnabled;
1172 + const [conditionsOpen, setConditionsOpen] = useState(false);
1173 + const hasAnyConditions = React.useMemo(() => {
1174 + const cfg = formData?.booking_form_config;
1175 + return !!cfg && Object.values(cfg).some(
1176 + (s: any) => Array.isArray(s?.conditions) && s.conditions.length > 0,
1177 + );
1178 + }, [formData?.booking_form_config]);
1179 + const { options: conditionTargetOptions } = useConditionTargets(
1180 + conditionsSupported && hasAnyConditions,
1181 + );
1182 +
1070 1183 // Save sub-tab to localStorage when it changes
1071 1184 const handleSubTabChange = (tab: BookingFormSubTab) => {
1072 1185 setActiveFormTab(tab);
1073 1186 if (typeof window !== "undefined") {
@@ -1073,66 +1186,15 @@
1073 1186 if (typeof window !== "undefined") {
1074 1187 localStorage.setItem("yatra_settings_booking_form_subtab", tab);
1075 1188 }
1076 1189 };
1077 - const [editingField, setEditingField] = useState<string | null>(null);
1078 - const [showAddField, setShowAddField] = useState(false);
1079 - const [newField, setNewField] = useState<Partial<FormFieldConfig>>({
1080 - id: "",
1081 - type: "text",
1082 - label: "",
1083 - placeholder: "",
1084 - required: false,
1085 - enabled: true,
1086 - width: "full",
1087 - options: [],
1088 - });
1190 + // Traveler-section "Applies to" choices (only shown on the Traveler tab).
1191 + const appliesToOptions: { value: "all" | "lead"; label: string }[] = [
1192 + { value: "all", label: __("All travelers", "yatra") },
1193 + { value: "lead", label: __("Lead traveler only", "yatra") },
1194 + ];
1089 1195
1090 - // Delete confirmation state
1091 - const [deleteConfirm, setDeleteConfirm] = useState<{
1092 - isOpen: boolean;
1093 - fieldId: string | null;
1094 - fieldLabel: string;
1095 - }>({
1096 - isOpen: false,
1097 - fieldId: null,
1098 - fieldLabel: "",
1099 - });
1100 1196
1101 - // Drag and drop state
1102 - const [draggedFieldId, setDraggedFieldId] = useState<string | null>(null);
1103 - const [dragOverFieldId, setDragOverFieldId] = useState<string | null>(null);
1104 -
1105 - // Helper to generate ID from label
1106 - const generateIdFromLabel = (label: string): string => {
1107 - return label
1108 - .toLowerCase()
1109 - .replace(/\s+/g, "_")
1110 - .replace(/[^a-z0-9_]/g, "");
1111 - };
1112 -
1113 - // Helper to sanitize ID input
1114 - const sanitizeId = (id: string): string => {
1115 - return id
1116 - .toLowerCase()
1117 - .replace(/\s+/g, "_")
1118 - .replace(/[^a-z0-9_]/g, "");
1119 - };
1120 -
1121 - // Handle label change with auto ID generation
1122 - const handleNewFieldLabelChange = (label: string) => {
1123 - const autoId = generateIdFromLabel(label);
1124 - setNewField((prev) => ({
1125 - ...prev,
1126 - label,
1127 - // Only auto-generate ID if it hasn't been manually edited
1128 - id:
1129 - prev.id === "" || prev.id === generateIdFromLabel(prev.label || "")
1130 - ? autoId
1131 - : prev.id,
1132 - }));
1133 - };
1134 -
1135 1197 const formTabs = [
1136 1198 {
1137 1199 id: "contact_form" as const,
1138 1200 label: __("Contact Form", "yatra"),
@@ -1150,17 +1212,18 @@
1150 1212 },
1151 1213 ];
1152 1214
1153 1215 const fieldTypes = [
1154 - { value: "text", label: "Text" },
1155 - { value: "email", label: "Email" },
1156 - { value: "tel", label: "Phone" },
1157 - { value: "date", label: "Date" },
1158 - { value: "select", label: "Dropdown" },
1159 - { value: "country", label: "Country Selector" },
1160 - { value: "textarea", label: "Text Area" },
1161 - { value: "number", label: "Number" },
1162 - { value: "checkbox", label: "Checkbox" },
1216 + { value: "text", label: __("Text", "yatra") },
1217 + { value: "email", label: __("Email", "yatra") },
1218 + { value: "tel", label: __("Phone", "yatra") },
1219 + { value: "date", label: __("Date", "yatra") },
1220 + { value: "select", label: __("Dropdown", "yatra") },
1221 + { value: "country", label: __("Country Selector", "yatra") },
1222 + { value: "textarea", label: __("Text Area", "yatra") },
1223 + { value: "number", label: __("Number", "yatra") },
1224 + { value: "checkbox", label: __("Checkbox", "yatra") },
1225 + { value: "text_block", label: __("Text Block (display only)", "yatra") },
1163 1226 ];
1164 1227
1165 1228 const widthOptions = [
1166 1229 { value: "full", label: "Full Width" },
@@ -1178,8 +1241,27 @@
1178 1241 }
1179 1242 );
1180 1243 };
1181 1244
1245 + // Booking confirmation, the customer account and the voucher all need an email.
1246 + // Warn the operator when neither the Contact nor the Traveler form will capture
1247 + // one (section disabled, or its email field disabled) — checkout enforces the
1248 + // same rule (lead-traveler email is used as a fallback when Contact is off).
1249 + const bookingFormCapturesEmail = (() => {
1250 + const cfg = formData?.booking_form_config;
1251 + if (!cfg) return true;
1252 + const sectionHasEnabledEmail = (section?: FormSectionConfig): boolean => {
1253 + if (!section || section.enabled === false) return false;
1254 + return (section.fields || []).some(
1255 + (f) => (f.type === "email" || f.id === "email") && f.enabled !== false,
1256 + );
1257 + };
1258 + return (
1259 + sectionHasEnabledEmail(cfg.contact_form) ||
1260 + sectionHasEnabledEmail(cfg.traveler_form)
1261 + );
1262 + })();
1263 +
1182 1264 const updateFormConfig = (updates: Partial<FormSectionConfig>) => {
1183 1265 setFormData((prev) => {
1184 1266 if (!prev) return prev;
1185 1267 return {
@@ -1194,165 +1276,9 @@
1194 1276 };
1195 1277 });
1196 1278 };
1197 1279
1198 - const updateField = (fieldId: string, updates: Partial<FormFieldConfig>) => {
1199 - const currentConfig = getCurrentFormConfig();
1200 - const updatedFields = currentConfig.fields.map((field) =>
1201 - field.id === fieldId ? { ...field, ...updates } : field,
1202 - );
1203 - updateFormConfig({ fields: updatedFields });
1204 - };
1205 1280
1206 - const toggleFieldEnabled = (fieldId: string) => {
1207 - const currentConfig = getCurrentFormConfig();
1208 - const field = currentConfig.fields.find((f) => f.id === fieldId);
1209 - // Locked fields cannot be disabled
1210 - if (field && !field.locked) {
1211 - updateField(fieldId, { enabled: !field.enabled });
1212 - }
1213 - };
1214 -
1215 - const toggleFieldRequired = (fieldId: string) => {
1216 - const currentConfig = getCurrentFormConfig();
1217 - const field = currentConfig.fields.find((f) => f.id === fieldId);
1218 - if (field) {
1219 - updateField(fieldId, { required: !field.required });
1220 - }
1221 - };
1222 -
1223 - const moveField = (fieldId: string, direction: "up" | "down") => {
1224 - const currentConfig = getCurrentFormConfig();
1225 - const fields = [...currentConfig.fields];
1226 - const index = fields.findIndex((f) => f.id === fieldId);
1227 -
1228 - if (direction === "up" && index > 0) {
1229 - [fields[index - 1], fields[index]] = [fields[index], fields[index - 1]];
1230 - } else if (direction === "down" && index < fields.length - 1) {
1231 - [fields[index], fields[index + 1]] = [fields[index + 1], fields[index]];
1232 - }
1233 -
1234 - // Update order values
1235 - fields.forEach((field, i) => {
1236 - field.order = i + 1;
1237 - });
1238 -
1239 - updateFormConfig({ fields });
1240 - };
1241 -
1242 - // Drag and Drop handlers
1243 - const handleDragStart = (e: React.DragEvent, fieldId: string) => {
1244 - setDraggedFieldId(fieldId);
1245 - e.dataTransfer.effectAllowed = "move";
1246 - };
1247 -
1248 - const handleDragOver = (e: React.DragEvent, fieldId: string) => {
1249 - e.preventDefault();
1250 - e.dataTransfer.dropEffect = "move";
1251 - if (fieldId !== draggedFieldId) {
1252 - setDragOverFieldId(fieldId);
1253 - }
1254 - };
1255 -
1256 - const handleDragLeave = () => {
1257 - setDragOverFieldId(null);
1258 - };
1259 -
1260 - const handleDrop = (e: React.DragEvent, targetFieldId: string) => {
1261 - e.preventDefault();
1262 - if (!draggedFieldId || draggedFieldId === targetFieldId) {
1263 - setDraggedFieldId(null);
1264 - setDragOverFieldId(null);
1265 - return;
1266 - }
1267 -
1268 - const currentConfig = getCurrentFormConfig();
1269 - const fields = [...currentConfig.fields];
1270 - const draggedIndex = fields.findIndex((f) => f.id === draggedFieldId);
1271 - const targetIndex = fields.findIndex((f) => f.id === targetFieldId);
1272 -
1273 - if (draggedIndex !== -1 && targetIndex !== -1) {
1274 - const [draggedField] = fields.splice(draggedIndex, 1);
1275 - fields.splice(targetIndex, 0, draggedField);
1276 -
1277 - // Update order values
1278 - fields.forEach((field, i) => {
1279 - field.order = i + 1;
1280 - });
1281 -
1282 - updateFormConfig({ fields });
1283 - }
1284 -
1285 - setDraggedFieldId(null);
1286 - setDragOverFieldId(null);
1287 - };
1288 -
1289 - const handleDragEnd = () => {
1290 - setDraggedFieldId(null);
1291 - setDragOverFieldId(null);
1292 - };
1293 -
1294 - const deleteField = (fieldId: string) => {
1295 - const currentConfig = getCurrentFormConfig();
1296 - const updatedFields = currentConfig.fields.filter((f) => f.id !== fieldId);
1297 - updatedFields.forEach((field, i) => {
1298 - field.order = i + 1;
1299 - });
1300 - updateFormConfig({ fields: updatedFields });
1301 - setDeleteConfirm({ isOpen: false, fieldId: null, fieldLabel: "" });
1302 - };
1303 -
1304 - const addNewField = () => {
1305 - if (!newField.label || !newField.id) return;
1306 -
1307 - const currentConfig = getCurrentFormConfig();
1308 - const fieldId = sanitizeId(newField.id);
1309 -
1310 - // Check if ID already exists
1311 - if (currentConfig.fields.some((f) => f.id === fieldId)) {
1312 - // TODO: Replace with toast notification when refactoring this nested component
1313 - window.alert(
1314 - "A field with this ID already exists. Please use a different ID.",
1315 - );
1316 - return;
1317 - }
1318 -
1319 - const newFieldConfig: FormFieldConfig = {
1320 - id: fieldId,
1321 - type: (newField.type as FormFieldConfig["type"]) || "text",
1322 - label: newField.label || "",
1323 - placeholder: newField.placeholder || "",
1324 - required: newField.required || false,
1325 - enabled: true,
1326 - order: currentConfig.fields.length + 1,
1327 - width: (newField.width as FormFieldConfig["width"]) || "full",
1328 - };
1329 -
1330 - // Add options if field type is select
1331 - if (
1332 - newField.type === "select" &&
1333 - newField.options &&
1334 - newField.options.length > 0
1335 - ) {
1336 - newFieldConfig.options = newField.options.filter(
1337 - (opt) => opt.value && opt.label,
1338 - );
1339 - }
1340 -
1341 - updateFormConfig({ fields: [...currentConfig.fields, newFieldConfig] });
1342 - setNewField({
1343 - id: "",
1344 - type: "text",
1345 - label: "",
1346 - placeholder: "",
1347 - required: false,
1348 - enabled: true,
1349 - width: "full",
1350 - options: [],
1351 - });
1352 - setShowAddField(false);
1353 - };
1354 -
1355 1281 const currentConfig = getCurrentFormConfig();
1356 1282
1357 1283 return (
1358 1284 <div className="space-y-6">
@@ -1385,14 +1311,51 @@
1385 1311 ))}
1386 1312 </nav>
1387 1313 </div>
1388 1314
1315 + {/* No-email warning — booking confirmation/account/voucher all need one */}
1316 + {!bookingFormCapturesEmail && (
1317 + <div className="flex items-start gap-2 p-3 rounded-md border border-red-300 bg-red-50 dark:border-red-700 dark:bg-red-900/20 text-sm text-red-800 dark:text-red-300">
1318 + <span className="font-semibold">⚠</span>
1319 + <span>
1320 + {__(
1321 + "No form is set to collect an email address. An email is required for booking confirmation, the customer account and the voucher. Please enable an email field on the Contact or Traveler form — otherwise customers can't complete checkout.",
1322 + "yatra",
1323 + )}
1324 + </span>
1325 + </div>
1326 + )}
1327 +
1389 1328 {/* Form Section Settings */}
1390 1329 <Card>
1391 1330 <CardHeader>
1392 - <CardTitle className="text-base">
1393 - {__("Form Section Settings", "yatra")}
1394 - </CardTitle>
1331 + <div className="flex flex-wrap items-center justify-between gap-2">
1332 + <CardTitle className="text-base">
1333 + {__("Form Section Settings", "yatra")}
1334 + </CardTitle>
1335 + {conditionsSupported && (
1336 + <Button
1337 + type="button"
1338 + variant="outline"
1339 + size="sm"
1340 + onClick={() => setConditionsOpen(true)}
1341 + className="border-purple-300 text-purple-700 hover:bg-purple-50 dark:border-purple-700 dark:text-purple-300 dark:hover:bg-purple-900/20"
1342 + title={__(
1343 + "Give some trips their own version of this form",
1344 + "yatra",
1345 + )}
1346 + data-testid="open-conditions"
1347 + >
1348 + <SlidersHorizontal className="w-4 h-4 mr-1.5" />
1349 + {__("Conditions", "yatra")}
1350 + {(currentConfig.conditions?.length ?? 0) > 0 && (
1351 + <span className="ml-1.5 text-[11px] px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300 font-semibold">
1352 + {currentConfig.conditions?.length}
1353 + </span>
1354 + )}
1355 + </Button>
1356 + )}
1357 + </div>
1395 1358 </CardHeader>
1396 1359 <CardContent className="space-y-4">
1397 1360 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
1398 1361 <input
@@ -1439,628 +1402,85 @@
1439 1402 className="mt-1"
1440 1403 />
1441 1404 </div>
1442 1405 </div>
1443 - </CardContent>
1444 - </Card>
1445 1406
1446 - {/* Form Fields */}
1447 - <Card>
1448 - <CardHeader className="flex flex-row items-center justify-between">
1449 - <CardTitle className="text-base">
1450 - {__("Form Fields", "yatra")}
1451 - </CardTitle>
1452 - <Button
1453 - variant="outline"
1454 - size="sm"
1455 - onClick={() => setShowAddField(!showAddField)}
1456 - className="flex items-center gap-2"
1457 - >
1458 - <Plus className="w-4 h-4" />
1459 - {__("Add Field", "yatra")}
1460 - </Button>
1461 - </CardHeader>
1462 - <CardContent className="space-y-3">
1463 - {/* Add New Field Form */}
1464 - {showAddField && (
1465 - <div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 mb-4">
1466 - <h4 className="font-medium text-sm mb-3">
1467 - {__("Add New Field", "yatra")}
1468 - </h4>
1469 - <div className="grid grid-cols-1 md:grid-cols-5 gap-3">
1470 - <div>
1471 - <Label className="text-xs">
1472 - {__("Field Type", "yatra")}
1473 - </Label>
1474 - <Select
1475 - value={newField.type || "text"}
1476 - onChange={(e) =>
1477 - setNewField((prev) => ({
1478 - ...prev,
1479 - type: e.target.value as FormFieldConfig["type"],
1480 - }))
1481 - }
1482 - className="mt-1"
1407 + {/* Per-trip conditions summary (Pro) */}
1408 + {conditionsSupported &&
1409 + (currentConfig.conditions?.length ?? 0) > 0 && (
1410 + <div className="space-y-2" data-testid="conditions-summary">
1411 + {currentConfig.conditions?.map((c, i) => (
1412 + <div
1413 + key={c.id || i}
1414 + className="flex flex-wrap items-center gap-2 px-3 py-2 rounded-md border border-dashed border-gray-300 dark:border-gray-600 text-sm"
1483 1415 >
1484 - {fieldTypes.map((type) => (
1485 - <option key={type.value} value={type.value}>
1486 - {type.label}
1487 - </option>
1488 - ))}
1489 - </Select>
1490 - </div>
1491 - <div>
1492 - <Label className="text-xs">{__("Label", "yatra")} *</Label>
1493 - <Input
1494 - value={newField.label || ""}
1495 - onChange={(e) =>
1496 - handleNewFieldLabelChange(e.target.value)
1497 - }
1498 - placeholder="Field label"
1499 - className="mt-1"
1500 - />
1501 - </div>
1502 - <div>
1503 - <Label className="text-xs">
1504 - {__("Field ID", "yatra")} *
1505 - </Label>
1506 - <Input
1507 - value={newField.id || ""}
1508 - onChange={(e) =>
1509 - setNewField((prev) => ({
1510 - ...prev,
1511 - id: sanitizeId(e.target.value),
1512 - }))
1513 - }
1514 - placeholder="field_id"
1515 - className="mt-1 font-mono text-xs"
1516 - />
1517 - <p className="text-[10px] text-gray-400 mt-0.5">
1518 - {__("Lowercase, no spaces", "yatra")}
1519 - </p>
1520 - </div>
1521 - <div>
1522 - <Label className="text-xs">
1523 - {__("Placeholder", "yatra")}
1524 - </Label>
1525 - <Input
1526 - value={newField.placeholder || ""}
1527 - onChange={(e) =>
1528 - setNewField((prev) => ({
1529 - ...prev,
1530 - placeholder: e.target.value,
1531 - }))
1532 - }
1533 - placeholder="Placeholder text"
1534 - className="mt-1"
1535 - />
1536 - </div>
1537 - <div>
1538 - <Label className="text-xs">{__("Width", "yatra")}</Label>
1539 - <Select
1540 - value={newField.width || "full"}
1541 - onChange={(e) =>
1542 - setNewField((prev) => ({
1543 - ...prev,
1544 - width: e.target.value as FormFieldConfig["width"],
1545 - }))
1546 - }
1547 - className="mt-1"
1548 - >
1549 - {widthOptions.map((w) => (
1550 - <option key={w.value} value={w.value}>
1551 - {w.label}
1552 - </option>
1553 - ))}
1554 - </Select>
1555 - </div>
1556 - </div>
1557 - {/* Options editor for select fields */}
1558 - {newField.type === "select" && (
1559 - <div className="mt-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-blue-100 dark:border-blue-900">
1560 - <div className="flex items-center justify-between mb-2">
1561 - <Label className="text-xs font-medium">
1562 - {__("Dropdown Options", "yatra")}
1563 - </Label>
1416 + <span className="text-gray-500">{__("On", "yatra")}</span>
1417 + <span className="font-medium">
1418 + {describeTargets(c.targets, conditionTargetOptions) ||
1419 + __("(no trips selected)", "yatra")}
1420 + </span>
1421 + <span className="text-gray-500">
1422 + {sprintf(
1423 + __("→ “%1$s” · %2$d fields", "yatra"),
1424 + c.title,
1425 + c.fields.filter((f) => f.enabled !== false).length,
1426 + )}
1427 + </span>
1428 + <span className="flex-1" />
1564 1429 <Button
1565 1430 type="button"
1566 1431 variant="ghost"
1567 1432 size="sm"
1568 - onClick={() => {
1569 - const currentOptions = newField.options || [];
1570 - setNewField((prev) => ({
1571 - ...prev,
1572 - options: [
1573 - ...currentOptions,
1574 - { value: "", label: "" },
1575 - ],
1576 - }));
1577 - }}
1578 - className="h-6 text-xs"
1433 + onClick={() => setConditionsOpen(true)}
1579 1434 >
1580 - <Plus className="w-3 h-3 mr-1" />
1581 - {__("Add Option", "yatra")}
1435 + {__("Edit", "yatra")}
1582 1436 </Button>
1583 1437 </div>
1584 - <div className="space-y-2">
1585 - {(newField.options || []).map((option, optIndex) => (
1586 - <div key={optIndex} className="flex items-center gap-2">
1587 - <Input
1588 - value={option.value}
1589 - onChange={(e) => {
1590 - const newOptions = [...(newField.options || [])];
1591 - newOptions[optIndex] = {
1592 - ...newOptions[optIndex],
1593 - value: e.target.value,
1594 - };
1595 - setNewField((prev) => ({
1596 - ...prev,
1597 - options: newOptions,
1598 - }));
1599 - }}
1600 - placeholder="Value (e.g., option1)"
1601 - className="text-xs flex-1"
1602 - />
1603 - <Input
1604 - value={option.label}
1605 - onChange={(e) => {
1606 - const newOptions = [...(newField.options || [])];
1607 - newOptions[optIndex] = {
1608 - ...newOptions[optIndex],
1609 - label: e.target.value,
1610 - };
1611 - setNewField((prev) => ({
1612 - ...prev,
1613 - options: newOptions,
1614 - }));
1615 - }}
1616 - placeholder="Label (e.g., Option 1)"
1617 - className="text-xs flex-1"
1618 - />
1619 - <button
1620 - type="button"
1621 - onClick={() => {
1622 - const newOptions = (
1623 - newField.options || []
1624 - ).filter((_, i) => i !== optIndex);
1625 - setNewField((prev) => ({
1626 - ...prev,
1627 - options: newOptions,
1628 - }));
1629 - }}
1630 - className="p-1 text-gray-400 hover:text-red-500"
1631 - >
1632 - <Trash2 className="w-3 h-3" />
1633 - </button>
1634 - </div>
1635 - ))}
1636 - {(!newField.options || newField.options.length === 0) && (
1637 - <p className="text-xs text-gray-400 italic">
1638 - {__(
1639 - 'Click "Add Option" to add dropdown choices.',
1640 - "yatra",
1641 - )}
1642 - </p>
1643 - )}
1644 - </div>
1438 + ))}
1439 + <div className="flex flex-wrap items-center gap-2 px-3 py-2 rounded-md bg-gray-50 dark:bg-gray-800 text-sm">
1440 + <span className="font-medium">
1441 + {__("All other trips", "yatra")}
1442 + </span>
1443 + <span className="text-gray-500">
1444 + {__("use this form (below)", "yatra")}
1445 + </span>
1645 1446 </div>
1646 - )}
1647 -
1648 - <div className="flex items-center gap-4 mt-3">
1649 - <label className="flex items-center gap-2 text-sm">
1650 - <input
1651 - type="checkbox"
1652 - checked={newField.required || false}
1653 - onChange={(e) =>
1654 - setNewField((prev) => ({
1655 - ...prev,
1656 - required: e.target.checked,
1657 - }))
1658 - }
1659 - className="w-4 h-4 rounded border-gray-300 text-blue-600"
1660 - />
1661 - {__("Required", "yatra")}
1662 - </label>
1663 - <div className="flex-1"></div>
1664 - <Button
1665 - variant="ghost"
1666 - size="sm"
1667 - onClick={() => setShowAddField(false)}
1668 - >
1669 - {__("Cancel", "yatra")}
1670 - </Button>
1671 - <Button
1672 - size="sm"
1673 - onClick={addNewField}
1674 - disabled={!newField.label || !newField.id}
1675 - >
1676 - {__("Add Field", "yatra")}
1677 - </Button>
1678 1447 </div>
1679 - </div>
1680 - )}
1681 -
1682 - {/* Field List */}
1683 - {currentConfig.fields?.length === 0 ? (
1684 - <div className="text-center py-8 text-gray-500 dark:text-gray-400">
1685 - <p>
1686 - {__(
1687 - 'No fields configured. Click "Add Field" to get started.',
1688 - "yatra",
1689 - )}
1690 - </p>
1691 - </div>
1692 - ) : (
1693 - <div className="space-y-2">
1694 - {currentConfig.fields?.map((field, index) => (
1695 - <div
1696 - key={field.id}
1697 - draggable
1698 - onDragStart={(e) => handleDragStart(e, field.id)}
1699 - onDragOver={(e) => handleDragOver(e, field.id)}
1700 - onDragLeave={handleDragLeave}
1701 - onDrop={(e) => handleDrop(e, field.id)}
1702 - onDragEnd={handleDragEnd}
1703 - className={`flex items-center gap-3 p-3 rounded-lg border transition-all cursor-grab active:cursor-grabbing ${
1704 - draggedFieldId === field.id
1705 - ? "opacity-50 border-dashed"
1706 - : ""
1707 - } ${
1708 - dragOverFieldId === field.id
1709 - ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
1710 - : ""
1711 - } ${
1712 - field.enabled
1713 - ? "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
1714 - : "bg-gray-50 dark:bg-gray-900 border-gray-100 dark:border-gray-800 opacity-60"
1715 - }`}
1716 - >
1717 - {/* Drag Handle & Order */}
1718 - <div className="flex items-center gap-1">
1719 - <GripVertical className="w-4 h-4 text-gray-400" />
1720 - <div className="flex flex-col gap-0.5">
1721 - <button
1722 - type="button"
1723 - onClick={() => moveField(field.id, "up")}
1724 - disabled={index === 0}
1725 - className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
1726 - >
1727 - <ArrowUp className="w-3 h-3" />
1728 - </button>
1729 - <button
1730 - type="button"
1731 - onClick={() => moveField(field.id, "down")}
1732 - disabled={index === currentConfig.fields.length - 1}
1733 - className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
1734 - >
1735 - <ArrowDown className="w-3 h-3" />
1736 - </button>
1737 - </div>
1738 - </div>
1739 -
1740 - {/* Field Info */}
1741 - <div className="flex-1 min-w-0">
1742 - {editingField === field.id ? (
1743 - <div className="space-y-3">
1744 - <div className="grid grid-cols-4 gap-2">
1745 - <Input
1746 - value={field.label}
1747 - onChange={(e) =>
1748 - updateField(field.id, { label: e.target.value })
1749 - }
1750 - placeholder="Label"
1751 - className="text-sm"
1752 - />
1753 - <Input
1754 - value={field.placeholder}
1755 - onChange={(e) =>
1756 - updateField(field.id, {
1757 - placeholder: e.target.value,
1758 - })
1759 - }
1760 - placeholder="Placeholder"
1761 - className="text-sm"
1762 - />
1763 - <Select
1764 - value={field.type}
1765 - onChange={(e) =>
1766 - updateField(field.id, {
1767 - type: e.target
1768 - .value as FormFieldConfig["type"],
1769 - })
1770 - }
1771 - className="text-sm"
1772 - >
1773 - {fieldTypes.map((type) => (
1774 - <option key={type.value} value={type.value}>
1775 - {type.label}
1776 - </option>
1777 - ))}
1778 - </Select>
1779 - <Select
1780 - value={field.width}
1781 - onChange={(e) =>
1782 - updateField(field.id, {
1783 - width: e.target
1784 - .value as FormFieldConfig["width"],
1785 - })
1786 - }
1787 - className="text-sm"
1788 - >
1789 - {widthOptions.map((w) => (
1790 - <option key={w.value} value={w.value}>
1791 - {w.label}
1792 - </option>
1793 - ))}
1794 - </Select>
1795 - </div>
1796 -
1797 - {/* Field ID - Below other fields */}
1798 - <div className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-900 rounded">
1799 - <Label className="text-xs text-gray-500 whitespace-nowrap">
1800 - {__("Field ID:", "yatra")}
1801 - </Label>
1802 - <Input
1803 - value={field.id}
1804 - onChange={(e) => {
1805 - if (!field.locked) {
1806 - updateField(field.id, {
1807 - id: sanitizeId(e.target.value),
1808 - });
1809 - }
1810 - }}
1811 - placeholder="field_id"
1812 - className="text-sm font-mono flex-1 max-w-xs"
1813 - disabled={field.locked}
1814 - title={
1815 - field.locked
1816 - ? "Locked fields cannot change ID"
1817 - : "Field ID (lowercase, no spaces)"
1818 - }
1819 - />
1820 - {field.locked && (
1821 - <span className="text-xs text-amber-600">
1822 - {__("(locked)", "yatra")}
1823 - </span>
1824 - )}
1825 - </div>
1826 -
1827 - {/* Dropdown Options Editor */}
1828 - {field.type === "select" && (
1829 - <div className="p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
1830 - <div className="flex items-center justify-between mb-2">
1831 - <Label className="text-xs font-medium">
1832 - {__("Dropdown Options", "yatra")}
1833 - </Label>
1834 - <Button
1835 - type="button"
1836 - variant="ghost"
1837 - size="sm"
1838 - onClick={() => {
1839 - const newOptions = [
1840 - ...(field.options || []),
1841 - { value: "", label: "" },
1842 - ];
1843 - updateField(field.id, {
1844 - options: newOptions,
1845 - });
1846 - }}
1847 - className="h-6 text-xs"
1848 - >
1849 - <Plus className="w-3 h-3 mr-1" />
1850 - {__("Add Option", "yatra")}
1851 - </Button>
1852 - </div>
1853 - <div className="space-y-2">
1854 - {(field.options || []).map(
1855 - (option, optIndex) => (
1856 - <div
1857 - key={optIndex}
1858 - className="flex items-center gap-2"
1859 - >
1860 - <Input
1861 - value={option.value}
1862 - onChange={(e) => {
1863 - const newOptions = [
1864 - ...(field.options || []),
1865 - ];
1866 - newOptions[optIndex] = {
1867 - ...newOptions[optIndex],
1868 - value: e.target.value,
1869 - };
1870 - updateField(field.id, {
1871 - options: newOptions,
1872 - });
1873 - }}
1874 - placeholder="Value (e.g., spouse)"
1875 - className="text-xs flex-1"
1876 - />
1877 - <Input
1878 - value={option.label}
1879 - onChange={(e) => {
1880 - const newOptions = [
1881 - ...(field.options || []),
1882 - ];
1883 - newOptions[optIndex] = {
1884 - ...newOptions[optIndex],
1885 - label: e.target.value,
1886 - };
1887 - updateField(field.id, {
1888 - options: newOptions,
1889 - });
1890 - }}
1891 - placeholder="Label (e.g., Spouse/Partner)"
1892 - className="text-xs flex-1"
1893 - />
1894 - <button
1895 - type="button"
1896 - onClick={() => {
1897 - const newOptions = (
1898 - field.options || []
1899 - ).filter((_, i) => i !== optIndex);
1900 - updateField(field.id, {
1901 - options: newOptions,
1902 - });
1903 - }}
1904 - className="p-1 text-gray-400 hover:text-red-500"
1905 - >
1906 - <Trash2 className="w-3 h-3" />
1907 - </button>
1908 - </div>
1909 - ),
1910 - )}
1911 - {(!field.options ||
1912 - field.options.length === 0) && (
1913 - <p className="text-xs text-gray-400 italic">
1914 - {__(
1915 - 'No options. Click "Add Option" to add dropdown choices.',
1916 - "yatra",
1917 - )}
1918 - </p>
1919 - )}
1920 - </div>
1921 - </div>
1922 - )}
1923 - </div>
1924 - ) : (
1925 - <div className="flex items-center gap-3 flex-wrap">
1926 - <span className="font-medium text-sm">
1927 - {field.label}
1928 - </span>
1929 - <code className="text-xs text-gray-500 dark:text-gray-400 px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded font-mono">
1930 - {field.id}
1931 - </code>
1932 - <span className="text-xs text-gray-400 dark:text-gray-500 px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">
1933 - {fieldTypes.find((t) => t.value === field.type)
1934 - ?.label || field.type}
1935 - </span>
1936 - <span className="text-xs text-gray-400 dark:text-gray-500">
1937 - {widthOptions.find((w) => w.value === field.width)
1938 - ?.label || "Full"}
1939 - </span>
1940 - {field.type === "select" &&
1941 - field.options &&
1942 - field.options.length > 0 && (
1943 - <span className="text-xs text-blue-500 dark:text-blue-400">
1944 - ({field.options.length}{" "}
1945 - {field.options.length === 1
1946 - ? "option"
1947 - : "options"}
1948 - )
1949 - </span>
1950 - )}
1951 - {field.required && (
1952 - <span className="text-xs text-red-500 font-medium">
1953 - {__("Required", "yatra")}
1954 - </span>
1955 - )}
1956 - {field.locked && (
1957 - <span
1958 - className="inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 px-1.5 py-0.5 bg-amber-50 dark:bg-amber-900/20 rounded"
1959 - title="This field is protected and cannot be deleted"
1960 - >
1961 - <Lock className="w-3 h-3" />
1962 - Locked
1963 - </span>
1964 - )}
1965 - </div>
1966 - )}
1967 - </div>
1968 -
1969 - {/* Actions */}
1970 - <div className="flex items-center gap-1">
1971 - <button
1972 - type="button"
1973 - onClick={() =>
1974 - !field.locked && toggleFieldRequired(field.id)
1975 - }
1976 - disabled={field.locked}
1977 - className={`p-1.5 rounded ${field.locked ? "cursor-not-allowed opacity-50" : ""} ${field.required ? "text-red-500 bg-red-50 dark:bg-red-900/20" : "text-gray-400 hover:text-gray-600"}`}
1978 - title={
1979 - field.locked
1980 - ? "This field is required and cannot be changed"
1981 - : field.required
1982 - ? "Make optional"
1983 - : "Make required"
1984 - }
1985 - >
1986 - <Star
1987 - className="w-4 h-4"
1988 - fill={field.required ? "currentColor" : "none"}
1989 - />
1990 - </button>
1991 - {/* Show enable/disable toggle only for non-locked fields */}
1992 - {!field.locked && (
1993 - <button
1994 - type="button"
1995 - onClick={() => toggleFieldEnabled(field.id)}
1996 - className={`p-1.5 rounded ${field.enabled ? "text-green-500" : "text-gray-400"}`}
1997 - title={
1998 - field.enabled ? "Disable field" : "Enable field"
1999 - }
2000 - >
2001 - {field.enabled ? (
2002 - <Eye className="w-4 h-4" />
2003 - ) : (
2004 - <EyeOff className="w-4 h-4" />
2005 - )}
2006 - </button>
2007 - )}
2008 - <button
2009 - type="button"
2010 - onClick={() =>
2011 - setEditingField(
2012 - editingField === field.id ? null : field.id,
2013 - )
2014 - }
2015 - className="p-1.5 rounded text-gray-400 hover:text-blue-500"
2016 - title="Edit field"
2017 - >
2018 - <Edit2 className="w-4 h-4" />
2019 - </button>
2020 - {/* Show delete button only for non-locked fields */}
2021 - {!field.locked && (
2022 - <button
2023 - type="button"
2024 - onClick={() =>
2025 - setDeleteConfirm({
2026 - isOpen: true,
2027 - fieldId: field.id,
2028 - fieldLabel: field.label,
2029 - })
2030 - }
2031 - className="p-1.5 rounded text-gray-400 hover:text-red-500"
2032 - title="Delete field"
2033 - >
2034 - <Trash2 className="w-4 h-4" />
2035 - </button>
2036 - )}
2037 - </div>
2038 - </div>
2039 - ))}
2040 - </div>
2041 - )}
1448 + )}
2042 1449 </CardContent>
2043 1450 </Card>
2044 1451
2045 - {/* Delete Confirmation Dialog */}
2046 - <ConfirmationDialog
2047 - isOpen={deleteConfirm.isOpen}
2048 - onClose={() =>
2049 - setDeleteConfirm({ isOpen: false, fieldId: null, fieldLabel: "" })
2050 - }
2051 - onConfirm={() => {
2052 - if (deleteConfirm.fieldId) {
2053 - deleteField(deleteConfirm.fieldId);
1452 + {conditionsSupported && (
1453 + <BookingFormConditionsModal
1454 + isOpen={conditionsOpen}
1455 + onClose={() => setConditionsOpen(false)}
1456 + formType={activeFormTab}
1457 + formLabel={
1458 + formTabs.find((t) => t.id === activeFormTab)?.label || ""
2054 1459 }
2055 - }}
2056 - title={__("Delete Field", "yatra")}
2057 - message={`Are you sure you want to delete the field "${deleteConfirm.fieldLabel}"? This action cannot be undone.`}
2058 - confirmText={__("Delete", "yatra")}
2059 - cancelText={__("Cancel", "yatra")}
2060 - variant="danger"
1460 + section={currentConfig}
1461 + onSave={(conditions) =>
1462 + updateFormConfig({
1463 + conditions: conditions.length > 0 ? conditions : undefined,
1464 + })
1465 + }
1466 + fieldTypes={fieldTypes}
1467 + widthOptions={widthOptions}
1468 + appliesToOptions={appliesToOptions}
1469 + />
1470 + )}
1471 +
1472 + {/* Form Fields — shared builder (also used by the Conditions popup) */}
1473 + <BookingFormFieldsEditor
1474 + formType={activeFormTab}
1475 + fields={currentConfig.fields}
1476 + onChange={(fields) => updateFormConfig({ fields })}
1477 + fieldTypes={fieldTypes}
1478 + widthOptions={widthOptions}
1479 + appliesToOptions={appliesToOptions}
2061 1480 />
2062 1481
1482 +
2063 1483 {/* Preview */}
2064 1484 <Card>
2065 1485 <CardHeader>
2066 1486 <CardTitle className="text-base">
@@ -2088,32 +1508,43 @@
2088 1508 ? "col-span-1"
2089 1509 : "col-span-1"
2090 1510 }
2091 1511 >
2092 - <Label className="text-sm">
2093 - {field.label}
2094 - {field.required && (
2095 - <span className="text-red-500 ml-1">*</span>
2096 - )}
2097 - </Label>
2098 - {field.type === "select" || field.type === "country" ? (
2099 - <Select disabled className="mt-1 w-full">
2100 - <option>{field.placeholder || "Select..."}</option>
2101 - </Select>
2102 - ) : field.type === "textarea" ? (
2103 - <textarea
2104 - disabled
2105 - placeholder={field.placeholder}
2106 - className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-md bg-white dark:bg-gray-700 text-sm"
2107 - rows={2}
2108 - />
1512 + {field.type === "text_block" ? (
1513 + <div className="yatra-form-text-block text-sm text-gray-600 dark:text-gray-300 whitespace-pre-line">
1514 + {(field.content || "").replace(/<[^>]*>/g, "")}
1515 + </div>
2109 1516 ) : (
2110 - <Input
2111 - disabled
2112 - type={field.type}
2113 - placeholder={field.placeholder}
2114 - className="mt-1"
2115 - />
1517 + <>
1518 + <Label className="text-sm">
1519 + {field.label}
1520 + {field.required && (
1521 + <span className="text-red-500 ml-1">*</span>
1522 + )}
1523 + </Label>
1524 + {field.type === "select" ||
1525 + field.type === "country" ? (
1526 + <Select disabled className="mt-1 w-full">
1527 + <option>
1528 + {field.placeholder || "Select..."}
1529 + </option>
1530 + </Select>
1531 + ) : field.type === "textarea" ? (
1532 + <textarea
1533 + disabled
1534 + placeholder={field.placeholder}
1535 + className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-md bg-white dark:bg-gray-700 text-sm"
1536 + rows={2}
1537 + />
1538 + ) : (
1539 + <Input
1540 + disabled
1541 + type={field.type}
1542 + placeholder={field.placeholder}
1543 + className="mt-1"
1544 + />
1545 + )}
1546 + </>
2116 1547 )}
2117 1548 </div>
2118 1549 ))}
2119 1550 </div>
@@ -2131,9 +1562,34 @@
2131 1562 const { showToast } = useToast();
2132 1563
2133 1564 // Get active section from localStorage or default to 'general'
2134 1565 const getInitialActiveSection = (): SettingsSection => {
1566 + const VALID_SECTIONS = [
1567 + "general",
1568 + "design",
1569 + "booking",
1570 + "booking_form",
1571 + "payment",
1572 + "trip",
1573 + "customer",
1574 + "review",
1575 + "tax",
1576 + "currency",
1577 + "integration",
1578 + "permalink",
1579 + "seo",
1580 + "advanced",
1581 + "uninstall",
1582 + ];
2135 1583 if (typeof window !== "undefined") {
1584 + // Allow deep-linking a section via ?section=… (e.g. the Google Calendar
1585 + // dashboard's "Back to Settings → Integration" link).
1586 + const fromUrl = new URLSearchParams(window.location.search).get(
1587 + "section",
1588 + );
1589 + if (fromUrl && VALID_SECTIONS.includes(fromUrl)) {
1590 + return fromUrl as SettingsSection;
1591 + }
2136 1592 const saved = localStorage.getItem("yatra_settings_active_section");
2137 1593 if (saved === "email" || saved === "notification") {
2138 1594 return "general";
2139 1595 }
@@ -2153,8 +1609,9 @@
2153 1609 "integration",
2154 1610 "permalink",
2155 1611 "seo",
2156 1612 "advanced",
1613 + "uninstall",
2157 1614 ].includes(saved)
2158 1615 ) {
2159 1616 return saved as SettingsSection;
2160 1617 }
@@ -2269,9 +1726,9 @@
2269 1726 // Default settings fallback
2270 1727 const defaultSettings: SettingsData = useMemo<SettingsData>(
2271 1728 () => ({
2272 1729 company_name: "Yatra Travel Agency",
2273 - company_email: "[email protected]",
1730 + company_email: "[email protected]",
2274 1731 company_phone: "+1-234-567-8900",
2275 1732 company_address: "123 Travel Street",
2276 1733 company_city: "Kathmandu",
2277 1734 company_state: "Bagmati",
@@ -2276,9 +1733,9 @@
2276 1733 company_city: "Kathmandu",
2277 1734 company_state: "Bagmati",
2278 1735 company_country: "Nepal",
2279 1736 company_zip: "44600",
2280 - company_website: "https://yatra.com",
1737 + company_website: "https://wpyatra.com",
2281 1738 company_logo: "",
2282 1739 timezone: "Asia/Kathmandu",
2283 1740 date_format: "Y-m-d",
2284 1741 time_format: "H:i",
@@ -2283,10 +1740,21 @@
2283 1740 date_format: "Y-m-d",
2284 1741 time_format: "H:i",
2285 1742 frontend_primary_color: "#3b82f6",
2286 1743 frontend_container_max_width: "",
1744 + frontend_listing_card_layout: "standard",
1745 + // Search & Listing — defaults match current behaviour (all fields shown,
1746 + // mobile filters expanded) so existing sites are unchanged.
1747 + search_show_keyword: true,
1748 + search_show_destination: true,
1749 + search_show_activities: true,
1750 + search_show_duration: true,
1751 + search_show_budget: true,
1752 + search_show_date: false,
1753 + collapse_filters_on_mobile: false,
2287 1754 booking_confirmation: true,
2288 1755 auto_confirm_bookings: false,
1756 + auto_confirm_mode: "online",
2289 1757 require_login: false,
2290 1758 allow_guest_checkout: true,
2291 1759 require_guest_email_verification: false,
2292 1760 booking_expiry_hours: 24,
@@ -2310,8 +1778,10 @@
2310 1778 scheduled_payment_installments: 1,
2311 1779 discount_stacking_mode: "both",
2312 1780 scheduled_payment_interval: 30,
2313 1781 scheduled_payment_reminder_days: 3,
1782 + balance_anchor: "booking",
1783 + balance_due_days: 14,
2314 1784 gateway_configs: {
2315 1785 stripe: {
2316 1786 enabled: true,
2317 1787 title: "Stripe",
@@ -2389,10 +1859,10 @@
2389 1859 "bank_transfer",
2390 1860 "esewa",
2391 1861 "khalti",
2392 1862 ],
2393 - admin_email: "[email protected]",
2394 - from_email: "[email protected]",
1863 + admin_email: "[email protected]",
1864 + from_email: "[email protected]",
2395 1865 from_name: "Yatra Travel",
2396 1866 email_template_booking: true,
2397 1867 email_template_confirmation: true,
2398 1868 email_template_cancellation: true,
@@ -2401,8 +1871,9 @@
2401 1871 email_template_admin_payment: true,
2402 1872 email_template_admin_cancellation: true,
2403 1873 email_template_trip_consent: true,
2404 1874 email_template_customer_verification: true,
1875 + email_template_guest_verification: true,
2405 1876 email_template_booking_completed: true,
2406 1877 email_template_booking_expired_customer: true,
2407 1878 email_template_admin_booking_expired: true,
2408 1879 email_template_scheduled_payment_reminder: true,
@@ -2411,9 +1882,9 @@
2411 1882 email_template_admin_scheduled_payment_failed: true,
2412 1883 email_template_enquiry_received: true,
2413 1884 email_template_enquiry_admin: true,
2414 1885 email_template_enquiry_response: true,
2415 - email_template_review_request: true,
1886 + email_template_review_request: false,
2416 1887 email_template_abandoned_booking_recovery_first: true,
2417 1888 email_template_abandoned_booking_recovery_second: true,
2418 1889 email_template_abandoned_booking_recovery_final: true,
2419 1890 smtp_enabled: false,
@@ -2439,8 +1910,10 @@
2439 1910 email_tpl_trip_consent_subject: "",
2440 1911 email_tpl_trip_consent_body: "",
2441 1912 email_tpl_customer_verification_subject: "",
2442 1913 email_tpl_customer_verification_body: "",
1914 + email_tpl_guest_verification_subject: "",
1915 + email_tpl_guest_verification_body: "",
2443 1916 email_tpl_booking_completed_subject: "",
2444 1917 email_tpl_booking_completed_body: "",
2445 1918 email_tpl_booking_expired_customer_subject: "",
2446 1919 email_tpl_booking_expired_customer_body: "",
@@ -2479,9 +1952,10 @@
2479 1952 auto_approve_reviews: false,
2480 1953 review_moderation: true,
2481 1954 min_rating: 1,
2482 1955 allow_anonymous_reviews: false,
2483 - review_reminder_days: 7,
1956 + review_reminder_days: 3,
1957 + review_reminder_max_age_days: 14,
2484 1958 enable_tax: true,
2485 1959 tax_name: __("Tax", "yatra"),
2486 1960 tax_rate: 10,
2487 1961 tax_inclusive: false,
@@ -2504,8 +1978,12 @@
2504 1978 facebook_pixel: "",
2505 1979 recaptcha_enabled: false,
2506 1980 recaptcha_site_key: "",
2507 1981 recaptcha_secret_key: "",
1982 + recaptcha_score_threshold: 0.5,
1983 + recaptcha_protect_enquiry: false,
1984 + recaptcha_protect_booking: false,
1985 + recaptcha_protect_registration: false,
2508 1986 trip_base: "trip",
2509 1987 destination_base: "destination",
2510 1988 activity_base: "activity",
2511 1989 trip_category_base: "trip-category",
@@ -2510,8 +1988,10 @@
2510 1988 activity_base: "activity",
2511 1989 trip_category_base: "trip-category",
2512 1990 booking_base: "book",
2513 1991 enable_wishlist: false,
1992 + show_sold_out: true,
1993 + availability_horizon_months: 12,
2514 1994 use_booking_page: false,
2515 1995 booking_page_id: 0,
2516 1996 terms_page_id: 0,
2517 1997 privacy_policy_page_id: 0,
@@ -2516,8 +1996,9 @@
2516 1996 terms_page_id: 0,
2517 1997 privacy_policy_page_id: 0,
2518 1998 debug_mode: false,
2519 1999 enable_logging: false,
2000 + delete_data_on_uninstall: false,
2520 2001 cache_enabled: true,
2521 2002 api_key: "",
2522 2003 api_rate_limit: 100,
2523 2004 session_timeout: 3600,
@@ -2751,8 +2232,11 @@
2751 2232 seo_trip_meta_title: "",
2752 2233 seo_trip_meta_description: "",
2753 2234 seo_trip_meta_keywords: "",
2754 2235 seo_trip_meta_image: 0,
2236 + enable_sitemap: true,
2237 + sitemap_types: ["archive", "trip", "destination", "activity", "category"],
2238 + sitemap_noindex_excluded: false,
2755 2239 }),
2756 2240 [],
2757 2241 );
2758 2242
@@ -3106,9 +2590,9 @@
3106 2590 // Use default settings if API fails or returns empty
3107 2591 setFormData(defaultSettings);
3108 2592 isInitializedRef.current = true;
3109 2593 }
3110 - // eslint-disable-next-line react-hooks/exhaustive-deps
2594 + // eslint-disable-next-line react-hooks/exhaustive-deps
3111 2595 }, [settings, isLoading]);
3112 2596
3113 2597 // Fetch SEO image URL when image ID changes with performance optimization
3114 2598 React.useEffect(() => {
@@ -3183,33 +2667,43 @@
3183 2667 controller.abort();
3184 2668 };
3185 2669 }, [formData?.seo_trip_meta_image]);
3186 2670
3187 - // Initialize gateway order when gateway definitions are loaded
2671 + // Initialize gateway order ONCE, and only after BOTH the gateway definitions
2672 + // AND the settings have loaded. These are two independent queries. The old
2673 + // guard (`gatewayOrder.length === 0`) initialized as soon as the definitions
2674 + // arrived — if that happened before `settings` resolved, `savedOrder` was
2675 + // still undefined, so it fell back to the DEFAULT order; the length guard then
2676 + // permanently shadowed the saved order once it arrived. That was the "payment
2677 + // option order is not saved" bug: the order persisted fine in the DB but the
2678 + // UI reverted to default on reload. Waiting for `settings` to resolve, tracked
2679 + // by a run-once ref, fixes it without clobbering in-progress drag reordering.
2680 + const gatewayOrderInitialized = React.useRef(false);
3188 2681 React.useEffect(() => {
3189 - if (
3190 - gatewayDefinitions &&
3191 - Object.keys(gatewayDefinitions).length > 0 &&
3192 - gatewayOrder.length === 0
3193 - ) {
3194 - // Use saved order if available, otherwise use default order from definitions
3195 - const savedOrder = settings?.gateway_order as string[] | undefined;
3196 - if (savedOrder) {
3197 - // Merge saved order with any new gateways
3198 - const allGateways = Object.keys(gatewayDefinitions);
3199 - const validSavedOrder = savedOrder.filter((id) =>
3200 - allGateways.includes(id),
3201 - );
3202 - const newGateways = allGateways.filter(
3203 - (id) => !validSavedOrder.includes(id),
3204 - );
3205 - setGatewayOrder([...validSavedOrder, ...newGateways]);
3206 - } else {
3207 - setGatewayOrder(Object.keys(gatewayDefinitions));
3208 - }
2682 + if (gatewayOrderInitialized.current) return;
2683 + if (!gatewayDefinitions || Object.keys(gatewayDefinitions).length === 0) {
2684 + return;
3209 2685 }
3210 - }, [gatewayDefinitions, settings?.gateway_order, gatewayOrder.length]);
2686 + // Wait for the settings query to resolve so the saved order is available.
2687 + if (settings === undefined) return;
3211 2688
2689 + const allGateways = Object.keys(gatewayDefinitions);
2690 + const savedOrder = settings?.gateway_order as string[] | undefined;
2691 + if (Array.isArray(savedOrder) && savedOrder.length > 0) {
2692 + // Keep the saved order, appending any gateways added since it was saved.
2693 + const validSavedOrder = savedOrder.filter((id) =>
2694 + allGateways.includes(id),
2695 + );
2696 + const newGateways = allGateways.filter(
2697 + (id) => !validSavedOrder.includes(id),
2698 + );
2699 + setGatewayOrder([...validSavedOrder, ...newGateways]);
2700 + } else {
2701 + setGatewayOrder(allGateways);
2702 + }
2703 + gatewayOrderInitialized.current = true;
2704 + }, [gatewayDefinitions, settings]);
2705 +
3212 2706 // Single stable onChange handler that reads field name from input's name or id attribute
3213 2707 const handleFieldChange = React.useCallback(
3214 2708 (
3215 2709 e: React.ChangeEvent<
@@ -3221,10 +2715,19 @@
3221 2715
3222 2716 if (e.target.type === "checkbox") {
3223 2717 value = (e.target as HTMLInputElement).checked;
3224 2718 } else if (e.target.type === "number") {
3225 - const numValue = parseFloat(e.target.value);
3226 - value = isNaN(numValue) ? 0 : numValue;
2719 + // Keep an empty field empty while editing so the user can clear the
2720 + // last character and retype. Previously empty coerced to 0, which
2721 + // "stuck" and blocked re-entry (deleting the last digit snapped a value
2722 + // back). The empty string is coerced to a number server-side on save.
2723 + const raw = e.target.value;
2724 + if (raw === "") {
2725 + value = "";
2726 + } else {
2727 + const numValue = parseFloat(raw);
2728 + value = isNaN(numValue) ? 0 : numValue;
2729 + }
3227 2730 } else {
3228 2731 value = e.target.value;
3229 2732 }
3230 2733
@@ -3587,8 +3090,13 @@
3587 3090 label: __("Booking Form", "yatra"),
3588 3091 icon: ClipboardList,
3589 3092 },
3590 3093 {
3094 + id: "search" as SettingsSection,
3095 + label: __("Search & Listing", "yatra"),
3096 + icon: SlidersHorizontal,
3097 + },
3098 + {
3591 3099 id: "payment" as SettingsSection,
3592 3100 label: __("Payment", "yatra"),
3593 3101 icon: DollarSign,
3594 3102 },
@@ -3646,8 +3154,13 @@
3646 3154 id: "advanced" as SettingsSection,
3647 3155 label: __("Advanced", "yatra"),
3648 3156 icon: Shield,
3649 3157 },
3158 + {
3159 + id: "uninstall" as SettingsSection,
3160 + label: __("Uninstall", "yatra"),
3161 + icon: Trash2,
3162 + },
3650 3163 ];
3651 3164
3652 3165 // Helper component for section divider
3653 3166 const SectionDivider = ({ title }: { title: string }) => (
@@ -4062,8 +3575,152 @@
4062 3575 spellCheck={false}
4063 3576 autoComplete="off"
4064 3577 />
4065 3578 </FormField>
3579 +
3580 + <FormField
3581 + id="frontend_listing_card_layout"
3582 + label={__("Listing card layout", "yatra")}
3583 + description={__(
3584 + "How trip cards appear in listings and search results. Compact shows a dense card — image, title, price and a View Details button only — so several trips fit per screen. Ideal for phones. Existing sites keep the Standard card.",
3585 + "yatra",
3586 + )}
3587 + >
3588 + <div
3589 + role="radiogroup"
3590 + aria-label={__("Listing card layout", "yatra")}
3591 + className="grid grid-cols-1 sm:grid-cols-3 gap-3 max-w-2xl"
3592 + >
3593 + {(
3594 + [
3595 + {
3596 + value: "standard",
3597 + label: __("Standard", "yatra"),
3598 + sub: __("Full card (default)", "yatra"),
3599 + svg: (
3600 + <svg
3601 + viewBox="0 0 160 100"
3602 + className="w-full h-full"
3603 + fill="none"
3604 + >
3605 + <rect x="46" y="6" width="68" height="88" rx="6" fill="#ffffff" stroke="#cbd5e1" strokeWidth="2" />
3606 + <rect x="52" y="12" width="56" height="30" rx="3" fill="#60a5fa" />
3607 + <rect x="52" y="49" width="44" height="5" rx="2.5" fill="#94a3b8" />
3608 + <rect x="52" y="59" width="34" height="3" rx="1.5" fill="#cbd5e1" />
3609 + <rect x="52" y="66" width="50" height="3" rx="1.5" fill="#e2e8f0" />
3610 + <rect x="52" y="73" width="50" height="3" rx="1.5" fill="#e2e8f0" />
3611 + <rect x="52" y="82" width="18" height="6" rx="2" fill="#475569" />
3612 + <rect x="86" y="81" width="22" height="8" rx="3" fill="#3b82f6" />
3613 + </svg>
3614 + ),
3615 + },
3616 + {
3617 + value: "compact_mobile",
3618 + label: __("Compact — mobile", "yatra"),
3619 + sub: __("Dense on phones · desktop unchanged", "yatra"),
3620 + svg: (
3621 + <svg
3622 + viewBox="0 0 160 100"
3623 + className="w-full h-full"
3624 + fill="none"
3625 + >
3626 + <rect x="50" y="4" width="60" height="92" rx="9" fill="#ffffff" stroke="#cbd5e1" strokeWidth="2" />
3627 + <rect x="72" y="8" width="16" height="3" rx="1.5" fill="#e2e8f0" />
3628 + {[0, 26, 52].map((dy) => (
3629 + <g key={dy} transform={`translate(0,${dy})`}>
3630 + <rect x="56" y="16" width="48" height="22" rx="3" fill="#f1f5f9" />
3631 + <rect x="59" y="19" width="16" height="16" rx="2" fill="#60a5fa" />
3632 + <rect x="79" y="21" width="20" height="3" rx="1.5" fill="#94a3b8" />
3633 + <rect x="79" y="27" width="12" height="2.5" rx="1.25" fill="#cbd5e1" />
3634 + <rect x="79" y="32" width="9" height="3" rx="1.5" fill="#475569" />
3635 + <rect x="93" y="31.5" width="6" height="4" rx="1.5" fill="#3b82f6" />
3636 + </g>
3637 + ))}
3638 + </svg>
3639 + ),
3640 + },
3641 + {
3642 + value: "compact_all",
3643 + label: __("Compact — everywhere", "yatra"),
3644 + sub: __("Dense on mobile & desktop", "yatra"),
3645 + svg: (
3646 + <svg
3647 + viewBox="0 0 160 100"
3648 + className="w-full h-full"
3649 + fill="none"
3650 + >
3651 + <rect x="10" y="12" width="98" height="58" rx="4" fill="#ffffff" stroke="#cbd5e1" strokeWidth="2" />
3652 + <rect x="51" y="70" width="16" height="8" fill="#cbd5e1" />
3653 + <rect x="39" y="78" width="40" height="4" rx="2" fill="#cbd5e1" />
3654 + {[0, 24].map((dy) => (
3655 + <g key={dy} transform={`translate(0,${dy})`}>
3656 + <rect x="16" y="18" width="86" height="20" rx="3" fill="#f1f5f9" />
3657 + <rect x="19" y="21" width="14" height="14" rx="2" fill="#60a5fa" />
3658 + <rect x="37" y="23" width="34" height="3" rx="1.5" fill="#94a3b8" />
3659 + <rect x="37" y="29" width="20" height="2.5" rx="1.25" fill="#cbd5e1" />
3660 + <rect x="86" y="27" width="12" height="6" rx="2" fill="#3b82f6" />
3661 + </g>
3662 + ))}
3663 + <rect x="120" y="26" width="30" height="62" rx="5" fill="#ffffff" stroke="#cbd5e1" strokeWidth="2" />
3664 + {[0, 18, 36].map((dy) => (
3665 + <g key={dy} transform={`translate(0,${dy})`}>
3666 + <rect x="124" y="32" width="22" height="14" rx="2" fill="#f1f5f9" />
3667 + <rect x="126" y="34" width="10" height="10" rx="1.5" fill="#60a5fa" />
3668 + <rect x="138" y="36" width="7" height="2" rx="1" fill="#94a3b8" />
3669 + <rect x="138" y="40" width="5" height="2" rx="1" fill="#cbd5e1" />
3670 + </g>
3671 + ))}
3672 + </svg>
3673 + ),
3674 + },
3675 + ] as const
3676 + ).map((opt) => {
3677 + const selected =
3678 + (formData.frontend_listing_card_layout || "standard") ===
3679 + opt.value;
3680 + return (
3681 + <button
3682 + key={opt.value}
3683 + type="button"
3684 + role="radio"
3685 + aria-checked={selected}
3686 + onClick={() =>
3687 + handleFieldChange({
3688 + target: {
3689 + name: "frontend_listing_card_layout",
3690 + id: "frontend_listing_card_layout",
3691 + value: opt.value,
3692 + type: "text",
3693 + },
3694 + } as React.ChangeEvent<HTMLInputElement>)
3695 + }
3696 + className={`relative rounded-lg border-2 p-2.5 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${
3697 + selected
3698 + ? "border-blue-600 ring-1 ring-blue-600 bg-blue-50/60 dark:bg-blue-900/20"
3699 + : "border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
3700 + }`}
3701 + >
3702 + <div className="aspect-[8/5] w-full overflow-hidden rounded-md bg-gray-50 dark:bg-gray-800/60">
3703 + {opt.svg}
3704 + </div>
3705 + <div className="mt-2 text-sm font-medium text-gray-900 dark:text-white">
3706 + {opt.label}
3707 + </div>
3708 + <div className="text-xs text-gray-500 dark:text-gray-400">
3709 + {opt.sub}
3710 + </div>
3711 + {selected && (
3712 + <span className="absolute top-2 right-2 inline-flex h-5 w-5 items-center justify-center rounded-full bg-blue-600 text-white">
3713 + <svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor">
3714 + <path d="M16.7 5.3a1 1 0 010 1.4l-7 7a1 1 0 01-1.4 0l-3-3a1 1 0 011.4-1.4L9 11.6l6.3-6.3a1 1 0 011.4 0z" />
3715 + </svg>
3716 + </span>
3717 + )}
3718 + </button>
3719 + );
3720 + })}
3721 + </div>
3722 + </FormField>
4066 3723 </div>
4067 3724 </div>
4068 3725 );
4069 3726
@@ -4258,8 +3915,53 @@
4258 3915
4259 3916 {formData.enable_scheduled_payments && (
4260 3917 <>
4261 3918 <FormField
3919 + id="balance_anchor"
3920 + label={__("Balance schedule", "yatra")}
3921 + description={__(
3922 + "Whether the remaining balance is scheduled a fixed number of days after the deposit, or relative to the tour start date.",
3923 + "yatra",
3924 + )}
3925 + >
3926 + <Select
3927 + id="balance_anchor"
3928 + name="balance_anchor"
3929 + value={formData.balance_anchor}
3930 + onChange={handleFieldChange}
3931 + >
3932 + <option value="booking">
3933 + {__("A fixed number of days after the deposit", "yatra")}
3934 + </option>
3935 + <option value="tour">
3936 + {__("A number of days before the tour date", "yatra")}
3937 + </option>
3938 + </Select>
3939 + </FormField>
3940 +
3941 + {formData.balance_anchor === "tour" && (
3942 + <FormField
3943 + id="balance_due_days"
3944 + label={__("Days before tour", "yatra")}
3945 + description={__(
3946 + "The balance is collected this many days before the tour starts. Bookings made inside this window must pay in full up front.",
3947 + "yatra",
3948 + )}
3949 + >
3950 + <Input
3951 + id="balance_due_days"
3952 + type="number"
3953 + min={1}
3954 + max={365}
3955 + value={formData.balance_due_days}
3956 + name="balance_due_days"
3957 + onChange={handleFieldChange}
3958 + className="max-w-xs"
3959 + />
3960 + </FormField>
3961 + )}
3962 +
3963 + <FormField
4262 3964 id="scheduled_payment_type"
4263 3965 label={__("Schedule type", "yatra")}
4264 3966 description={__(
4265 3967 "Single final charge on a date, or multiple installments.",
@@ -4280,27 +3982,29 @@
4280 3982 </option>
4281 3983 </Select>
4282 3984 </FormField>
4283 3985
4284 - <FormField
4285 - id="scheduled_payment_days"
4286 - label={__("Days until first charge", "yatra")}
4287 - description={__(
4288 - "How many days after the initial payment to schedule the first balance collection.",
4289 - "yatra",
4290 - )}
4291 - >
4292 - <Input
3986 + {formData.balance_anchor !== "tour" && (
3987 + <FormField
4293 3988 id="scheduled_payment_days"
4294 - type="number"
4295 - min={0}
4296 - max={365}
4297 - value={formData.scheduled_payment_days}
4298 - name="scheduled_payment_days"
4299 - onChange={handleFieldChange}
4300 - className="max-w-xs"
4301 - />
4302 - </FormField>
3989 + label={__("Days until first charge", "yatra")}
3990 + description={__(
3991 + "How many days after the initial payment to schedule the first balance collection.",
3992 + "yatra",
3993 + )}
3994 + >
3995 + <Input
3996 + id="scheduled_payment_days"
3997 + type="number"
3998 + min={0}
3999 + max={365}
4000 + value={formData.scheduled_payment_days}
4001 + name="scheduled_payment_days"
4002 + onChange={handleFieldChange}
4003 + className="max-w-xs"
4004 + />
4005 + </FormField>
4006 + )}
4303 4007
4304 4008 {formData.scheduled_payment_type === "installments" && (
4305 4009 <>
4306 4010 <FormField
@@ -4733,8 +4437,117 @@
4733 4437 return null;
4734 4438 }
4735 4439 }
4736 4440
4441 + // Read-only webhook endpoint URL for THIS
4442 + // site, with a copy button + setup steps.
4443 + // The operator pastes this into the gateway
4444 + // dashboard; it is display-only (never saved).
4445 + if (field.type === "webhook_url") {
4446 + const webhookUrl = String(
4447 + field.default || config[field.id] || "",
4448 + );
4449 + const stripeSetupUrl =
4450 + formData.payment_test_mode
4451 + ? field.help_url_test ||
4452 + field.help_url_live
4453 + : field.help_url_live ||
4454 + field.help_url_test;
4455 + return (
4456 + <FormField
4457 + key={field.id}
4458 + id={`${gatewayId}_${field.id}`}
4459 + label={field.label}
4460 + >
4461 + <div className="flex gap-2">
4462 + <Input
4463 + readOnly
4464 + value={webhookUrl}
4465 + onFocus={(e) =>
4466 + e.currentTarget.select()
4467 + }
4468 + className="font-mono text-xs"
4469 + />
4470 + <Button
4471 + type="button"
4472 + variant="outline"
4473 + size="sm"
4474 + onClick={(e) => {
4475 + const btn = e.currentTarget;
4476 + const input =
4477 + btn.parentElement?.querySelector(
4478 + "input",
4479 + );
4480 + let copied = false;
4481 + // execCommand path works even on
4482 + // non-HTTPS admin origins where the
4483 + // async Clipboard API is unavailable.
4484 + try {
4485 + if (input) {
4486 + input.focus();
4487 + input.select();
4488 + input.setSelectionRange(
4489 + 0,
4490 + webhookUrl.length,
4491 + );
4492 + copied =
4493 + document.execCommand(
4494 + "copy",
4495 + );
4496 + }
4497 + } catch {
4498 + /* ignore */
4499 + }
4500 + if (
4501 + navigator.clipboard?.writeText
4502 + ) {
4503 + navigator.clipboard
4504 + .writeText(webhookUrl)
4505 + .catch(() => {});
4506 + copied = true;
4507 + }
4508 + const orig = __(
4509 + "Copy",
4510 + "yatra",
4511 + );
4512 + btn.textContent = copied
4513 + ? __("Copied!", "yatra")
4514 + : __(
4515 + "Select & copy",
4516 + "yatra",
4517 + );
4518 + window.setTimeout(() => {
4519 + btn.textContent = orig;
4520 + }, 1500);
4521 + }}
4522 + >
4523 + {__("Copy", "yatra")}
4524 + </Button>
4525 + </div>
4526 + {field.description && (
4527 + <p className="mt-1.5 text-xs text-gray-500 dark:text-gray-400 leading-relaxed">
4528 + {field.description}
4529 + </p>
4530 + )}
4531 + {stripeSetupUrl && (
4532 + <a
4533 + href={stripeSetupUrl}
4534 + target="_blank"
4535 + rel="noopener noreferrer"
4536 + className="mt-1.5 inline-block text-xs font-medium text-blue-600 dark:text-blue-400 border-b border-blue-600 dark:border-blue-400 hover:border-transparent"
4537 + >
4538 + {field.help_text ||
4539 + __(
4540 + "Open Stripe → Webhooks",
4541 + "yatra",
4542 + )}{" "}
4543 + →
4544 + </a>
4545 + )}
4546 + </FormField>
4547 + );
4548 + }
4549 +
4737 4550 // Render checkbox fields differently
4738 4551 if (field.type === "checkbox") {
4739 4552 return (
4740 4553 <div
@@ -5015,8 +4828,53 @@
5015 4828 </div>
5016 4829 );
5017 4830 }
5018 4831
4832 + // Read-only display field (e.g. PayPal webhook URL): show the
4833 + // value with a Copy button; not editable, never saved.
4834 + if (field.readonly) {
4835 + const roValue = String(
4836 + config[field.id] ?? field.default ?? "",
4837 + );
4838 + return (
4839 + <div
4840 + key={field.id}
4841 + className="space-y-2"
4842 + >
4843 + <FormField
4844 + id={`${gatewayId}_${field.id}`}
4845 + label={field.label}
4846 + description={field.description}
4847 + >
4848 + <div className="flex items-center gap-2">
4849 + <Input
4850 + id={`${gatewayId}_${field.id}`}
4851 + value={roValue}
4852 + readOnly
4853 + className="bg-gray-50 dark:bg-gray-900 font-mono text-sm"
4854 + />
4855 + <Button
4856 + variant="outline"
4857 + size="sm"
4858 + onClick={() =>
4859 + navigator.clipboard.writeText(
4860 + roValue,
4861 + )
4862 + }
4863 + >
4864 + {__("Copy", "yatra")}
4865 + </Button>
4866 + </div>
4867 + </FormField>
4868 + {field.help_text && (
4869 + <p className="text-xs text-blue-600 dark:text-blue-400">
4870 + {field.help_text}
4871 + </p>
4872 + )}
4873 + </div>
4874 + );
4875 + }
4876 +
5019 4877 // Render text, password, number, email inputs
5020 4878 return (
5021 4879 <div key={field.id} className="space-y-2">
5022 4880 <FormField
@@ -5038,12 +4896,21 @@
5038 4896 onChange={(e) =>
5039 4897 handleGatewayConfigChange(
5040 4898 gatewayId,
5041 4899 field.id,
4900 + // Keep an empty field empty while
4901 + // editing so the last character can
4902 + // be cleared and retyped. Coercing
4903 + // "" to 0 (parseFloat("") || 0) made
4904 + // the field snap back and blocked
4905 + // re-entry. Numbers are coerced on
4906 + // save; "" persists harmlessly.
5042 4907 field.type === "number"
5043 - ? parseFloat(
5044 - e.target.value,
5045 - ) || 0
4908 + ? e.target.value === ""
4909 + ? ""
4910 + : parseFloat(
4911 + e.target.value,
4912 + ) || 0
5046 4913 : e.target.value,
5047 4914 )
5048 4915 }
5049 4916 placeholder={field.placeholder}
@@ -5106,57 +4973,162 @@
5106 4973 </div>
5107 4974 </>
5108 4975 );
5109 4976
5110 - // Continue with other sections... (I'll add the most important ones)
5111 - case "booking":
4977 + case "search":
5112 4978 return (
5113 4979 <div className="space-y-6">
5114 - <div className="space-y-4">
4980 + <div>
4981 + <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
4982 + {__("Search Bar Fields", "yatra")}
4983 + </h3>
4984 + <p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
4985 + {__(
4986 + "Choose which fields appear in the trip search bar. Turn off the ones you don't need for a cleaner search.",
4987 + "yatra",
4988 + )}
4989 + </p>
4990 + <div className="space-y-4">
4991 + {[
4992 + {
4993 + key: "search_show_keyword" as const,
4994 + label: __("Keyword search", "yatra"),
4995 + description: __(
4996 + "Free-text field to search by trip name or keyword.",
4997 + "yatra",
4998 + ),
4999 + },
5000 + {
5001 + key: "search_show_destination" as const,
5002 + label: __("Destination", "yatra"),
5003 + description: __(
5004 + "Dropdown to filter trips by destination.",
5005 + "yatra",
5006 + ),
5007 + },
5008 + {
5009 + key: "search_show_activities" as const,
5010 + label: __("Activities", "yatra"),
5011 + description: __(
5012 + "Dropdown to filter trips by activity.",
5013 + "yatra",
5014 + ),
5015 + },
5016 + {
5017 + key: "search_show_duration" as const,
5018 + label: __("Duration", "yatra"),
5019 + description: __(
5020 + "Range slider to filter trips by number of days.",
5021 + "yatra",
5022 + ),
5023 + },
5024 + {
5025 + key: "search_show_budget" as const,
5026 + label: __("Budget", "yatra"),
5027 + description: __(
5028 + "Dropdown to filter trips by price range.",
5029 + "yatra",
5030 + ),
5031 + },
5032 + {
5033 + key: "search_show_date" as const,
5034 + label: __("Date", "yatra"),
5035 + description: __(
5036 + "Date picker to find trips with a departure on a specific date.",
5037 + "yatra",
5038 + ),
5039 + },
5040 + ].map((row) => (
5041 + <div
5042 + key={row.key}
5043 + className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"
5044 + >
5045 + <input
5046 + type="checkbox"
5047 + id={row.key}
5048 + checked={!!formData[row.key]}
5049 + name={row.key}
5050 + onChange={handleFieldChange}
5051 + className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
5052 + />
5053 + <div className="flex-1">
5054 + <Label
5055 + htmlFor={row.key}
5056 + className="font-medium cursor-pointer"
5057 + >
5058 + {row.label}
5059 + </Label>
5060 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
5061 + {row.description}
5062 + </p>
5063 + </div>
5064 + </div>
5065 + ))}
5066 + </div>
5067 + </div>
5068 +
5069 + <div>
5070 + <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
5071 + {__("Mobile Filters", "yatra")}
5072 + </h3>
5073 + <p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
5074 + {__(
5075 + "Controls the filter sidebar on the trip listing page on mobile devices.",
5076 + "yatra",
5077 + )}
5078 + </p>
5115 5079 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
5116 5080 <input
5117 5081 type="checkbox"
5118 - id="booking_confirmation"
5119 - checked={formData.booking_confirmation}
5120 - name="booking_confirmation"
5082 + id="collapse_filters_on_mobile"
5083 + checked={!!formData.collapse_filters_on_mobile}
5084 + name="collapse_filters_on_mobile"
5121 5085 onChange={handleFieldChange}
5122 5086 className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
5123 5087 />
5124 5088 <div className="flex-1">
5125 5089 <Label
5126 - htmlFor="booking_confirmation"
5090 + htmlFor="collapse_filters_on_mobile"
5127 5091 className="font-medium cursor-pointer"
5128 5092 >
5129 - {__("Send Booking Confirmation Email", "yatra")}
5093 + {__("Collapse filters by default on mobile", "yatra")}
5130 5094 </Label>
5131 5095 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
5132 5096 {__(
5133 - "Send a confirmation email to the customer immediately after a successful booking. The post-checkout page is always shown; this toggle only controls the email.",
5097 + "On mobile, show filter sections collapsed so trip listings are visible right away. Visitors tap a section to expand it. Desktop is unaffected.",
5134 5098 "yatra",
5135 5099 )}
5136 5100 </p>
5137 5101 </div>
5138 5102 </div>
5103 + </div>
5104 + </div>
5105 + );
5139 5106
5107 + // Continue with other sections... (I'll add the most important ones)
5108 + case "booking":
5109 + return (
5110 + <div className="space-y-6">
5111 + <div className="space-y-4">
5140 5112 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
5141 5113 <input
5142 5114 type="checkbox"
5143 - id="auto_confirm_bookings"
5144 - checked={formData.auto_confirm_bookings}
5145 - name="auto_confirm_bookings"
5115 + id="booking_confirmation"
5116 + checked={formData.booking_confirmation}
5117 + name="booking_confirmation"
5146 5118 onChange={handleFieldChange}
5147 5119 className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
5148 5120 />
5149 5121 <div className="flex-1">
5150 5122 <Label
5151 - htmlFor="auto_confirm_bookings"
5123 + htmlFor="booking_confirmation"
5152 5124 className="font-medium cursor-pointer"
5153 5125 >
5154 - {__("Auto-Confirm Bookings", "yatra")}
5126 + {__("Send Booking Confirmation Email", "yatra")}
5155 5127 </Label>
5156 5128 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
5157 5129 {__(
5158 - "Automatically confirm bookings without manual approval",
5130 + "Send a confirmation email to the customer immediately after a successful booking. The post-checkout page is always shown; this toggle only controls the email.",
5159 5131 "yatra",
5160 5132 )}
5161 5133 </p>
5162 5134 </div>
@@ -5161,8 +5133,85 @@
5161 5133 </p>
5162 5134 </div>
5163 5135 </div>
5164 5136
5137 + <div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
5138 + <div className="font-medium">
5139 + {__("Auto-Confirm Bookings", "yatra")}
5140 + </div>
5141 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 mb-2">
5142 + {__(
5143 + "Choose when a booking is automatically set to Confirmed. Offline methods (bank transfer, pay later) stay pending until you confirm them.",
5144 + "yatra",
5145 + )}
5146 + </p>
5147 + <div role="radiogroup" className="flex flex-col gap-2">
5148 + {[
5149 + {
5150 + value: "none",
5151 + label: __("Don't auto-confirm", "yatra"),
5152 + sub: __(
5153 + "Every booking stays Pending until you confirm it manually.",
5154 + "yatra",
5155 + ),
5156 + },
5157 + {
5158 + value: "online",
5159 + label: __("Auto-confirm online payments only", "yatra"),
5160 + sub: __(
5161 + "When an online payment (Stripe, PayPal, Razorpay…) pays the balance in full, the booking is confirmed. Deposits/partial payments and offline methods stay Pending.",
5162 + "yatra",
5163 + ),
5164 + },
5165 + {
5166 + value: "all",
5167 + label: __("Auto-confirm all bookings", "yatra"),
5168 + sub: __(
5169 + "Every booking is confirmed at checkout, whether or not it was paid.",
5170 + "yatra",
5171 + ),
5172 + },
5173 + ].map((opt) => (
5174 + <label
5175 + key={opt.value}
5176 + htmlFor={`auto_confirm_mode_${opt.value}`}
5177 + className="flex items-start gap-2 cursor-pointer"
5178 + >
5179 + <input
5180 + type="radio"
5181 + id={`auto_confirm_mode_${opt.value}`}
5182 + name="auto_confirm_mode"
5183 + value={opt.value}
5184 + checked={
5185 + (formData.auto_confirm_mode || "none") === opt.value
5186 + }
5187 + onChange={() =>
5188 + setFormData((prev) =>
5189 + prev
5190 + ? {
5191 + ...prev,
5192 + auto_confirm_mode:
5193 + opt.value as SettingsData["auto_confirm_mode"],
5194 + // Keep the legacy boolean in sync for any
5195 + // external reader (all → true, otherwise false).
5196 + auto_confirm_bookings: opt.value === "all",
5197 + }
5198 + : prev,
5199 + )
5200 + }
5201 + className="mt-0.5 w-4 h-4 border-gray-300 text-blue-600 focus:ring-blue-500"
5202 + />
5203 + <div className="flex-1">
5204 + <span className="font-medium text-sm">{opt.label}</span>
5205 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
5206 + {opt.sub}
5207 + </p>
5208 + </div>
5209 + </label>
5210 + ))}
5211 + </div>
5212 + </div>
5213 +
5165 5214 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
5166 5215 <input
5167 5216 type="checkbox"
5168 5217 id="require_login"
@@ -5251,8 +5300,54 @@
5251 5300
5252 5301 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
5253 5302 <input
5254 5303 type="checkbox"
5304 + id="show_sold_out"
5305 + checked={formData.show_sold_out !== false}
5306 + name="show_sold_out"
5307 + onChange={handleFieldChange}
5308 + className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
5309 + />
5310 + <div className="flex-1">
5311 + <Label
5312 + htmlFor="show_sold_out"
5313 + className="font-medium cursor-pointer"
5314 + >
5315 + {__("Show sold-out dates", "yatra")}
5316 + </Label>
5317 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
5318 + {__(
5319 + "Keep sold-out dates on the calendar marked \"Sold out\" — needed for the waitlist below. Turn this off to hide them entirely, the way blocked dates are hidden.",
5320 + "yatra",
5321 + )}
5322 + </p>
5323 + </div>
5324 + </div>
5325 +
5326 + <div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
5327 + <FormField
5328 + id="availability_horizon_months"
5329 + label={__("Booking horizon (months)", "yatra")}
5330 + description={__(
5331 + "How far ahead customers can see and book dates on the storefront calendar — 1 to 36 months (default 12). A trip whose Available To date is earlier stops there. Long horizons make flexible-booking trips (no dates or rules configured) load more dates per page.",
5332 + "yatra",
5333 + )}
5334 + >
5335 + <Input
5336 + id="availability_horizon_months"
5337 + type="number"
5338 + value={formData.availability_horizon_months ?? 12}
5339 + name="availability_horizon_months"
5340 + onChange={handleFieldChange}
5341 + min="1"
5342 + max="36"
5343 + />
5344 + </FormField>
5345 + </div>
5346 +
5347 + <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
5348 + <input
5349 + type="checkbox"
5255 5350 id="allow_waitlist"
5256 5351 checked={formData.allow_waitlist}
5257 5352 name="allow_waitlist"
5258 5353 onChange={handleFieldChange}
@@ -5844,9 +5939,9 @@
5844 5939 <FormField
5845 5940 id="review_reminder_days"
5846 5941 label={__("Review Reminder Days", "yatra")}
5847 5942 description={__(
5848 - "Send review reminder email this many days after trip completion",
5943 + "Send the review request this many days after the trip ends. Set to 0 to never send one.",
5849 5944 "yatra",
5850 5945 )}
5851 5946 >
5852 5947 <Input
@@ -5857,8 +5952,26 @@
5857 5952 onChange={handleFieldChange}
5858 5953 min="0"
5859 5954 />
5860 5955 </FormField>
5956 +
5957 + <FormField
5958 + id="review_reminder_max_age_days"
5959 + label={__("Don't Ask About Trips Older Than", "yatra")}
5960 + description={__(
5961 + "Days. A trip that ended longer ago than this is never asked about — so marking a backlog of old bookings complete does not email those customers.",
5962 + "yatra",
5963 + )}
5964 + >
5965 + <Input
5966 + id="review_reminder_max_age_days"
5967 + type="number"
5968 + value={formData.review_reminder_max_age_days}
5969 + name="review_reminder_max_age_days"
5970 + onChange={handleFieldChange}
5971 + min="0"
5972 + />
5973 + </FormField>
5861 5974 </>
5862 5975 )}
5863 5976 </div>
5864 5977 </div>
@@ -7673,8 +7786,93 @@
7673 7786 onChange={handleFieldChange}
7674 7787 placeholder={__("Enter secret key", "yatra")}
7675 7788 />
7676 7789 </FormField>
7790 +
7791 + <p className="text-xs text-gray-500 dark:text-gray-400">
7792 + {__(
7793 + "Uses reCAPTCHA v3 (invisible, score-based). Create v3 keys at google.com/recaptcha and paste them above.",
7794 + "yatra",
7795 + )}
7796 + </p>
7797 +
7798 + <FormField
7799 + id="recaptcha_score_threshold"
7800 + label={__("Score Threshold", "yatra")}
7801 + description={__(
7802 + "Minimum v3 score (0.0–1.0) to accept a submission. Lower is more lenient, higher is stricter. Default 0.5.",
7803 + "yatra",
7804 + )}
7805 + >
7806 + <Input
7807 + id="recaptcha_score_threshold"
7808 + type="number"
7809 + min="0"
7810 + max="1"
7811 + step="0.1"
7812 + value={String(formData.recaptcha_score_threshold ?? 0.5)}
7813 + name="recaptcha_score_threshold"
7814 + onChange={(e) =>
7815 + setFormData((prev) =>
7816 + prev
7817 + ? {
7818 + ...prev,
7819 + recaptcha_score_threshold: parseFloat(
7820 + e.target.value,
7821 + ),
7822 + }
7823 + : prev,
7824 + )
7825 + }
7826 + />
7827 + </FormField>
7828 +
7829 + <div className="pt-2">
7830 + <Label className="font-medium">
7831 + {__("Protect these forms", "yatra")}
7832 + </Label>
7833 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 mb-2">
7834 + {__(
7835 + "Choose which forms reCAPTCHA should verify. Booking protection is off by default to avoid blocking a legitimate payment on a low score.",
7836 + "yatra",
7837 + )}
7838 + </p>
7839 + <div className="space-y-2">
7840 + {[
7841 + {
7842 + key: "recaptcha_protect_enquiry",
7843 + label: __("Enquiry form", "yatra"),
7844 + },
7845 + {
7846 + key: "recaptcha_protect_booking",
7847 + label: __("Booking / checkout form", "yatra"),
7848 + },
7849 + {
7850 + key: "recaptcha_protect_registration",
7851 + label: __("Customer registration", "yatra"),
7852 + },
7853 + ].map((f) => (
7854 + <label
7855 + key={f.key}
7856 + className="flex items-center gap-2 cursor-pointer"
7857 + >
7858 + <input
7859 + type="checkbox"
7860 + id={f.key}
7861 + name={f.key}
7862 + checked={Boolean(
7863 + (formData as unknown as Record<string, unknown>)[
7864 + f.key
7865 + ],
7866 + )}
7867 + onChange={handleFieldChange}
7868 + className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
7869 + />
7870 + <span className="text-sm">{f.label}</span>
7871 + </label>
7872 + ))}
7873 + </div>
7874 + </div>
7677 7875 </>
7678 7876 )}
7679 7877 </div>
7680 7878 </div>
@@ -8367,8 +8565,232 @@
8367 8565 )}
8368 8566 </div>
8369 8567 </FormField>
8370 8568 </div>
8569 + </div>
8570 +
8571 + <div>
8572 + <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">
8573 + {__("Sitemap", "yatra")}
8574 + </h3>
8575 + <p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
8576 + {__(
8577 + "Yatra publishes one sitemap of all trips, destinations, activities and categories so search engines can find them. It is advertised in robots.txt and linked into Yoast, Rank Math, AIOSEO and WordPress core sitemaps automatically.",
8578 + "yatra",
8579 + )}
8580 + </p>
8581 + <div className="space-y-4">
8582 + <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
8583 + <input
8584 + type="checkbox"
8585 + id="enable_sitemap"
8586 + checked={formData.enable_sitemap}
8587 + name="enable_sitemap"
8588 + onChange={handleFieldChange}
8589 + className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
8590 + />
8591 + <div className="flex-1">
8592 + <Label
8593 + htmlFor="enable_sitemap"
8594 + className="font-medium cursor-pointer"
8595 + >
8596 + {__("Enable Sitemap", "yatra")}
8597 + </Label>
8598 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
8599 + {__(
8600 + "Generate /yatra-sitemap.xml and advertise it to search engines.",
8601 + "yatra",
8602 + )}
8603 + </p>
8604 + </div>
8605 + </div>
8606 + {formData.enable_sitemap && (
8607 + <div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
8608 + <Label className="font-medium">
8609 + {__("Sitemap URL", "yatra")}
8610 + </Label>
8611 + <div className="mt-2 flex items-center gap-2">
8612 + <a
8613 + href={
8614 + (window as { yatraAdmin?: { sitemapUrl?: string } })
8615 + .yatraAdmin?.sitemapUrl || "#"
8616 + }
8617 + target="_blank"
8618 + rel="noopener noreferrer"
8619 + className="flex-1 break-all text-sm text-blue-600 dark:text-blue-400 underline"
8620 + >
8621 + {(window as { yatraAdmin?: { sitemapUrl?: string } })
8622 + .yatraAdmin?.sitemapUrl || ""}
8623 + </a>
8624 + <button
8625 + type="button"
8626 + onClick={() =>
8627 + navigator.clipboard?.writeText(
8628 + (window as { yatraAdmin?: { sitemapUrl?: string } })
8629 + .yatraAdmin?.sitemapUrl || "",
8630 + )
8631 + }
8632 + className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
8633 + >
8634 + {__("Copy", "yatra")}
8635 + </button>
8636 + </div>
8637 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
8638 + {__(
8639 + "Optional: submit this URL in Google Search Console or Bing Webmaster Tools. It is already listed in robots.txt, so search engines find it automatically.",
8640 + "yatra",
8641 + )}
8642 +
8643 + {formData.enable_sitemap && (
8644 + <div className="p-3 border border-gray-200 dark:border-gray-700 rounded-md space-y-3">
8645 + <div>
8646 + <Label className="font-medium">
8647 + {__("Content in the sitemap", "yatra")}
8648 + </Label>
8649 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
8650 + {__(
8651 + "Uncheck a type to keep it out of /yatra-sitemap.xml.",
8652 + "yatra",
8653 + )}
8654 + </p>
8655 + </div>
8656 + <div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
8657 + {[
8658 + { key: "trip", label: __("Trips", "yatra") },
8659 + { key: "destination", label: __("Destinations", "yatra") },
8660 + { key: "activity", label: __("Activities", "yatra") },
8661 + { key: "category", label: __("Trip categories", "yatra") },
8662 + { key: "archive", label: __("Listing pages", "yatra") },
8663 + ].map((t) => {
8664 + const selected = formData.sitemap_types ?? [];
8665 + const checked = selected.includes(t.key);
8666 + return (
8667 + <label
8668 + key={t.key}
8669 + className="flex items-center gap-2 text-sm cursor-pointer"
8670 + >
8671 + <input
8672 + type="checkbox"
8673 + checked={checked}
8674 + onChange={() =>
8675 + setFormData((prev) => {
8676 + if (!prev) return prev;
8677 + return {
8678 + ...prev,
8679 + sitemap_types: checked
8680 + ? (prev.sitemap_types ?? []).filter(
8681 + (x: string) => x !== t.key,
8682 + )
8683 + : [...(prev.sitemap_types ?? []), t.key],
8684 + };
8685 + })
8686 + }
8687 + className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
8688 + />
8689 + <span>{t.label}</span>
8690 + </label>
8691 + );
8692 + })}
8693 + </div>
8694 +
8695 + <div className="flex items-start gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
8696 + <input
8697 + type="checkbox"
8698 + id="sitemap_noindex_excluded"
8699 + name="sitemap_noindex_excluded"
8700 + checked={formData.sitemap_noindex_excluded}
8701 + onChange={(e) =>
8702 + setFormData((prev) => {
8703 + if (!prev) return prev;
8704 + return {
8705 + ...prev,
8706 + sitemap_noindex_excluded: e.target.checked,
8707 + };
8708 + })
8709 + }
8710 + className="mt-0.5 w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
8711 + />
8712 + <div className="flex-1">
8713 + <Label
8714 + htmlFor="sitemap_noindex_excluded"
8715 + className="font-medium cursor-pointer"
8716 + >
8717 + {__("Also add noindex to excluded types", "yatra")}
8718 + </Label>
8719 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
8720 + {__(
8721 + "Leaving a type out of the sitemap does not stop Google indexing it. Turn this on to send noindex on those pages as well — they will drop out of search results, so only do this for content you do not want found.",
8722 + "yatra",
8723 + )}
8724 + </p>
8725 + </div>
8726 + </div>
8727 + </div>
8728 + )}
8729 + </p>
8730 + </div>
8731 + )}
8732 + </div>
8733 + </div>
8734 + </div>
8735 + );
8736 +
8737 + case "uninstall":
8738 + return (
8739 + <div className="space-y-6">
8740 + <div className="space-y-4">
8741 + <div className="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-300">
8742 + {__(
8743 + "This only applies when Yatra is deleted from the Plugins screen. Deactivating the plugin never removes anything.",
8744 + "yatra",
8745 + )}
8746 + </div>
8747 +
8748 + <div className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
8749 + <input
8750 + type="checkbox"
8751 + id="delete_data_on_uninstall"
8752 + checked={formData.delete_data_on_uninstall}
8753 + name="delete_data_on_uninstall"
8754 + onChange={handleFieldChange}
8755 + className="mt-1 w-4 h-4 rounded border-gray-300 text-red-600 focus:ring-red-500"
8756 + />
8757 + <div className="flex-1">
8758 + <Label
8759 + htmlFor="delete_data_on_uninstall"
8760 + className="font-medium cursor-pointer"
8761 + >
8762 + {__("Delete all data on uninstall", "yatra")}
8763 + </Label>
8764 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
8765 + {__(
8766 + "Permanently remove every Yatra database table, setting and scheduled task when the plugin is deleted. Yatra Pro follows the same switch and removes its own data too.",
8767 + "yatra",
8768 + )}
8769 + </p>
8770 + </div>
8771 + </div>
8772 +
8773 + {formData.delete_data_on_uninstall ? (
8774 + <div className="rounded-md border border-red-300 bg-red-50 p-3 dark:border-red-900 dark:bg-red-950/40">
8775 + <p className="text-sm font-semibold text-red-800 dark:text-red-200">
8776 + {__("This cannot be undone", "yatra")}
8777 + </p>
8778 + <p className="text-xs text-red-700 dark:text-red-300 mt-1">
8779 + {__(
8780 + "Trips, bookings, customers, payments, reviews and enquiries are destroyed along with the plugin. Take a backup, or use Tools to export first, if you may want this data again.",
8781 + "yatra",
8782 + )}
8783 + </p>
8784 + </div>
8785 + ) : (
8786 + <div className="rounded-md border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400">
8787 + {__(
8788 + "Your data is kept when Yatra is deleted, so reinstalling restores everything as it was.",
8789 + "yatra",
8790 + )}
8791 + </div>
8792 + )}
8371 8793 </div>
8372 8794 </div>
8373 8795 );
8374 8796