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 +2747 -1315 3.0.2.6 → 3.0.17 View file →
@@ -14,8 +14,9 @@
14 14 Calendar,
15 15 DollarSign,
16 16 Mail,
17 17 MapPin,
18 + SlidersHorizontal,
18 19 Users,
19 20 Star,
20 21 Receipt,
21 22 Globe,
@@ -43,13 +44,16 @@
43 44 Image,
44 45 X,
45 46 TrendingUp,
46 47 BarChart3,
48 + Palette,
49 + Percent,
47 50 } from "lucide-react";
48 -import { __ } from "../lib/i18n";
51 +import { __, sprintf } from "../lib/i18n";
49 52 import { prepareWordPressMediaFrameOpen } from "../lib/wp-media-open";
50 53 import { usePermissions } from "../hooks/usePermissions";
51 54 import { useToast } from "../components/ui/toast";
55 +import { navigateMenu } from "../hooks/useNavigate";
52 56 import { apiClient, apiService } from "../lib/api-client";
53 57 import {
54 58 fetchBookingPageShortcodeStatus,
55 59 fetchPaymentGatewayDefinitions,
@@ -88,8 +92,20 @@
88 92 } from "../lib/frontend-permalink-urls";
89 93 import { getCurrencyOptions } from "../data/currencies";
90 94 import { SearchableSelect } from "../components/ui/searchable-select";
91 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";
92 108
93 109 // Multiple Taxes Editor Component
94 110 const MultipleTaxesEditor = React.memo(
95 111 ({
@@ -223,8 +239,11 @@
223 239 }> = ({ formData, setFormData }) => {
224 240 const yatraAdmin = (window as any).yatraAdmin || {};
225 241 const gcSettings = yatraAdmin.googleCalendar || {};
226 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`;
227 246
228 247 // Use state from parent formData if available, otherwise use from yatraAdmin
229 248 const [clientId, setClientId] = useState(
230 249 formData.google_calendar_client_id || gcSettings.client_id || "",
@@ -242,8 +261,10 @@
242 261 formData.google_calendar_connected || gcSettings.connected || false,
243 262 );
244 263 const [connecting, setConnecting] = useState(false);
245 264 const [syncing, setSyncing] = useState(false);
265 + const [savingCalendar, setSavingCalendar] = useState(false);
266 + const { showToast } = useToast();
246 267
247 268 const redirectUri =
248 269 yatraAdmin.googleCalendarRedirectUri ||
249 270 gcSettings.redirect_uri ||
@@ -257,8 +278,13 @@
257 278 const allSettings = {
258 279 google_calendar_client_id: clientId,
259 280 google_calendar_client_secret: clientSecret,
260 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,
261 287 };
262 288
263 289 // Update formData with all settings
264 290 setFormData((prev) => {
@@ -272,9 +298,10 @@
272 298
273 299 // Update formData when any setting changes
274 300 useEffect(() => {
275 301 syncSettingsToFormData();
276 - }, [clientId, clientSecret]);
302 + // eslint-disable-next-line react-hooks/exhaustive-deps
303 + }, [clientId, clientSecret, calendarId, calendarName]);
277 304
278 305 const handleConnect = async () => {
279 306 setConnecting(true);
280 307 try {
@@ -306,10 +333,14 @@
306 333 // Update settings state
307 334 setConnected(false);
308 335 setCalendarId("");
309 336 setCalendarName("");
337 + showToast(__("Disconnected from Google Calendar.", "yatra"), "success");
310 338 } catch (error) {
311 - console.error("Failed to disconnect:", error);
339 + showToast(
340 + __("Failed to disconnect. Please try again.", "yatra"),
341 + "error",
342 + );
312 343 }
313 344 };
314 345
315 346 const handleSync = async () => {
@@ -314,18 +345,52 @@
314 345
315 346 const handleSync = async () => {
316 347 setSyncing(true);
317 348 try {
318 - await apiClient.post("/google-calendar/sync-all");
319 - // No need to update lastSync state directly as it's derived from formData
320 - // 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 + }
321 362 } catch (error) {
322 - console.error("Failed to sync:", error);
363 + showToast(
364 + __("Failed to sync bookings. Please try again.", "yatra"),
365 + "error",
366 + );
323 367 } finally {
324 368 setSyncing(false);
325 369 }
326 370 };
327 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 +
328 393 return (
329 394 <div className="space-y-6">
330 395 {/* Settings Card */}
331 396 <Card>
@@ -503,9 +568,9 @@
503 568 <h4 className="font-medium text-sm text-green-800 dark:text-green-400 flex items-center gap-2">
504 569 <CheckCircle className="w-4 h-4" />
505 570 {__("Connected Calendar", "yatra")}
506 571 </h4>
507 - <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">
508 573 <FormField
509 574 id="gc_calendar_id"
510 575 label={__("Calendar ID", "yatra")}
511 576 description={__(
@@ -547,20 +612,42 @@
547 612 ) : (
548 613 __("Never synced", "yatra")
549 614 )}
550 615 </p>
551 - <Button
552 - variant="outline"
553 - size="sm"
554 - onClick={handleSync}
555 - disabled={syncing}
556 - className="flex items-center gap-2"
557 - >
558 - <RefreshCw
559 - className={`w-4 h-4 ${syncing ? "animate-spin" : ""}`}
560 - />
561 - {__("Sync Now", "yatra")}
562 - </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>
563 650 </div>
564 651 </div>
565 652 )}
566 653
@@ -566,8 +653,15 @@
566 653
567 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">
568 655 <div className="flex flex-wrap gap-3">
569 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
570 664 href="https://console.cloud.google.com/apis/credentials"
571 665 target="_blank"
572 666 rel="noopener noreferrer"
573 667 className="flex items-center gap-1 text-blue-600 hover:text-blue-500 dark:text-blue-400 text-sm"
@@ -626,12 +720,15 @@
626 720 };
627 721
628 722 type SettingsSection =
629 723 | "general"
724 + | "design"
630 725 | "booking"
631 726 | "booking_form"
632 727 | "payment"
728 + | "pricing"
633 729 | "trip"
730 + | "search"
634 731 | "customer"
635 732 | "review"
636 733 | "tax"
637 734 | "currency"
@@ -637,47 +734,14 @@
637 734 | "currency"
638 735 | "integration"
639 736 | "permalink"
640 737 | "seo"
641 - | "advanced";
738 + | "advanced"
739 + | "uninstall";
642 740
643 741 // Form Builder Types
644 -interface FormFieldConfig {
645 - id: string;
646 - type:
647 - | "text"
648 - | "email"
649 - | "tel"
650 - | "date"
651 - | "select"
652 - | "country"
653 - | "textarea"
654 - | "checkbox"
655 - | "number";
656 - label: string;
657 - placeholder: string;
658 - required: boolean;
659 - enabled: boolean;
660 - order: number;
661 - width: "full" | "half" | "third";
662 - section?: string;
663 - options?: { value: string; label: string }[];
664 - locked?: boolean; // If true, field cannot be deleted and required cannot be changed
665 -}
666 -
667 -interface FormSectionConfig {
668 - title: string;
669 - description: string;
670 - enabled?: boolean;
671 - fields: FormFieldConfig[];
672 -}
673 -
674 -interface BookingFormConfig {
675 - contact_form: FormSectionConfig;
676 - emergency_contact_form: FormSectionConfig;
677 - traveler_form: FormSectionConfig;
678 -}
679 -
742 +// Booking form builder types live in components/settings/booking-form-types.ts
743 +// (shared with the per-trip Conditions popup).
680 744 interface PaymentGatewayConfig {
681 745 enabled: boolean;
682 746 icon?: string;
683 747 title?: string;
@@ -733,8 +797,14 @@
733 797 company_logo: string;
734 798 timezone: string;
735 799 date_format: string;
736 800 time_format: string;
801 + /** Hex brand color for public trip/booking/listing UI (buttons, links, accents) */
802 + frontend_primary_color: string;
803 + /** CSS max width for Yatra containers; empty = follow theme / theme.json */
804 + frontend_container_max_width: string;
805 + /** Trip listing card density: "standard" | "compact_mobile" | "compact_all" */
806 + frontend_listing_card_layout: string;
737 807
738 808 // Google Calendar Settings
739 809 google_calendar_client_id?: string;
740 810 google_calendar_client_secret?: string;
@@ -743,20 +813,29 @@
743 813 google_calendar_connected?: boolean;
744 814 google_calendar_last_sync?: string | null;
745 815 google_calendar_enabled?: boolean;
746 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 +
747 826 // Booking Settings
748 827 booking_confirmation: boolean;
749 828 auto_confirm_bookings: boolean;
829 + auto_confirm_mode: "none" | "online" | "all";
750 830 require_login: boolean;
751 831 allow_guest_checkout: boolean;
752 - cancellation_policy: string;
753 - cancellation_days: number;
754 - refund_policy: string;
832 + require_guest_email_verification: boolean;
755 833 booking_expiry_hours: number;
756 834 booking_reminder_days: number;
757 835 allow_waitlist: boolean;
758 836 waitlist_auto_confirm: boolean;
837 + date_picker_as_dropdown: boolean;
759 838
760 839 // Payment Settings
761 840 currency: string;
762 841 payment_test_mode: boolean;
@@ -766,11 +845,30 @@
766 845 partial_payment_percentage: number;
767 846 deposit_required: boolean;
768 847 deposit_percentage: number;
769 848 auto_confirm_pay_later: boolean;
849 + enable_scheduled_payments: boolean;
850 + scheduled_payment_type: string;
851 + scheduled_payment_days: number;
852 + scheduled_payment_installments: number;
853 + scheduled_payment_interval: number;
854 + scheduled_payment_reminder_days: number;
855 + balance_anchor: string;
856 + balance_due_days: number;
770 857 gateway_configs: Record<string, PaymentGatewayConfig>;
771 858 gateway_order?: string[];
772 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 +
773 871 // Email Settings
774 872 admin_email: string;
775 873 from_email: string;
776 874 from_name: string;
@@ -780,8 +878,25 @@
780 878 email_template_reminder: boolean;
781 879 email_template_admin_new_booking: boolean;
782 880 email_template_admin_payment: boolean;
783 881 email_template_admin_cancellation: boolean;
882 + email_template_trip_consent: boolean;
883 + email_template_customer_verification: boolean;
884 + email_template_guest_verification: boolean;
885 + email_template_booking_completed: boolean;
886 + email_template_booking_expired_customer: boolean;
887 + email_template_admin_booking_expired: boolean;
888 + email_template_scheduled_payment_reminder: boolean;
889 + email_template_scheduled_payment_succeeded: boolean;
890 + email_template_scheduled_payment_failed: boolean;
891 + email_template_admin_scheduled_payment_failed: boolean;
892 + email_template_enquiry_received: boolean;
893 + email_template_enquiry_admin: boolean;
894 + email_template_enquiry_response: boolean;
895 + email_template_review_request: 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;
784 899 smtp_enabled: boolean;
785 900 smtp_host: string;
786 901 smtp_port: number;
787 902 smtp_username: string;
@@ -801,8 +916,42 @@
801 916 email_tpl_admin_payment_subject: string;
802 917 email_tpl_admin_payment_body: string;
803 918 email_tpl_admin_cancellation_subject: string;
804 919 email_tpl_admin_cancellation_body: string;
920 + email_tpl_trip_consent_subject: string;
921 + email_tpl_trip_consent_body: string;
922 + email_tpl_customer_verification_subject: string;
923 + email_tpl_customer_verification_body: string;
924 + email_tpl_guest_verification_subject: string;
925 + email_tpl_guest_verification_body: string;
926 + email_tpl_booking_completed_subject: string;
927 + email_tpl_booking_completed_body: string;
928 + email_tpl_booking_expired_customer_subject: string;
929 + email_tpl_booking_expired_customer_body: string;
930 + email_tpl_admin_booking_expired_subject: string;
931 + email_tpl_admin_booking_expired_body: string;
932 + email_tpl_scheduled_payment_reminder_subject: string;
933 + email_tpl_scheduled_payment_reminder_body: string;
934 + email_tpl_scheduled_payment_succeeded_subject: string;
935 + email_tpl_scheduled_payment_succeeded_body: string;
936 + email_tpl_scheduled_payment_failed_subject: string;
937 + email_tpl_scheduled_payment_failed_body: string;
938 + email_tpl_admin_scheduled_payment_failed_subject: string;
939 + email_tpl_admin_scheduled_payment_failed_body: string;
940 + email_tpl_enquiry_received_subject: string;
941 + email_tpl_enquiry_received_body: string;
942 + email_tpl_enquiry_admin_subject: string;
943 + email_tpl_enquiry_admin_body: string;
944 + email_tpl_enquiry_response_subject: string;
945 + email_tpl_enquiry_response_body: string;
946 + email_tpl_review_request_subject: string;
947 + email_tpl_review_request_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;
805 954
806 955 // Customer Settings
807 956 customer_registration: boolean;
808 957 customer_fields: string[];
@@ -811,8 +960,12 @@
811 960 allow_customer_reviews: boolean;
812 961 customer_dashboard_enabled: boolean;
813 962 /** Wishlist / saved trips (Yatra Pro); toggled in Customer settings */
814 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;
815 968
816 969 // Review Settings
817 970 enable_reviews: boolean;
818 971 require_booking: boolean;
@@ -820,8 +973,9 @@
820 973 review_moderation: boolean;
821 974 min_rating: number;
822 975 allow_anonymous_reviews: boolean;
823 976 review_reminder_days: number;
977 + review_reminder_max_age_days: number;
824 978
825 979 // Tax Settings
826 980 enable_tax: boolean;
827 981 tax_name: string;
@@ -855,8 +1009,12 @@
855 1009 facebook_pixel: string;
856 1010 recaptcha_enabled: boolean;
857 1011 recaptcha_site_key: string;
858 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;
859 1017
860 1018 // Mailchimp Integration (Pro)
861 1019 mailchimp_api_key?: string;
862 1020 mailchimp_list_id?: string;
@@ -911,12 +1069,15 @@
911 1069
912 1070 // Booking Page Settings
913 1071 use_booking_page: boolean;
914 1072 booking_page_id: number;
1073 + terms_page_id: number;
1074 + privacy_policy_page_id: number;
915 1075
916 1076 // Advanced Settings
917 1077 debug_mode: boolean;
918 1078 enable_logging: boolean;
1079 + delete_data_on_uninstall: boolean;
919 1080 cache_enabled: boolean;
920 1081 api_key: string;
921 1082 api_rate_limit: number;
922 1083 session_timeout: number;
@@ -928,8 +1089,11 @@
928 1089 seo_trip_meta_title: string;
929 1090 seo_trip_meta_description: string;
930 1091 seo_trip_meta_keywords: string;
931 1092 seo_trip_meta_image: number;
1093 + enable_sitemap: boolean;
1094 + sitemap_types: string[];
1095 + sitemap_noindex_excluded: boolean;
932 1096 }
933 1097
934 1098 // Form Builder Component
935 1099 type BookingFormSubTab =
@@ -964,11 +1128,17 @@
964 1128 }) => {
965 1129 const [activeFormTab, setActiveFormTab] =
966 1130 useState<BookingFormSubTab>(getInitialFormSubTab);
967 1131
968 - // 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.
969 1139 const { data: modulesData } = useQuery({
970 - queryKey: ["modules"],
1140 + queryKey: ["modules", "raw-envelope"],
971 1141 queryFn: async () => {
972 1142 const response = await apiService.getModules();
973 1143 return response;
974 1144 },
@@ -982,12 +1152,35 @@
982 1152 (m: any) => m.slug === "dynamic_form_field",
983 1153 );
984 1154 return module?.enabled === true && module?.is_available === true;
985 1155 }
986 - // Fallback to yatraAdmin if modules API hasn't loaded yet
987 - 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;
988 1162 }, [modulesData]);
989 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 +
990 1183 // Save sub-tab to localStorage when it changes
991 1184 const handleSubTabChange = (tab: BookingFormSubTab) => {
992 1185 setActiveFormTab(tab);
993 1186 if (typeof window !== "undefined") {
@@ -993,66 +1186,15 @@
993 1186 if (typeof window !== "undefined") {
994 1187 localStorage.setItem("yatra_settings_booking_form_subtab", tab);
995 1188 }
996 1189 };
997 - const [editingField, setEditingField] = useState<string | null>(null);
998 - const [showAddField, setShowAddField] = useState(false);
999 - const [newField, setNewField] = useState<Partial<FormFieldConfig>>({
1000 - id: "",
1001 - type: "text",
1002 - label: "",
1003 - placeholder: "",
1004 - required: false,
1005 - enabled: true,
1006 - width: "full",
1007 - options: [],
1008 - });
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 + ];
1009 1195
1010 - // Delete confirmation state
1011 - const [deleteConfirm, setDeleteConfirm] = useState<{
1012 - isOpen: boolean;
1013 - fieldId: string | null;
1014 - fieldLabel: string;
1015 - }>({
1016 - isOpen: false,
1017 - fieldId: null,
1018 - fieldLabel: "",
1019 - });
1020 1196
1021 - // Drag and drop state
1022 - const [draggedFieldId, setDraggedFieldId] = useState<string | null>(null);
1023 - const [dragOverFieldId, setDragOverFieldId] = useState<string | null>(null);
1024 -
1025 - // Helper to generate ID from label
1026 - const generateIdFromLabel = (label: string): string => {
1027 - return label
1028 - .toLowerCase()
1029 - .replace(/\s+/g, "_")
1030 - .replace(/[^a-z0-9_]/g, "");
1031 - };
1032 -
1033 - // Helper to sanitize ID input
1034 - const sanitizeId = (id: string): string => {
1035 - return id
1036 - .toLowerCase()
1037 - .replace(/\s+/g, "_")
1038 - .replace(/[^a-z0-9_]/g, "");
1039 - };
1040 -
1041 - // Handle label change with auto ID generation
1042 - const handleNewFieldLabelChange = (label: string) => {
1043 - const autoId = generateIdFromLabel(label);
1044 - setNewField((prev) => ({
1045 - ...prev,
1046 - label,
1047 - // Only auto-generate ID if it hasn't been manually edited
1048 - id:
1049 - prev.id === "" || prev.id === generateIdFromLabel(prev.label || "")
1050 - ? autoId
1051 - : prev.id,
1052 - }));
1053 - };
1054 -
1055 1197 const formTabs = [
1056 1198 {
1057 1199 id: "contact_form" as const,
1058 1200 label: __("Contact Form", "yatra"),
@@ -1070,17 +1212,18 @@
1070 1212 },
1071 1213 ];
1072 1214
1073 1215 const fieldTypes = [
1074 - { value: "text", label: "Text" },
1075 - { value: "email", label: "Email" },
1076 - { value: "tel", label: "Phone" },
1077 - { value: "date", label: "Date" },
1078 - { value: "select", label: "Dropdown" },
1079 - { value: "country", label: "Country Selector" },
1080 - { value: "textarea", label: "Text Area" },
1081 - { value: "number", label: "Number" },
1082 - { 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") },
1083 1226 ];
1084 1227
1085 1228 const widthOptions = [
1086 1229 { value: "full", label: "Full Width" },
@@ -1098,8 +1241,27 @@
1098 1241 }
1099 1242 );
1100 1243 };
1101 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 +
1102 1264 const updateFormConfig = (updates: Partial<FormSectionConfig>) => {
1103 1265 setFormData((prev) => {
1104 1266 if (!prev) return prev;
1105 1267 return {
@@ -1114,165 +1276,9 @@
1114 1276 };
1115 1277 });
1116 1278 };
1117 1279
1118 - const updateField = (fieldId: string, updates: Partial<FormFieldConfig>) => {
1119 - const currentConfig = getCurrentFormConfig();
1120 - const updatedFields = currentConfig.fields.map((field) =>
1121 - field.id === fieldId ? { ...field, ...updates } : field,
1122 - );
1123 - updateFormConfig({ fields: updatedFields });
1124 - };
1125 1280
1126 - const toggleFieldEnabled = (fieldId: string) => {
1127 - const currentConfig = getCurrentFormConfig();
1128 - const field = currentConfig.fields.find((f) => f.id === fieldId);
1129 - // Locked fields cannot be disabled
1130 - if (field && !field.locked) {
1131 - updateField(fieldId, { enabled: !field.enabled });
1132 - }
1133 - };
1134 -
1135 - const toggleFieldRequired = (fieldId: string) => {
1136 - const currentConfig = getCurrentFormConfig();
1137 - const field = currentConfig.fields.find((f) => f.id === fieldId);
1138 - if (field) {
1139 - updateField(fieldId, { required: !field.required });
1140 - }
1141 - };
1142 -
1143 - const moveField = (fieldId: string, direction: "up" | "down") => {
1144 - const currentConfig = getCurrentFormConfig();
1145 - const fields = [...currentConfig.fields];
1146 - const index = fields.findIndex((f) => f.id === fieldId);
1147 -
1148 - if (direction === "up" && index > 0) {
1149 - [fields[index - 1], fields[index]] = [fields[index], fields[index - 1]];
1150 - } else if (direction === "down" && index < fields.length - 1) {
1151 - [fields[index], fields[index + 1]] = [fields[index + 1], fields[index]];
1152 - }
1153 -
1154 - // Update order values
1155 - fields.forEach((field, i) => {
1156 - field.order = i + 1;
1157 - });
1158 -
1159 - updateFormConfig({ fields });
1160 - };
1161 -
1162 - // Drag and Drop handlers
1163 - const handleDragStart = (e: React.DragEvent, fieldId: string) => {
1164 - setDraggedFieldId(fieldId);
1165 - e.dataTransfer.effectAllowed = "move";
1166 - };
1167 -
1168 - const handleDragOver = (e: React.DragEvent, fieldId: string) => {
1169 - e.preventDefault();
1170 - e.dataTransfer.dropEffect = "move";
1171 - if (fieldId !== draggedFieldId) {
1172 - setDragOverFieldId(fieldId);
1173 - }
1174 - };
1175 -
1176 - const handleDragLeave = () => {
1177 - setDragOverFieldId(null);
1178 - };
1179 -
1180 - const handleDrop = (e: React.DragEvent, targetFieldId: string) => {
1181 - e.preventDefault();
1182 - if (!draggedFieldId || draggedFieldId === targetFieldId) {
1183 - setDraggedFieldId(null);
1184 - setDragOverFieldId(null);
1185 - return;
1186 - }
1187 -
1188 - const currentConfig = getCurrentFormConfig();
1189 - const fields = [...currentConfig.fields];
1190 - const draggedIndex = fields.findIndex((f) => f.id === draggedFieldId);
1191 - const targetIndex = fields.findIndex((f) => f.id === targetFieldId);
1192 -
1193 - if (draggedIndex !== -1 && targetIndex !== -1) {
1194 - const [draggedField] = fields.splice(draggedIndex, 1);
1195 - fields.splice(targetIndex, 0, draggedField);
1196 -
1197 - // Update order values
1198 - fields.forEach((field, i) => {
1199 - field.order = i + 1;
1200 - });
1201 -
1202 - updateFormConfig({ fields });
1203 - }
1204 -
1205 - setDraggedFieldId(null);
1206 - setDragOverFieldId(null);
1207 - };
1208 -
1209 - const handleDragEnd = () => {
1210 - setDraggedFieldId(null);
1211 - setDragOverFieldId(null);
1212 - };
1213 -
1214 - const deleteField = (fieldId: string) => {
1215 - const currentConfig = getCurrentFormConfig();
1216 - const updatedFields = currentConfig.fields.filter((f) => f.id !== fieldId);
1217 - updatedFields.forEach((field, i) => {
1218 - field.order = i + 1;
1219 - });
1220 - updateFormConfig({ fields: updatedFields });
1221 - setDeleteConfirm({ isOpen: false, fieldId: null, fieldLabel: "" });
1222 - };
1223 -
1224 - const addNewField = () => {
1225 - if (!newField.label || !newField.id) return;
1226 -
1227 - const currentConfig = getCurrentFormConfig();
1228 - const fieldId = sanitizeId(newField.id);
1229 -
1230 - // Check if ID already exists
1231 - if (currentConfig.fields.some((f) => f.id === fieldId)) {
1232 - // TODO: Replace with toast notification when refactoring this nested component
1233 - window.alert(
1234 - "A field with this ID already exists. Please use a different ID.",
1235 - );
1236 - return;
1237 - }
1238 -
1239 - const newFieldConfig: FormFieldConfig = {
1240 - id: fieldId,
1241 - type: (newField.type as FormFieldConfig["type"]) || "text",
1242 - label: newField.label || "",
1243 - placeholder: newField.placeholder || "",
1244 - required: newField.required || false,
1245 - enabled: true,
1246 - order: currentConfig.fields.length + 1,
1247 - width: (newField.width as FormFieldConfig["width"]) || "full",
1248 - };
1249 -
1250 - // Add options if field type is select
1251 - if (
1252 - newField.type === "select" &&
1253 - newField.options &&
1254 - newField.options.length > 0
1255 - ) {
1256 - newFieldConfig.options = newField.options.filter(
1257 - (opt) => opt.value && opt.label,
1258 - );
1259 - }
1260 -
1261 - updateFormConfig({ fields: [...currentConfig.fields, newFieldConfig] });
1262 - setNewField({
1263 - id: "",
1264 - type: "text",
1265 - label: "",
1266 - placeholder: "",
1267 - required: false,
1268 - enabled: true,
1269 - width: "full",
1270 - options: [],
1271 - });
1272 - setShowAddField(false);
1273 - };
1274 -
1275 1281 const currentConfig = getCurrentFormConfig();
1276 1282
1277 1283 return (
1278 1284 <div className="space-y-6">
@@ -1305,14 +1311,51 @@
1305 1311 ))}
1306 1312 </nav>
1307 1313 </div>
1308 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 +
1309 1328 {/* Form Section Settings */}
1310 1329 <Card>
1311 1330 <CardHeader>
1312 - <CardTitle className="text-base">
1313 - {__("Form Section Settings", "yatra")}
1314 - </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>
1315 1358 </CardHeader>
1316 1359 <CardContent className="space-y-4">
1317 1360 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
1318 1361 <input
@@ -1359,628 +1402,85 @@
1359 1402 className="mt-1"
1360 1403 />
1361 1404 </div>
1362 1405 </div>
1363 - </CardContent>
1364 - </Card>
1365 1406
1366 - {/* Form Fields */}
1367 - <Card>
1368 - <CardHeader className="flex flex-row items-center justify-between">
1369 - <CardTitle className="text-base">
1370 - {__("Form Fields", "yatra")}
1371 - </CardTitle>
1372 - <Button
1373 - variant="outline"
1374 - size="sm"
1375 - onClick={() => setShowAddField(!showAddField)}
1376 - className="flex items-center gap-2"
1377 - >
1378 - <Plus className="w-4 h-4" />
1379 - {__("Add Field", "yatra")}
1380 - </Button>
1381 - </CardHeader>
1382 - <CardContent className="space-y-3">
1383 - {/* Add New Field Form */}
1384 - {showAddField && (
1385 - <div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 mb-4">
1386 - <h4 className="font-medium text-sm mb-3">
1387 - {__("Add New Field", "yatra")}
1388 - </h4>
1389 - <div className="grid grid-cols-1 md:grid-cols-5 gap-3">
1390 - <div>
1391 - <Label className="text-xs">
1392 - {__("Field Type", "yatra")}
1393 - </Label>
1394 - <Select
1395 - value={newField.type || "text"}
1396 - onChange={(e) =>
1397 - setNewField((prev) => ({
1398 - ...prev,
1399 - type: e.target.value as FormFieldConfig["type"],
1400 - }))
1401 - }
1402 - 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"
1403 1415 >
1404 - {fieldTypes.map((type) => (
1405 - <option key={type.value} value={type.value}>
1406 - {type.label}
1407 - </option>
1408 - ))}
1409 - </Select>
1410 - </div>
1411 - <div>
1412 - <Label className="text-xs">{__("Label", "yatra")} *</Label>
1413 - <Input
1414 - value={newField.label || ""}
1415 - onChange={(e) =>
1416 - handleNewFieldLabelChange(e.target.value)
1417 - }
1418 - placeholder="Field label"
1419 - className="mt-1"
1420 - />
1421 - </div>
1422 - <div>
1423 - <Label className="text-xs">
1424 - {__("Field ID", "yatra")} *
1425 - </Label>
1426 - <Input
1427 - value={newField.id || ""}
1428 - onChange={(e) =>
1429 - setNewField((prev) => ({
1430 - ...prev,
1431 - id: sanitizeId(e.target.value),
1432 - }))
1433 - }
1434 - placeholder="field_id"
1435 - className="mt-1 font-mono text-xs"
1436 - />
1437 - <p className="text-[10px] text-gray-400 mt-0.5">
1438 - {__("Lowercase, no spaces", "yatra")}
1439 - </p>
1440 - </div>
1441 - <div>
1442 - <Label className="text-xs">
1443 - {__("Placeholder", "yatra")}
1444 - </Label>
1445 - <Input
1446 - value={newField.placeholder || ""}
1447 - onChange={(e) =>
1448 - setNewField((prev) => ({
1449 - ...prev,
1450 - placeholder: e.target.value,
1451 - }))
1452 - }
1453 - placeholder="Placeholder text"
1454 - className="mt-1"
1455 - />
1456 - </div>
1457 - <div>
1458 - <Label className="text-xs">{__("Width", "yatra")}</Label>
1459 - <Select
1460 - value={newField.width || "full"}
1461 - onChange={(e) =>
1462 - setNewField((prev) => ({
1463 - ...prev,
1464 - width: e.target.value as FormFieldConfig["width"],
1465 - }))
1466 - }
1467 - className="mt-1"
1468 - >
1469 - {widthOptions.map((w) => (
1470 - <option key={w.value} value={w.value}>
1471 - {w.label}
1472 - </option>
1473 - ))}
1474 - </Select>
1475 - </div>
1476 - </div>
1477 - {/* Options editor for select fields */}
1478 - {newField.type === "select" && (
1479 - <div className="mt-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-blue-100 dark:border-blue-900">
1480 - <div className="flex items-center justify-between mb-2">
1481 - <Label className="text-xs font-medium">
1482 - {__("Dropdown Options", "yatra")}
1483 - </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" />
1484 1429 <Button
1485 1430 type="button"
1486 1431 variant="ghost"
1487 1432 size="sm"
1488 - onClick={() => {
1489 - const currentOptions = newField.options || [];
1490 - setNewField((prev) => ({
1491 - ...prev,
1492 - options: [
1493 - ...currentOptions,
1494 - { value: "", label: "" },
1495 - ],
1496 - }));
1497 - }}
1498 - className="h-6 text-xs"
1433 + onClick={() => setConditionsOpen(true)}
1499 1434 >
1500 - <Plus className="w-3 h-3 mr-1" />
1501 - {__("Add Option", "yatra")}
1435 + {__("Edit", "yatra")}
1502 1436 </Button>
1503 1437 </div>
1504 - <div className="space-y-2">
1505 - {(newField.options || []).map((option, optIndex) => (
1506 - <div key={optIndex} className="flex items-center gap-2">
1507 - <Input
1508 - value={option.value}
1509 - onChange={(e) => {
1510 - const newOptions = [...(newField.options || [])];
1511 - newOptions[optIndex] = {
1512 - ...newOptions[optIndex],
1513 - value: e.target.value,
1514 - };
1515 - setNewField((prev) => ({
1516 - ...prev,
1517 - options: newOptions,
1518 - }));
1519 - }}
1520 - placeholder="Value (e.g., option1)"
1521 - className="text-xs flex-1"
1522 - />
1523 - <Input
1524 - value={option.label}
1525 - onChange={(e) => {
1526 - const newOptions = [...(newField.options || [])];
1527 - newOptions[optIndex] = {
1528 - ...newOptions[optIndex],
1529 - label: e.target.value,
1530 - };
1531 - setNewField((prev) => ({
1532 - ...prev,
1533 - options: newOptions,
1534 - }));
1535 - }}
1536 - placeholder="Label (e.g., Option 1)"
1537 - className="text-xs flex-1"
1538 - />
1539 - <button
1540 - type="button"
1541 - onClick={() => {
1542 - const newOptions = (
1543 - newField.options || []
1544 - ).filter((_, i) => i !== optIndex);
1545 - setNewField((prev) => ({
1546 - ...prev,
1547 - options: newOptions,
1548 - }));
1549 - }}
1550 - className="p-1 text-gray-400 hover:text-red-500"
1551 - >
1552 - <Trash2 className="w-3 h-3" />
1553 - </button>
1554 - </div>
1555 - ))}
1556 - {(!newField.options || newField.options.length === 0) && (
1557 - <p className="text-xs text-gray-400 italic">
1558 - {__(
1559 - 'Click "Add Option" to add dropdown choices.',
1560 - "yatra",
1561 - )}
1562 - </p>
1563 - )}
1564 - </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>
1565 1446 </div>
1566 - )}
1567 -
1568 - <div className="flex items-center gap-4 mt-3">
1569 - <label className="flex items-center gap-2 text-sm">
1570 - <input
1571 - type="checkbox"
1572 - checked={newField.required || false}
1573 - onChange={(e) =>
1574 - setNewField((prev) => ({
1575 - ...prev,
1576 - required: e.target.checked,
1577 - }))
1578 - }
1579 - className="w-4 h-4 rounded border-gray-300 text-blue-600"
1580 - />
1581 - {__("Required", "yatra")}
1582 - </label>
1583 - <div className="flex-1"></div>
1584 - <Button
1585 - variant="ghost"
1586 - size="sm"
1587 - onClick={() => setShowAddField(false)}
1588 - >
1589 - {__("Cancel", "yatra")}
1590 - </Button>
1591 - <Button
1592 - size="sm"
1593 - onClick={addNewField}
1594 - disabled={!newField.label || !newField.id}
1595 - >
1596 - {__("Add Field", "yatra")}
1597 - </Button>
1598 1447 </div>
1599 - </div>
1600 - )}
1601 -
1602 - {/* Field List */}
1603 - {currentConfig.fields?.length === 0 ? (
1604 - <div className="text-center py-8 text-gray-500 dark:text-gray-400">
1605 - <p>
1606 - {__(
1607 - 'No fields configured. Click "Add Field" to get started.',
1608 - "yatra",
1609 - )}
1610 - </p>
1611 - </div>
1612 - ) : (
1613 - <div className="space-y-2">
1614 - {currentConfig.fields?.map((field, index) => (
1615 - <div
1616 - key={field.id}
1617 - draggable
1618 - onDragStart={(e) => handleDragStart(e, field.id)}
1619 - onDragOver={(e) => handleDragOver(e, field.id)}
1620 - onDragLeave={handleDragLeave}
1621 - onDrop={(e) => handleDrop(e, field.id)}
1622 - onDragEnd={handleDragEnd}
1623 - className={`flex items-center gap-3 p-3 rounded-lg border transition-all cursor-grab active:cursor-grabbing ${
1624 - draggedFieldId === field.id
1625 - ? "opacity-50 border-dashed"
1626 - : ""
1627 - } ${
1628 - dragOverFieldId === field.id
1629 - ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
1630 - : ""
1631 - } ${
1632 - field.enabled
1633 - ? "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
1634 - : "bg-gray-50 dark:bg-gray-900 border-gray-100 dark:border-gray-800 opacity-60"
1635 - }`}
1636 - >
1637 - {/* Drag Handle & Order */}
1638 - <div className="flex items-center gap-1">
1639 - <GripVertical className="w-4 h-4 text-gray-400" />
1640 - <div className="flex flex-col gap-0.5">
1641 - <button
1642 - type="button"
1643 - onClick={() => moveField(field.id, "up")}
1644 - disabled={index === 0}
1645 - className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
1646 - >
1647 - <ArrowUp className="w-3 h-3" />
1648 - </button>
1649 - <button
1650 - type="button"
1651 - onClick={() => moveField(field.id, "down")}
1652 - disabled={index === currentConfig.fields.length - 1}
1653 - className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
1654 - >
1655 - <ArrowDown className="w-3 h-3" />
1656 - </button>
1657 - </div>
1658 - </div>
1659 -
1660 - {/* Field Info */}
1661 - <div className="flex-1 min-w-0">
1662 - {editingField === field.id ? (
1663 - <div className="space-y-3">
1664 - <div className="grid grid-cols-4 gap-2">
1665 - <Input
1666 - value={field.label}
1667 - onChange={(e) =>
1668 - updateField(field.id, { label: e.target.value })
1669 - }
1670 - placeholder="Label"
1671 - className="text-sm"
1672 - />
1673 - <Input
1674 - value={field.placeholder}
1675 - onChange={(e) =>
1676 - updateField(field.id, {
1677 - placeholder: e.target.value,
1678 - })
1679 - }
1680 - placeholder="Placeholder"
1681 - className="text-sm"
1682 - />
1683 - <Select
1684 - value={field.type}
1685 - onChange={(e) =>
1686 - updateField(field.id, {
1687 - type: e.target
1688 - .value as FormFieldConfig["type"],
1689 - })
1690 - }
1691 - className="text-sm"
1692 - >
1693 - {fieldTypes.map((type) => (
1694 - <option key={type.value} value={type.value}>
1695 - {type.label}
1696 - </option>
1697 - ))}
1698 - </Select>
1699 - <Select
1700 - value={field.width}
1701 - onChange={(e) =>
1702 - updateField(field.id, {
1703 - width: e.target
1704 - .value as FormFieldConfig["width"],
1705 - })
1706 - }
1707 - className="text-sm"
1708 - >
1709 - {widthOptions.map((w) => (
1710 - <option key={w.value} value={w.value}>
1711 - {w.label}
1712 - </option>
1713 - ))}
1714 - </Select>
1715 - </div>
1716 -
1717 - {/* Field ID - Below other fields */}
1718 - <div className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-900 rounded">
1719 - <Label className="text-xs text-gray-500 whitespace-nowrap">
1720 - {__("Field ID:", "yatra")}
1721 - </Label>
1722 - <Input
1723 - value={field.id}
1724 - onChange={(e) => {
1725 - if (!field.locked) {
1726 - updateField(field.id, {
1727 - id: sanitizeId(e.target.value),
1728 - });
1729 - }
1730 - }}
1731 - placeholder="field_id"
1732 - className="text-sm font-mono flex-1 max-w-xs"
1733 - disabled={field.locked}
1734 - title={
1735 - field.locked
1736 - ? "Locked fields cannot change ID"
1737 - : "Field ID (lowercase, no spaces)"
1738 - }
1739 - />
1740 - {field.locked && (
1741 - <span className="text-xs text-amber-600">
1742 - {__("(locked)", "yatra")}
1743 - </span>
1744 - )}
1745 - </div>
1746 -
1747 - {/* Dropdown Options Editor */}
1748 - {field.type === "select" && (
1749 - <div className="p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
1750 - <div className="flex items-center justify-between mb-2">
1751 - <Label className="text-xs font-medium">
1752 - {__("Dropdown Options", "yatra")}
1753 - </Label>
1754 - <Button
1755 - type="button"
1756 - variant="ghost"
1757 - size="sm"
1758 - onClick={() => {
1759 - const newOptions = [
1760 - ...(field.options || []),
1761 - { value: "", label: "" },
1762 - ];
1763 - updateField(field.id, {
1764 - options: newOptions,
1765 - });
1766 - }}
1767 - className="h-6 text-xs"
1768 - >
1769 - <Plus className="w-3 h-3 mr-1" />
1770 - {__("Add Option", "yatra")}
1771 - </Button>
1772 - </div>
1773 - <div className="space-y-2">
1774 - {(field.options || []).map(
1775 - (option, optIndex) => (
1776 - <div
1777 - key={optIndex}
1778 - className="flex items-center gap-2"
1779 - >
1780 - <Input
1781 - value={option.value}
1782 - onChange={(e) => {
1783 - const newOptions = [
1784 - ...(field.options || []),
1785 - ];
1786 - newOptions[optIndex] = {
1787 - ...newOptions[optIndex],
1788 - value: e.target.value,
1789 - };
1790 - updateField(field.id, {
1791 - options: newOptions,
1792 - });
1793 - }}
1794 - placeholder="Value (e.g., spouse)"
1795 - className="text-xs flex-1"
1796 - />
1797 - <Input
1798 - value={option.label}
1799 - onChange={(e) => {
1800 - const newOptions = [
1801 - ...(field.options || []),
1802 - ];
1803 - newOptions[optIndex] = {
1804 - ...newOptions[optIndex],
1805 - label: e.target.value,
1806 - };
1807 - updateField(field.id, {
1808 - options: newOptions,
1809 - });
1810 - }}
1811 - placeholder="Label (e.g., Spouse/Partner)"
1812 - className="text-xs flex-1"
1813 - />
1814 - <button
1815 - type="button"
1816 - onClick={() => {
1817 - const newOptions = (
1818 - field.options || []
1819 - ).filter((_, i) => i !== optIndex);
1820 - updateField(field.id, {
1821 - options: newOptions,
1822 - });
1823 - }}
1824 - className="p-1 text-gray-400 hover:text-red-500"
1825 - >
1826 - <Trash2 className="w-3 h-3" />
1827 - </button>
1828 - </div>
1829 - ),
1830 - )}
1831 - {(!field.options ||
1832 - field.options.length === 0) && (
1833 - <p className="text-xs text-gray-400 italic">
1834 - {__(
1835 - 'No options. Click "Add Option" to add dropdown choices.',
1836 - "yatra",
1837 - )}
1838 - </p>
1839 - )}
1840 - </div>
1841 - </div>
1842 - )}
1843 - </div>
1844 - ) : (
1845 - <div className="flex items-center gap-3 flex-wrap">
1846 - <span className="font-medium text-sm">
1847 - {field.label}
1848 - </span>
1849 - <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">
1850 - {field.id}
1851 - </code>
1852 - <span className="text-xs text-gray-400 dark:text-gray-500 px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">
1853 - {fieldTypes.find((t) => t.value === field.type)
1854 - ?.label || field.type}
1855 - </span>
1856 - <span className="text-xs text-gray-400 dark:text-gray-500">
1857 - {widthOptions.find((w) => w.value === field.width)
1858 - ?.label || "Full"}
1859 - </span>
1860 - {field.type === "select" &&
1861 - field.options &&
1862 - field.options.length > 0 && (
1863 - <span className="text-xs text-blue-500 dark:text-blue-400">
1864 - ({field.options.length}{" "}
1865 - {field.options.length === 1
1866 - ? "option"
1867 - : "options"}
1868 - )
1869 - </span>
1870 - )}
1871 - {field.required && (
1872 - <span className="text-xs text-red-500 font-medium">
1873 - {__("Required", "yatra")}
1874 - </span>
1875 - )}
1876 - {field.locked && (
1877 - <span
1878 - 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"
1879 - title="This field is protected and cannot be deleted"
1880 - >
1881 - <Lock className="w-3 h-3" />
1882 - Locked
1883 - </span>
1884 - )}
1885 - </div>
1886 - )}
1887 - </div>
1888 -
1889 - {/* Actions */}
1890 - <div className="flex items-center gap-1">
1891 - <button
1892 - type="button"
1893 - onClick={() =>
1894 - !field.locked && toggleFieldRequired(field.id)
1895 - }
1896 - disabled={field.locked}
1897 - 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"}`}
1898 - title={
1899 - field.locked
1900 - ? "This field is required and cannot be changed"
1901 - : field.required
1902 - ? "Make optional"
1903 - : "Make required"
1904 - }
1905 - >
1906 - <Star
1907 - className="w-4 h-4"
1908 - fill={field.required ? "currentColor" : "none"}
1909 - />
1910 - </button>
1911 - {/* Show enable/disable toggle only for non-locked fields */}
1912 - {!field.locked && (
1913 - <button
1914 - type="button"
1915 - onClick={() => toggleFieldEnabled(field.id)}
1916 - className={`p-1.5 rounded ${field.enabled ? "text-green-500" : "text-gray-400"}`}
1917 - title={
1918 - field.enabled ? "Disable field" : "Enable field"
1919 - }
1920 - >
1921 - {field.enabled ? (
1922 - <Eye className="w-4 h-4" />
1923 - ) : (
1924 - <EyeOff className="w-4 h-4" />
1925 - )}
1926 - </button>
1927 - )}
1928 - <button
1929 - type="button"
1930 - onClick={() =>
1931 - setEditingField(
1932 - editingField === field.id ? null : field.id,
1933 - )
1934 - }
1935 - className="p-1.5 rounded text-gray-400 hover:text-blue-500"
1936 - title="Edit field"
1937 - >
1938 - <Edit2 className="w-4 h-4" />
1939 - </button>
1940 - {/* Show delete button only for non-locked fields */}
1941 - {!field.locked && (
1942 - <button
1943 - type="button"
1944 - onClick={() =>
1945 - setDeleteConfirm({
1946 - isOpen: true,
1947 - fieldId: field.id,
1948 - fieldLabel: field.label,
1949 - })
1950 - }
1951 - className="p-1.5 rounded text-gray-400 hover:text-red-500"
1952 - title="Delete field"
1953 - >
1954 - <Trash2 className="w-4 h-4" />
1955 - </button>
1956 - )}
1957 - </div>
1958 - </div>
1959 - ))}
1960 - </div>
1961 - )}
1448 + )}
1962 1449 </CardContent>
1963 1450 </Card>
1964 1451
1965 - {/* Delete Confirmation Dialog */}
1966 - <ConfirmationDialog
1967 - isOpen={deleteConfirm.isOpen}
1968 - onClose={() =>
1969 - setDeleteConfirm({ isOpen: false, fieldId: null, fieldLabel: "" })
1970 - }
1971 - onConfirm={() => {
1972 - if (deleteConfirm.fieldId) {
1973 - 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 || ""
1974 1459 }
1975 - }}
1976 - title={__("Delete Field", "yatra")}
1977 - message={`Are you sure you want to delete the field "${deleteConfirm.fieldLabel}"? This action cannot be undone.`}
1978 - confirmText={__("Delete", "yatra")}
1979 - cancelText={__("Cancel", "yatra")}
1980 - 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}
1981 1480 />
1982 1481
1482 +
1983 1483 {/* Preview */}
1984 1484 <Card>
1985 1485 <CardHeader>
1986 1486 <CardTitle className="text-base">
@@ -2008,32 +1508,43 @@
2008 1508 ? "col-span-1"
2009 1509 : "col-span-1"
2010 1510 }
2011 1511 >
2012 - <Label className="text-sm">
2013 - {field.label}
2014 - {field.required && (
2015 - <span className="text-red-500 ml-1">*</span>
2016 - )}
2017 - </Label>
2018 - {field.type === "select" || field.type === "country" ? (
2019 - <Select disabled className="mt-1 w-full">
2020 - <option>{field.placeholder || "Select..."}</option>
2021 - </Select>
2022 - ) : field.type === "textarea" ? (
2023 - <textarea
2024 - disabled
2025 - placeholder={field.placeholder}
2026 - className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-md bg-white dark:bg-gray-700 text-sm"
2027 - rows={2}
2028 - />
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>
2029 1516 ) : (
2030 - <Input
2031 - disabled
2032 - type={field.type}
2033 - placeholder={field.placeholder}
2034 - className="mt-1"
2035 - />
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 + </>
2036 1547 )}
2037 1548 </div>
2038 1549 ))}
2039 1550 </div>
@@ -2051,9 +1562,34 @@
2051 1562 const { showToast } = useToast();
2052 1563
2053 1564 // Get active section from localStorage or default to 'general'
2054 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 + ];
2055 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 + }
2056 1592 const saved = localStorage.getItem("yatra_settings_active_section");
2057 1593 if (saved === "email" || saved === "notification") {
2058 1594 return "general";
2059 1595 }
@@ -2060,8 +1596,9 @@
2060 1596 if (
2061 1597 saved &&
2062 1598 [
2063 1599 "general",
1600 + "design",
2064 1601 "booking",
2065 1602 "booking_form",
2066 1603 "payment",
2067 1604 "trip",
@@ -2072,8 +1609,9 @@
2072 1609 "integration",
2073 1610 "permalink",
2074 1611 "seo",
2075 1612 "advanced",
1613 + "uninstall",
2076 1614 ].includes(saved)
2077 1615 ) {
2078 1616 return saved as SettingsSection;
2079 1617 }
@@ -2098,8 +1636,9 @@
2098 1636 null,
2099 1637 );
2100 1638 const [usageBusy, setUsageBusy] = useState(false);
2101 1639 const [usageLoading, setUsageLoading] = useState(false);
1640 + const [usageError, setUsageError] = useState<string>("");
2102 1641
2103 1642 // Initialize viewingSection from activeSection on mount
2104 1643 useEffect(() => {
2105 1644 setViewingSection(activeSection);
@@ -2123,9 +1662,9 @@
2123 1662 );
2124 1663 throw error;
2125 1664 }
2126 1665 },
2127 - enabled: can("manage_yatra"),
1666 + enabled: can("yatra_manage_settings"),
2128 1667 // Avoid overwriting in-progress edits due to background refetches
2129 1668 });
2130 1669
2131 1670 // Cancellation settings are now saved with main settings
@@ -2145,18 +1684,19 @@
2145 1684 console.error("Failed to load gateway definitions:", error);
2146 1685 return {};
2147 1686 }
2148 1687 },
2149 - enabled: can("manage_yatra"),
1688 + enabled: can("yatra_manage_settings"),
2150 1689 staleTime: Infinity,
2151 1690 });
2152 1691
2153 1692 useEffect(() => {
2154 - if (viewingSection !== "advanced" || !can("manage_yatra")) {
1693 + if (viewingSection !== "advanced" || !can("yatra_manage_settings")) {
2155 1694 return;
2156 1695 }
2157 1696 let cancelled = false;
2158 1697 setUsageLoading(true);
1698 + setUsageError("");
2159 1699 (async () => {
2160 1700 try {
2161 1701 const s = await fetchUsageTrackingStatus();
2162 1702 if (!cancelled) {
@@ -2161,11 +1701,18 @@
2161 1701 const s = await fetchUsageTrackingStatus();
2162 1702 if (!cancelled) {
2163 1703 setUsageStatus(s);
2164 1704 }
2165 - } catch {
1705 + } catch (e: any) {
2166 1706 if (!cancelled) {
2167 1707 setUsageStatus(null);
1708 + const status =
1709 + e?.response?.status != null ? ` (HTTP ${e.response.status})` : "";
1710 + const msg =
1711 + (typeof e?.message === "string" && e.message.trim() !== ""
1712 + ? e.message
1713 + : __("Failed to load usage tracking status", "yatra")) + status;
1714 + setUsageError(msg);
2168 1715 }
2169 1716 } finally {
2170 1717 // Always clear loading — otherwise a strict-mode remount / dependency churn can leave the spinner stuck.
2171 1718 setUsageLoading(false);
@@ -2179,9 +1726,9 @@
2179 1726 // Default settings fallback
2180 1727 const defaultSettings: SettingsData = useMemo<SettingsData>(
2181 1728 () => ({
2182 1729 company_name: "Yatra Travel Agency",
2183 - company_email: "[email protected]",
1730 + company_email: "[email protected]",
2184 1731 company_phone: "+1-234-567-8900",
2185 1732 company_address: "123 Travel Street",
2186 1733 company_city: "Kathmandu",
2187 1734 company_state: "Bagmati",
@@ -2186,24 +1733,36 @@
2186 1733 company_city: "Kathmandu",
2187 1734 company_state: "Bagmati",
2188 1735 company_country: "Nepal",
2189 1736 company_zip: "44600",
2190 - company_website: "https://yatra.com",
1737 + company_website: "https://wpyatra.com",
2191 1738 company_logo: "",
2192 1739 timezone: "Asia/Kathmandu",
2193 1740 date_format: "Y-m-d",
2194 1741 time_format: "H:i",
1742 + frontend_primary_color: "#3b82f6",
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,
2195 1754 booking_confirmation: true,
2196 1755 auto_confirm_bookings: false,
1756 + auto_confirm_mode: "online",
2197 1757 require_login: false,
2198 1758 allow_guest_checkout: true,
2199 - cancellation_policy: "full_refund",
2200 - cancellation_days: 7,
2201 - refund_policy: "Full refund if cancelled 7 days before departure",
1759 + require_guest_email_verification: false,
2202 1760 booking_expiry_hours: 24,
2203 1761 booking_reminder_days: 3,
2204 1762 allow_waitlist: true,
2205 1763 waitlist_auto_confirm: false,
1764 + date_picker_as_dropdown: false,
2206 1765
2207 1766 currency: "USD",
2208 1767 payment_test_mode: true,
2209 1768 payment_gateways: ["pay_later"],
@@ -2212,8 +1771,17 @@
2212 1771 partial_payment_percentage: 30,
2213 1772 deposit_required: true,
2214 1773 deposit_percentage: 20,
2215 1774 auto_confirm_pay_later: true,
1775 + enable_scheduled_payments: false,
1776 + scheduled_payment_type: "single",
1777 + scheduled_payment_days: 15,
1778 + scheduled_payment_installments: 1,
1779 + discount_stacking_mode: "both",
1780 + scheduled_payment_interval: 30,
1781 + scheduled_payment_reminder_days: 3,
1782 + balance_anchor: "booking",
1783 + balance_due_days: 14,
2216 1784 gateway_configs: {
2217 1785 stripe: {
2218 1786 enabled: true,
2219 1787 title: "Stripe",
@@ -2291,10 +1859,10 @@
2291 1859 "bank_transfer",
2292 1860 "esewa",
2293 1861 "khalti",
2294 1862 ],
2295 - admin_email: "[email protected]",
2296 - from_email: "[email protected]",
1863 + admin_email: "[email protected]",
1864 + from_email: "[email protected]",
2297 1865 from_name: "Yatra Travel",
2298 1866 email_template_booking: true,
2299 1867 email_template_confirmation: true,
2300 1868 email_template_cancellation: true,
@@ -2301,8 +1869,25 @@
2301 1869 email_template_reminder: true,
2302 1870 email_template_admin_new_booking: true,
2303 1871 email_template_admin_payment: true,
2304 1872 email_template_admin_cancellation: true,
1873 + email_template_trip_consent: true,
1874 + email_template_customer_verification: true,
1875 + email_template_guest_verification: true,
1876 + email_template_booking_completed: true,
1877 + email_template_booking_expired_customer: true,
1878 + email_template_admin_booking_expired: true,
1879 + email_template_scheduled_payment_reminder: true,
1880 + email_template_scheduled_payment_succeeded: true,
1881 + email_template_scheduled_payment_failed: true,
1882 + email_template_admin_scheduled_payment_failed: true,
1883 + email_template_enquiry_received: true,
1884 + email_template_enquiry_admin: true,
1885 + email_template_enquiry_response: 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,
2305 1890 smtp_enabled: false,
2306 1891 smtp_host: "smtp.gmail.com",
2307 1892 smtp_port: 587,
2308 1893 smtp_username: "",
@@ -2321,8 +1906,42 @@
2321 1906 email_tpl_admin_payment_subject: "",
2322 1907 email_tpl_admin_payment_body: "",
2323 1908 email_tpl_admin_cancellation_subject: "",
2324 1909 email_tpl_admin_cancellation_body: "",
1910 + email_tpl_trip_consent_subject: "",
1911 + email_tpl_trip_consent_body: "",
1912 + email_tpl_customer_verification_subject: "",
1913 + email_tpl_customer_verification_body: "",
1914 + email_tpl_guest_verification_subject: "",
1915 + email_tpl_guest_verification_body: "",
1916 + email_tpl_booking_completed_subject: "",
1917 + email_tpl_booking_completed_body: "",
1918 + email_tpl_booking_expired_customer_subject: "",
1919 + email_tpl_booking_expired_customer_body: "",
1920 + email_tpl_admin_booking_expired_subject: "",
1921 + email_tpl_admin_booking_expired_body: "",
1922 + email_tpl_scheduled_payment_reminder_subject: "",
1923 + email_tpl_scheduled_payment_reminder_body: "",
1924 + email_tpl_scheduled_payment_succeeded_subject: "",
1925 + email_tpl_scheduled_payment_succeeded_body: "",
1926 + email_tpl_scheduled_payment_failed_subject: "",
1927 + email_tpl_scheduled_payment_failed_body: "",
1928 + email_tpl_admin_scheduled_payment_failed_subject: "",
1929 + email_tpl_admin_scheduled_payment_failed_body: "",
1930 + email_tpl_enquiry_received_subject: "",
1931 + email_tpl_enquiry_received_body: "",
1932 + email_tpl_enquiry_admin_subject: "",
1933 + email_tpl_enquiry_admin_body: "",
1934 + email_tpl_enquiry_response_subject: "",
1935 + email_tpl_enquiry_response_body: "",
1936 + email_tpl_review_request_subject: "",
1937 + email_tpl_review_request_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: "",
2325 1944 customer_registration: true,
2326 1945 customer_fields: ["name", "email", "phone", "address"],
2327 1946 require_email_verification: false,
2328 1947 customer_account_page: "/my-account",
@@ -2333,9 +1952,10 @@
2333 1952 auto_approve_reviews: false,
2334 1953 review_moderation: true,
2335 1954 min_rating: 1,
2336 1955 allow_anonymous_reviews: false,
2337 - review_reminder_days: 7,
1956 + review_reminder_days: 3,
1957 + review_reminder_max_age_days: 14,
2338 1958 enable_tax: true,
2339 1959 tax_name: __("Tax", "yatra"),
2340 1960 tax_rate: 10,
2341 1961 tax_inclusive: false,
@@ -2358,8 +1978,12 @@
2358 1978 facebook_pixel: "",
2359 1979 recaptcha_enabled: false,
2360 1980 recaptcha_site_key: "",
2361 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,
2362 1986 trip_base: "trip",
2363 1987 destination_base: "destination",
2364 1988 activity_base: "activity",
2365 1989 trip_category_base: "trip-category",
@@ -2364,12 +1988,17 @@
2364 1988 activity_base: "activity",
2365 1989 trip_category_base: "trip-category",
2366 1990 booking_base: "book",
2367 1991 enable_wishlist: false,
1992 + show_sold_out: true,
1993 + availability_horizon_months: 12,
2368 1994 use_booking_page: false,
2369 1995 booking_page_id: 0,
1996 + terms_page_id: 0,
1997 + privacy_policy_page_id: 0,
2370 1998 debug_mode: false,
2371 1999 enable_logging: false,
2000 + delete_data_on_uninstall: false,
2372 2001 cache_enabled: true,
2373 2002 api_key: "",
2374 2003 api_rate_limit: 100,
2375 2004 session_timeout: 3600,
@@ -2603,8 +2232,11 @@
2603 2232 seo_trip_meta_title: "",
2604 2233 seo_trip_meta_description: "",
2605 2234 seo_trip_meta_keywords: "",
2606 2235 seo_trip_meta_image: 0,
2236 + enable_sitemap: true,
2237 + sitemap_types: ["archive", "trip", "destination", "activity", "category"],
2238 + sitemap_noindex_excluded: false,
2607 2239 }),
2608 2240 [],
2609 2241 );
2610 2242
@@ -2632,8 +2264,9 @@
2632 2264 >([]);
2633 2265 const [showApiKey, setShowApiKey] = useState(false);
2634 2266 const [validatingApiKey, setValidatingApiKey] = useState(false);
2635 2267 const [showFacebookToken, setShowFacebookToken] = useState(false);
2268 + const [showGa4ApiSecret, setShowGa4ApiSecret] = useState(false);
2636 2269 const [seoImageUrl, setSeoImageUrl] = useState<string>("");
2637 2270 const [seoImageLoading, setSeoImageLoading] = useState<boolean>(false);
2638 2271 const [seoImageError, setSeoImageError] = useState<string>("");
2639 2272
@@ -2787,13 +2420,13 @@
2787 2420 if (!formData?.ga4_measurement_id) return;
2788 2421
2789 2422 setValidatingMeasurementId(true);
2790 2423 try {
2791 - const response = await apiService.validateGoogleAnalyticsMeasurementId(
2424 + const raw = (await apiService.validateGoogleAnalyticsMeasurementId(
2792 2425 formData.ga4_measurement_id,
2793 - );
2426 + )) as { success?: boolean; message?: string };
2794 2427
2795 - if (response.success) {
2428 + if (raw?.success) {
2796 2429 showToast(
2797 2430 __("Measurement ID validated successfully!", "yatra"),
2798 2431 "success",
2799 2432 );
@@ -2798,9 +2431,9 @@
2798 2431 "success",
2799 2432 );
2800 2433 } else {
2801 2434 showToast(
2802 - response.message || __("Measurement ID validation failed.", "yatra"),
2435 + raw?.message || __("Measurement ID validation failed.", "yatra"),
2803 2436 "error",
2804 2437 );
2805 2438 }
2806 2439 } catch (error: any) {
@@ -2817,18 +2450,18 @@
2817 2450 if (!formData?.ga4_measurement_id || !formData?.ga4_api_secret) return;
2818 2451
2819 2452 setValidatingApiSecret(true);
2820 2453 try {
2821 - const response = await apiService.validateGoogleAnalyticsApiSecret(
2454 + const raw = (await apiService.validateGoogleAnalyticsApiSecret(
2822 2455 formData.ga4_measurement_id,
2823 2456 formData.ga4_api_secret,
2824 - );
2457 + )) as { success?: boolean; message?: string };
2825 2458
2826 - if (response.success) {
2459 + if (raw?.success) {
2827 2460 showToast(__("API Secret validated successfully!", "yatra"), "success");
2828 2461 } else {
2829 2462 showToast(
2830 - response.message || __("API Secret validation failed.", "yatra"),
2463 + raw?.message || __("API Secret validation failed.", "yatra"),
2831 2464 "error",
2832 2465 );
2833 2466 }
2834 2467 } catch (error: any) {
@@ -2957,8 +2590,9 @@
2957 2590 // Use default settings if API fails or returns empty
2958 2591 setFormData(defaultSettings);
2959 2592 isInitializedRef.current = true;
2960 2593 }
2594 + // eslint-disable-next-line react-hooks/exhaustive-deps
2961 2595 }, [settings, isLoading]);
2962 2596
2963 2597 // Fetch SEO image URL when image ID changes with performance optimization
2964 2598 React.useEffect(() => {
@@ -3033,33 +2667,43 @@
3033 2667 controller.abort();
3034 2668 };
3035 2669 }, [formData?.seo_trip_meta_image]);
3036 2670
3037 - // 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);
3038 2681 React.useEffect(() => {
3039 - if (
3040 - gatewayDefinitions &&
3041 - Object.keys(gatewayDefinitions).length > 0 &&
3042 - gatewayOrder.length === 0
3043 - ) {
3044 - // Use saved order if available, otherwise use default order from definitions
3045 - const savedOrder = settings?.gateway_order as string[] | undefined;
3046 - if (savedOrder) {
3047 - // Merge saved order with any new gateways
3048 - const allGateways = Object.keys(gatewayDefinitions);
3049 - const validSavedOrder = savedOrder.filter((id) =>
3050 - allGateways.includes(id),
3051 - );
3052 - const newGateways = allGateways.filter(
3053 - (id) => !validSavedOrder.includes(id),
3054 - );
3055 - setGatewayOrder([...validSavedOrder, ...newGateways]);
3056 - } else {
3057 - setGatewayOrder(Object.keys(gatewayDefinitions));
3058 - }
2682 + if (gatewayOrderInitialized.current) return;
2683 + if (!gatewayDefinitions || Object.keys(gatewayDefinitions).length === 0) {
2684 + return;
3059 2685 }
3060 - }, [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;
3061 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 +
3062 2706 // Single stable onChange handler that reads field name from input's name or id attribute
3063 2707 const handleFieldChange = React.useCallback(
3064 2708 (
3065 2709 e: React.ChangeEvent<
@@ -3071,10 +2715,19 @@
3071 2715
3072 2716 if (e.target.type === "checkbox") {
3073 2717 value = (e.target as HTMLInputElement).checked;
3074 2718 } else if (e.target.type === "number") {
3075 - const numValue = parseFloat(e.target.value);
3076 - 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 + }
3077 2730 } else {
3078 2731 value = e.target.value;
3079 2732 }
3080 2733
@@ -3085,8 +2738,48 @@
3085 2738 },
3086 2739 [],
3087 2740 );
3088 2741
2742 + const timeZoneSelectOptions = useMemo(() => {
2743 + const ya = (window as any).yatraAdmin || {};
2744 + const raw = ya.timeZoneIdentifiers;
2745 + let ids: string[] = [];
2746 + if (
2747 + Array.isArray(raw) &&
2748 + raw.length > 0 &&
2749 + raw.every((x: unknown) => typeof x === "string")
2750 + ) {
2751 + ids = raw as string[];
2752 + } else {
2753 + ids = [
2754 + "UTC",
2755 + "America/New_York",
2756 + "America/Los_Angeles",
2757 + "Europe/London",
2758 + "Europe/Paris",
2759 + "Asia/Dubai",
2760 + "Asia/Kolkata",
2761 + "Asia/Kathmandu",
2762 + "Australia/Sydney",
2763 + ];
2764 + }
2765 + const set = new Set(ids);
2766 + const wpTz =
2767 + typeof ya.wordPressTimezone === "string"
2768 + ? ya.wordPressTimezone.trim()
2769 + : "";
2770 + if (wpTz) {
2771 + set.add(wpTz);
2772 + }
2773 + const current = formData?.timezone?.trim();
2774 + if (current) {
2775 + set.add(current);
2776 + }
2777 + return Array.from(set)
2778 + .sort((a, b) => a.localeCompare(b))
2779 + .map((z) => ({ value: z, label: z }));
2780 + }, [formData?.timezone]);
2781 +
3089 2782 const handleGatewayConfigChange = React.useCallback(
3090 2783 (gateway: string, field: keyof PaymentGatewayConfig, value: any) => {
3091 2784 setFormData((prev) => {
3092 2785 if (!prev) return prev;
@@ -3246,9 +2939,11 @@
3246 2939 slug: string;
3247 2940 url: string;
3248 2941 }>;
3249 2942 },
3250 - enabled: can("manage_yatra") && viewingSection === "permalink",
2943 + // Used by multiple sections (Booking page, Legal pages, etc.), so keep it available
2944 + // whenever the user can manage Yatra settings.
2945 + enabled: can("yatra_manage_settings"),
3251 2946 });
3252 2947
3253 2948 // Check shortcode on-demand when user selects a page
3254 2949 const checkShortcodeMutation = useMutation({
@@ -3380,8 +3075,13 @@
3380 3075 label: __("General", "yatra"),
3381 3076 icon: Building2,
3382 3077 },
3383 3078 {
3079 + id: "design" as SettingsSection,
3080 + label: __("Design", "yatra"),
3081 + icon: Palette,
3082 + },
3083 + {
3384 3084 id: "booking" as SettingsSection,
3385 3085 label: __("Booking", "yatra"),
3386 3086 icon: Calendar,
3387 3087 },
@@ -3390,12 +3090,36 @@
3390 3090 label: __("Booking Form", "yatra"),
3391 3091 icon: ClipboardList,
3392 3092 },
3393 3093 {
3094 + id: "search" as SettingsSection,
3095 + label: __("Search & Listing", "yatra"),
3096 + icon: SlidersHorizontal,
3097 + },
3098 + {
3394 3099 id: "payment" as SettingsSection,
3395 3100 label: __("Payment", "yatra"),
3396 3101 icon: DollarSign,
3397 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 + : []),
3398 3122 {
3399 3123 id: "customer" as SettingsSection,
3400 3124 label: __("Customer", "yatra"),
3401 3125 icon: Users,
@@ -3430,8 +3154,13 @@
3430 3154 id: "advanced" as SettingsSection,
3431 3155 label: __("Advanced", "yatra"),
3432 3156 icon: Shield,
3433 3157 },
3158 + {
3159 + id: "uninstall" as SettingsSection,
3160 + label: __("Uninstall", "yatra"),
3161 + icon: Trash2,
3162 + },
3434 3163 ];
3435 3164
3436 3165 // Helper component for section divider
3437 3166 const SectionDivider = ({ title }: { title: string }) => (
@@ -3594,29 +3323,61 @@
3594 3323 <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
3595 3324 <FormField
3596 3325 id="timezone"
3597 3326 label={__("Timezone", "yatra")}
3598 - description={__("Select your local timezone", "yatra")}
3327 + description={__(
3328 + "Used for booking logic, emails, and date display. Search the full IANA list (same identifiers PHP uses).",
3329 + "yatra",
3330 + )}
3599 3331 >
3600 - <Select
3601 - id="timezone"
3602 - value={formData.timezone}
3603 - name="timezone"
3604 - onChange={handleFieldChange}
3605 - >
3606 - <option value="UTC">{__("UTC", "yatra")}</option>
3607 - <option value="America/New_York">
3608 - {__("America/New_York", "yatra")}
3609 - </option>
3610 - <option value="Europe/London">
3611 - {__("Europe/London", "yatra")}
3612 - </option>
3613 - <option value="Asia/Kathmandu">
3614 - {__("Asia/Kathmandu", "yatra")}
3615 - </option>
3616 - <option value="Asia/Dubai">Asia/Dubai</option>
3617 - <option value="Asia/Kolkata">Asia/Kolkata</option>
3618 - </Select>
3332 + <div className="space-y-2">
3333 + {(window as any).yatraAdmin?.wordPressTimezone ? (
3334 + <Button
3335 + type="button"
3336 + variant="outline"
3337 + size="sm"
3338 + className="w-full sm:w-auto"
3339 + onClick={() =>
3340 + handleFieldChange({
3341 + target: {
3342 + name: "timezone",
3343 + id: "timezone",
3344 + value: String(
3345 + (window as any).yatraAdmin.wordPressTimezone,
3346 + ),
3347 + type: "select-one",
3348 + },
3349 + } as React.ChangeEvent<HTMLSelectElement>)
3350 + }
3351 + >
3352 + {__("Use WordPress site timezone", "yatra")}
3353 + {": "}
3354 + <span className="font-mono text-xs">
3355 + {(window as any).yatraAdmin.wordPressTimezone}
3356 + </span>
3357 + </Button>
3358 + ) : null}
3359 + <SearchableSelect
3360 + value={formData.timezone}
3361 + onChange={(val) =>
3362 + handleFieldChange({
3363 + target: {
3364 + name: "timezone",
3365 + id: "timezone",
3366 + value: val,
3367 + type: "select-one",
3368 + },
3369 + } as React.ChangeEvent<HTMLSelectElement>)
3370 + }
3371 + options={timeZoneSelectOptions}
3372 + searchPlaceholder={__(
3373 + "Search by city, region, or code…",
3374 + "yatra",
3375 + )}
3376 + placeholder={__("Select timezone…", "yatra")}
3377 + showValueId={false}
3378 + />
3379 + </div>
3619 3380 </FormField>
3620 3381
3621 3382 <FormField
3622 3383 id="date_format"
@@ -3724,8 +3485,246 @@
3724 3485 </div>
3725 3486 </div>
3726 3487 );
3727 3488
3489 + case "design":
3490 + return (
3491 + <div className="space-y-6">
3492 + <p className="text-sm text-gray-600 dark:text-gray-400">
3493 + {__(
3494 + "Control how Yatra looks on the public site (trip pages, booking, listings, and shortcodes). These options complement your WordPress theme.",
3495 + "yatra",
3496 + )}
3497 + </p>
3498 + <div className="space-y-4">
3499 + <FormField
3500 + id="frontend_primary_color"
3501 + label={__("Primary brand color", "yatra")}
3502 + description={__(
3503 + "Buttons, links, and highlights. Default is the standard Yatra blue; choose a hex color that matches your brand or theme accent.",
3504 + "yatra",
3505 + )}
3506 + >
3507 + <div className="flex flex-wrap items-center gap-3">
3508 + <input
3509 + type="color"
3510 + id="frontend_primary_color_picker"
3511 + aria-label={__("Pick primary color", "yatra")}
3512 + className="h-10 w-14 cursor-pointer rounded border border-gray-300 bg-white p-0.5 dark:border-gray-600"
3513 + value={
3514 + /^#[0-9A-Fa-f]{6}$/.test(
3515 + (formData.frontend_primary_color || "").trim(),
3516 + )
3517 + ? (formData.frontend_primary_color || "").trim()
3518 + : "#3b82f6"
3519 + }
3520 + onChange={(e) =>
3521 + handleFieldChange({
3522 + target: {
3523 + name: "frontend_primary_color",
3524 + id: "frontend_primary_color",
3525 + value: e.target.value,
3526 + type: "text",
3527 + },
3528 + } as React.ChangeEvent<HTMLInputElement>)
3529 + }
3530 + />
3531 + <Input
3532 + id="frontend_primary_color"
3533 + name="frontend_primary_color"
3534 + className="max-w-[10rem] font-mono text-sm"
3535 + value={formData.frontend_primary_color || ""}
3536 + onChange={handleFieldChange}
3537 + placeholder="#3b82f6"
3538 + spellCheck={false}
3539 + />
3540 + <Button
3541 + type="button"
3542 + variant="outline"
3543 + size="sm"
3544 + onClick={() =>
3545 + handleFieldChange({
3546 + target: {
3547 + name: "frontend_primary_color",
3548 + id: "frontend_primary_color",
3549 + value: "#3b82f6",
3550 + type: "text",
3551 + },
3552 + } as React.ChangeEvent<HTMLInputElement>)
3553 + }
3554 + >
3555 + {__("Reset to default", "yatra")}
3556 + </Button>
3557 + </div>
3558 + </FormField>
3559 +
3560 + <FormField
3561 + id="frontend_container_max_width"
3562 + label={__("Container max width", "yatra")}
3563 + description={__(
3564 + "Optional. CSS width for trip listings, booking flow, and shortcode layouts (e.g. 1200px, 72rem, min(100%,80rem)). Leave empty to use your block theme layout (theme.json) or theme content width.",
3565 + "yatra",
3566 + )}
3567 + >
3568 + <Input
3569 + id="frontend_container_max_width"
3570 + name="frontend_container_max_width"
3571 + className="max-w-md font-mono text-sm"
3572 + value={formData.frontend_container_max_width || ""}
3573 + onChange={handleFieldChange}
3574 + placeholder="1200px"
3575 + spellCheck={false}
3576 + autoComplete="off"
3577 + />
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>
3723 + </div>
3724 + </div>
3725 + );
3726 +
3728 3727 case "booking_form":
3729 3728 return (
3730 3729 <BookingFormBuilder formData={formData} setFormData={setFormData} />
3731 3730 );
@@ -3863,8 +3862,218 @@
3863 3862 </div>
3864 3863 </ProFeature>
3865 3864 </div>
3866 3865
3866 + <div>
3867 + <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">
3868 + {__("Scheduled balance payments", "yatra")}
3869 + <ProBadge isProActive={(window as any).yatraAdmin?.isPro} />
3870 + </h3>
3871 + <p className="text-sm text-gray-600 dark:text-gray-300 mb-4">
3872 + {__(
3873 + "After a deposit or partial payment, automatically schedule the remaining balance with your gateway (Stripe invoices or subscription schedules). Requires a gateway customer and payment method from checkout.",
3874 + "yatra",
3875 + )}
3876 + </p>
3877 + <ProFeature
3878 + title={__("Scheduled Payments", "yatra")}
3879 + description={__(
3880 + "schedule the remaining balance after partial or deposit payments",
3881 + "yatra",
3882 + )}
3883 + moduleName="Scheduled Payments"
3884 + pricingUrl="https://wpyatra.com/pricing?module=scheduled-payments"
3885 + isProActive={(window as any).yatraAdmin?.isPro}
3886 + isModuleEnabled={
3887 + (window as any).yatraAdmin?.scheduledPaymentsEnabled
3888 + }
3889 + >
3890 + <div className="space-y-4">
3891 + <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
3892 + <input
3893 + type="checkbox"
3894 + id="enable_scheduled_payments"
3895 + checked={formData.enable_scheduled_payments}
3896 + name="enable_scheduled_payments"
3897 + onChange={handleFieldChange}
3898 + className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
3899 + />
3900 + <div className="flex-1">
3901 + <Label
3902 + htmlFor="enable_scheduled_payments"
3903 + className="font-medium cursor-pointer"
3904 + >
3905 + {__("Enable scheduled balance payments", "yatra")}
3906 + </Label>
3907 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
3908 + {__(
3909 + "When a booking still has an amount due after checkout, create gateway-side schedules using the rules below.",
3910 + "yatra",
3911 + )}
3912 + </p>
3913 + </div>
3914 + </div>
3915 +
3916 + {formData.enable_scheduled_payments && (
3917 + <>
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
3964 + id="scheduled_payment_type"
3965 + label={__("Schedule type", "yatra")}
3966 + description={__(
3967 + "Single final charge on a date, or multiple installments.",
3968 + "yatra",
3969 + )}
3970 + >
3971 + <Select
3972 + id="scheduled_payment_type"
3973 + name="scheduled_payment_type"
3974 + value={formData.scheduled_payment_type}
3975 + onChange={handleFieldChange}
3976 + >
3977 + <option value="single">
3978 + {__("Single balance payment", "yatra")}
3979 + </option>
3980 + <option value="installments">
3981 + {__("Multiple installments", "yatra")}
3982 + </option>
3983 + </Select>
3984 + </FormField>
3985 +
3986 + {formData.balance_anchor !== "tour" && (
3987 + <FormField
3988 + id="scheduled_payment_days"
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 + )}
4007 +
4008 + {formData.scheduled_payment_type === "installments" && (
4009 + <>
4010 + <FormField
4011 + id="scheduled_payment_installments"
4012 + label={__("Number of installments", "yatra")}
4013 + description={__(
4014 + "Total installments including the scheduled balance (Stripe subscription schedule).",
4015 + "yatra",
4016 + )}
4017 + >
4018 + <Input
4019 + id="scheduled_payment_installments"
4020 + type="number"
4021 + min={2}
4022 + max={24}
4023 + value={formData.scheduled_payment_installments}
4024 + name="scheduled_payment_installments"
4025 + onChange={handleFieldChange}
4026 + className="max-w-xs"
4027 + />
4028 + </FormField>
4029 + <FormField
4030 + id="scheduled_payment_interval"
4031 + label={__("Days between installments", "yatra")}
4032 + description={__(
4033 + "Spacing between installment charges when using multiple installments.",
4034 + "yatra",
4035 + )}
4036 + >
4037 + <Input
4038 + id="scheduled_payment_interval"
4039 + type="number"
4040 + min={1}
4041 + max={365}
4042 + value={formData.scheduled_payment_interval}
4043 + name="scheduled_payment_interval"
4044 + onChange={handleFieldChange}
4045 + className="max-w-xs"
4046 + />
4047 + </FormField>
4048 + </>
4049 + )}
4050 +
4051 + <FormField
4052 + id="scheduled_payment_reminder_days"
4053 + label={__("Payment reminder (days before)", "yatra")}
4054 + description={__(
4055 + "Send the scheduled payment reminder email this many days before the due date (0 to disable reminders).",
4056 + "yatra",
4057 + )}
4058 + >
4059 + <Input
4060 + id="scheduled_payment_reminder_days"
4061 + type="number"
4062 + min={0}
4063 + max={30}
4064 + value={formData.scheduled_payment_reminder_days}
4065 + name="scheduled_payment_reminder_days"
4066 + onChange={handleFieldChange}
4067 + className="max-w-xs"
4068 + />
4069 + </FormField>
4070 + </>
4071 + )}
4072 + </div>
4073 + </ProFeature>
4074 + </div>
4075 +
3867 4076 {/* Auto-confirm Pay Later Bookings - Available in Free version */}
3868 4077 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
3869 4078 <input
3870 4079 type="checkbox"
@@ -4228,8 +4437,117 @@
4228 4437 return null;
4229 4438 }
4230 4439 }
4231 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 +
4232 4550 // Render checkbox fields differently
4233 4551 if (field.type === "checkbox") {
4234 4552 return (
4235 4553 <div
@@ -4510,8 +4828,53 @@
4510 4828 </div>
4511 4829 );
4512 4830 }
4513 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 +
4514 4877 // Render text, password, number, email inputs
4515 4878 return (
4516 4879 <div key={field.id} className="space-y-2">
4517 4880 <FormField
@@ -4533,12 +4896,21 @@
4533 4896 onChange={(e) =>
4534 4897 handleGatewayConfigChange(
4535 4898 gatewayId,
4536 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.
4537 4907 field.type === "number"
4538 - ? parseFloat(
4539 - e.target.value,
4540 - ) || 0
4908 + ? e.target.value === ""
4909 + ? ""
4910 + : parseFloat(
4911 + e.target.value,
4912 + ) || 0
4541 4913 : e.target.value,
4542 4914 )
4543 4915 }
4544 4916 placeholder={field.placeholder}
@@ -4601,57 +4973,162 @@
4601 4973 </div>
4602 4974 </>
4603 4975 );
4604 4976
4605 - // Continue with other sections... (I'll add the most important ones)
4606 - case "booking":
4977 + case "search":
4607 4978 return (
4608 4979 <div className="space-y-6">
4609 - <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>
4610 5079 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
4611 5080 <input
4612 5081 type="checkbox"
4613 - id="booking_confirmation"
4614 - checked={formData.booking_confirmation}
4615 - name="booking_confirmation"
5082 + id="collapse_filters_on_mobile"
5083 + checked={!!formData.collapse_filters_on_mobile}
5084 + name="collapse_filters_on_mobile"
4616 5085 onChange={handleFieldChange}
4617 5086 className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
4618 5087 />
4619 5088 <div className="flex-1">
4620 5089 <Label
4621 - htmlFor="booking_confirmation"
5090 + htmlFor="collapse_filters_on_mobile"
4622 5091 className="font-medium cursor-pointer"
4623 5092 >
4624 - {__("Enable Booking Confirmation", "yatra")}
5093 + {__("Collapse filters by default on mobile", "yatra")}
4625 5094 </Label>
4626 5095 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
4627 5096 {__(
4628 - "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.",
4629 5098 "yatra",
4630 5099 )}
4631 5100 </p>
4632 5101 </div>
4633 5102 </div>
5103 + </div>
5104 + </div>
5105 + );
4634 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">
4635 5112 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
4636 5113 <input
4637 5114 type="checkbox"
4638 - id="auto_confirm_bookings"
4639 - checked={formData.auto_confirm_bookings}
4640 - name="auto_confirm_bookings"
5115 + id="booking_confirmation"
5116 + checked={formData.booking_confirmation}
5117 + name="booking_confirmation"
4641 5118 onChange={handleFieldChange}
4642 5119 className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
4643 5120 />
4644 5121 <div className="flex-1">
4645 5122 <Label
4646 - htmlFor="auto_confirm_bookings"
5123 + htmlFor="booking_confirmation"
4647 5124 className="font-medium cursor-pointer"
4648 5125 >
4649 - {__("Auto-Confirm Bookings", "yatra")}
5126 + {__("Send Booking Confirmation Email", "yatra")}
4650 5127 </Label>
4651 5128 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
4652 5129 {__(
4653 - "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.",
4654 5131 "yatra",
4655 5132 )}
4656 5133 </p>
4657 5134 </div>
@@ -4656,8 +5133,85 @@
4656 5133 </p>
4657 5134 </div>
4658 5135 </div>
4659 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 +
4660 5214 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
4661 5215 <input
4662 5216 type="checkbox"
4663 5217 id="require_login"
@@ -4706,11 +5260,94 @@
4706 5260 </p>
4707 5261 </div>
4708 5262 </div>
4709 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 + */}
4710 5273 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
4711 5274 <input
4712 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"
4713 5350 id="allow_waitlist"
4714 5351 checked={formData.allow_waitlist}
4715 5352 name="allow_waitlist"
4716 5353 onChange={handleFieldChange}
@@ -4755,80 +5392,65 @@
4755 5392 )}
4756 5393 </p>
4757 5394 </div>
4758 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>
4759 5430 </div>
4760 5431
4761 - <SectionDivider
4762 - title={__("Cancellation & Refund Policy", "yatra")}
4763 - />
5432 + {/*
5433 + Cancellation & Refund Policy section removed.
4764 5434
4765 - <div className="space-y-4">
4766 - <FormField
4767 - id="cancellation_policy"
4768 - label={__("Cancellation Policy", "yatra")}
4769 - description={__(
4770 - "What happens when a booking is cancelled",
4771 - "yatra",
4772 - )}
4773 - >
4774 - <Select
4775 - id="cancellation_policy"
4776 - value={formData.cancellation_policy}
4777 - name="cancellation_policy"
4778 - onChange={handleFieldChange}
4779 - >
4780 - <option value="no_refund">{__("No Refund", "yatra")}</option>
4781 - <option value="full_refund">
4782 - {__("Full Refund", "yatra")}
4783 - </option>
4784 - <option value="partial_refund">
4785 - {__("Partial Refund", "yatra")}
4786 - </option>
4787 - </Select>
4788 - </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.
4789 5444
4790 - <FormField
4791 - id="cancellation_days"
4792 - label={__("Cancellation Days Before Departure", "yatra")}
4793 - description={__(
4794 - "Number of days before departure when cancellation is allowed",
4795 - "yatra",
4796 - )}
4797 - >
4798 - <Input
4799 - id="cancellation_days"
4800 - type="number"
4801 - value={formData.cancellation_days}
4802 - name="cancellation_days"
4803 - onChange={handleFieldChange}
4804 - min="0"
4805 - />
4806 - </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 + */}
4807 5452
4808 - <FormField
4809 - id="refund_policy"
4810 - label={__("Refund Policy", "yatra")}
4811 - description={__(
4812 - "Detailed refund policy description shown to customers",
4813 - "yatra",
4814 - )}
4815 - >
4816 - <textarea
4817 - id="refund_policy"
4818 - value={formData.refund_policy}
4819 - name="refund_policy"
4820 - onChange={handleFieldChange}
4821 - rows={4}
4822 - 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"
4823 - placeholder={__(
4824 - "Enter your refund policy details...",
4825 - "yatra",
4826 - )}
4827 - />
4828 - </FormField>
4829 - </div>
4830 -
4831 5453 <SectionDivider title={__("Booking Expiry & Reminders", "yatra")} />
4832 5454
4833 5455 <div className="space-y-4">
4834 5456 <FormField
@@ -4869,8 +5491,188 @@
4869 5491 </div>
4870 5492 </div>
4871 5493 );
4872 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 +
4873 5675 case "customer":
4874 5676 return (
4875 5677 <div className="space-y-6">
4876 5678 <div className="space-y-4">
@@ -5137,9 +5939,9 @@
5137 5939 <FormField
5138 5940 id="review_reminder_days"
5139 5941 label={__("Review Reminder Days", "yatra")}
5140 5942 description={__(
5141 - "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.",
5142 5944 "yatra",
5143 5945 )}
5144 5946 >
5145 5947 <Input
@@ -5150,8 +5952,26 @@
5150 5952 onChange={handleFieldChange}
5151 5953 min="0"
5152 5954 />
5153 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>
5154 5974 </>
5155 5975 )}
5156 5976 </div>
5157 5977 </div>
@@ -6339,37 +7159,46 @@
6339 7159 {/* Google Analytics 4 Enhanced - Pro Feature */}
6340 7160 <SectionDivider
6341 7161 title={__("Google Analytics 4 Enhanced", "yatra")}
6342 7162 />
6343 - <Card>
6344 - <CardHeader className="flex flex-row items-center justify-between pb-2">
6345 - <CardTitle className="text-lg flex items-center gap-2">
6346 - <svg
6347 - className="w-5 h-5 text-orange-500"
6348 - viewBox="0 0 24 24"
6349 - fill="currentColor"
6350 - >
6351 - <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" />
6352 - </svg>
6353 - {__("Google Analytics 4 Enhanced", "yatra")}
6354 - <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>
6355 7176 </CardTitle>
6356 - {(window as any).yatraAdmin?.googleAnalytics?.connected ? (
6357 - <div className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400">
6358 - <CheckCircle className="w-4 h-4" />
6359 - {__("Connected", "yatra")}
6360 - </div>
6361 - ) : (
6362 - <div className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
6363 - <XCircle className="w-4 h-4" />
6364 - {__("Not Connected", "yatra")}
6365 - </div>
6366 - )}
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 + })()}
6367 7196 </CardHeader>
6368 - <CardContent className="space-y-4">
6369 - <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">
6370 7199 {__(
6371 - "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.",
6372 7201 "yatra",
6373 7202 )}
6374 7203 </p>
6375 7204
@@ -6385,206 +7214,114 @@
6385 7214 isModuleEnabled={
6386 7215 (window as any).yatraAdmin?.showGoogleAnalyticsSettingsUI
6387 7216 }
6388 7217 >
6389 - <div className="space-y-4">
6390 - <FormField
6391 - id="ga4_measurement_id"
6392 - label={__("Measurement ID", "yatra")}
6393 - description={
6394 - <>
6395 - {__("Get your Measurement ID from", "yatra")}{" "}
6396 - <a
6397 - href="https://analytics.google.com/analytics/web/"
6398 - target="_blank"
6399 - rel="noopener noreferrer"
6400 - className="text-blue-600 hover:text-blue-500 dark:text-blue-400 underline"
6401 - >
6402 - {__("Google Analytics", "yatra")}
6403 - </a>{" "}
6404 - {__("(Admin > Data Streams)", "yatra")}
6405 - </>
6406 - }
6407 - actionButton={
6408 - <Button
6409 - type="button"
6410 - variant="outline"
6411 - size="sm"
6412 - onClick={() => validateMeasurementId()}
6413 - disabled={
6414 - !formData.ga4_measurement_id ||
6415 - validatingMeasurementId
6416 - }
6417 - className="shrink-0"
6418 - >
6419 - {validatingMeasurementId ? (
6420 - <>
6421 - <svg
6422 - className="animate-spin -ml-1 mr-2 h-4 w-4"
6423 - fill="none"
6424 - viewBox="0 0 24 24"
6425 - >
6426 - <circle
6427 - className="opacity-25"
6428 - cx="12"
6429 - cy="12"
6430 - r="10"
6431 - stroke="currentColor"
6432 - strokeWidth="4"
6433 - ></circle>
6434 - <path
6435 - className="opacity-75"
6436 - fill="currentColor"
6437 - 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"
6438 - ></path>
6439 - </svg>
6440 - {__("Validating...", "yatra")}
6441 - </>
6442 - ) : (
6443 - <>
6444 - <svg
6445 - className="-ml-1 mr-2 h-4 w-4"
6446 - fill="none"
6447 - viewBox="0 0 24 24"
6448 - stroke="currentColor"
6449 - >
6450 - <path
6451 - strokeLinecap="round"
6452 - strokeLinejoin="round"
6453 - strokeWidth={2}
6454 - d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
6455 - />
6456 - </svg>
6457 - {__("Validate", "yatra")}
6458 - </>
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",
6459 7229 )}
6460 - </Button>
6461 - }
6462 - >
6463 - <Input
6464 - id="ga4_measurement_id"
6465 - value={formData.ga4_measurement_id || ""}
6466 - name="ga4_measurement_id"
6467 - onChange={handleFieldChange}
6468 - placeholder="G-XXXXXXXXXX"
6469 - />
6470 - </FormField>
6471 -
6472 - <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
6473 - <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
6474 - <input
6475 - type="checkbox"
6476 - id="ga4_track_view_item"
6477 - checked={formData.ga4_track_view_item ?? false}
6478 - name="ga4_track_view_item"
6479 - onChange={handleFieldChange}
6480 - className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
6481 - />
6482 - <Label
6483 - htmlFor="ga4_track_view_item"
6484 - className="text-sm cursor-pointer"
6485 - >
6486 - {__("Track View Item", "yatra")}
6487 - </Label>
7230 + </p>
6488 7231 </div>
6489 -
6490 - <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
6491 - <input
6492 - type="checkbox"
6493 - id="ga4_track_add_to_cart"
6494 - checked={formData.ga4_track_add_to_cart ?? false}
6495 - name="ga4_track_add_to_cart"
6496 - onChange={handleFieldChange}
6497 - className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
6498 - />
6499 - <Label
6500 - htmlFor="ga4_track_add_to_cart"
6501 - className="text-sm cursor-pointer"
6502 - >
6503 - {__("Track Add to Cart", "yatra")}
6504 - </Label>
6505 - </div>
6506 -
6507 - <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
6508 - <input
6509 - type="checkbox"
6510 - id="ga4_track_begin_checkout"
6511 - checked={formData.ga4_track_begin_checkout ?? false}
6512 - name="ga4_track_begin_checkout"
6513 - onChange={handleFieldChange}
6514 - className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
6515 - />
6516 - <Label
6517 - htmlFor="ga4_track_begin_checkout"
6518 - className="text-sm cursor-pointer"
6519 - >
6520 - {__("Track Begin Checkout", "yatra")}
6521 - </Label>
6522 - </div>
6523 -
6524 - <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
6525 - <input
6526 - type="checkbox"
6527 - id="ga4_track_purchase"
6528 - checked={formData.ga4_track_purchase ?? false}
6529 - name="ga4_track_purchase"
6530 - onChange={handleFieldChange}
6531 - className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
6532 - />
6533 - <Label
6534 - htmlFor="ga4_track_purchase"
6535 - className="text-sm cursor-pointer"
6536 - >
6537 - {__("Track Purchase", "yatra")}
6538 - </Label>
6539 - </div>
6540 - </div>
6541 - <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
6542 - <input
6543 - type="checkbox"
6544 - id="ga4_use_measurement_protocol"
6545 - checked={formData.ga4_use_measurement_protocol ?? false}
6546 - name="ga4_use_measurement_protocol"
6547 - onChange={handleFieldChange}
6548 - className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
6549 - />
6550 - <Label
6551 - htmlFor="ga4_use_measurement_protocol"
6552 - className="text-sm cursor-pointer"
6553 - >
6554 - {__("Use Measurement Protocol", "yatra")}
6555 - </Label>
6556 - </div>
6557 -
6558 - {formData.ga4_use_measurement_protocol && (
6559 7232 <FormField
6560 - id="ga4_api_secret"
6561 - label={__("API Secret", "yatra")}
6562 - description={__(
6563 - "Generate API secret from Google Analytics Admin > Data Streams > Measurement Protocol",
6564 - "yatra",
6565 - )}
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 + }
6566 7304 actionButton={
6567 7305 <Button
6568 7306 type="button"
6569 7307 variant="outline"
6570 7308 size="sm"
6571 - onClick={() => validateApiSecret()}
7309 + onClick={() => validateMeasurementId()}
6572 7310 disabled={
6573 - !formData.ga4_api_secret ||
6574 - !formData.ga4_measurement_id ||
6575 - validatingApiSecret
7311 + !formData.ga4_measurement_id?.trim() ||
7312 + validatingMeasurementId
6576 7313 }
6577 7314 className="shrink-0"
6578 7315 >
6579 - {validatingApiSecret ? (
7316 + {validatingMeasurementId ? (
6580 7317 <>
6581 - <Loader2 className="w-4 h-4 animate-spin" />
7318 + <Loader2 className="mr-2 h-4 w-4 animate-spin" />
6582 7319 {__("Validating...", "yatra")}
6583 7320 </>
6584 7321 ) : (
6585 7322 <>
6586 - <CheckCircle className="w-4 h-4" />
7323 + <Check className="mr-2 h-4 w-4" />
6587 7324 {__("Validate", "yatra")}
6588 7325 </>
6589 7326 )}
6590 7327 </Button>
@@ -6590,56 +7327,375 @@
6590 7327 </Button>
6591 7328 }
6592 7329 >
6593 7330 <Input
6594 - id="ga4_api_secret"
6595 - value={formData.ga4_api_secret || ""}
6596 - name="ga4_api_secret"
7331 + id="ga4_measurement_id"
7332 + value={formData.ga4_measurement_id || ""}
7333 + name="ga4_measurement_id"
6597 7334 onChange={handleFieldChange}
6598 - placeholder="Your API secret"
7335 + placeholder="G-XXXXXXXXXX"
7336 + className="font-mono text-sm"
7337 + spellCheck={false}
7338 + autoComplete="off"
6599 7339 />
6600 7340 </FormField>
6601 - )}
6602 - </div>
6603 - </ProFeature>
7341 + </div>
6604 7342
6605 - {/* Google Analytics Monitoring Notice */}
6606 - {(window as any).yatraAdmin?.googleAnalytics?.measurementId && (
6607 - <div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
6608 - <div className="flex items-center gap-2">
6609 - <svg
6610 - className="w-4 h-4 text-blue-600 dark:text-blue-400"
6611 - fill="none"
6612 - stroke="currentColor"
6613 - viewBox="0 0 24 24"
6614 - >
6615 - <path
6616 - strokeLinecap="round"
6617 - strokeLinejoin="round"
6618 - strokeWidth={2}
6619 - d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
6620 - />
6621 - </svg>
6622 - <div className="text-sm">
6623 - <span className="text-blue-800 dark:text-blue-300 font-medium">
6624 - {__("Google Analytics Monitoring:", "yatra")}
6625 - </span>
6626 - <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">
6627 7350 {__(
6628 - "View event statistics and activity in the ",
7351 + "Choose which standard ecommerce events Yatra should emit to GA4 on the storefront and booking flow.",
6629 7352 "yatra",
6630 7353 )}
6631 - <a
6632 - href={`${(window as any).yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=reports`}
6633 - 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"
6634 7485 >
6635 - {__("Reports page", "yatra")}
6636 - </a>
6637 - {__(" → Google Analytics 4 tab.", "yatra")}
6638 - </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>
6639 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 + )}
6640 7667 </div>
6641 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>
6642 7698 )}
6643 7699 </CardContent>
6644 7700 </Card>
6645 7701
@@ -6730,8 +7786,93 @@
6730 7786 onChange={handleFieldChange}
6731 7787 placeholder={__("Enter secret key", "yatra")}
6732 7788 />
6733 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>
6734 7875 </>
6735 7876 )}
6736 7877 </div>
6737 7878 </div>
@@ -7425,11 +8566,235 @@
7425 8566 </div>
7426 8567 </FormField>
7427 8568 </div>
7428 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>
7429 8734 </div>
7430 8735 );
7431 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 +
7432 8797 case "advanced":
7433 8798 return (
7434 8799 <div className="space-y-6">
7435 8800 <div className="space-y-4">
@@ -7515,8 +8880,68 @@
7515 8880 </div>
7516 8881 </div>
7517 8882 </div>
7518 8883
8884 + <div className="border-t border-gray-200 dark:border-gray-700 pt-6">
8885 + <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
8886 + {__("Legal pages (Booking UI)", "yatra")}
8887 + </h3>
8888 + <p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
8889 + {__(
8890 + "Select the pages that will be linked from the booking Terms and Privacy consent checkboxes.",
8891 + "yatra",
8892 + )}
8893 + </p>
8894 +
8895 + <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
8896 + <div>
8897 + <Label htmlFor="terms_page_id">
8898 + {__("Terms & Conditions page", "yatra")}
8899 + </Label>
8900 + <select
8901 + id="terms_page_id"
8902 + name="terms_page_id"
8903 + value={formData.terms_page_id || 0}
8904 + onChange={handleFieldChange}
8905 + className="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-900"
8906 + >
8907 + <option value={0}>{__("— Select —", "yatra")}</option>
8908 + {pagesData?.map((p) => (
8909 + <option key={p.id} value={p.id}>
8910 + {p.title}
8911 + </option>
8912 + ))}
8913 + </select>
8914 + </div>
8915 +
8916 + <div>
8917 + <Label htmlFor="privacy_policy_page_id">
8918 + {__("Privacy Policy page", "yatra")}
8919 + </Label>
8920 + <select
8921 + id="privacy_policy_page_id"
8922 + name="privacy_policy_page_id"
8923 + value={formData.privacy_policy_page_id || 0}
8924 + onChange={handleFieldChange}
8925 + className="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-900"
8926 + >
8927 + <option value={0}>{__("— Select —", "yatra")}</option>
8928 + {pagesData?.map((p) => (
8929 + <option key={p.id} value={p.id}>
8930 + {p.title}
8931 + </option>
8932 + ))}
8933 + </select>
8934 + <p className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
8935 + {__(
8936 + "If not set here, WordPress Settings → Privacy will be used when available.",
8937 + "yatra",
8938 + )}
8939 + </p>
8940 + </div>
8941 + </div>
8942 + </div>
8943 +
7519 8944 <div className="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
7520 8945 <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-2 flex items-center gap-2">
7521 8946 <BarChart3 className="w-4 h-4" />
7522 8947 {__("Help us improve Yatra", "yatra")}
@@ -7553,14 +8978,21 @@
7553 8978 </p>
7554 8979 )}
7555 8980
7556 8981 {!usageLoading && !usageStatus && (
7557 - <p className="text-sm text-amber-600">
7558 - {__(
7559 - "Could not load this section. You may need permission to manage Yatra.",
7560 - "yatra",
8982 + <div className="space-y-1">
8983 + <p className="text-sm text-amber-600">
8984 + {__(
8985 + "Could not load this section. You may need permission to manage Yatra.",
8986 + "yatra",
8987 + )}
8988 + </p>
8989 + {usageError && (
8990 + <p className="text-xs text-gray-500 dark:text-gray-400">
8991 + {usageError}
8992 + </p>
7561 8993 )}
7562 - </p>
8994 + </div>
7563 8995 )}
7564 8996
7565 8997 {!usageLoading && usageStatus && (
7566 8998 <div className="space-y-4">
@@ -7761,9 +9193,9 @@
7761 9193 </Button>
7762 9194 }
7763 9195 />
7764 9196
7765 - <ConditionalRender capability="manage_yatra">
9197 + <ConditionalRender capability="yatra_manage_settings">
7766 9198 <div className="grid grid-cols-1 lg:grid-cols-4 gap-3">
7767 9199 {/* Left Sidebar */}
7768 9200 <div className="lg:col-span-1">
7769 9201 <Card>