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 +2225 -1314 3.0.3 → 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,
@@ -44,13 +45,15 @@
44 45 X,
45 46 TrendingUp,
46 47 BarChart3,
47 48 Palette,
49 + Percent,
48 50 } from "lucide-react";
49 -import { __ } from "../lib/i18n";
51 +import { __, sprintf } from "../lib/i18n";
50 52 import { prepareWordPressMediaFrameOpen } from "../lib/wp-media-open";
51 53 import { usePermissions } from "../hooks/usePermissions";
52 54 import { useToast } from "../components/ui/toast";
55 +import { navigateMenu } from "../hooks/useNavigate";
53 56 import { apiClient, apiService } from "../lib/api-client";
54 57 import {
55 58 fetchBookingPageShortcodeStatus,
56 59 fetchPaymentGatewayDefinitions,
@@ -89,8 +92,20 @@
89 92 } from "../lib/frontend-permalink-urls";
90 93 import { getCurrencyOptions } from "../data/currencies";
91 94 import { SearchableSelect } from "../components/ui/searchable-select";
92 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";
93 108
94 109 // Multiple Taxes Editor Component
95 110 const MultipleTaxesEditor = React.memo(
96 111 ({
@@ -224,8 +239,11 @@
224 239 }> = ({ formData, setFormData }) => {
225 240 const yatraAdmin = (window as any).yatraAdmin || {};
226 241 const gcSettings = yatraAdmin.googleCalendar || {};
227 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`;
228 246
229 247 // Use state from parent formData if available, otherwise use from yatraAdmin
230 248 const [clientId, setClientId] = useState(
231 249 formData.google_calendar_client_id || gcSettings.client_id || "",
@@ -243,8 +261,10 @@
243 261 formData.google_calendar_connected || gcSettings.connected || false,
244 262 );
245 263 const [connecting, setConnecting] = useState(false);
246 264 const [syncing, setSyncing] = useState(false);
265 + const [savingCalendar, setSavingCalendar] = useState(false);
266 + const { showToast } = useToast();
247 267
248 268 const redirectUri =
249 269 yatraAdmin.googleCalendarRedirectUri ||
250 270 gcSettings.redirect_uri ||
@@ -258,8 +278,13 @@
258 278 const allSettings = {
259 279 google_calendar_client_id: clientId,
260 280 google_calendar_client_secret: clientSecret,
261 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,
262 287 };
263 288
264 289 // Update formData with all settings
265 290 setFormData((prev) => {
@@ -273,9 +298,10 @@
273 298
274 299 // Update formData when any setting changes
275 300 useEffect(() => {
276 301 syncSettingsToFormData();
277 - }, [clientId, clientSecret]);
302 + // eslint-disable-next-line react-hooks/exhaustive-deps
303 + }, [clientId, clientSecret, calendarId, calendarName]);
278 304
279 305 const handleConnect = async () => {
280 306 setConnecting(true);
281 307 try {
@@ -307,10 +333,14 @@
307 333 // Update settings state
308 334 setConnected(false);
309 335 setCalendarId("");
310 336 setCalendarName("");
337 + showToast(__("Disconnected from Google Calendar.", "yatra"), "success");
311 338 } catch (error) {
312 - console.error("Failed to disconnect:", error);
339 + showToast(
340 + __("Failed to disconnect. Please try again.", "yatra"),
341 + "error",
342 + );
313 343 }
314 344 };
315 345
316 346 const handleSync = async () => {
@@ -315,18 +345,52 @@
315 345
316 346 const handleSync = async () => {
317 347 setSyncing(true);
318 348 try {
319 - await apiClient.post("/google-calendar/sync-all");
320 - // No need to update lastSync state directly as it's derived from formData
321 - // 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 + }
322 362 } catch (error) {
323 - console.error("Failed to sync:", error);
363 + showToast(
364 + __("Failed to sync bookings. Please try again.", "yatra"),
365 + "error",
366 + );
324 367 } finally {
325 368 setSyncing(false);
326 369 }
327 370 };
328 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 +
329 393 return (
330 394 <div className="space-y-6">
331 395 {/* Settings Card */}
332 396 <Card>
@@ -504,9 +568,9 @@
504 568 <h4 className="font-medium text-sm text-green-800 dark:text-green-400 flex items-center gap-2">
505 569 <CheckCircle className="w-4 h-4" />
506 570 {__("Connected Calendar", "yatra")}
507 571 </h4>
508 - <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">
509 573 <FormField
510 574 id="gc_calendar_id"
511 575 label={__("Calendar ID", "yatra")}
512 576 description={__(
@@ -548,20 +612,42 @@
548 612 ) : (
549 613 __("Never synced", "yatra")
550 614 )}
551 615 </p>
552 - <Button
553 - variant="outline"
554 - size="sm"
555 - onClick={handleSync}
556 - disabled={syncing}
557 - className="flex items-center gap-2"
558 - >
559 - <RefreshCw
560 - className={`w-4 h-4 ${syncing ? "animate-spin" : ""}`}
561 - />
562 - {__("Sync Now", "yatra")}
563 - </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>
564 650 </div>
565 651 </div>
566 652 )}
567 653
@@ -567,8 +653,15 @@
567 653
568 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">
569 655 <div className="flex flex-wrap gap-3">
570 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
571 664 href="https://console.cloud.google.com/apis/credentials"
572 665 target="_blank"
573 666 rel="noopener noreferrer"
574 667 className="flex items-center gap-1 text-blue-600 hover:text-blue-500 dark:text-blue-400 text-sm"
@@ -631,9 +724,11 @@
631 724 | "design"
632 725 | "booking"
633 726 | "booking_form"
634 727 | "payment"
728 + | "pricing"
635 729 | "trip"
730 + | "search"
636 731 | "customer"
637 732 | "review"
638 733 | "tax"
639 734 | "currency"
@@ -639,47 +734,14 @@
639 734 | "currency"
640 735 | "integration"
641 736 | "permalink"
642 737 | "seo"
643 - | "advanced";
738 + | "advanced"
739 + | "uninstall";
644 740
645 741 // Form Builder Types
646 -interface FormFieldConfig {
647 - id: string;
648 - type:
649 - | "text"
650 - | "email"
651 - | "tel"
652 - | "date"
653 - | "select"
654 - | "country"
655 - | "textarea"
656 - | "checkbox"
657 - | "number";
658 - label: string;
659 - placeholder: string;
660 - required: boolean;
661 - enabled: boolean;
662 - order: number;
663 - width: "full" | "half" | "third";
664 - section?: string;
665 - options?: { value: string; label: string }[];
666 - locked?: boolean; // If true, field cannot be deleted and required cannot be changed
667 -}
668 -
669 -interface FormSectionConfig {
670 - title: string;
671 - description: string;
672 - enabled?: boolean;
673 - fields: FormFieldConfig[];
674 -}
675 -
676 -interface BookingFormConfig {
677 - contact_form: FormSectionConfig;
678 - emergency_contact_form: FormSectionConfig;
679 - traveler_form: FormSectionConfig;
680 -}
681 -
742 +// Booking form builder types live in components/settings/booking-form-types.ts
743 +// (shared with the per-trip Conditions popup).
682 744 interface PaymentGatewayConfig {
683 745 enabled: boolean;
684 746 icon?: string;
685 747 title?: string;
@@ -739,8 +801,10 @@
739 801 /** Hex brand color for public trip/booking/listing UI (buttons, links, accents) */
740 802 frontend_primary_color: string;
741 803 /** CSS max width for Yatra containers; empty = follow theme / theme.json */
742 804 frontend_container_max_width: string;
805 + /** Trip listing card density: "standard" | "compact_mobile" | "compact_all" */
806 + frontend_listing_card_layout: string;
743 807
744 808 // Google Calendar Settings
745 809 google_calendar_client_id?: string;
746 810 google_calendar_client_secret?: string;
@@ -749,20 +813,29 @@
749 813 google_calendar_connected?: boolean;
750 814 google_calendar_last_sync?: string | null;
751 815 google_calendar_enabled?: boolean;
752 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 +
753 826 // Booking Settings
754 827 booking_confirmation: boolean;
755 828 auto_confirm_bookings: boolean;
829 + auto_confirm_mode: "none" | "online" | "all";
756 830 require_login: boolean;
757 831 allow_guest_checkout: boolean;
758 - cancellation_policy: string;
759 - cancellation_days: number;
760 - refund_policy: string;
832 + require_guest_email_verification: boolean;
761 833 booking_expiry_hours: number;
762 834 booking_reminder_days: number;
763 835 allow_waitlist: boolean;
764 836 waitlist_auto_confirm: boolean;
837 + date_picker_as_dropdown: boolean;
765 838
766 839 // Payment Settings
767 840 currency: string;
768 841 payment_test_mode: boolean;
@@ -778,11 +851,24 @@
778 851 scheduled_payment_days: number;
779 852 scheduled_payment_installments: number;
780 853 scheduled_payment_interval: number;
781 854 scheduled_payment_reminder_days: number;
855 + balance_anchor: string;
856 + balance_due_days: number;
782 857 gateway_configs: Record<string, PaymentGatewayConfig>;
783 858 gateway_order?: string[];
784 859
860 + // Pricing Settings
861 + // Controls how Advanced Discount + Dynamic Pricing combine on the same
862 + // booking. Surfaced in Settings → Pricing only when BOTH modules are
863 + // enabled; backend enforcement is also gated by both modules being on,
864 + // so a stale 'discount_only' value on a site that disabled DP is inert.
865 + discount_stacking_mode?:
866 + | "both"
867 + | "discount_only"
868 + | "dynamic_pricing_only"
869 + | "best_for_customer";
870 +
785 871 // Email Settings
786 872 admin_email: string;
787 873 from_email: string;
788 874 from_name: string;
@@ -794,8 +880,9 @@
794 880 email_template_admin_payment: boolean;
795 881 email_template_admin_cancellation: boolean;
796 882 email_template_trip_consent: boolean;
797 883 email_template_customer_verification: boolean;
884 + email_template_guest_verification: boolean;
798 885 email_template_booking_completed: boolean;
799 886 email_template_booking_expired_customer: boolean;
800 887 email_template_admin_booking_expired: boolean;
801 888 email_template_scheduled_payment_reminder: boolean;
@@ -805,9 +892,11 @@
805 892 email_template_enquiry_received: boolean;
806 893 email_template_enquiry_admin: boolean;
807 894 email_template_enquiry_response: boolean;
808 895 email_template_review_request: boolean;
809 - email_template_abandoned_booking_recovery: boolean;
896 + email_template_abandoned_booking_recovery_first: boolean;
897 + email_template_abandoned_booking_recovery_second: boolean;
898 + email_template_abandoned_booking_recovery_final: boolean;
810 899 smtp_enabled: boolean;
811 900 smtp_host: string;
812 901 smtp_port: number;
813 902 smtp_username: string;
@@ -831,8 +920,10 @@
831 920 email_tpl_trip_consent_subject: string;
832 921 email_tpl_trip_consent_body: string;
833 922 email_tpl_customer_verification_subject: string;
834 923 email_tpl_customer_verification_body: string;
924 + email_tpl_guest_verification_subject: string;
925 + email_tpl_guest_verification_body: string;
835 926 email_tpl_booking_completed_subject: string;
836 927 email_tpl_booking_completed_body: string;
837 928 email_tpl_booking_expired_customer_subject: string;
838 929 email_tpl_booking_expired_customer_body: string;
@@ -853,10 +944,14 @@
853 944 email_tpl_enquiry_response_subject: string;
854 945 email_tpl_enquiry_response_body: string;
855 946 email_tpl_review_request_subject: string;
856 947 email_tpl_review_request_body: string;
857 - email_tpl_abandoned_booking_recovery_subject: string;
858 - email_tpl_abandoned_booking_recovery_body: string;
948 + email_tpl_abandoned_booking_recovery_first_subject: string;
949 + email_tpl_abandoned_booking_recovery_first_body: string;
950 + email_tpl_abandoned_booking_recovery_second_subject: string;
951 + email_tpl_abandoned_booking_recovery_second_body: string;
952 + email_tpl_abandoned_booking_recovery_final_subject: string;
953 + email_tpl_abandoned_booking_recovery_final_body: string;
859 954
860 955 // Customer Settings
861 956 customer_registration: boolean;
862 957 customer_fields: string[];
@@ -865,8 +960,12 @@
865 960 allow_customer_reviews: boolean;
866 961 customer_dashboard_enabled: boolean;
867 962 /** Wishlist / saved trips (Yatra Pro); toggled in Customer settings */
868 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;
869 968
870 969 // Review Settings
871 970 enable_reviews: boolean;
872 971 require_booking: boolean;
@@ -874,8 +973,9 @@
874 973 review_moderation: boolean;
875 974 min_rating: number;
876 975 allow_anonymous_reviews: boolean;
877 976 review_reminder_days: number;
977 + review_reminder_max_age_days: number;
878 978
879 979 // Tax Settings
880 980 enable_tax: boolean;
881 981 tax_name: string;
@@ -909,8 +1009,12 @@
909 1009 facebook_pixel: string;
910 1010 recaptcha_enabled: boolean;
911 1011 recaptcha_site_key: string;
912 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;
913 1017
914 1018 // Mailchimp Integration (Pro)
915 1019 mailchimp_api_key?: string;
916 1020 mailchimp_list_id?: string;
@@ -971,8 +1075,9 @@
971 1075
972 1076 // Advanced Settings
973 1077 debug_mode: boolean;
974 1078 enable_logging: boolean;
1079 + delete_data_on_uninstall: boolean;
975 1080 cache_enabled: boolean;
976 1081 api_key: string;
977 1082 api_rate_limit: number;
978 1083 session_timeout: number;
@@ -984,8 +1089,11 @@
984 1089 seo_trip_meta_title: string;
985 1090 seo_trip_meta_description: string;
986 1091 seo_trip_meta_keywords: string;
987 1092 seo_trip_meta_image: number;
1093 + enable_sitemap: boolean;
1094 + sitemap_types: string[];
1095 + sitemap_noindex_excluded: boolean;
988 1096 }
989 1097
990 1098 // Form Builder Component
991 1099 type BookingFormSubTab =
@@ -1020,11 +1128,17 @@
1020 1128 }) => {
1021 1129 const [activeFormTab, setActiveFormTab] =
1022 1130 useState<BookingFormSubTab>(getInitialFormSubTab);
1023 1131
1024 - // 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.
1025 1139 const { data: modulesData } = useQuery({
1026 - queryKey: ["modules"],
1140 + queryKey: ["modules", "raw-envelope"],
1027 1141 queryFn: async () => {
1028 1142 const response = await apiService.getModules();
1029 1143 return response;
1030 1144 },
@@ -1038,12 +1152,35 @@
1038 1152 (m: any) => m.slug === "dynamic_form_field",
1039 1153 );
1040 1154 return module?.enabled === true && module?.is_available === true;
1041 1155 }
1042 - // Fallback to yatraAdmin if modules API hasn't loaded yet
1043 - return window.yatraAdmin?.dynamicFormFieldEnabled === true;
1156 + // Fallback to yatraAdmin if modules API hasn't loaded yet.
1157 + // Truthy check (not `=== true`) because wp_localize_script
1158 + // serialises every scalar to a string before exposing it to JS —
1159 + // true becomes "1", false becomes "". Same gotcha that bit the
1160 + // Discount Stacking tab visibility earlier this session.
1161 + return !!window.yatraAdmin?.dynamicFormFieldEnabled;
1044 1162 }, [modulesData]);
1045 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 +
1046 1183 // Save sub-tab to localStorage when it changes
1047 1184 const handleSubTabChange = (tab: BookingFormSubTab) => {
1048 1185 setActiveFormTab(tab);
1049 1186 if (typeof window !== "undefined") {
@@ -1049,66 +1186,15 @@
1049 1186 if (typeof window !== "undefined") {
1050 1187 localStorage.setItem("yatra_settings_booking_form_subtab", tab);
1051 1188 }
1052 1189 };
1053 - const [editingField, setEditingField] = useState<string | null>(null);
1054 - const [showAddField, setShowAddField] = useState(false);
1055 - const [newField, setNewField] = useState<Partial<FormFieldConfig>>({
1056 - id: "",
1057 - type: "text",
1058 - label: "",
1059 - placeholder: "",
1060 - required: false,
1061 - enabled: true,
1062 - width: "full",
1063 - options: [],
1064 - });
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 + ];
1065 1195
1066 - // Delete confirmation state
1067 - const [deleteConfirm, setDeleteConfirm] = useState<{
1068 - isOpen: boolean;
1069 - fieldId: string | null;
1070 - fieldLabel: string;
1071 - }>({
1072 - isOpen: false,
1073 - fieldId: null,
1074 - fieldLabel: "",
1075 - });
1076 1196
1077 - // Drag and drop state
1078 - const [draggedFieldId, setDraggedFieldId] = useState<string | null>(null);
1079 - const [dragOverFieldId, setDragOverFieldId] = useState<string | null>(null);
1080 -
1081 - // Helper to generate ID from label
1082 - const generateIdFromLabel = (label: string): string => {
1083 - return label
1084 - .toLowerCase()
1085 - .replace(/\s+/g, "_")
1086 - .replace(/[^a-z0-9_]/g, "");
1087 - };
1088 -
1089 - // Helper to sanitize ID input
1090 - const sanitizeId = (id: string): string => {
1091 - return id
1092 - .toLowerCase()
1093 - .replace(/\s+/g, "_")
1094 - .replace(/[^a-z0-9_]/g, "");
1095 - };
1096 -
1097 - // Handle label change with auto ID generation
1098 - const handleNewFieldLabelChange = (label: string) => {
1099 - const autoId = generateIdFromLabel(label);
1100 - setNewField((prev) => ({
1101 - ...prev,
1102 - label,
1103 - // Only auto-generate ID if it hasn't been manually edited
1104 - id:
1105 - prev.id === "" || prev.id === generateIdFromLabel(prev.label || "")
1106 - ? autoId
1107 - : prev.id,
1108 - }));
1109 - };
1110 -
1111 1197 const formTabs = [
1112 1198 {
1113 1199 id: "contact_form" as const,
1114 1200 label: __("Contact Form", "yatra"),
@@ -1126,17 +1212,18 @@
1126 1212 },
1127 1213 ];
1128 1214
1129 1215 const fieldTypes = [
1130 - { value: "text", label: "Text" },
1131 - { value: "email", label: "Email" },
1132 - { value: "tel", label: "Phone" },
1133 - { value: "date", label: "Date" },
1134 - { value: "select", label: "Dropdown" },
1135 - { value: "country", label: "Country Selector" },
1136 - { value: "textarea", label: "Text Area" },
1137 - { value: "number", label: "Number" },
1138 - { 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") },
1139 1226 ];
1140 1227
1141 1228 const widthOptions = [
1142 1229 { value: "full", label: "Full Width" },
@@ -1154,8 +1241,27 @@
1154 1241 }
1155 1242 );
1156 1243 };
1157 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 +
1158 1264 const updateFormConfig = (updates: Partial<FormSectionConfig>) => {
1159 1265 setFormData((prev) => {
1160 1266 if (!prev) return prev;
1161 1267 return {
@@ -1170,165 +1276,9 @@
1170 1276 };
1171 1277 });
1172 1278 };
1173 1279
1174 - const updateField = (fieldId: string, updates: Partial<FormFieldConfig>) => {
1175 - const currentConfig = getCurrentFormConfig();
1176 - const updatedFields = currentConfig.fields.map((field) =>
1177 - field.id === fieldId ? { ...field, ...updates } : field,
1178 - );
1179 - updateFormConfig({ fields: updatedFields });
1180 - };
1181 1280
1182 - const toggleFieldEnabled = (fieldId: string) => {
1183 - const currentConfig = getCurrentFormConfig();
1184 - const field = currentConfig.fields.find((f) => f.id === fieldId);
1185 - // Locked fields cannot be disabled
1186 - if (field && !field.locked) {
1187 - updateField(fieldId, { enabled: !field.enabled });
1188 - }
1189 - };
1190 -
1191 - const toggleFieldRequired = (fieldId: string) => {
1192 - const currentConfig = getCurrentFormConfig();
1193 - const field = currentConfig.fields.find((f) => f.id === fieldId);
1194 - if (field) {
1195 - updateField(fieldId, { required: !field.required });
1196 - }
1197 - };
1198 -
1199 - const moveField = (fieldId: string, direction: "up" | "down") => {
1200 - const currentConfig = getCurrentFormConfig();
1201 - const fields = [...currentConfig.fields];
1202 - const index = fields.findIndex((f) => f.id === fieldId);
1203 -
1204 - if (direction === "up" && index > 0) {
1205 - [fields[index - 1], fields[index]] = [fields[index], fields[index - 1]];
1206 - } else if (direction === "down" && index < fields.length - 1) {
1207 - [fields[index], fields[index + 1]] = [fields[index + 1], fields[index]];
1208 - }
1209 -
1210 - // Update order values
1211 - fields.forEach((field, i) => {
1212 - field.order = i + 1;
1213 - });
1214 -
1215 - updateFormConfig({ fields });
1216 - };
1217 -
1218 - // Drag and Drop handlers
1219 - const handleDragStart = (e: React.DragEvent, fieldId: string) => {
1220 - setDraggedFieldId(fieldId);
1221 - e.dataTransfer.effectAllowed = "move";
1222 - };
1223 -
1224 - const handleDragOver = (e: React.DragEvent, fieldId: string) => {
1225 - e.preventDefault();
1226 - e.dataTransfer.dropEffect = "move";
1227 - if (fieldId !== draggedFieldId) {
1228 - setDragOverFieldId(fieldId);
1229 - }
1230 - };
1231 -
1232 - const handleDragLeave = () => {
1233 - setDragOverFieldId(null);
1234 - };
1235 -
1236 - const handleDrop = (e: React.DragEvent, targetFieldId: string) => {
1237 - e.preventDefault();
1238 - if (!draggedFieldId || draggedFieldId === targetFieldId) {
1239 - setDraggedFieldId(null);
1240 - setDragOverFieldId(null);
1241 - return;
1242 - }
1243 -
1244 - const currentConfig = getCurrentFormConfig();
1245 - const fields = [...currentConfig.fields];
1246 - const draggedIndex = fields.findIndex((f) => f.id === draggedFieldId);
1247 - const targetIndex = fields.findIndex((f) => f.id === targetFieldId);
1248 -
1249 - if (draggedIndex !== -1 && targetIndex !== -1) {
1250 - const [draggedField] = fields.splice(draggedIndex, 1);
1251 - fields.splice(targetIndex, 0, draggedField);
1252 -
1253 - // Update order values
1254 - fields.forEach((field, i) => {
1255 - field.order = i + 1;
1256 - });
1257 -
1258 - updateFormConfig({ fields });
1259 - }
1260 -
1261 - setDraggedFieldId(null);
1262 - setDragOverFieldId(null);
1263 - };
1264 -
1265 - const handleDragEnd = () => {
1266 - setDraggedFieldId(null);
1267 - setDragOverFieldId(null);
1268 - };
1269 -
1270 - const deleteField = (fieldId: string) => {
1271 - const currentConfig = getCurrentFormConfig();
1272 - const updatedFields = currentConfig.fields.filter((f) => f.id !== fieldId);
1273 - updatedFields.forEach((field, i) => {
1274 - field.order = i + 1;
1275 - });
1276 - updateFormConfig({ fields: updatedFields });
1277 - setDeleteConfirm({ isOpen: false, fieldId: null, fieldLabel: "" });
1278 - };
1279 -
1280 - const addNewField = () => {
1281 - if (!newField.label || !newField.id) return;
1282 -
1283 - const currentConfig = getCurrentFormConfig();
1284 - const fieldId = sanitizeId(newField.id);
1285 -
1286 - // Check if ID already exists
1287 - if (currentConfig.fields.some((f) => f.id === fieldId)) {
1288 - // TODO: Replace with toast notification when refactoring this nested component
1289 - window.alert(
1290 - "A field with this ID already exists. Please use a different ID.",
1291 - );
1292 - return;
1293 - }
1294 -
1295 - const newFieldConfig: FormFieldConfig = {
1296 - id: fieldId,
1297 - type: (newField.type as FormFieldConfig["type"]) || "text",
1298 - label: newField.label || "",
1299 - placeholder: newField.placeholder || "",
1300 - required: newField.required || false,
1301 - enabled: true,
1302 - order: currentConfig.fields.length + 1,
1303 - width: (newField.width as FormFieldConfig["width"]) || "full",
1304 - };
1305 -
1306 - // Add options if field type is select
1307 - if (
1308 - newField.type === "select" &&
1309 - newField.options &&
1310 - newField.options.length > 0
1311 - ) {
1312 - newFieldConfig.options = newField.options.filter(
1313 - (opt) => opt.value && opt.label,
1314 - );
1315 - }
1316 -
1317 - updateFormConfig({ fields: [...currentConfig.fields, newFieldConfig] });
1318 - setNewField({
1319 - id: "",
1320 - type: "text",
1321 - label: "",
1322 - placeholder: "",
1323 - required: false,
1324 - enabled: true,
1325 - width: "full",
1326 - options: [],
1327 - });
1328 - setShowAddField(false);
1329 - };
1330 -
1331 1281 const currentConfig = getCurrentFormConfig();
1332 1282
1333 1283 return (
1334 1284 <div className="space-y-6">
@@ -1361,14 +1311,51 @@
1361 1311 ))}
1362 1312 </nav>
1363 1313 </div>
1364 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 +
1365 1328 {/* Form Section Settings */}
1366 1329 <Card>
1367 1330 <CardHeader>
1368 - <CardTitle className="text-base">
1369 - {__("Form Section Settings", "yatra")}
1370 - </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>
1371 1358 </CardHeader>
1372 1359 <CardContent className="space-y-4">
1373 1360 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
1374 1361 <input
@@ -1415,628 +1402,85 @@
1415 1402 className="mt-1"
1416 1403 />
1417 1404 </div>
1418 1405 </div>
1419 - </CardContent>
1420 - </Card>
1421 1406
1422 - {/* Form Fields */}
1423 - <Card>
1424 - <CardHeader className="flex flex-row items-center justify-between">
1425 - <CardTitle className="text-base">
1426 - {__("Form Fields", "yatra")}
1427 - </CardTitle>
1428 - <Button
1429 - variant="outline"
1430 - size="sm"
1431 - onClick={() => setShowAddField(!showAddField)}
1432 - className="flex items-center gap-2"
1433 - >
1434 - <Plus className="w-4 h-4" />
1435 - {__("Add Field", "yatra")}
1436 - </Button>
1437 - </CardHeader>
1438 - <CardContent className="space-y-3">
1439 - {/* Add New Field Form */}
1440 - {showAddField && (
1441 - <div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 mb-4">
1442 - <h4 className="font-medium text-sm mb-3">
1443 - {__("Add New Field", "yatra")}
1444 - </h4>
1445 - <div className="grid grid-cols-1 md:grid-cols-5 gap-3">
1446 - <div>
1447 - <Label className="text-xs">
1448 - {__("Field Type", "yatra")}
1449 - </Label>
1450 - <Select
1451 - value={newField.type || "text"}
1452 - onChange={(e) =>
1453 - setNewField((prev) => ({
1454 - ...prev,
1455 - type: e.target.value as FormFieldConfig["type"],
1456 - }))
1457 - }
1458 - 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"
1459 1415 >
1460 - {fieldTypes.map((type) => (
1461 - <option key={type.value} value={type.value}>
1462 - {type.label}
1463 - </option>
1464 - ))}
1465 - </Select>
1466 - </div>
1467 - <div>
1468 - <Label className="text-xs">{__("Label", "yatra")} *</Label>
1469 - <Input
1470 - value={newField.label || ""}
1471 - onChange={(e) =>
1472 - handleNewFieldLabelChange(e.target.value)
1473 - }
1474 - placeholder="Field label"
1475 - className="mt-1"
1476 - />
1477 - </div>
1478 - <div>
1479 - <Label className="text-xs">
1480 - {__("Field ID", "yatra")} *
1481 - </Label>
1482 - <Input
1483 - value={newField.id || ""}
1484 - onChange={(e) =>
1485 - setNewField((prev) => ({
1486 - ...prev,
1487 - id: sanitizeId(e.target.value),
1488 - }))
1489 - }
1490 - placeholder="field_id"
1491 - className="mt-1 font-mono text-xs"
1492 - />
1493 - <p className="text-[10px] text-gray-400 mt-0.5">
1494 - {__("Lowercase, no spaces", "yatra")}
1495 - </p>
1496 - </div>
1497 - <div>
1498 - <Label className="text-xs">
1499 - {__("Placeholder", "yatra")}
1500 - </Label>
1501 - <Input
1502 - value={newField.placeholder || ""}
1503 - onChange={(e) =>
1504 - setNewField((prev) => ({
1505 - ...prev,
1506 - placeholder: e.target.value,
1507 - }))
1508 - }
1509 - placeholder="Placeholder text"
1510 - className="mt-1"
1511 - />
1512 - </div>
1513 - <div>
1514 - <Label className="text-xs">{__("Width", "yatra")}</Label>
1515 - <Select
1516 - value={newField.width || "full"}
1517 - onChange={(e) =>
1518 - setNewField((prev) => ({
1519 - ...prev,
1520 - width: e.target.value as FormFieldConfig["width"],
1521 - }))
1522 - }
1523 - className="mt-1"
1524 - >
1525 - {widthOptions.map((w) => (
1526 - <option key={w.value} value={w.value}>
1527 - {w.label}
1528 - </option>
1529 - ))}
1530 - </Select>
1531 - </div>
1532 - </div>
1533 - {/* Options editor for select fields */}
1534 - {newField.type === "select" && (
1535 - <div className="mt-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-blue-100 dark:border-blue-900">
1536 - <div className="flex items-center justify-between mb-2">
1537 - <Label className="text-xs font-medium">
1538 - {__("Dropdown Options", "yatra")}
1539 - </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" />
1540 1429 <Button
1541 1430 type="button"
1542 1431 variant="ghost"
1543 1432 size="sm"
1544 - onClick={() => {
1545 - const currentOptions = newField.options || [];
1546 - setNewField((prev) => ({
1547 - ...prev,
1548 - options: [
1549 - ...currentOptions,
1550 - { value: "", label: "" },
1551 - ],
1552 - }));
1553 - }}
1554 - className="h-6 text-xs"
1433 + onClick={() => setConditionsOpen(true)}
1555 1434 >
1556 - <Plus className="w-3 h-3 mr-1" />
1557 - {__("Add Option", "yatra")}
1435 + {__("Edit", "yatra")}
1558 1436 </Button>
1559 1437 </div>
1560 - <div className="space-y-2">
1561 - {(newField.options || []).map((option, optIndex) => (
1562 - <div key={optIndex} className="flex items-center gap-2">
1563 - <Input
1564 - value={option.value}
1565 - onChange={(e) => {
1566 - const newOptions = [...(newField.options || [])];
1567 - newOptions[optIndex] = {
1568 - ...newOptions[optIndex],
1569 - value: e.target.value,
1570 - };
1571 - setNewField((prev) => ({
1572 - ...prev,
1573 - options: newOptions,
1574 - }));
1575 - }}
1576 - placeholder="Value (e.g., option1)"
1577 - className="text-xs flex-1"
1578 - />
1579 - <Input
1580 - value={option.label}
1581 - onChange={(e) => {
1582 - const newOptions = [...(newField.options || [])];
1583 - newOptions[optIndex] = {
1584 - ...newOptions[optIndex],
1585 - label: e.target.value,
1586 - };
1587 - setNewField((prev) => ({
1588 - ...prev,
1589 - options: newOptions,
1590 - }));
1591 - }}
1592 - placeholder="Label (e.g., Option 1)"
1593 - className="text-xs flex-1"
1594 - />
1595 - <button
1596 - type="button"
1597 - onClick={() => {
1598 - const newOptions = (
1599 - newField.options || []
1600 - ).filter((_, i) => i !== optIndex);
1601 - setNewField((prev) => ({
1602 - ...prev,
1603 - options: newOptions,
1604 - }));
1605 - }}
1606 - className="p-1 text-gray-400 hover:text-red-500"
1607 - >
1608 - <Trash2 className="w-3 h-3" />
1609 - </button>
1610 - </div>
1611 - ))}
1612 - {(!newField.options || newField.options.length === 0) && (
1613 - <p className="text-xs text-gray-400 italic">
1614 - {__(
1615 - 'Click "Add Option" to add dropdown choices.',
1616 - "yatra",
1617 - )}
1618 - </p>
1619 - )}
1620 - </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>
1621 1446 </div>
1622 - )}
1623 -
1624 - <div className="flex items-center gap-4 mt-3">
1625 - <label className="flex items-center gap-2 text-sm">
1626 - <input
1627 - type="checkbox"
1628 - checked={newField.required || false}
1629 - onChange={(e) =>
1630 - setNewField((prev) => ({
1631 - ...prev,
1632 - required: e.target.checked,
1633 - }))
1634 - }
1635 - className="w-4 h-4 rounded border-gray-300 text-blue-600"
1636 - />
1637 - {__("Required", "yatra")}
1638 - </label>
1639 - <div className="flex-1"></div>
1640 - <Button
1641 - variant="ghost"
1642 - size="sm"
1643 - onClick={() => setShowAddField(false)}
1644 - >
1645 - {__("Cancel", "yatra")}
1646 - </Button>
1647 - <Button
1648 - size="sm"
1649 - onClick={addNewField}
1650 - disabled={!newField.label || !newField.id}
1651 - >
1652 - {__("Add Field", "yatra")}
1653 - </Button>
1654 1447 </div>
1655 - </div>
1656 - )}
1657 -
1658 - {/* Field List */}
1659 - {currentConfig.fields?.length === 0 ? (
1660 - <div className="text-center py-8 text-gray-500 dark:text-gray-400">
1661 - <p>
1662 - {__(
1663 - 'No fields configured. Click "Add Field" to get started.',
1664 - "yatra",
1665 - )}
1666 - </p>
1667 - </div>
1668 - ) : (
1669 - <div className="space-y-2">
1670 - {currentConfig.fields?.map((field, index) => (
1671 - <div
1672 - key={field.id}
1673 - draggable
1674 - onDragStart={(e) => handleDragStart(e, field.id)}
1675 - onDragOver={(e) => handleDragOver(e, field.id)}
1676 - onDragLeave={handleDragLeave}
1677 - onDrop={(e) => handleDrop(e, field.id)}
1678 - onDragEnd={handleDragEnd}
1679 - className={`flex items-center gap-3 p-3 rounded-lg border transition-all cursor-grab active:cursor-grabbing ${
1680 - draggedFieldId === field.id
1681 - ? "opacity-50 border-dashed"
1682 - : ""
1683 - } ${
1684 - dragOverFieldId === field.id
1685 - ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
1686 - : ""
1687 - } ${
1688 - field.enabled
1689 - ? "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
1690 - : "bg-gray-50 dark:bg-gray-900 border-gray-100 dark:border-gray-800 opacity-60"
1691 - }`}
1692 - >
1693 - {/* Drag Handle & Order */}
1694 - <div className="flex items-center gap-1">
1695 - <GripVertical className="w-4 h-4 text-gray-400" />
1696 - <div className="flex flex-col gap-0.5">
1697 - <button
1698 - type="button"
1699 - onClick={() => moveField(field.id, "up")}
1700 - disabled={index === 0}
1701 - className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
1702 - >
1703 - <ArrowUp className="w-3 h-3" />
1704 - </button>
1705 - <button
1706 - type="button"
1707 - onClick={() => moveField(field.id, "down")}
1708 - disabled={index === currentConfig.fields.length - 1}
1709 - className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
1710 - >
1711 - <ArrowDown className="w-3 h-3" />
1712 - </button>
1713 - </div>
1714 - </div>
1715 -
1716 - {/* Field Info */}
1717 - <div className="flex-1 min-w-0">
1718 - {editingField === field.id ? (
1719 - <div className="space-y-3">
1720 - <div className="grid grid-cols-4 gap-2">
1721 - <Input
1722 - value={field.label}
1723 - onChange={(e) =>
1724 - updateField(field.id, { label: e.target.value })
1725 - }
1726 - placeholder="Label"
1727 - className="text-sm"
1728 - />
1729 - <Input
1730 - value={field.placeholder}
1731 - onChange={(e) =>
1732 - updateField(field.id, {
1733 - placeholder: e.target.value,
1734 - })
1735 - }
1736 - placeholder="Placeholder"
1737 - className="text-sm"
1738 - />
1739 - <Select
1740 - value={field.type}
1741 - onChange={(e) =>
1742 - updateField(field.id, {
1743 - type: e.target
1744 - .value as FormFieldConfig["type"],
1745 - })
1746 - }
1747 - className="text-sm"
1748 - >
1749 - {fieldTypes.map((type) => (
1750 - <option key={type.value} value={type.value}>
1751 - {type.label}
1752 - </option>
1753 - ))}
1754 - </Select>
1755 - <Select
1756 - value={field.width}
1757 - onChange={(e) =>
1758 - updateField(field.id, {
1759 - width: e.target
1760 - .value as FormFieldConfig["width"],
1761 - })
1762 - }
1763 - className="text-sm"
1764 - >
1765 - {widthOptions.map((w) => (
1766 - <option key={w.value} value={w.value}>
1767 - {w.label}
1768 - </option>
1769 - ))}
1770 - </Select>
1771 - </div>
1772 -
1773 - {/* Field ID - Below other fields */}
1774 - <div className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-900 rounded">
1775 - <Label className="text-xs text-gray-500 whitespace-nowrap">
1776 - {__("Field ID:", "yatra")}
1777 - </Label>
1778 - <Input
1779 - value={field.id}
1780 - onChange={(e) => {
1781 - if (!field.locked) {
1782 - updateField(field.id, {
1783 - id: sanitizeId(e.target.value),
1784 - });
1785 - }
1786 - }}
1787 - placeholder="field_id"
1788 - className="text-sm font-mono flex-1 max-w-xs"
1789 - disabled={field.locked}
1790 - title={
1791 - field.locked
1792 - ? "Locked fields cannot change ID"
1793 - : "Field ID (lowercase, no spaces)"
1794 - }
1795 - />
1796 - {field.locked && (
1797 - <span className="text-xs text-amber-600">
1798 - {__("(locked)", "yatra")}
1799 - </span>
1800 - )}
1801 - </div>
1802 -
1803 - {/* Dropdown Options Editor */}
1804 - {field.type === "select" && (
1805 - <div className="p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
1806 - <div className="flex items-center justify-between mb-2">
1807 - <Label className="text-xs font-medium">
1808 - {__("Dropdown Options", "yatra")}
1809 - </Label>
1810 - <Button
1811 - type="button"
1812 - variant="ghost"
1813 - size="sm"
1814 - onClick={() => {
1815 - const newOptions = [
1816 - ...(field.options || []),
1817 - { value: "", label: "" },
1818 - ];
1819 - updateField(field.id, {
1820 - options: newOptions,
1821 - });
1822 - }}
1823 - className="h-6 text-xs"
1824 - >
1825 - <Plus className="w-3 h-3 mr-1" />
1826 - {__("Add Option", "yatra")}
1827 - </Button>
1828 - </div>
1829 - <div className="space-y-2">
1830 - {(field.options || []).map(
1831 - (option, optIndex) => (
1832 - <div
1833 - key={optIndex}
1834 - className="flex items-center gap-2"
1835 - >
1836 - <Input
1837 - value={option.value}
1838 - onChange={(e) => {
1839 - const newOptions = [
1840 - ...(field.options || []),
1841 - ];
1842 - newOptions[optIndex] = {
1843 - ...newOptions[optIndex],
1844 - value: e.target.value,
1845 - };
1846 - updateField(field.id, {
1847 - options: newOptions,
1848 - });
1849 - }}
1850 - placeholder="Value (e.g., spouse)"
1851 - className="text-xs flex-1"
1852 - />
1853 - <Input
1854 - value={option.label}
1855 - onChange={(e) => {
1856 - const newOptions = [
1857 - ...(field.options || []),
1858 - ];
1859 - newOptions[optIndex] = {
1860 - ...newOptions[optIndex],
1861 - label: e.target.value,
1862 - };
1863 - updateField(field.id, {
1864 - options: newOptions,
1865 - });
1866 - }}
1867 - placeholder="Label (e.g., Spouse/Partner)"
1868 - className="text-xs flex-1"
1869 - />
1870 - <button
1871 - type="button"
1872 - onClick={() => {
1873 - const newOptions = (
1874 - field.options || []
1875 - ).filter((_, i) => i !== optIndex);
1876 - updateField(field.id, {
1877 - options: newOptions,
1878 - });
1879 - }}
1880 - className="p-1 text-gray-400 hover:text-red-500"
1881 - >
1882 - <Trash2 className="w-3 h-3" />
1883 - </button>
1884 - </div>
1885 - ),
1886 - )}
1887 - {(!field.options ||
1888 - field.options.length === 0) && (
1889 - <p className="text-xs text-gray-400 italic">
1890 - {__(
1891 - 'No options. Click "Add Option" to add dropdown choices.',
1892 - "yatra",
1893 - )}
1894 - </p>
1895 - )}
1896 - </div>
1897 - </div>
1898 - )}
1899 - </div>
1900 - ) : (
1901 - <div className="flex items-center gap-3 flex-wrap">
1902 - <span className="font-medium text-sm">
1903 - {field.label}
1904 - </span>
1905 - <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">
1906 - {field.id}
1907 - </code>
1908 - <span className="text-xs text-gray-400 dark:text-gray-500 px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">
1909 - {fieldTypes.find((t) => t.value === field.type)
1910 - ?.label || field.type}
1911 - </span>
1912 - <span className="text-xs text-gray-400 dark:text-gray-500">
1913 - {widthOptions.find((w) => w.value === field.width)
1914 - ?.label || "Full"}
1915 - </span>
1916 - {field.type === "select" &&
1917 - field.options &&
1918 - field.options.length > 0 && (
1919 - <span className="text-xs text-blue-500 dark:text-blue-400">
1920 - ({field.options.length}{" "}
1921 - {field.options.length === 1
1922 - ? "option"
1923 - : "options"}
1924 - )
1925 - </span>
1926 - )}
1927 - {field.required && (
1928 - <span className="text-xs text-red-500 font-medium">
1929 - {__("Required", "yatra")}
1930 - </span>
1931 - )}
1932 - {field.locked && (
1933 - <span
1934 - 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"
1935 - title="This field is protected and cannot be deleted"
1936 - >
1937 - <Lock className="w-3 h-3" />
1938 - Locked
1939 - </span>
1940 - )}
1941 - </div>
1942 - )}
1943 - </div>
1944 -
1945 - {/* Actions */}
1946 - <div className="flex items-center gap-1">
1947 - <button
1948 - type="button"
1949 - onClick={() =>
1950 - !field.locked && toggleFieldRequired(field.id)
1951 - }
1952 - disabled={field.locked}
1953 - 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"}`}
1954 - title={
1955 - field.locked
1956 - ? "This field is required and cannot be changed"
1957 - : field.required
1958 - ? "Make optional"
1959 - : "Make required"
1960 - }
1961 - >
1962 - <Star
1963 - className="w-4 h-4"
1964 - fill={field.required ? "currentColor" : "none"}
1965 - />
1966 - </button>
1967 - {/* Show enable/disable toggle only for non-locked fields */}
1968 - {!field.locked && (
1969 - <button
1970 - type="button"
1971 - onClick={() => toggleFieldEnabled(field.id)}
1972 - className={`p-1.5 rounded ${field.enabled ? "text-green-500" : "text-gray-400"}`}
1973 - title={
1974 - field.enabled ? "Disable field" : "Enable field"
1975 - }
1976 - >
1977 - {field.enabled ? (
1978 - <Eye className="w-4 h-4" />
1979 - ) : (
1980 - <EyeOff className="w-4 h-4" />
1981 - )}
1982 - </button>
1983 - )}
1984 - <button
1985 - type="button"
1986 - onClick={() =>
1987 - setEditingField(
1988 - editingField === field.id ? null : field.id,
1989 - )
1990 - }
1991 - className="p-1.5 rounded text-gray-400 hover:text-blue-500"
1992 - title="Edit field"
1993 - >
1994 - <Edit2 className="w-4 h-4" />
1995 - </button>
1996 - {/* Show delete button only for non-locked fields */}
1997 - {!field.locked && (
1998 - <button
1999 - type="button"
2000 - onClick={() =>
2001 - setDeleteConfirm({
2002 - isOpen: true,
2003 - fieldId: field.id,
2004 - fieldLabel: field.label,
2005 - })
2006 - }
2007 - className="p-1.5 rounded text-gray-400 hover:text-red-500"
2008 - title="Delete field"
2009 - >
2010 - <Trash2 className="w-4 h-4" />
2011 - </button>
2012 - )}
2013 - </div>
2014 - </div>
2015 - ))}
2016 - </div>
2017 - )}
1448 + )}
2018 1449 </CardContent>
2019 1450 </Card>
2020 1451
2021 - {/* Delete Confirmation Dialog */}
2022 - <ConfirmationDialog
2023 - isOpen={deleteConfirm.isOpen}
2024 - onClose={() =>
2025 - setDeleteConfirm({ isOpen: false, fieldId: null, fieldLabel: "" })
2026 - }
2027 - onConfirm={() => {
2028 - if (deleteConfirm.fieldId) {
2029 - 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 || ""
2030 1459 }
2031 - }}
2032 - title={__("Delete Field", "yatra")}
2033 - message={`Are you sure you want to delete the field "${deleteConfirm.fieldLabel}"? This action cannot be undone.`}
2034 - confirmText={__("Delete", "yatra")}
2035 - cancelText={__("Cancel", "yatra")}
2036 - 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}
2037 1480 />
2038 1481
1482 +
2039 1483 {/* Preview */}
2040 1484 <Card>
2041 1485 <CardHeader>
2042 1486 <CardTitle className="text-base">
@@ -2064,32 +1508,43 @@
2064 1508 ? "col-span-1"
2065 1509 : "col-span-1"
2066 1510 }
2067 1511 >
2068 - <Label className="text-sm">
2069 - {field.label}
2070 - {field.required && (
2071 - <span className="text-red-500 ml-1">*</span>
2072 - )}
2073 - </Label>
2074 - {field.type === "select" || field.type === "country" ? (
2075 - <Select disabled className="mt-1 w-full">
2076 - <option>{field.placeholder || "Select..."}</option>
2077 - </Select>
2078 - ) : field.type === "textarea" ? (
2079 - <textarea
2080 - disabled
2081 - placeholder={field.placeholder}
2082 - className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-md bg-white dark:bg-gray-700 text-sm"
2083 - rows={2}
2084 - />
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>
2085 1516 ) : (
2086 - <Input
2087 - disabled
2088 - type={field.type}
2089 - placeholder={field.placeholder}
2090 - className="mt-1"
2091 - />
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 + </>
2092 1547 )}
2093 1548 </div>
2094 1549 ))}
2095 1550 </div>
@@ -2107,9 +1562,34 @@
2107 1562 const { showToast } = useToast();
2108 1563
2109 1564 // Get active section from localStorage or default to 'general'
2110 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 + ];
2111 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 + }
2112 1592 const saved = localStorage.getItem("yatra_settings_active_section");
2113 1593 if (saved === "email" || saved === "notification") {
2114 1594 return "general";
2115 1595 }
@@ -2129,8 +1609,9 @@
2129 1609 "integration",
2130 1610 "permalink",
2131 1611 "seo",
2132 1612 "advanced",
1613 + "uninstall",
2133 1614 ].includes(saved)
2134 1615 ) {
2135 1616 return saved as SettingsSection;
2136 1617 }
@@ -2181,9 +1662,9 @@
2181 1662 );
2182 1663 throw error;
2183 1664 }
2184 1665 },
2185 - enabled: can("manage_yatra"),
1666 + enabled: can("yatra_manage_settings"),
2186 1667 // Avoid overwriting in-progress edits due to background refetches
2187 1668 });
2188 1669
2189 1670 // Cancellation settings are now saved with main settings
@@ -2203,14 +1684,14 @@
2203 1684 console.error("Failed to load gateway definitions:", error);
2204 1685 return {};
2205 1686 }
2206 1687 },
2207 - enabled: can("manage_yatra"),
1688 + enabled: can("yatra_manage_settings"),
2208 1689 staleTime: Infinity,
2209 1690 });
2210 1691
2211 1692 useEffect(() => {
2212 - if (viewingSection !== "advanced" || !can("manage_yatra")) {
1693 + if (viewingSection !== "advanced" || !can("yatra_manage_settings")) {
2213 1694 return;
2214 1695 }
2215 1696 let cancelled = false;
2216 1697 setUsageLoading(true);
@@ -2245,9 +1726,9 @@
2245 1726 // Default settings fallback
2246 1727 const defaultSettings: SettingsData = useMemo<SettingsData>(
2247 1728 () => ({
2248 1729 company_name: "Yatra Travel Agency",
2249 - company_email: "[email protected]",
1730 + company_email: "[email protected]",
2250 1731 company_phone: "+1-234-567-8900",
2251 1732 company_address: "123 Travel Street",
2252 1733 company_city: "Kathmandu",
2253 1734 company_state: "Bagmati",
@@ -2252,9 +1733,9 @@
2252 1733 company_city: "Kathmandu",
2253 1734 company_state: "Bagmati",
2254 1735 company_country: "Nepal",
2255 1736 company_zip: "44600",
2256 - company_website: "https://yatra.com",
1737 + company_website: "https://wpyatra.com",
2257 1738 company_logo: "",
2258 1739 timezone: "Asia/Kathmandu",
2259 1740 date_format: "Y-m-d",
2260 1741 time_format: "H:i",
@@ -2259,19 +1740,29 @@
2259 1740 date_format: "Y-m-d",
2260 1741 time_format: "H:i",
2261 1742 frontend_primary_color: "#3b82f6",
2262 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,
2263 1754 booking_confirmation: true,
2264 1755 auto_confirm_bookings: false,
1756 + auto_confirm_mode: "online",
2265 1757 require_login: false,
2266 1758 allow_guest_checkout: true,
2267 - cancellation_policy: "full_refund",
2268 - cancellation_days: 7,
2269 - refund_policy: "Full refund if cancelled 7 days before departure",
1759 + require_guest_email_verification: false,
2270 1760 booking_expiry_hours: 24,
2271 1761 booking_reminder_days: 3,
2272 1762 allow_waitlist: true,
2273 1763 waitlist_auto_confirm: false,
1764 + date_picker_as_dropdown: false,
2274 1765
2275 1766 currency: "USD",
2276 1767 payment_test_mode: true,
2277 1768 payment_gateways: ["pay_later"],
@@ -2284,10 +1775,13 @@
2284 1775 enable_scheduled_payments: false,
2285 1776 scheduled_payment_type: "single",
2286 1777 scheduled_payment_days: 15,
2287 1778 scheduled_payment_installments: 1,
1779 + discount_stacking_mode: "both",
2288 1780 scheduled_payment_interval: 30,
2289 1781 scheduled_payment_reminder_days: 3,
1782 + balance_anchor: "booking",
1783 + balance_due_days: 14,
2290 1784 gateway_configs: {
2291 1785 stripe: {
2292 1786 enabled: true,
2293 1787 title: "Stripe",
@@ -2365,10 +1859,10 @@
2365 1859 "bank_transfer",
2366 1860 "esewa",
2367 1861 "khalti",
2368 1862 ],
2369 - admin_email: "[email protected]",
2370 - from_email: "[email protected]",
1863 + admin_email: "[email protected]",
1864 + from_email: "[email protected]",
2371 1865 from_name: "Yatra Travel",
2372 1866 email_template_booking: true,
2373 1867 email_template_confirmation: true,
2374 1868 email_template_cancellation: true,
@@ -2377,8 +1871,9 @@
2377 1871 email_template_admin_payment: true,
2378 1872 email_template_admin_cancellation: true,
2379 1873 email_template_trip_consent: true,
2380 1874 email_template_customer_verification: true,
1875 + email_template_guest_verification: true,
2381 1876 email_template_booking_completed: true,
2382 1877 email_template_booking_expired_customer: true,
2383 1878 email_template_admin_booking_expired: true,
2384 1879 email_template_scheduled_payment_reminder: true,
@@ -2387,10 +1882,12 @@
2387 1882 email_template_admin_scheduled_payment_failed: true,
2388 1883 email_template_enquiry_received: true,
2389 1884 email_template_enquiry_admin: true,
2390 1885 email_template_enquiry_response: true,
2391 - email_template_review_request: true,
2392 - email_template_abandoned_booking_recovery: true,
1886 + email_template_review_request: false,
1887 + email_template_abandoned_booking_recovery_first: true,
1888 + email_template_abandoned_booking_recovery_second: true,
1889 + email_template_abandoned_booking_recovery_final: true,
2393 1890 smtp_enabled: false,
2394 1891 smtp_host: "smtp.gmail.com",
2395 1892 smtp_port: 587,
2396 1893 smtp_username: "",
@@ -2413,8 +1910,10 @@
2413 1910 email_tpl_trip_consent_subject: "",
2414 1911 email_tpl_trip_consent_body: "",
2415 1912 email_tpl_customer_verification_subject: "",
2416 1913 email_tpl_customer_verification_body: "",
1914 + email_tpl_guest_verification_subject: "",
1915 + email_tpl_guest_verification_body: "",
2417 1916 email_tpl_booking_completed_subject: "",
2418 1917 email_tpl_booking_completed_body: "",
2419 1918 email_tpl_booking_expired_customer_subject: "",
2420 1919 email_tpl_booking_expired_customer_body: "",
@@ -2435,10 +1934,14 @@
2435 1934 email_tpl_enquiry_response_subject: "",
2436 1935 email_tpl_enquiry_response_body: "",
2437 1936 email_tpl_review_request_subject: "",
2438 1937 email_tpl_review_request_body: "",
2439 - email_tpl_abandoned_booking_recovery_subject: "",
2440 - email_tpl_abandoned_booking_recovery_body: "",
1938 + email_tpl_abandoned_booking_recovery_first_subject: "",
1939 + email_tpl_abandoned_booking_recovery_first_body: "",
1940 + email_tpl_abandoned_booking_recovery_second_subject: "",
1941 + email_tpl_abandoned_booking_recovery_second_body: "",
1942 + email_tpl_abandoned_booking_recovery_final_subject: "",
1943 + email_tpl_abandoned_booking_recovery_final_body: "",
2441 1944 customer_registration: true,
2442 1945 customer_fields: ["name", "email", "phone", "address"],
2443 1946 require_email_verification: false,
2444 1947 customer_account_page: "/my-account",
@@ -2449,9 +1952,10 @@
2449 1952 auto_approve_reviews: false,
2450 1953 review_moderation: true,
2451 1954 min_rating: 1,
2452 1955 allow_anonymous_reviews: false,
2453 - review_reminder_days: 7,
1956 + review_reminder_days: 3,
1957 + review_reminder_max_age_days: 14,
2454 1958 enable_tax: true,
2455 1959 tax_name: __("Tax", "yatra"),
2456 1960 tax_rate: 10,
2457 1961 tax_inclusive: false,
@@ -2474,8 +1978,12 @@
2474 1978 facebook_pixel: "",
2475 1979 recaptcha_enabled: false,
2476 1980 recaptcha_site_key: "",
2477 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,
2478 1986 trip_base: "trip",
2479 1987 destination_base: "destination",
2480 1988 activity_base: "activity",
2481 1989 trip_category_base: "trip-category",
@@ -2480,8 +1988,10 @@
2480 1988 activity_base: "activity",
2481 1989 trip_category_base: "trip-category",
2482 1990 booking_base: "book",
2483 1991 enable_wishlist: false,
1992 + show_sold_out: true,
1993 + availability_horizon_months: 12,
2484 1994 use_booking_page: false,
2485 1995 booking_page_id: 0,
2486 1996 terms_page_id: 0,
2487 1997 privacy_policy_page_id: 0,
@@ -2486,8 +1996,9 @@
2486 1996 terms_page_id: 0,
2487 1997 privacy_policy_page_id: 0,
2488 1998 debug_mode: false,
2489 1999 enable_logging: false,
2000 + delete_data_on_uninstall: false,
2490 2001 cache_enabled: true,
2491 2002 api_key: "",
2492 2003 api_rate_limit: 100,
2493 2004 session_timeout: 3600,
@@ -2721,8 +2232,11 @@
2721 2232 seo_trip_meta_title: "",
2722 2233 seo_trip_meta_description: "",
2723 2234 seo_trip_meta_keywords: "",
2724 2235 seo_trip_meta_image: 0,
2236 + enable_sitemap: true,
2237 + sitemap_types: ["archive", "trip", "destination", "activity", "category"],
2238 + sitemap_noindex_excluded: false,
2725 2239 }),
2726 2240 [],
2727 2241 );
2728 2242
@@ -2750,8 +2264,9 @@
2750 2264 >([]);
2751 2265 const [showApiKey, setShowApiKey] = useState(false);
2752 2266 const [validatingApiKey, setValidatingApiKey] = useState(false);
2753 2267 const [showFacebookToken, setShowFacebookToken] = useState(false);
2268 + const [showGa4ApiSecret, setShowGa4ApiSecret] = useState(false);
2754 2269 const [seoImageUrl, setSeoImageUrl] = useState<string>("");
2755 2270 const [seoImageLoading, setSeoImageLoading] = useState<boolean>(false);
2756 2271 const [seoImageError, setSeoImageError] = useState<string>("");
2757 2272
@@ -2905,13 +2420,13 @@
2905 2420 if (!formData?.ga4_measurement_id) return;
2906 2421
2907 2422 setValidatingMeasurementId(true);
2908 2423 try {
2909 - const response = await apiService.validateGoogleAnalyticsMeasurementId(
2424 + const raw = (await apiService.validateGoogleAnalyticsMeasurementId(
2910 2425 formData.ga4_measurement_id,
2911 - );
2426 + )) as { success?: boolean; message?: string };
2912 2427
2913 - if (response.success) {
2428 + if (raw?.success) {
2914 2429 showToast(
2915 2430 __("Measurement ID validated successfully!", "yatra"),
2916 2431 "success",
2917 2432 );
@@ -2916,9 +2431,9 @@
2916 2431 "success",
2917 2432 );
2918 2433 } else {
2919 2434 showToast(
2920 - response.message || __("Measurement ID validation failed.", "yatra"),
2435 + raw?.message || __("Measurement ID validation failed.", "yatra"),
2921 2436 "error",
2922 2437 );
2923 2438 }
2924 2439 } catch (error: any) {
@@ -2935,18 +2450,18 @@
2935 2450 if (!formData?.ga4_measurement_id || !formData?.ga4_api_secret) return;
2936 2451
2937 2452 setValidatingApiSecret(true);
2938 2453 try {
2939 - const response = await apiService.validateGoogleAnalyticsApiSecret(
2454 + const raw = (await apiService.validateGoogleAnalyticsApiSecret(
2940 2455 formData.ga4_measurement_id,
2941 2456 formData.ga4_api_secret,
2942 - );
2457 + )) as { success?: boolean; message?: string };
2943 2458
2944 - if (response.success) {
2459 + if (raw?.success) {
2945 2460 showToast(__("API Secret validated successfully!", "yatra"), "success");
2946 2461 } else {
2947 2462 showToast(
2948 - response.message || __("API Secret validation failed.", "yatra"),
2463 + raw?.message || __("API Secret validation failed.", "yatra"),
2949 2464 "error",
2950 2465 );
2951 2466 }
2952 2467 } catch (error: any) {
@@ -3075,8 +2590,9 @@
3075 2590 // Use default settings if API fails or returns empty
3076 2591 setFormData(defaultSettings);
3077 2592 isInitializedRef.current = true;
3078 2593 }
2594 + // eslint-disable-next-line react-hooks/exhaustive-deps
3079 2595 }, [settings, isLoading]);
3080 2596
3081 2597 // Fetch SEO image URL when image ID changes with performance optimization
3082 2598 React.useEffect(() => {
@@ -3151,33 +2667,43 @@
3151 2667 controller.abort();
3152 2668 };
3153 2669 }, [formData?.seo_trip_meta_image]);
3154 2670
3155 - // 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);
3156 2681 React.useEffect(() => {
3157 - if (
3158 - gatewayDefinitions &&
3159 - Object.keys(gatewayDefinitions).length > 0 &&
3160 - gatewayOrder.length === 0
3161 - ) {
3162 - // Use saved order if available, otherwise use default order from definitions
3163 - const savedOrder = settings?.gateway_order as string[] | undefined;
3164 - if (savedOrder) {
3165 - // Merge saved order with any new gateways
3166 - const allGateways = Object.keys(gatewayDefinitions);
3167 - const validSavedOrder = savedOrder.filter((id) =>
3168 - allGateways.includes(id),
3169 - );
3170 - const newGateways = allGateways.filter(
3171 - (id) => !validSavedOrder.includes(id),
3172 - );
3173 - setGatewayOrder([...validSavedOrder, ...newGateways]);
3174 - } else {
3175 - setGatewayOrder(Object.keys(gatewayDefinitions));
3176 - }
2682 + if (gatewayOrderInitialized.current) return;
2683 + if (!gatewayDefinitions || Object.keys(gatewayDefinitions).length === 0) {
2684 + return;
3177 2685 }
3178 - }, [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;
3179 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 +
3180 2706 // Single stable onChange handler that reads field name from input's name or id attribute
3181 2707 const handleFieldChange = React.useCallback(
3182 2708 (
3183 2709 e: React.ChangeEvent<
@@ -3189,10 +2715,19 @@
3189 2715
3190 2716 if (e.target.type === "checkbox") {
3191 2717 value = (e.target as HTMLInputElement).checked;
3192 2718 } else if (e.target.type === "number") {
3193 - const numValue = parseFloat(e.target.value);
3194 - 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 + }
3195 2730 } else {
3196 2731 value = e.target.value;
3197 2732 }
3198 2733
@@ -3406,9 +2941,9 @@
3406 2941 }>;
3407 2942 },
3408 2943 // Used by multiple sections (Booking page, Legal pages, etc.), so keep it available
3409 2944 // whenever the user can manage Yatra settings.
3410 - enabled: can("manage_yatra"),
2945 + enabled: can("yatra_manage_settings"),
3411 2946 });
3412 2947
3413 2948 // Check shortcode on-demand when user selects a page
3414 2949 const checkShortcodeMutation = useMutation({
@@ -3555,12 +3090,36 @@
3555 3090 label: __("Booking Form", "yatra"),
3556 3091 icon: ClipboardList,
3557 3092 },
3558 3093 {
3094 + id: "search" as SettingsSection,
3095 + label: __("Search & Listing", "yatra"),
3096 + icon: SlidersHorizontal,
3097 + },
3098 + {
3559 3099 id: "payment" as SettingsSection,
3560 3100 label: __("Payment", "yatra"),
3561 3101 icon: DollarSign,
3562 3102 },
3103 + // Pricing tab — visible on every Pro install so the Discount
3104 + // Stacking control is *discoverable*. The setting itself only
3105 + // takes effect when BOTH Advanced Discount and Dynamic Pricing
3106 + // are enabled; the case "pricing" render gives a clear status
3107 + // banner + disables the dropdown when prerequisites are missing.
3108 + //
3109 + // Truthy check (not `=== true`) because some WP installs end up
3110 + // with `isPro` serialized as `1` instead of `true` through the
3111 + // PHP→JSON→JS pipeline. Free installs serialize false (or omit
3112 + // the key), which stays falsy and correctly hides the tab.
3113 + ...((window as any)?.yatraAdmin?.isPro
3114 + ? [
3115 + {
3116 + id: "pricing" as SettingsSection,
3117 + label: __("Pricing", "yatra"),
3118 + icon: Percent,
3119 + },
3120 + ]
3121 + : []),
3563 3122 {
3564 3123 id: "customer" as SettingsSection,
3565 3124 label: __("Customer", "yatra"),
3566 3125 icon: Users,
@@ -3595,8 +3154,13 @@
3595 3154 id: "advanced" as SettingsSection,
3596 3155 label: __("Advanced", "yatra"),
3597 3156 icon: Shield,
3598 3157 },
3158 + {
3159 + id: "uninstall" as SettingsSection,
3160 + label: __("Uninstall", "yatra"),
3161 + icon: Trash2,
3162 + },
3599 3163 ];
3600 3164
3601 3165 // Helper component for section divider
3602 3166 const SectionDivider = ({ title }: { title: string }) => (
@@ -4011,8 +3575,152 @@
4011 3575 spellCheck={false}
4012 3576 autoComplete="off"
4013 3577 />
4014 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>
4015 3723 </div>
4016 3724 </div>
4017 3725 );
4018 3726
@@ -4207,8 +3915,53 @@
4207 3915
4208 3916 {formData.enable_scheduled_payments && (
4209 3917 <>
4210 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
4211 3964 id="scheduled_payment_type"
4212 3965 label={__("Schedule type", "yatra")}
4213 3966 description={__(
4214 3967 "Single final charge on a date, or multiple installments.",
@@ -4229,27 +3982,29 @@
4229 3982 </option>
4230 3983 </Select>
4231 3984 </FormField>
4232 3985
4233 - <FormField
4234 - id="scheduled_payment_days"
4235 - label={__("Days until first charge", "yatra")}
4236 - description={__(
4237 - "How many days after the initial payment to schedule the first balance collection.",
4238 - "yatra",
4239 - )}
4240 - >
4241 - <Input
3986 + {formData.balance_anchor !== "tour" && (
3987 + <FormField
4242 3988 id="scheduled_payment_days"
4243 - type="number"
4244 - min={0}
4245 - max={365}
4246 - value={formData.scheduled_payment_days}
4247 - name="scheduled_payment_days"
4248 - onChange={handleFieldChange}
4249 - className="max-w-xs"
4250 - />
4251 - </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 + )}
4252 4007
4253 4008 {formData.scheduled_payment_type === "installments" && (
4254 4009 <>
4255 4010 <FormField
@@ -4682,8 +4437,117 @@
4682 4437 return null;
4683 4438 }
4684 4439 }
4685 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 +
4686 4550 // Render checkbox fields differently
4687 4551 if (field.type === "checkbox") {
4688 4552 return (
4689 4553 <div
@@ -4964,8 +4828,53 @@
4964 4828 </div>
4965 4829 );
4966 4830 }
4967 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 +
4968 4877 // Render text, password, number, email inputs
4969 4878 return (
4970 4879 <div key={field.id} className="space-y-2">
4971 4880 <FormField
@@ -4987,12 +4896,21 @@
4987 4896 onChange={(e) =>
4988 4897 handleGatewayConfigChange(
4989 4898 gatewayId,
4990 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.
4991 4907 field.type === "number"
4992 - ? parseFloat(
4993 - e.target.value,
4994 - ) || 0
4908 + ? e.target.value === ""
4909 + ? ""
4910 + : parseFloat(
4911 + e.target.value,
4912 + ) || 0
4995 4913 : e.target.value,
4996 4914 )
4997 4915 }
4998 4916 placeholder={field.placeholder}
@@ -5055,57 +4973,162 @@
5055 4973 </div>
5056 4974 </>
5057 4975 );
5058 4976
5059 - // Continue with other sections... (I'll add the most important ones)
5060 - case "booking":
4977 + case "search":
5061 4978 return (
5062 4979 <div className="space-y-6">
5063 - <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>
5064 5079 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
5065 5080 <input
5066 5081 type="checkbox"
5067 - id="booking_confirmation"
5068 - checked={formData.booking_confirmation}
5069 - name="booking_confirmation"
5082 + id="collapse_filters_on_mobile"
5083 + checked={!!formData.collapse_filters_on_mobile}
5084 + name="collapse_filters_on_mobile"
5070 5085 onChange={handleFieldChange}
5071 5086 className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
5072 5087 />
5073 5088 <div className="flex-1">
5074 5089 <Label
5075 - htmlFor="booking_confirmation"
5090 + htmlFor="collapse_filters_on_mobile"
5076 5091 className="font-medium cursor-pointer"
5077 5092 >
5078 - {__("Enable Booking Confirmation", "yatra")}
5093 + {__("Collapse filters by default on mobile", "yatra")}
5079 5094 </Label>
5080 5095 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
5081 5096 {__(
5082 - "Controls post-checkout confirmation (page and customer email when templates/notifications allow).",
5097 + "On mobile, show filter sections collapsed so trip listings are visible right away. Visitors tap a section to expand it. Desktop is unaffected.",
5083 5098 "yatra",
5084 5099 )}
5085 5100 </p>
5086 5101 </div>
5087 5102 </div>
5103 + </div>
5104 + </div>
5105 + );
5088 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">
5089 5112 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
5090 5113 <input
5091 5114 type="checkbox"
5092 - id="auto_confirm_bookings"
5093 - checked={formData.auto_confirm_bookings}
5094 - name="auto_confirm_bookings"
5115 + id="booking_confirmation"
5116 + checked={formData.booking_confirmation}
5117 + name="booking_confirmation"
5095 5118 onChange={handleFieldChange}
5096 5119 className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
5097 5120 />
5098 5121 <div className="flex-1">
5099 5122 <Label
5100 - htmlFor="auto_confirm_bookings"
5123 + htmlFor="booking_confirmation"
5101 5124 className="font-medium cursor-pointer"
5102 5125 >
5103 - {__("Auto-Confirm Bookings", "yatra")}
5126 + {__("Send Booking Confirmation Email", "yatra")}
5104 5127 </Label>
5105 5128 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
5106 5129 {__(
5107 - "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.",
5108 5131 "yatra",
5109 5132 )}
5110 5133 </p>
5111 5134 </div>
@@ -5110,8 +5133,85 @@
5110 5133 </p>
5111 5134 </div>
5112 5135 </div>
5113 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 +
5114 5214 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
5115 5215 <input
5116 5216 type="checkbox"
5117 5217 id="require_login"
@@ -5160,11 +5260,94 @@
5160 5260 </p>
5161 5261 </div>
5162 5262 </div>
5163 5263
5264 + {/*
5265 + Guest email verification — only meaningful when guest
5266 + checkout is allowed AND login isn't required, since
5267 + logged-in customers' emails are verified by WordPress
5268 + on registration. We still render the checkbox at all
5269 + times so operators can pre-configure before flipping
5270 + the parent toggles; the help text explains the
5271 + dependency rather than hiding the control.
5272 + */}
5164 5273 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
5165 5274 <input
5166 5275 type="checkbox"
5276 + id="require_guest_email_verification"
5277 + checked={formData.require_guest_email_verification}
5278 + name="require_guest_email_verification"
5279 + onChange={handleFieldChange}
5280 + disabled={
5281 + !formData.allow_guest_checkout || formData.require_login
5282 + }
5283 + className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:opacity-50"
5284 + />
5285 + <div className="flex-1">
5286 + <Label
5287 + htmlFor="require_guest_email_verification"
5288 + className="font-medium cursor-pointer"
5289 + >
5290 + {__("Require Guest Email Verification", "yatra")}
5291 + </Label>
5292 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
5293 + {__(
5294 + "Hold guest bookings until the customer clicks a magic link sent to their email. Stops typo'd addresses and form-spam bots. Only applies when guest checkout is allowed and login isn't required — logged-in customers are already verified.",
5295 + "yatra",
5296 + )}
5297 + </p>
5298 + </div>
5299 + </div>
5300 +
5301 + <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
5302 + <input
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"
5167 5350 id="allow_waitlist"
5168 5351 checked={formData.allow_waitlist}
5169 5352 name="allow_waitlist"
5170 5353 onChange={handleFieldChange}
@@ -5209,80 +5392,65 @@
5209 5392 )}
5210 5393 </p>
5211 5394 </div>
5212 5395 </div>
5396 +
5397 + {/* Pro feature: replace flatpickr calendar with a dropdown of
5398 + available departure dates on the single-trip sidebar + the
5399 + mobile sticky bar. The checkbox stays interactive for non-Pro
5400 + installs so the value can be staged, but the frontend gate
5401 + ignores it unless Pro is active. */}
5402 + <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
5403 + <input
5404 + type="checkbox"
5405 + id="date_picker_as_dropdown"
5406 + checked={!!formData.date_picker_as_dropdown}
5407 + name="date_picker_as_dropdown"
5408 + onChange={handleFieldChange}
5409 + disabled={!(window as any).yatraAdmin?.isPro}
5410 + className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:opacity-50"
5411 + />
5412 + <div className="flex-1">
5413 + <Label
5414 + htmlFor="date_picker_as_dropdown"
5415 + className="font-medium cursor-pointer inline-flex items-center"
5416 + >
5417 + {__("Show available dates as a dropdown", "yatra")}
5418 + <ProBadge
5419 + isProActive={!!(window as any).yatraAdmin?.isPro}
5420 + />
5421 + </Label>
5422 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
5423 + {__(
5424 + "Replace the calendar date picker with a dropdown listing every available departure date on the single-trip sidebar and the mobile sticky bar. Best for trips with a small set of fixed departures.",
5425 + "yatra",
5426 + )}
5427 + </p>
5428 + </div>
5429 + </div>
5213 5430 </div>
5214 5431
5215 - <SectionDivider
5216 - title={__("Cancellation & Refund Policy", "yatra")}
5217 - />
5432 + {/*
5433 + Cancellation & Refund Policy section removed.
5218 5434
5219 - <div className="space-y-4">
5220 - <FormField
5221 - id="cancellation_policy"
5222 - label={__("Cancellation Policy", "yatra")}
5223 - description={__(
5224 - "What happens when a booking is cancelled",
5225 - "yatra",
5226 - )}
5227 - >
5228 - <Select
5229 - id="cancellation_policy"
5230 - value={formData.cancellation_policy}
5231 - name="cancellation_policy"
5232 - onChange={handleFieldChange}
5233 - >
5234 - <option value="no_refund">{__("No Refund", "yatra")}</option>
5235 - <option value="full_refund">
5236 - {__("Full Refund", "yatra")}
5237 - </option>
5238 - <option value="partial_refund">
5239 - {__("Partial Refund", "yatra")}
5240 - </option>
5241 - </Select>
5242 - </FormField>
5435 + Earlier versions exposed three settings here
5436 + (cancellation_policy dropdown, cancellation_days number,
5437 + refund_policy textarea). All three were display-only —
5438 + they appeared in the booking confirmation email but
5439 + did NOT enforce any cancellation cutoff or compute any
5440 + refund amount, because Yatra has no customer-facing
5441 + self-service cancellation flow. The names implied
5442 + enforcement that did not exist, which is worse than
5443 + having no setting at all.
5243 5444
5244 - <FormField
5245 - id="cancellation_days"
5246 - label={__("Cancellation Days Before Departure", "yatra")}
5247 - description={__(
5248 - "Number of days before departure when cancellation is allowed",
5249 - "yatra",
5250 - )}
5251 - >
5252 - <Input
5253 - id="cancellation_days"
5254 - type="number"
5255 - value={formData.cancellation_days}
5256 - name="cancellation_days"
5257 - onChange={handleFieldChange}
5258 - min="0"
5259 - />
5260 - </FormField>
5445 + Per-trip cancellation copy still lives on the Trip
5446 + editor (Trip Builder → cancellation_policy field) and
5447 + is what the booking confirmation page + email now
5448 + show. To restore site-wide cancellation enforcement,
5449 + build the cancellation REST endpoint and re-add gated
5450 + settings here.
5451 + */}
5261 5452
5262 - <FormField
5263 - id="refund_policy"
5264 - label={__("Refund Policy", "yatra")}
5265 - description={__(
5266 - "Detailed refund policy description shown to customers",
5267 - "yatra",
5268 - )}
5269 - >
5270 - <textarea
5271 - id="refund_policy"
5272 - value={formData.refund_policy}
5273 - name="refund_policy"
5274 - onChange={handleFieldChange}
5275 - rows={4}
5276 - className="flex w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:ring-offset-gray-900 dark:placeholder:text-gray-400 dark:focus-visible:ring-blue-400 resize-none"
5277 - placeholder={__(
5278 - "Enter your refund policy details...",
5279 - "yatra",
5280 - )}
5281 - />
5282 - </FormField>
5283 - </div>
5284 -
5285 5453 <SectionDivider title={__("Booking Expiry & Reminders", "yatra")} />
5286 5454
5287 5455 <div className="space-y-4">
5288 5456 <FormField
@@ -5323,8 +5491,188 @@
5323 5491 </div>
5324 5492 </div>
5325 5493 );
5326 5494
5495 + case "pricing": {
5496 + // Snapshot the two Pro-module flags. The free plugin's
5497 + // AdminAssetsProvider sets these directly from the canonical
5498 + // ModuleManager::isModuleEnabled(). Truthy check (not
5499 + // `=== true`) is mandatory because wp_localize_script
5500 + // serializes every scalar to a string before handing it to
5501 + // JS — true becomes "1", false becomes "". Comparing
5502 + // === true would always be false for an enabled module,
5503 + // which was why every operator saw the "not active" banner
5504 + // even on a correctly-configured site.
5505 + const adEnabled = !!(window as any)?.yatraAdmin
5506 + ?.advancedDiscountEnabled;
5507 + const dpEnabled = !!(window as any)?.yatraAdmin?.dynamicPricingEnabled;
5508 + const bothActive = adEnabled && dpEnabled;
5509 + return (
5510 + <div className="space-y-6">
5511 + <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">
5512 + {__(
5513 + "Controls what happens when both a Discount (coupon or group discount) and a Dynamic Pricing rule could apply to the same booking. The control only takes effect when both the Advanced Discount and Dynamic Pricing modules are turned on.",
5514 + "yatra",
5515 + )}
5516 + </div>
5517 +
5518 + {/* Module-status banner. Green when both modules are on,
5519 + amber when the setting is inert because one (or both)
5520 + is off. Either way the dropdown stays rendered so the
5521 + operator knows the feature exists and what it does. */}
5522 + {bothActive ? (
5523 + <div className="rounded-md border border-green-200 bg-green-50 p-3 text-xs text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-200">
5524 + <div className="flex items-start gap-2">
5525 + <Check className="mt-0.5 h-4 w-4 flex-shrink-0" />
5526 + <div>
5527 + <div className="font-medium">{__("Active", "yatra")}</div>
5528 + <div className="mt-0.5">
5529 + {__(
5530 + "Advanced Discount and Dynamic Pricing are both enabled. Your stacking choice below is being applied to every new booking calculation.",
5531 + "yatra",
5532 + )}
5533 + </div>
5534 + </div>
5535 + </div>
5536 + </div>
5537 + ) : (
5538 + <div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-200">
5539 + <div className="flex items-start gap-2">
5540 + <AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
5541 + <div>
5542 + <div className="font-medium">
5543 + {__("Not active yet", "yatra")}
5544 + </div>
5545 + <div className="mt-0.5">
5546 + {__(
5547 + "This setting only applies when BOTH modules below are enabled. Without both, bookings calculate exactly as they do today (no stacking control).",
5548 + "yatra",
5549 + )}
5550 + </div>
5551 + <ul className="mt-2 space-y-1">
5552 + <li className="flex items-center gap-1.5">
5553 + {adEnabled ? (
5554 + <Check className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />
5555 + ) : (
5556 + <XCircle className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400" />
5557 + )}
5558 + <span>
5559 + {__("Advanced Discount module", "yatra")}{" "}
5560 + {adEnabled
5561 + ? __("(enabled)", "yatra")
5562 + : __("(not enabled)", "yatra")}
5563 + </span>
5564 + </li>
5565 + <li className="flex items-center gap-1.5">
5566 + {dpEnabled ? (
5567 + <Check className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />
5568 + ) : (
5569 + <XCircle className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400" />
5570 + )}
5571 + <span>
5572 + {__("Dynamic Pricing module", "yatra")}{" "}
5573 + {dpEnabled
5574 + ? __("(enabled)", "yatra")
5575 + : __("(not enabled)", "yatra")}
5576 + </span>
5577 + </li>
5578 + </ul>
5579 + <button
5580 + type="button"
5581 + onClick={() => navigateMenu("modules")}
5582 + className="mt-2 inline-flex items-center gap-1 text-amber-900 underline hover:text-amber-700 dark:text-amber-100 dark:hover:text-amber-300"
5583 + >
5584 + {__("Open Modules", "yatra")}
5585 + <ExternalLink className="h-3 w-3" />
5586 + </button>
5587 + </div>
5588 + </div>
5589 + </div>
5590 + )}
5591 +
5592 + <FormField
5593 + id="discount_stacking_mode"
5594 + label={__("Discount Stacking", "yatra")}
5595 + description={__(
5596 + "Pick how Discounts and Dynamic Pricing combine on the same booking.",
5597 + "yatra",
5598 + )}
5599 + >
5600 + <Select
5601 + id="discount_stacking_mode"
5602 + name="discount_stacking_mode"
5603 + value={formData.discount_stacking_mode ?? "both"}
5604 + onChange={handleFieldChange}
5605 + disabled={!bothActive}
5606 + >
5607 + <option value="both">
5608 + {__(
5609 + "Both apply (legacy) — Dynamic Pricing and Discount stack",
5610 + "yatra",
5611 + )}
5612 + </option>
5613 + <option value="best_for_customer">
5614 + {__(
5615 + "Best for the customer — pick whichever single mechanism gives the larger reduction",
5616 + "yatra",
5617 + )}
5618 + </option>
5619 + <option value="discount_only">
5620 + {__(
5621 + "Discount only — if a coupon or group discount applies, ignore Dynamic Pricing",
5622 + "yatra",
5623 + )}
5624 + </option>
5625 + <option value="dynamic_pricing_only">
5626 + {__(
5627 + "Dynamic Pricing only — if a Dynamic Pricing rule fires, ignore the discount",
5628 + "yatra",
5629 + )}
5630 + </option>
5631 + </Select>
5632 + </FormField>
5633 +
5634 + <div className="rounded-md border border-blue-200 bg-blue-50 p-3 text-xs text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
5635 + {__("What each mode does:", "yatra")}
5636 + <ul className="mt-2 ml-4 list-disc space-y-1">
5637 + <li>
5638 + <strong>{__("Both apply", "yatra")}</strong>
5639 + {" — "}
5640 + {__(
5641 + "default and matches the original behavior. A coupon stacks on top of the Dynamic Pricing-adjusted price.",
5642 + "yatra",
5643 + )}
5644 + </li>
5645 + <li>
5646 + <strong>{__("Best for the customer", "yatra")}</strong>
5647 + {" — "}
5648 + {__(
5649 + "calculates both scenarios and picks the cheaper one for the customer. Never combines.",
5650 + "yatra",
5651 + )}
5652 + </li>
5653 + <li>
5654 + <strong>{__("Discount only", "yatra")}</strong>
5655 + {" — "}
5656 + {__(
5657 + "when a discount is valid, Dynamic Pricing is skipped for that booking.",
5658 + "yatra",
5659 + )}
5660 + </li>
5661 + <li>
5662 + <strong>{__("Dynamic Pricing only", "yatra")}</strong>
5663 + {" — "}
5664 + {__(
5665 + "when a Dynamic Pricing rule reduced the price, discount codes and group discounts are ignored for that booking.",
5666 + "yatra",
5667 + )}
5668 + </li>
5669 + </ul>
5670 + </div>
5671 + </div>
5672 + );
5673 + }
5674 +
5327 5675 case "customer":
5328 5676 return (
5329 5677 <div className="space-y-6">
5330 5678 <div className="space-y-4">
@@ -5591,9 +5939,9 @@
5591 5939 <FormField
5592 5940 id="review_reminder_days"
5593 5941 label={__("Review Reminder Days", "yatra")}
5594 5942 description={__(
5595 - "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.",
5596 5944 "yatra",
5597 5945 )}
5598 5946 >
5599 5947 <Input
@@ -5604,8 +5952,26 @@
5604 5952 onChange={handleFieldChange}
5605 5953 min="0"
5606 5954 />
5607 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>
5608 5974 </>
5609 5975 )}
5610 5976 </div>
5611 5977 </div>
@@ -6793,37 +7159,46 @@
6793 7159 {/* Google Analytics 4 Enhanced - Pro Feature */}
6794 7160 <SectionDivider
6795 7161 title={__("Google Analytics 4 Enhanced", "yatra")}
6796 7162 />
6797 - <Card>
6798 - <CardHeader className="flex flex-row items-center justify-between pb-2">
6799 - <CardTitle className="text-lg flex items-center gap-2">
6800 - <svg
6801 - className="w-5 h-5 text-orange-500"
6802 - viewBox="0 0 24 24"
6803 - fill="currentColor"
6804 - >
6805 - <path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z" />
6806 - </svg>
6807 - {__("Google Analytics 4 Enhanced", "yatra")}
6808 - <ProBadge isProActive={(window as any).yatraAdmin?.isPro} />
7163 + <Card className="overflow-hidden border-gray-200 dark:border-gray-700 shadow-sm">
7164 + <CardHeader className="flex flex-row flex-wrap items-start gap-3 justify-between border-b border-gray-100 dark:border-gray-800 bg-gray-50/80 dark:bg-gray-900/40 pb-4">
7165 + <CardTitle className="text-lg flex items-center gap-2.5 text-gray-900 dark:text-white">
7166 + <span className="flex h-9 w-9 items-center justify-center rounded-lg bg-orange-500/10 dark:bg-orange-500/15">
7167 + <BarChart3
7168 + className="w-5 h-5 text-orange-600 dark:text-orange-400"
7169 + aria-hidden
7170 + />
7171 + </span>
7172 + <span className="flex flex-wrap items-center gap-2 leading-tight">
7173 + {__("Google Analytics 4 Enhanced", "yatra")}
7174 + <ProBadge isProActive={(window as any).yatraAdmin?.isPro} />
7175 + </span>
6809 7176 </CardTitle>
6810 - {(window as any).yatraAdmin?.googleAnalytics?.connected ? (
6811 - <div className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400">
6812 - <CheckCircle className="w-4 h-4" />
6813 - {__("Connected", "yatra")}
6814 - </div>
6815 - ) : (
6816 - <div className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
6817 - <XCircle className="w-4 h-4" />
6818 - {__("Not Connected", "yatra")}
6819 - </div>
6820 - )}
7177 + {(() => {
7178 + const ga4HasId = Boolean(formData.ga4_measurement_id?.trim());
7179 + const ga4Connected =
7180 + ga4HasId ||
7181 + Boolean(
7182 + (window as any).yatraAdmin?.googleAnalytics?.connected,
7183 + );
7184 + return ga4Connected ? (
7185 + <div className="inline-flex items-center gap-1.5 rounded-full border border-green-200 bg-green-50 px-2.5 py-1 text-xs font-medium text-green-800 dark:border-green-800 dark:bg-green-950/40 dark:text-green-300">
7186 + <CheckCircle className="w-3.5 h-3.5 shrink-0" />
7187 + {__("Measurement ID set", "yatra")}
7188 + </div>
7189 + ) : (
7190 + <div className="inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400">
7191 + <XCircle className="w-3.5 h-3.5 shrink-0" />
7192 + {__("Not configured", "yatra")}
7193 + </div>
7194 + );
7195 + })()}
6821 7196 </CardHeader>
6822 - <CardContent className="space-y-4">
6823 - <p className="text-sm text-gray-600 dark:text-gray-300">
7197 + <CardContent className="space-y-6 pt-6">
7198 + <p className="text-sm leading-relaxed text-gray-600 dark:text-gray-300">
6824 7199 {__(
6825 - "Enhanced e-commerce tracking for GA4. Track view_item, begin_checkout, and purchase events with server-side Measurement Protocol support.",
7200 + "Connect GA4 for ecommerce events (view item, add to cart, checkout, purchase). Optional Measurement Protocol sends verified server-side events for more reliable attribution.",
6826 7201 "yatra",
6827 7202 )}
6828 7203 </p>
6829 7204
@@ -6839,206 +7214,114 @@
6839 7214 isModuleEnabled={
6840 7215 (window as any).yatraAdmin?.showGoogleAnalyticsSettingsUI
6841 7216 }
6842 7217 >
6843 - <div className="space-y-4">
6844 - <FormField
6845 - id="ga4_measurement_id"
6846 - label={__("Measurement ID", "yatra")}
6847 - description={
6848 - <>
6849 - {__("Get your Measurement ID from", "yatra")}{" "}
6850 - <a
6851 - href="https://analytics.google.com/analytics/web/"
6852 - target="_blank"
6853 - rel="noopener noreferrer"
6854 - className="text-blue-600 hover:text-blue-500 dark:text-blue-400 underline"
6855 - >
6856 - {__("Google Analytics", "yatra")}
6857 - </a>{" "}
6858 - {__("(Admin > Data Streams)", "yatra")}
6859 - </>
6860 - }
6861 - actionButton={
6862 - <Button
6863 - type="button"
6864 - variant="outline"
6865 - size="sm"
6866 - onClick={() => validateMeasurementId()}
6867 - disabled={
6868 - !formData.ga4_measurement_id ||
6869 - validatingMeasurementId
6870 - }
6871 - className="shrink-0"
6872 - >
6873 - {validatingMeasurementId ? (
6874 - <>
6875 - <svg
6876 - className="animate-spin -ml-1 mr-2 h-4 w-4"
6877 - fill="none"
6878 - viewBox="0 0 24 24"
6879 - >
6880 - <circle
6881 - className="opacity-25"
6882 - cx="12"
6883 - cy="12"
6884 - r="10"
6885 - stroke="currentColor"
6886 - strokeWidth="4"
6887 - ></circle>
6888 - <path
6889 - className="opacity-75"
6890 - fill="currentColor"
6891 - d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
6892 - ></path>
6893 - </svg>
6894 - {__("Validating...", "yatra")}
6895 - </>
6896 - ) : (
6897 - <>
6898 - <svg
6899 - className="-ml-1 mr-2 h-4 w-4"
6900 - fill="none"
6901 - viewBox="0 0 24 24"
6902 - stroke="currentColor"
6903 - >
6904 - <path
6905 - strokeLinecap="round"
6906 - strokeLinejoin="round"
6907 - strokeWidth={2}
6908 - d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
6909 - />
6910 - </svg>
6911 - {__("Validate", "yatra")}
6912 - </>
7218 + <div className="space-y-8">
7219 + {/* Connection */}
7220 + <div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-950/30 sm:p-5">
7221 + <div className="mb-4">
7222 + <h4 className="text-sm font-semibold text-gray-900 dark:text-white">
7223 + {__("Connection", "yatra")}
7224 + </h4>
7225 + <p className="mt-1 text-xs leading-relaxed text-gray-500 dark:text-gray-400">
7226 + {__(
7227 + "Use your GA4 web data stream Measurement ID (format G-XXXXXXXXXX). Find it under Admin → Data streams → your stream.",
7228 + "yatra",
6913 7229 )}
6914 - </Button>
6915 - }
6916 - >
6917 - <Input
6918 - id="ga4_measurement_id"
6919 - value={formData.ga4_measurement_id || ""}
6920 - name="ga4_measurement_id"
6921 - onChange={handleFieldChange}
6922 - placeholder="G-XXXXXXXXXX"
6923 - />
6924 - </FormField>
6925 -
6926 - <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
6927 - <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
6928 - <input
6929 - type="checkbox"
6930 - id="ga4_track_view_item"
6931 - checked={formData.ga4_track_view_item ?? false}
6932 - name="ga4_track_view_item"
6933 - onChange={handleFieldChange}
6934 - className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
6935 - />
6936 - <Label
6937 - htmlFor="ga4_track_view_item"
6938 - className="text-sm cursor-pointer"
6939 - >
6940 - {__("Track View Item", "yatra")}
6941 - </Label>
7230 + </p>
6942 7231 </div>
6943 -
6944 - <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
6945 - <input
6946 - type="checkbox"
6947 - id="ga4_track_add_to_cart"
6948 - checked={formData.ga4_track_add_to_cart ?? false}
6949 - name="ga4_track_add_to_cart"
6950 - onChange={handleFieldChange}
6951 - className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
6952 - />
6953 - <Label
6954 - htmlFor="ga4_track_add_to_cart"
6955 - className="text-sm cursor-pointer"
6956 - >
6957 - {__("Track Add to Cart", "yatra")}
6958 - </Label>
6959 - </div>
6960 -
6961 - <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
6962 - <input
6963 - type="checkbox"
6964 - id="ga4_track_begin_checkout"
6965 - checked={formData.ga4_track_begin_checkout ?? false}
6966 - name="ga4_track_begin_checkout"
6967 - onChange={handleFieldChange}
6968 - className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
6969 - />
6970 - <Label
6971 - htmlFor="ga4_track_begin_checkout"
6972 - className="text-sm cursor-pointer"
6973 - >
6974 - {__("Track Begin Checkout", "yatra")}
6975 - </Label>
6976 - </div>
6977 -
6978 - <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
6979 - <input
6980 - type="checkbox"
6981 - id="ga4_track_purchase"
6982 - checked={formData.ga4_track_purchase ?? false}
6983 - name="ga4_track_purchase"
6984 - onChange={handleFieldChange}
6985 - className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
6986 - />
6987 - <Label
6988 - htmlFor="ga4_track_purchase"
6989 - className="text-sm cursor-pointer"
6990 - >
6991 - {__("Track Purchase", "yatra")}
6992 - </Label>
6993 - </div>
6994 - </div>
6995 - <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
6996 - <input
6997 - type="checkbox"
6998 - id="ga4_use_measurement_protocol"
6999 - checked={formData.ga4_use_measurement_protocol ?? false}
7000 - name="ga4_use_measurement_protocol"
7001 - onChange={handleFieldChange}
7002 - className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
7003 - />
7004 - <Label
7005 - htmlFor="ga4_use_measurement_protocol"
7006 - className="text-sm cursor-pointer"
7007 - >
7008 - {__("Use Measurement Protocol", "yatra")}
7009 - </Label>
7010 - </div>
7011 -
7012 - {formData.ga4_use_measurement_protocol && (
7013 7232 <FormField
7014 - id="ga4_api_secret"
7015 - label={__("API Secret", "yatra")}
7016 - description={__(
7017 - "Generate API secret from Google Analytics Admin > Data Streams > Measurement Protocol",
7018 - "yatra",
7019 - )}
7233 + id="ga4_measurement_id"
7234 + label={__("Measurement ID", "yatra")}
7235 + description={
7236 + <div className="space-y-2">
7237 + <p>
7238 + {__(
7239 + "Your GA4 web data stream Measurement ID (format G-XXXXXXXXXX). The Validate button checks the format only — confirm the stream is live in your GA4 Admin.",
7240 + "yatra",
7241 + )}
7242 + </p>
7243 + <details className="text-sm">
7244 + <summary className="cursor-pointer font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400">
7245 + {__("How to get your Measurement ID:", "yatra")}
7246 + </summary>
7247 + <ol className="mt-2 ml-4 list-decimal space-y-1 text-gray-600 dark:text-gray-400">
7248 + <li>
7249 + {__(
7250 + "Go to Google Analytics: analytics.google.com",
7251 + "yatra",
7252 + )}
7253 + </li>
7254 + <li>
7255 + {__(
7256 + "Sign in and select the property you want to track this site with (or create one: Admin → Create → Property → choose your business details).",
7257 + "yatra",
7258 + )}
7259 + </li>
7260 + <li>
7261 + {__(
7262 + "In the bottom-left corner click the gear icon (Admin).",
7263 + "yatra",
7264 + )}
7265 + </li>
7266 + <li>
7267 + {__(
7268 + "In the Property column click Data Streams.",
7269 + "yatra",
7270 + )}
7271 + </li>
7272 + <li>
7273 + {__(
7274 + "Click your existing Web stream — or click Add stream → Web, enter your site URL and a stream name, then Create stream.",
7275 + "yatra",
7276 + )}
7277 + </li>
7278 + <li>
7279 + {__(
7280 + "On the stream details panel, copy the Measurement ID at the top right (format G-XXXXXXXXXX).",
7281 + "yatra",
7282 + )}
7283 + </li>
7284 + <li>
7285 + {__(
7286 + "Paste it in the field above and click Validate.",
7287 + "yatra",
7288 + )}
7289 + </li>
7290 + </ol>
7291 + </details>
7292 + <div className="mt-2">
7293 + <a
7294 + href="https://analytics.google.com/analytics/web/"
7295 + target="_blank"
7296 + rel="noopener noreferrer"
7297 + className="text-blue-600 hover:text-blue-500 dark:text-blue-400 underline text-sm"
7298 + >
7299 + {__("Open Google Analytics →", "yatra")}
7300 + </a>
7301 + </div>
7302 + </div>
7303 + }
7020 7304 actionButton={
7021 7305 <Button
7022 7306 type="button"
7023 7307 variant="outline"
7024 7308 size="sm"
7025 - onClick={() => validateApiSecret()}
7309 + onClick={() => validateMeasurementId()}
7026 7310 disabled={
7027 - !formData.ga4_api_secret ||
7028 - !formData.ga4_measurement_id ||
7029 - validatingApiSecret
7311 + !formData.ga4_measurement_id?.trim() ||
7312 + validatingMeasurementId
7030 7313 }
7031 7314 className="shrink-0"
7032 7315 >
7033 - {validatingApiSecret ? (
7316 + {validatingMeasurementId ? (
7034 7317 <>
7035 - <Loader2 className="w-4 h-4 animate-spin" />
7318 + <Loader2 className="mr-2 h-4 w-4 animate-spin" />
7036 7319 {__("Validating...", "yatra")}
7037 7320 </>
7038 7321 ) : (
7039 7322 <>
7040 - <CheckCircle className="w-4 h-4" />
7323 + <Check className="mr-2 h-4 w-4" />
7041 7324 {__("Validate", "yatra")}
7042 7325 </>
7043 7326 )}
7044 7327 </Button>
@@ -7044,56 +7327,375 @@
7044 7327 </Button>
7045 7328 }
7046 7329 >
7047 7330 <Input
7048 - id="ga4_api_secret"
7049 - value={formData.ga4_api_secret || ""}
7050 - name="ga4_api_secret"
7331 + id="ga4_measurement_id"
7332 + value={formData.ga4_measurement_id || ""}
7333 + name="ga4_measurement_id"
7051 7334 onChange={handleFieldChange}
7052 - placeholder="Your API secret"
7335 + placeholder="G-XXXXXXXXXX"
7336 + className="font-mono text-sm"
7337 + spellCheck={false}
7338 + autoComplete="off"
7053 7339 />
7054 7340 </FormField>
7055 - )}
7056 - </div>
7057 - </ProFeature>
7341 + </div>
7058 7342
7059 - {/* Google Analytics Monitoring Notice */}
7060 - {(window as any).yatraAdmin?.googleAnalytics?.measurementId && (
7061 - <div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
7062 - <div className="flex items-center gap-2">
7063 - <svg
7064 - className="w-4 h-4 text-blue-600 dark:text-blue-400"
7065 - fill="none"
7066 - stroke="currentColor"
7067 - viewBox="0 0 24 24"
7068 - >
7069 - <path
7070 - strokeLinecap="round"
7071 - strokeLinejoin="round"
7072 - strokeWidth={2}
7073 - d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
7074 - />
7075 - </svg>
7076 - <div className="text-sm">
7077 - <span className="text-blue-800 dark:text-blue-300 font-medium">
7078 - {__("Google Analytics Monitoring:", "yatra")}
7079 - </span>
7080 - <span className="text-blue-700 dark:text-blue-400 ml-1">
7343 + {/* Events */}
7344 + <div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-950/30 sm:p-5">
7345 + <div className="mb-4">
7346 + <h4 className="text-sm font-semibold text-gray-900 dark:text-white">
7347 + {__("Events", "yatra")}
7348 + </h4>
7349 + <p className="mt-1 text-xs leading-relaxed text-gray-500 dark:text-gray-400">
7081 7350 {__(
7082 - "View event statistics and activity in the ",
7351 + "Choose which standard ecommerce events Yatra should emit to GA4 on the storefront and booking flow.",
7083 7352 "yatra",
7084 7353 )}
7085 - <a
7086 - href={`${(window as any).yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=reports`}
7087 - className="underline hover:text-blue-600 dark:hover:text-blue-300 font-medium"
7354 + </p>
7355 + </div>
7356 + <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
7357 + <div className="flex gap-3 rounded-lg border border-gray-100 bg-gray-50/80 p-3 dark:border-gray-700 dark:bg-gray-900/50">
7358 + <input
7359 + type="checkbox"
7360 + id="ga4_track_view_item"
7361 + checked={formData.ga4_track_view_item ?? true}
7362 + name="ga4_track_view_item"
7363 + onChange={handleFieldChange}
7364 + className="mt-0.5 h-4 w-4 shrink-0 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
7365 + />
7366 + <div className="min-w-0 flex-1">
7367 + <Label
7368 + htmlFor="ga4_track_view_item"
7369 + className="cursor-pointer text-sm font-medium text-gray-900 dark:text-white"
7370 + >
7371 + {__("View item", "yatra")}
7372 + </Label>
7373 + <p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
7374 + {__(
7375 + "Trip detail pages (recommended for remarketing).",
7376 + "yatra",
7377 + )}
7378 + </p>
7379 + </div>
7380 + </div>
7381 + <div className="flex gap-3 rounded-lg border border-gray-100 bg-gray-50/80 p-3 dark:border-gray-700 dark:bg-gray-900/50">
7382 + <input
7383 + type="checkbox"
7384 + id="ga4_track_add_to_cart"
7385 + checked={formData.ga4_track_add_to_cart ?? true}
7386 + name="ga4_track_add_to_cart"
7387 + onChange={handleFieldChange}
7388 + className="mt-0.5 h-4 w-4 shrink-0 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
7389 + />
7390 + <div className="min-w-0 flex-1">
7391 + <Label
7392 + htmlFor="ga4_track_add_to_cart"
7393 + className="cursor-pointer text-sm font-medium text-gray-900 dark:text-white"
7394 + >
7395 + {__("Add to cart", "yatra")}
7396 + </Label>
7397 + <p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
7398 + {__(
7399 + "When a trip is added to the booking session / checkout.",
7400 + "yatra",
7401 + )}
7402 + </p>
7403 + </div>
7404 + </div>
7405 + <div className="flex gap-3 rounded-lg border border-gray-100 bg-gray-50/80 p-3 dark:border-gray-700 dark:bg-gray-900/50">
7406 + <input
7407 + type="checkbox"
7408 + id="ga4_track_begin_checkout"
7409 + checked={formData.ga4_track_begin_checkout ?? true}
7410 + name="ga4_track_begin_checkout"
7411 + onChange={handleFieldChange}
7412 + className="mt-0.5 h-4 w-4 shrink-0 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
7413 + />
7414 + <div className="min-w-0 flex-1">
7415 + <Label
7416 + htmlFor="ga4_track_begin_checkout"
7417 + className="cursor-pointer text-sm font-medium text-gray-900 dark:text-white"
7418 + >
7419 + {__("Begin checkout", "yatra")}
7420 + </Label>
7421 + <p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
7422 + {__(
7423 + "When a booking is created (checkout started).",
7424 + "yatra",
7425 + )}
7426 + </p>
7427 + </div>
7428 + </div>
7429 + <div className="flex gap-3 rounded-lg border border-gray-100 bg-gray-50/80 p-3 dark:border-gray-700 dark:bg-gray-900/50">
7430 + <input
7431 + type="checkbox"
7432 + id="ga4_track_purchase"
7433 + checked={formData.ga4_track_purchase ?? true}
7434 + name="ga4_track_purchase"
7435 + onChange={handleFieldChange}
7436 + className="mt-0.5 h-4 w-4 shrink-0 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
7437 + />
7438 + <div className="min-w-0 flex-1">
7439 + <Label
7440 + htmlFor="ga4_track_purchase"
7441 + className="cursor-pointer text-sm font-medium text-gray-900 dark:text-white"
7442 + >
7443 + {__("Purchase", "yatra")}
7444 + </Label>
7445 + <p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
7446 + {__(
7447 + "When payment completes (and on confirmation page).",
7448 + "yatra",
7449 + )}
7450 + </p>
7451 + </div>
7452 + </div>
7453 + </div>
7454 + </div>
7455 +
7456 + {/* Measurement Protocol */}
7457 + <div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-950/30 sm:p-5">
7458 + <div className="mb-4">
7459 + <h4 className="text-sm font-semibold text-gray-900 dark:text-white">
7460 + {__("Server-side (Measurement Protocol)", "yatra")}
7461 + </h4>
7462 + <p className="mt-1 text-xs leading-relaxed text-gray-500 dark:text-gray-400">
7463 + {__(
7464 + "Send duplicate events from your server with an API secret—useful when ad blockers affect the browser tag or for reconciliation.",
7465 + "yatra",
7466 + )}
7467 + </p>
7468 + </div>
7469 +
7470 + <div className="flex gap-3 rounded-lg border border-gray-100 bg-gray-50/80 p-3 dark:border-gray-700 dark:bg-gray-900/50">
7471 + <input
7472 + type="checkbox"
7473 + id="ga4_use_measurement_protocol"
7474 + checked={
7475 + formData.ga4_use_measurement_protocol ?? false
7476 + }
7477 + name="ga4_use_measurement_protocol"
7478 + onChange={handleFieldChange}
7479 + className="mt-0.5 h-4 w-4 shrink-0 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
7480 + />
7481 + <div className="min-w-0 flex-1">
7482 + <Label
7483 + htmlFor="ga4_use_measurement_protocol"
7484 + className="cursor-pointer text-sm font-medium text-gray-900 dark:text-white"
7088 7485 >
7089 - {__("Reports page", "yatra")}
7090 - </a>
7091 - {__(" → Google Analytics 4 tab.", "yatra")}
7092 - </span>
7486 + {__("Enable Measurement Protocol", "yatra")}
7487 + </Label>
7488 + <p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
7489 + {__(
7490 + "Requires an API secret from the same GA4 data stream.",
7491 + "yatra",
7492 + )}
7493 + </p>
7494 + </div>
7093 7495 </div>
7496 +
7497 + {formData.ga4_use_measurement_protocol && (
7498 + <div className="mt-4 space-y-4 border-t border-gray-100 pt-4 dark:border-gray-800">
7499 + <div className="flex gap-3 rounded-lg border border-amber-100 bg-amber-50/60 p-3 dark:border-amber-900/40 dark:bg-amber-950/20">
7500 + <input
7501 + type="checkbox"
7502 + id="ga4_debug_mode"
7503 + checked={formData.ga4_debug_mode ?? false}
7504 + name="ga4_debug_mode"
7505 + onChange={handleFieldChange}
7506 + className="mt-0.5 h-4 w-4 shrink-0 rounded border-amber-300 text-amber-600 focus:ring-amber-500"
7507 + />
7508 + <div className="min-w-0 flex-1">
7509 + <Label
7510 + htmlFor="ga4_debug_mode"
7511 + className="cursor-pointer text-sm font-medium text-amber-950 dark:text-amber-100"
7512 + >
7513 + {__(
7514 + "Validation mode (debug endpoint)",
7515 + "yatra",
7516 + )}
7517 + </Label>
7518 + <p className="mt-0.5 text-xs text-amber-900/90 dark:text-amber-200/90">
7519 + {__(
7520 + "Send Measurement Protocol hits to Google’s validation URL for troubleshooting. Events won’t appear in standard reports.",
7521 + "yatra",
7522 + )}
7523 + </p>
7524 + </div>
7525 + </div>
7526 +
7527 + <FormField
7528 + id="ga4_api_secret"
7529 + label={__(
7530 + "Measurement Protocol API secret",
7531 + "yatra",
7532 + )}
7533 + description={
7534 + <div className="space-y-2">
7535 + <p>
7536 + {__(
7537 + "Treat this like a password — anyone with it can send fake events to your GA4 property. Restrict who can view Yatra settings.",
7538 + "yatra",
7539 + )}
7540 + </p>
7541 + <details className="text-sm">
7542 + <summary className="cursor-pointer font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400">
7543 + {__("How to get your API secret:", "yatra")}
7544 + </summary>
7545 + <ol className="mt-2 ml-4 list-decimal space-y-1 text-gray-600 dark:text-gray-400">
7546 + <li>
7547 + {__(
7548 + "Go to Google Analytics: analytics.google.com",
7549 + "yatra",
7550 + )}
7551 + </li>
7552 + <li>
7553 + {__(
7554 + "Sign in and select the same property you used for the Measurement ID above.",
7555 + "yatra",
7556 + )}
7557 + </li>
7558 + <li>
7559 + {__(
7560 + "Click the gear icon (Admin) in the bottom-left corner.",
7561 + "yatra",
7562 + )}
7563 + </li>
7564 + <li>
7565 + {__(
7566 + "In the Property column click Data Streams, then click your Web stream.",
7567 + "yatra",
7568 + )}
7569 + </li>
7570 + <li>
7571 + {__(
7572 + "Scroll to Events → Measurement Protocol API secrets → Create.",
7573 + "yatra",
7574 + )}
7575 + </li>
7576 + <li>
7577 + {__(
7578 + "Give the secret a nickname (e.g. “Yatra server”) and click Create.",
7579 + "yatra",
7580 + )}
7581 + </li>
7582 + <li>
7583 + {__(
7584 + "Copy the Secret value (not the nickname) and paste it in the field above.",
7585 + "yatra",
7586 + )}
7587 + </li>
7588 + </ol>
7589 + </details>
7590 + <div className="mt-2">
7591 + <a
7592 + href="https://analytics.google.com/analytics/web/"
7593 + target="_blank"
7594 + rel="noopener noreferrer"
7595 + className="text-blue-600 hover:text-blue-500 dark:text-blue-400 underline text-sm"
7596 + >
7597 + {__("Open Google Analytics →", "yatra")}
7598 + </a>
7599 + </div>
7600 + </div>
7601 + }
7602 + actionButton={
7603 + <Button
7604 + type="button"
7605 + variant="outline"
7606 + size="sm"
7607 + onClick={() => validateApiSecret()}
7608 + disabled={
7609 + !formData.ga4_api_secret ||
7610 + !formData.ga4_measurement_id ||
7611 + validatingApiSecret
7612 + }
7613 + className="shrink-0"
7614 + >
7615 + {validatingApiSecret ? (
7616 + <>
7617 + <Loader2 className="mr-2 h-4 w-4 animate-spin" />
7618 + {__("Validating...", "yatra")}
7619 + </>
7620 + ) : (
7621 + <>
7622 + <Check className="mr-2 h-4 w-4" />
7623 + {__("Validate", "yatra")}
7624 + </>
7625 + )}
7626 + </Button>
7627 + }
7628 + >
7629 + <div className="relative">
7630 + <Input
7631 + id="ga4_api_secret"
7632 + type={showGa4ApiSecret ? "text" : "password"}
7633 + value={formData.ga4_api_secret || ""}
7634 + name="ga4_api_secret"
7635 + onChange={handleFieldChange}
7636 + placeholder={__(
7637 + "Paste secret from GA4",
7638 + "yatra",
7639 + )}
7640 + className="pr-10 font-mono text-sm"
7641 + spellCheck={false}
7642 + autoComplete="new-password"
7643 + />
7644 + <button
7645 + type="button"
7646 + onClick={() =>
7647 + setShowGa4ApiSecret(!showGa4ApiSecret)
7648 + }
7649 + className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
7650 + tabIndex={-1}
7651 + aria-label={
7652 + showGa4ApiSecret
7653 + ? __("Hide secret", "yatra")
7654 + : __("Show secret", "yatra")
7655 + }
7656 + >
7657 + {showGa4ApiSecret ? (
7658 + <EyeOff className="h-4 w-4" />
7659 + ) : (
7660 + <Eye className="h-4 w-4" />
7661 + )}
7662 + </button>
7663 + </div>
7664 + </FormField>
7665 + </div>
7666 + )}
7094 7667 </div>
7095 7668 </div>
7669 + </ProFeature>
7670 +
7671 + {/* Reports shortcut */}
7672 + {(Boolean(formData.ga4_measurement_id?.trim()) ||
7673 + Boolean(
7674 + (window as any).yatraAdmin?.googleAnalytics?.measurementId,
7675 + )) && (
7676 + <div className="flex gap-3 rounded-lg border border-blue-200 bg-blue-50/90 p-4 dark:border-blue-800 dark:bg-blue-950/30">
7677 + <BarChart3 className="mt-0.5 h-5 w-5 shrink-0 text-blue-600 dark:text-blue-400" />
7678 + <div className="min-w-0 text-sm leading-relaxed">
7679 + <p className="font-medium text-blue-900 dark:text-blue-100">
7680 + {__("Reports & event log", "yatra")}
7681 + </p>
7682 + <p className="mt-1 text-blue-800/90 dark:text-blue-200/90">
7683 + {__(
7684 + "Review connection status and recent Measurement Protocol activity under",
7685 + "yatra",
7686 + )}{" "}
7687 + <a
7688 + href={`${(window as any).yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=reports`}
7689 + className="font-medium underline decoration-blue-600/40 underline-offset-2 hover:text-blue-950 dark:hover:text-white"
7690 + >
7691 + {__("Reports", "yatra")}
7692 + </a>
7693 + {" → "}
7694 + {__("Google Analytics 4.", "yatra")}
7695 + </p>
7696 + </div>
7697 + </div>
7096 7698 )}
7097 7699 </CardContent>
7098 7700 </Card>
7099 7701
@@ -7184,8 +7786,93 @@
7184 7786 onChange={handleFieldChange}
7185 7787 placeholder={__("Enter secret key", "yatra")}
7186 7788 />
7187 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>
7188 7875 </>
7189 7876 )}
7190 7877 </div>
7191 7878 </div>
@@ -7879,11 +8566,235 @@
7879 8566 </div>
7880 8567 </FormField>
7881 8568 </div>
7882 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>
7883 8734 </div>
7884 8735 );
7885 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 + )}
8793 + </div>
8794 + </div>
8795 + );
8796 +
7886 8797 case "advanced":
7887 8798 return (
7888 8799 <div className="space-y-6">
7889 8800 <div className="space-y-4">
@@ -8282,9 +9193,9 @@
8282 9193 </Button>
8283 9194 }
8284 9195 />
8285 9196
8286 - <ConditionalRender capability="manage_yatra">
9197 + <ConditionalRender capability="yatra_manage_settings">
8287 9198 <div className="grid grid-cols-1 lg:grid-cols-4 gap-3">
8288 9199 {/* Left Sidebar */}
8289 9200 <div className="lg:col-span-1">
8290 9201 <Card>