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 +2681 -1315 3.0.2.7 → 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;
@@ -917,8 +1075,9 @@
917 1075
918 1076 // Advanced Settings
919 1077 debug_mode: boolean;
920 1078 enable_logging: boolean;
1079 + delete_data_on_uninstall: boolean;
921 1080 cache_enabled: boolean;
922 1081 api_key: string;
923 1082 api_rate_limit: number;
924 1083 session_timeout: number;
@@ -930,8 +1089,11 @@
930 1089 seo_trip_meta_title: string;
931 1090 seo_trip_meta_description: string;
932 1091 seo_trip_meta_keywords: string;
933 1092 seo_trip_meta_image: number;
1093 + enable_sitemap: boolean;
1094 + sitemap_types: string[];
1095 + sitemap_noindex_excluded: boolean;
934 1096 }
935 1097
936 1098 // Form Builder Component
937 1099 type BookingFormSubTab =
@@ -966,11 +1128,17 @@
966 1128 }) => {
967 1129 const [activeFormTab, setActiveFormTab] =
968 1130 useState<BookingFormSubTab>(getInitialFormSubTab);
969 1131
970 - // 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.
971 1139 const { data: modulesData } = useQuery({
972 - queryKey: ["modules"],
1140 + queryKey: ["modules", "raw-envelope"],
973 1141 queryFn: async () => {
974 1142 const response = await apiService.getModules();
975 1143 return response;
976 1144 },
@@ -984,12 +1152,35 @@
984 1152 (m: any) => m.slug === "dynamic_form_field",
985 1153 );
986 1154 return module?.enabled === true && module?.is_available === true;
987 1155 }
988 - // Fallback to yatraAdmin if modules API hasn't loaded yet
989 - 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;
990 1162 }, [modulesData]);
991 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 +
992 1183 // Save sub-tab to localStorage when it changes
993 1184 const handleSubTabChange = (tab: BookingFormSubTab) => {
994 1185 setActiveFormTab(tab);
995 1186 if (typeof window !== "undefined") {
@@ -995,66 +1186,15 @@
995 1186 if (typeof window !== "undefined") {
996 1187 localStorage.setItem("yatra_settings_booking_form_subtab", tab);
997 1188 }
998 1189 };
999 - const [editingField, setEditingField] = useState<string | null>(null);
1000 - const [showAddField, setShowAddField] = useState(false);
1001 - const [newField, setNewField] = useState<Partial<FormFieldConfig>>({
1002 - id: "",
1003 - type: "text",
1004 - label: "",
1005 - placeholder: "",
1006 - required: false,
1007 - enabled: true,
1008 - width: "full",
1009 - options: [],
1010 - });
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 + ];
1011 1195
1012 - // Delete confirmation state
1013 - const [deleteConfirm, setDeleteConfirm] = useState<{
1014 - isOpen: boolean;
1015 - fieldId: string | null;
1016 - fieldLabel: string;
1017 - }>({
1018 - isOpen: false,
1019 - fieldId: null,
1020 - fieldLabel: "",
1021 - });
1022 1196
1023 - // Drag and drop state
1024 - const [draggedFieldId, setDraggedFieldId] = useState<string | null>(null);
1025 - const [dragOverFieldId, setDragOverFieldId] = useState<string | null>(null);
1026 -
1027 - // Helper to generate ID from label
1028 - const generateIdFromLabel = (label: string): string => {
1029 - return label
1030 - .toLowerCase()
1031 - .replace(/\s+/g, "_")
1032 - .replace(/[^a-z0-9_]/g, "");
1033 - };
1034 -
1035 - // Helper to sanitize ID input
1036 - const sanitizeId = (id: string): string => {
1037 - return id
1038 - .toLowerCase()
1039 - .replace(/\s+/g, "_")
1040 - .replace(/[^a-z0-9_]/g, "");
1041 - };
1042 -
1043 - // Handle label change with auto ID generation
1044 - const handleNewFieldLabelChange = (label: string) => {
1045 - const autoId = generateIdFromLabel(label);
1046 - setNewField((prev) => ({
1047 - ...prev,
1048 - label,
1049 - // Only auto-generate ID if it hasn't been manually edited
1050 - id:
1051 - prev.id === "" || prev.id === generateIdFromLabel(prev.label || "")
1052 - ? autoId
1053 - : prev.id,
1054 - }));
1055 - };
1056 -
1057 1197 const formTabs = [
1058 1198 {
1059 1199 id: "contact_form" as const,
1060 1200 label: __("Contact Form", "yatra"),
@@ -1072,17 +1212,18 @@
1072 1212 },
1073 1213 ];
1074 1214
1075 1215 const fieldTypes = [
1076 - { value: "text", label: "Text" },
1077 - { value: "email", label: "Email" },
1078 - { value: "tel", label: "Phone" },
1079 - { value: "date", label: "Date" },
1080 - { value: "select", label: "Dropdown" },
1081 - { value: "country", label: "Country Selector" },
1082 - { value: "textarea", label: "Text Area" },
1083 - { value: "number", label: "Number" },
1084 - { 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") },
1085 1226 ];
1086 1227
1087 1228 const widthOptions = [
1088 1229 { value: "full", label: "Full Width" },
@@ -1100,8 +1241,27 @@
1100 1241 }
1101 1242 );
1102 1243 };
1103 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 +
1104 1264 const updateFormConfig = (updates: Partial<FormSectionConfig>) => {
1105 1265 setFormData((prev) => {
1106 1266 if (!prev) return prev;
1107 1267 return {
@@ -1116,165 +1276,9 @@
1116 1276 };
1117 1277 });
1118 1278 };
1119 1279
1120 - const updateField = (fieldId: string, updates: Partial<FormFieldConfig>) => {
1121 - const currentConfig = getCurrentFormConfig();
1122 - const updatedFields = currentConfig.fields.map((field) =>
1123 - field.id === fieldId ? { ...field, ...updates } : field,
1124 - );
1125 - updateFormConfig({ fields: updatedFields });
1126 - };
1127 1280
1128 - const toggleFieldEnabled = (fieldId: string) => {
1129 - const currentConfig = getCurrentFormConfig();
1130 - const field = currentConfig.fields.find((f) => f.id === fieldId);
1131 - // Locked fields cannot be disabled
1132 - if (field && !field.locked) {
1133 - updateField(fieldId, { enabled: !field.enabled });
1134 - }
1135 - };
1136 -
1137 - const toggleFieldRequired = (fieldId: string) => {
1138 - const currentConfig = getCurrentFormConfig();
1139 - const field = currentConfig.fields.find((f) => f.id === fieldId);
1140 - if (field) {
1141 - updateField(fieldId, { required: !field.required });
1142 - }
1143 - };
1144 -
1145 - const moveField = (fieldId: string, direction: "up" | "down") => {
1146 - const currentConfig = getCurrentFormConfig();
1147 - const fields = [...currentConfig.fields];
1148 - const index = fields.findIndex((f) => f.id === fieldId);
1149 -
1150 - if (direction === "up" && index > 0) {
1151 - [fields[index - 1], fields[index]] = [fields[index], fields[index - 1]];
1152 - } else if (direction === "down" && index < fields.length - 1) {
1153 - [fields[index], fields[index + 1]] = [fields[index + 1], fields[index]];
1154 - }
1155 -
1156 - // Update order values
1157 - fields.forEach((field, i) => {
1158 - field.order = i + 1;
1159 - });
1160 -
1161 - updateFormConfig({ fields });
1162 - };
1163 -
1164 - // Drag and Drop handlers
1165 - const handleDragStart = (e: React.DragEvent, fieldId: string) => {
1166 - setDraggedFieldId(fieldId);
1167 - e.dataTransfer.effectAllowed = "move";
1168 - };
1169 -
1170 - const handleDragOver = (e: React.DragEvent, fieldId: string) => {
1171 - e.preventDefault();
1172 - e.dataTransfer.dropEffect = "move";
1173 - if (fieldId !== draggedFieldId) {
1174 - setDragOverFieldId(fieldId);
1175 - }
1176 - };
1177 -
1178 - const handleDragLeave = () => {
1179 - setDragOverFieldId(null);
1180 - };
1181 -
1182 - const handleDrop = (e: React.DragEvent, targetFieldId: string) => {
1183 - e.preventDefault();
1184 - if (!draggedFieldId || draggedFieldId === targetFieldId) {
1185 - setDraggedFieldId(null);
1186 - setDragOverFieldId(null);
1187 - return;
1188 - }
1189 -
1190 - const currentConfig = getCurrentFormConfig();
1191 - const fields = [...currentConfig.fields];
1192 - const draggedIndex = fields.findIndex((f) => f.id === draggedFieldId);
1193 - const targetIndex = fields.findIndex((f) => f.id === targetFieldId);
1194 -
1195 - if (draggedIndex !== -1 && targetIndex !== -1) {
1196 - const [draggedField] = fields.splice(draggedIndex, 1);
1197 - fields.splice(targetIndex, 0, draggedField);
1198 -
1199 - // Update order values
1200 - fields.forEach((field, i) => {
1201 - field.order = i + 1;
1202 - });
1203 -
1204 - updateFormConfig({ fields });
1205 - }
1206 -
1207 - setDraggedFieldId(null);
1208 - setDragOverFieldId(null);
1209 - };
1210 -
1211 - const handleDragEnd = () => {
1212 - setDraggedFieldId(null);
1213 - setDragOverFieldId(null);
1214 - };
1215 -
1216 - const deleteField = (fieldId: string) => {
1217 - const currentConfig = getCurrentFormConfig();
1218 - const updatedFields = currentConfig.fields.filter((f) => f.id !== fieldId);
1219 - updatedFields.forEach((field, i) => {
1220 - field.order = i + 1;
1221 - });
1222 - updateFormConfig({ fields: updatedFields });
1223 - setDeleteConfirm({ isOpen: false, fieldId: null, fieldLabel: "" });
1224 - };
1225 -
1226 - const addNewField = () => {
1227 - if (!newField.label || !newField.id) return;
1228 -
1229 - const currentConfig = getCurrentFormConfig();
1230 - const fieldId = sanitizeId(newField.id);
1231 -
1232 - // Check if ID already exists
1233 - if (currentConfig.fields.some((f) => f.id === fieldId)) {
1234 - // TODO: Replace with toast notification when refactoring this nested component
1235 - window.alert(
1236 - "A field with this ID already exists. Please use a different ID.",
1237 - );
1238 - return;
1239 - }
1240 -
1241 - const newFieldConfig: FormFieldConfig = {
1242 - id: fieldId,
1243 - type: (newField.type as FormFieldConfig["type"]) || "text",
1244 - label: newField.label || "",
1245 - placeholder: newField.placeholder || "",
1246 - required: newField.required || false,
1247 - enabled: true,
1248 - order: currentConfig.fields.length + 1,
1249 - width: (newField.width as FormFieldConfig["width"]) || "full",
1250 - };
1251 -
1252 - // Add options if field type is select
1253 - if (
1254 - newField.type === "select" &&
1255 - newField.options &&
1256 - newField.options.length > 0
1257 - ) {
1258 - newFieldConfig.options = newField.options.filter(
1259 - (opt) => opt.value && opt.label,
1260 - );
1261 - }
1262 -
1263 - updateFormConfig({ fields: [...currentConfig.fields, newFieldConfig] });
1264 - setNewField({
1265 - id: "",
1266 - type: "text",
1267 - label: "",
1268 - placeholder: "",
1269 - required: false,
1270 - enabled: true,
1271 - width: "full",
1272 - options: [],
1273 - });
1274 - setShowAddField(false);
1275 - };
1276 -
1277 1281 const currentConfig = getCurrentFormConfig();
1278 1282
1279 1283 return (
1280 1284 <div className="space-y-6">
@@ -1307,14 +1311,51 @@
1307 1311 ))}
1308 1312 </nav>
1309 1313 </div>
1310 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 +
1311 1328 {/* Form Section Settings */}
1312 1329 <Card>
1313 1330 <CardHeader>
1314 - <CardTitle className="text-base">
1315 - {__("Form Section Settings", "yatra")}
1316 - </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>
1317 1358 </CardHeader>
1318 1359 <CardContent className="space-y-4">
1319 1360 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
1320 1361 <input
@@ -1361,628 +1402,85 @@
1361 1402 className="mt-1"
1362 1403 />
1363 1404 </div>
1364 1405 </div>
1365 - </CardContent>
1366 - </Card>
1367 1406
1368 - {/* Form Fields */}
1369 - <Card>
1370 - <CardHeader className="flex flex-row items-center justify-between">
1371 - <CardTitle className="text-base">
1372 - {__("Form Fields", "yatra")}
1373 - </CardTitle>
1374 - <Button
1375 - variant="outline"
1376 - size="sm"
1377 - onClick={() => setShowAddField(!showAddField)}
1378 - className="flex items-center gap-2"
1379 - >
1380 - <Plus className="w-4 h-4" />
1381 - {__("Add Field", "yatra")}
1382 - </Button>
1383 - </CardHeader>
1384 - <CardContent className="space-y-3">
1385 - {/* Add New Field Form */}
1386 - {showAddField && (
1387 - <div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 mb-4">
1388 - <h4 className="font-medium text-sm mb-3">
1389 - {__("Add New Field", "yatra")}
1390 - </h4>
1391 - <div className="grid grid-cols-1 md:grid-cols-5 gap-3">
1392 - <div>
1393 - <Label className="text-xs">
1394 - {__("Field Type", "yatra")}
1395 - </Label>
1396 - <Select
1397 - value={newField.type || "text"}
1398 - onChange={(e) =>
1399 - setNewField((prev) => ({
1400 - ...prev,
1401 - type: e.target.value as FormFieldConfig["type"],
1402 - }))
1403 - }
1404 - 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"
1405 1415 >
1406 - {fieldTypes.map((type) => (
1407 - <option key={type.value} value={type.value}>
1408 - {type.label}
1409 - </option>
1410 - ))}
1411 - </Select>
1412 - </div>
1413 - <div>
1414 - <Label className="text-xs">{__("Label", "yatra")} *</Label>
1415 - <Input
1416 - value={newField.label || ""}
1417 - onChange={(e) =>
1418 - handleNewFieldLabelChange(e.target.value)
1419 - }
1420 - placeholder="Field label"
1421 - className="mt-1"
1422 - />
1423 - </div>
1424 - <div>
1425 - <Label className="text-xs">
1426 - {__("Field ID", "yatra")} *
1427 - </Label>
1428 - <Input
1429 - value={newField.id || ""}
1430 - onChange={(e) =>
1431 - setNewField((prev) => ({
1432 - ...prev,
1433 - id: sanitizeId(e.target.value),
1434 - }))
1435 - }
1436 - placeholder="field_id"
1437 - className="mt-1 font-mono text-xs"
1438 - />
1439 - <p className="text-[10px] text-gray-400 mt-0.5">
1440 - {__("Lowercase, no spaces", "yatra")}
1441 - </p>
1442 - </div>
1443 - <div>
1444 - <Label className="text-xs">
1445 - {__("Placeholder", "yatra")}
1446 - </Label>
1447 - <Input
1448 - value={newField.placeholder || ""}
1449 - onChange={(e) =>
1450 - setNewField((prev) => ({
1451 - ...prev,
1452 - placeholder: e.target.value,
1453 - }))
1454 - }
1455 - placeholder="Placeholder text"
1456 - className="mt-1"
1457 - />
1458 - </div>
1459 - <div>
1460 - <Label className="text-xs">{__("Width", "yatra")}</Label>
1461 - <Select
1462 - value={newField.width || "full"}
1463 - onChange={(e) =>
1464 - setNewField((prev) => ({
1465 - ...prev,
1466 - width: e.target.value as FormFieldConfig["width"],
1467 - }))
1468 - }
1469 - className="mt-1"
1470 - >
1471 - {widthOptions.map((w) => (
1472 - <option key={w.value} value={w.value}>
1473 - {w.label}
1474 - </option>
1475 - ))}
1476 - </Select>
1477 - </div>
1478 - </div>
1479 - {/* Options editor for select fields */}
1480 - {newField.type === "select" && (
1481 - <div className="mt-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-blue-100 dark:border-blue-900">
1482 - <div className="flex items-center justify-between mb-2">
1483 - <Label className="text-xs font-medium">
1484 - {__("Dropdown Options", "yatra")}
1485 - </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" />
1486 1429 <Button
1487 1430 type="button"
1488 1431 variant="ghost"
1489 1432 size="sm"
1490 - onClick={() => {
1491 - const currentOptions = newField.options || [];
1492 - setNewField((prev) => ({
1493 - ...prev,
1494 - options: [
1495 - ...currentOptions,
1496 - { value: "", label: "" },
1497 - ],
1498 - }));
1499 - }}
1500 - className="h-6 text-xs"
1433 + onClick={() => setConditionsOpen(true)}
1501 1434 >
1502 - <Plus className="w-3 h-3 mr-1" />
1503 - {__("Add Option", "yatra")}
1435 + {__("Edit", "yatra")}
1504 1436 </Button>
1505 1437 </div>
1506 - <div className="space-y-2">
1507 - {(newField.options || []).map((option, optIndex) => (
1508 - <div key={optIndex} className="flex items-center gap-2">
1509 - <Input
1510 - value={option.value}
1511 - onChange={(e) => {
1512 - const newOptions = [...(newField.options || [])];
1513 - newOptions[optIndex] = {
1514 - ...newOptions[optIndex],
1515 - value: e.target.value,
1516 - };
1517 - setNewField((prev) => ({
1518 - ...prev,
1519 - options: newOptions,
1520 - }));
1521 - }}
1522 - placeholder="Value (e.g., option1)"
1523 - className="text-xs flex-1"
1524 - />
1525 - <Input
1526 - value={option.label}
1527 - onChange={(e) => {
1528 - const newOptions = [...(newField.options || [])];
1529 - newOptions[optIndex] = {
1530 - ...newOptions[optIndex],
1531 - label: e.target.value,
1532 - };
1533 - setNewField((prev) => ({
1534 - ...prev,
1535 - options: newOptions,
1536 - }));
1537 - }}
1538 - placeholder="Label (e.g., Option 1)"
1539 - className="text-xs flex-1"
1540 - />
1541 - <button
1542 - type="button"
1543 - onClick={() => {
1544 - const newOptions = (
1545 - newField.options || []
1546 - ).filter((_, i) => i !== optIndex);
1547 - setNewField((prev) => ({
1548 - ...prev,
1549 - options: newOptions,
1550 - }));
1551 - }}
1552 - className="p-1 text-gray-400 hover:text-red-500"
1553 - >
1554 - <Trash2 className="w-3 h-3" />
1555 - </button>
1556 - </div>
1557 - ))}
1558 - {(!newField.options || newField.options.length === 0) && (
1559 - <p className="text-xs text-gray-400 italic">
1560 - {__(
1561 - 'Click "Add Option" to add dropdown choices.',
1562 - "yatra",
1563 - )}
1564 - </p>
1565 - )}
1566 - </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>
1567 1446 </div>
1568 - )}
1569 -
1570 - <div className="flex items-center gap-4 mt-3">
1571 - <label className="flex items-center gap-2 text-sm">
1572 - <input
1573 - type="checkbox"
1574 - checked={newField.required || false}
1575 - onChange={(e) =>
1576 - setNewField((prev) => ({
1577 - ...prev,
1578 - required: e.target.checked,
1579 - }))
1580 - }
1581 - className="w-4 h-4 rounded border-gray-300 text-blue-600"
1582 - />
1583 - {__("Required", "yatra")}
1584 - </label>
1585 - <div className="flex-1"></div>
1586 - <Button
1587 - variant="ghost"
1588 - size="sm"
1589 - onClick={() => setShowAddField(false)}
1590 - >
1591 - {__("Cancel", "yatra")}
1592 - </Button>
1593 - <Button
1594 - size="sm"
1595 - onClick={addNewField}
1596 - disabled={!newField.label || !newField.id}
1597 - >
1598 - {__("Add Field", "yatra")}
1599 - </Button>
1600 1447 </div>
1601 - </div>
1602 - )}
1603 -
1604 - {/* Field List */}
1605 - {currentConfig.fields?.length === 0 ? (
1606 - <div className="text-center py-8 text-gray-500 dark:text-gray-400">
1607 - <p>
1608 - {__(
1609 - 'No fields configured. Click "Add Field" to get started.',
1610 - "yatra",
1611 - )}
1612 - </p>
1613 - </div>
1614 - ) : (
1615 - <div className="space-y-2">
1616 - {currentConfig.fields?.map((field, index) => (
1617 - <div
1618 - key={field.id}
1619 - draggable
1620 - onDragStart={(e) => handleDragStart(e, field.id)}
1621 - onDragOver={(e) => handleDragOver(e, field.id)}
1622 - onDragLeave={handleDragLeave}
1623 - onDrop={(e) => handleDrop(e, field.id)}
1624 - onDragEnd={handleDragEnd}
1625 - className={`flex items-center gap-3 p-3 rounded-lg border transition-all cursor-grab active:cursor-grabbing ${
1626 - draggedFieldId === field.id
1627 - ? "opacity-50 border-dashed"
1628 - : ""
1629 - } ${
1630 - dragOverFieldId === field.id
1631 - ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
1632 - : ""
1633 - } ${
1634 - field.enabled
1635 - ? "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
1636 - : "bg-gray-50 dark:bg-gray-900 border-gray-100 dark:border-gray-800 opacity-60"
1637 - }`}
1638 - >
1639 - {/* Drag Handle & Order */}
1640 - <div className="flex items-center gap-1">
1641 - <GripVertical className="w-4 h-4 text-gray-400" />
1642 - <div className="flex flex-col gap-0.5">
1643 - <button
1644 - type="button"
1645 - onClick={() => moveField(field.id, "up")}
1646 - disabled={index === 0}
1647 - className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
1648 - >
1649 - <ArrowUp className="w-3 h-3" />
1650 - </button>
1651 - <button
1652 - type="button"
1653 - onClick={() => moveField(field.id, "down")}
1654 - disabled={index === currentConfig.fields.length - 1}
1655 - className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
1656 - >
1657 - <ArrowDown className="w-3 h-3" />
1658 - </button>
1659 - </div>
1660 - </div>
1661 -
1662 - {/* Field Info */}
1663 - <div className="flex-1 min-w-0">
1664 - {editingField === field.id ? (
1665 - <div className="space-y-3">
1666 - <div className="grid grid-cols-4 gap-2">
1667 - <Input
1668 - value={field.label}
1669 - onChange={(e) =>
1670 - updateField(field.id, { label: e.target.value })
1671 - }
1672 - placeholder="Label"
1673 - className="text-sm"
1674 - />
1675 - <Input
1676 - value={field.placeholder}
1677 - onChange={(e) =>
1678 - updateField(field.id, {
1679 - placeholder: e.target.value,
1680 - })
1681 - }
1682 - placeholder="Placeholder"
1683 - className="text-sm"
1684 - />
1685 - <Select
1686 - value={field.type}
1687 - onChange={(e) =>
1688 - updateField(field.id, {
1689 - type: e.target
1690 - .value as FormFieldConfig["type"],
1691 - })
1692 - }
1693 - className="text-sm"
1694 - >
1695 - {fieldTypes.map((type) => (
1696 - <option key={type.value} value={type.value}>
1697 - {type.label}
1698 - </option>
1699 - ))}
1700 - </Select>
1701 - <Select
1702 - value={field.width}
1703 - onChange={(e) =>
1704 - updateField(field.id, {
1705 - width: e.target
1706 - .value as FormFieldConfig["width"],
1707 - })
1708 - }
1709 - className="text-sm"
1710 - >
1711 - {widthOptions.map((w) => (
1712 - <option key={w.value} value={w.value}>
1713 - {w.label}
1714 - </option>
1715 - ))}
1716 - </Select>
1717 - </div>
1718 -
1719 - {/* Field ID - Below other fields */}
1720 - <div className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-900 rounded">
1721 - <Label className="text-xs text-gray-500 whitespace-nowrap">
1722 - {__("Field ID:", "yatra")}
1723 - </Label>
1724 - <Input
1725 - value={field.id}
1726 - onChange={(e) => {
1727 - if (!field.locked) {
1728 - updateField(field.id, {
1729 - id: sanitizeId(e.target.value),
1730 - });
1731 - }
1732 - }}
1733 - placeholder="field_id"
1734 - className="text-sm font-mono flex-1 max-w-xs"
1735 - disabled={field.locked}
1736 - title={
1737 - field.locked
1738 - ? "Locked fields cannot change ID"
1739 - : "Field ID (lowercase, no spaces)"
1740 - }
1741 - />
1742 - {field.locked && (
1743 - <span className="text-xs text-amber-600">
1744 - {__("(locked)", "yatra")}
1745 - </span>
1746 - )}
1747 - </div>
1748 -
1749 - {/* Dropdown Options Editor */}
1750 - {field.type === "select" && (
1751 - <div className="p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
1752 - <div className="flex items-center justify-between mb-2">
1753 - <Label className="text-xs font-medium">
1754 - {__("Dropdown Options", "yatra")}
1755 - </Label>
1756 - <Button
1757 - type="button"
1758 - variant="ghost"
1759 - size="sm"
1760 - onClick={() => {
1761 - const newOptions = [
1762 - ...(field.options || []),
1763 - { value: "", label: "" },
1764 - ];
1765 - updateField(field.id, {
1766 - options: newOptions,
1767 - });
1768 - }}
1769 - className="h-6 text-xs"
1770 - >
1771 - <Plus className="w-3 h-3 mr-1" />
1772 - {__("Add Option", "yatra")}
1773 - </Button>
1774 - </div>
1775 - <div className="space-y-2">
1776 - {(field.options || []).map(
1777 - (option, optIndex) => (
1778 - <div
1779 - key={optIndex}
1780 - className="flex items-center gap-2"
1781 - >
1782 - <Input
1783 - value={option.value}
1784 - onChange={(e) => {
1785 - const newOptions = [
1786 - ...(field.options || []),
1787 - ];
1788 - newOptions[optIndex] = {
1789 - ...newOptions[optIndex],
1790 - value: e.target.value,
1791 - };
1792 - updateField(field.id, {
1793 - options: newOptions,
1794 - });
1795 - }}
1796 - placeholder="Value (e.g., spouse)"
1797 - className="text-xs flex-1"
1798 - />
1799 - <Input
1800 - value={option.label}
1801 - onChange={(e) => {
1802 - const newOptions = [
1803 - ...(field.options || []),
1804 - ];
1805 - newOptions[optIndex] = {
1806 - ...newOptions[optIndex],
1807 - label: e.target.value,
1808 - };
1809 - updateField(field.id, {
1810 - options: newOptions,
1811 - });
1812 - }}
1813 - placeholder="Label (e.g., Spouse/Partner)"
1814 - className="text-xs flex-1"
1815 - />
1816 - <button
1817 - type="button"
1818 - onClick={() => {
1819 - const newOptions = (
1820 - field.options || []
1821 - ).filter((_, i) => i !== optIndex);
1822 - updateField(field.id, {
1823 - options: newOptions,
1824 - });
1825 - }}
1826 - className="p-1 text-gray-400 hover:text-red-500"
1827 - >
1828 - <Trash2 className="w-3 h-3" />
1829 - </button>
1830 - </div>
1831 - ),
1832 - )}
1833 - {(!field.options ||
1834 - field.options.length === 0) && (
1835 - <p className="text-xs text-gray-400 italic">
1836 - {__(
1837 - 'No options. Click "Add Option" to add dropdown choices.',
1838 - "yatra",
1839 - )}
1840 - </p>
1841 - )}
1842 - </div>
1843 - </div>
1844 - )}
1845 - </div>
1846 - ) : (
1847 - <div className="flex items-center gap-3 flex-wrap">
1848 - <span className="font-medium text-sm">
1849 - {field.label}
1850 - </span>
1851 - <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">
1852 - {field.id}
1853 - </code>
1854 - <span className="text-xs text-gray-400 dark:text-gray-500 px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">
1855 - {fieldTypes.find((t) => t.value === field.type)
1856 - ?.label || field.type}
1857 - </span>
1858 - <span className="text-xs text-gray-400 dark:text-gray-500">
1859 - {widthOptions.find((w) => w.value === field.width)
1860 - ?.label || "Full"}
1861 - </span>
1862 - {field.type === "select" &&
1863 - field.options &&
1864 - field.options.length > 0 && (
1865 - <span className="text-xs text-blue-500 dark:text-blue-400">
1866 - ({field.options.length}{" "}
1867 - {field.options.length === 1
1868 - ? "option"
1869 - : "options"}
1870 - )
1871 - </span>
1872 - )}
1873 - {field.required && (
1874 - <span className="text-xs text-red-500 font-medium">
1875 - {__("Required", "yatra")}
1876 - </span>
1877 - )}
1878 - {field.locked && (
1879 - <span
1880 - 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"
1881 - title="This field is protected and cannot be deleted"
1882 - >
1883 - <Lock className="w-3 h-3" />
1884 - Locked
1885 - </span>
1886 - )}
1887 - </div>
1888 - )}
1889 - </div>
1890 -
1891 - {/* Actions */}
1892 - <div className="flex items-center gap-1">
1893 - <button
1894 - type="button"
1895 - onClick={() =>
1896 - !field.locked && toggleFieldRequired(field.id)
1897 - }
1898 - disabled={field.locked}
1899 - 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"}`}
1900 - title={
1901 - field.locked
1902 - ? "This field is required and cannot be changed"
1903 - : field.required
1904 - ? "Make optional"
1905 - : "Make required"
1906 - }
1907 - >
1908 - <Star
1909 - className="w-4 h-4"
1910 - fill={field.required ? "currentColor" : "none"}
1911 - />
1912 - </button>
1913 - {/* Show enable/disable toggle only for non-locked fields */}
1914 - {!field.locked && (
1915 - <button
1916 - type="button"
1917 - onClick={() => toggleFieldEnabled(field.id)}
1918 - className={`p-1.5 rounded ${field.enabled ? "text-green-500" : "text-gray-400"}`}
1919 - title={
1920 - field.enabled ? "Disable field" : "Enable field"
1921 - }
1922 - >
1923 - {field.enabled ? (
1924 - <Eye className="w-4 h-4" />
1925 - ) : (
1926 - <EyeOff className="w-4 h-4" />
1927 - )}
1928 - </button>
1929 - )}
1930 - <button
1931 - type="button"
1932 - onClick={() =>
1933 - setEditingField(
1934 - editingField === field.id ? null : field.id,
1935 - )
1936 - }
1937 - className="p-1.5 rounded text-gray-400 hover:text-blue-500"
1938 - title="Edit field"
1939 - >
1940 - <Edit2 className="w-4 h-4" />
1941 - </button>
1942 - {/* Show delete button only for non-locked fields */}
1943 - {!field.locked && (
1944 - <button
1945 - type="button"
1946 - onClick={() =>
1947 - setDeleteConfirm({
1948 - isOpen: true,
1949 - fieldId: field.id,
1950 - fieldLabel: field.label,
1951 - })
1952 - }
1953 - className="p-1.5 rounded text-gray-400 hover:text-red-500"
1954 - title="Delete field"
1955 - >
1956 - <Trash2 className="w-4 h-4" />
1957 - </button>
1958 - )}
1959 - </div>
1960 - </div>
1961 - ))}
1962 - </div>
1963 - )}
1448 + )}
1964 1449 </CardContent>
1965 1450 </Card>
1966 1451
1967 - {/* Delete Confirmation Dialog */}
1968 - <ConfirmationDialog
1969 - isOpen={deleteConfirm.isOpen}
1970 - onClose={() =>
1971 - setDeleteConfirm({ isOpen: false, fieldId: null, fieldLabel: "" })
1972 - }
1973 - onConfirm={() => {
1974 - if (deleteConfirm.fieldId) {
1975 - 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 || ""
1976 1459 }
1977 - }}
1978 - title={__("Delete Field", "yatra")}
1979 - message={`Are you sure you want to delete the field "${deleteConfirm.fieldLabel}"? This action cannot be undone.`}
1980 - confirmText={__("Delete", "yatra")}
1981 - cancelText={__("Cancel", "yatra")}
1982 - 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}
1983 1480 />
1984 1481
1482 +
1985 1483 {/* Preview */}
1986 1484 <Card>
1987 1485 <CardHeader>
1988 1486 <CardTitle className="text-base">
@@ -2010,32 +1508,43 @@
2010 1508 ? "col-span-1"
2011 1509 : "col-span-1"
2012 1510 }
2013 1511 >
2014 - <Label className="text-sm">
2015 - {field.label}
2016 - {field.required && (
2017 - <span className="text-red-500 ml-1">*</span>
2018 - )}
2019 - </Label>
2020 - {field.type === "select" || field.type === "country" ? (
2021 - <Select disabled className="mt-1 w-full">
2022 - <option>{field.placeholder || "Select..."}</option>
2023 - </Select>
2024 - ) : field.type === "textarea" ? (
2025 - <textarea
2026 - disabled
2027 - placeholder={field.placeholder}
2028 - className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-md bg-white dark:bg-gray-700 text-sm"
2029 - rows={2}
2030 - />
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>
2031 1516 ) : (
2032 - <Input
2033 - disabled
2034 - type={field.type}
2035 - placeholder={field.placeholder}
2036 - className="mt-1"
2037 - />
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 + </>
2038 1547 )}
2039 1548 </div>
2040 1549 ))}
2041 1550 </div>
@@ -2053,9 +1562,34 @@
2053 1562 const { showToast } = useToast();
2054 1563
2055 1564 // Get active section from localStorage or default to 'general'
2056 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 + ];
2057 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 + }
2058 1592 const saved = localStorage.getItem("yatra_settings_active_section");
2059 1593 if (saved === "email" || saved === "notification") {
2060 1594 return "general";
2061 1595 }
@@ -2062,8 +1596,9 @@
2062 1596 if (
2063 1597 saved &&
2064 1598 [
2065 1599 "general",
1600 + "design",
2066 1601 "booking",
2067 1602 "booking_form",
2068 1603 "payment",
2069 1604 "trip",
@@ -2074,8 +1609,9 @@
2074 1609 "integration",
2075 1610 "permalink",
2076 1611 "seo",
2077 1612 "advanced",
1613 + "uninstall",
2078 1614 ].includes(saved)
2079 1615 ) {
2080 1616 return saved as SettingsSection;
2081 1617 }
@@ -2100,8 +1636,9 @@
2100 1636 null,
2101 1637 );
2102 1638 const [usageBusy, setUsageBusy] = useState(false);
2103 1639 const [usageLoading, setUsageLoading] = useState(false);
1640 + const [usageError, setUsageError] = useState<string>("");
2104 1641
2105 1642 // Initialize viewingSection from activeSection on mount
2106 1643 useEffect(() => {
2107 1644 setViewingSection(activeSection);
@@ -2125,9 +1662,9 @@
2125 1662 );
2126 1663 throw error;
2127 1664 }
2128 1665 },
2129 - enabled: can("manage_yatra"),
1666 + enabled: can("yatra_manage_settings"),
2130 1667 // Avoid overwriting in-progress edits due to background refetches
2131 1668 });
2132 1669
2133 1670 // Cancellation settings are now saved with main settings
@@ -2147,18 +1684,19 @@
2147 1684 console.error("Failed to load gateway definitions:", error);
2148 1685 return {};
2149 1686 }
2150 1687 },
2151 - enabled: can("manage_yatra"),
1688 + enabled: can("yatra_manage_settings"),
2152 1689 staleTime: Infinity,
2153 1690 });
2154 1691
2155 1692 useEffect(() => {
2156 - if (viewingSection !== "advanced" || !can("manage_yatra")) {
1693 + if (viewingSection !== "advanced" || !can("yatra_manage_settings")) {
2157 1694 return;
2158 1695 }
2159 1696 let cancelled = false;
2160 1697 setUsageLoading(true);
1698 + setUsageError("");
2161 1699 (async () => {
2162 1700 try {
2163 1701 const s = await fetchUsageTrackingStatus();
2164 1702 if (!cancelled) {
@@ -2163,11 +1701,18 @@
2163 1701 const s = await fetchUsageTrackingStatus();
2164 1702 if (!cancelled) {
2165 1703 setUsageStatus(s);
2166 1704 }
2167 - } catch {
1705 + } catch (e: any) {
2168 1706 if (!cancelled) {
2169 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);
2170 1715 }
2171 1716 } finally {
2172 1717 // Always clear loading — otherwise a strict-mode remount / dependency churn can leave the spinner stuck.
2173 1718 setUsageLoading(false);
@@ -2181,9 +1726,9 @@
2181 1726 // Default settings fallback
2182 1727 const defaultSettings: SettingsData = useMemo<SettingsData>(
2183 1728 () => ({
2184 1729 company_name: "Yatra Travel Agency",
2185 - company_email: "[email protected]",
1730 + company_email: "[email protected]",
2186 1731 company_phone: "+1-234-567-8900",
2187 1732 company_address: "123 Travel Street",
2188 1733 company_city: "Kathmandu",
2189 1734 company_state: "Bagmati",
@@ -2188,24 +1733,36 @@
2188 1733 company_city: "Kathmandu",
2189 1734 company_state: "Bagmati",
2190 1735 company_country: "Nepal",
2191 1736 company_zip: "44600",
2192 - company_website: "https://yatra.com",
1737 + company_website: "https://wpyatra.com",
2193 1738 company_logo: "",
2194 1739 timezone: "Asia/Kathmandu",
2195 1740 date_format: "Y-m-d",
2196 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,
2197 1754 booking_confirmation: true,
2198 1755 auto_confirm_bookings: false,
1756 + auto_confirm_mode: "online",
2199 1757 require_login: false,
2200 1758 allow_guest_checkout: true,
2201 - cancellation_policy: "full_refund",
2202 - cancellation_days: 7,
2203 - refund_policy: "Full refund if cancelled 7 days before departure",
1759 + require_guest_email_verification: false,
2204 1760 booking_expiry_hours: 24,
2205 1761 booking_reminder_days: 3,
2206 1762 allow_waitlist: true,
2207 1763 waitlist_auto_confirm: false,
1764 + date_picker_as_dropdown: false,
2208 1765
2209 1766 currency: "USD",
2210 1767 payment_test_mode: true,
2211 1768 payment_gateways: ["pay_later"],
@@ -2214,8 +1771,17 @@
2214 1771 partial_payment_percentage: 30,
2215 1772 deposit_required: true,
2216 1773 deposit_percentage: 20,
2217 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,
2218 1784 gateway_configs: {
2219 1785 stripe: {
2220 1786 enabled: true,
2221 1787 title: "Stripe",
@@ -2293,10 +1859,10 @@
2293 1859 "bank_transfer",
2294 1860 "esewa",
2295 1861 "khalti",
2296 1862 ],
2297 - admin_email: "[email protected]",
2298 - from_email: "[email protected]",
1863 + admin_email: "[email protected]",
1864 + from_email: "[email protected]",
2299 1865 from_name: "Yatra Travel",
2300 1866 email_template_booking: true,
2301 1867 email_template_confirmation: true,
2302 1868 email_template_cancellation: true,
@@ -2303,8 +1869,25 @@
2303 1869 email_template_reminder: true,
2304 1870 email_template_admin_new_booking: true,
2305 1871 email_template_admin_payment: true,
2306 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,
2307 1890 smtp_enabled: false,
2308 1891 smtp_host: "smtp.gmail.com",
2309 1892 smtp_port: 587,
2310 1893 smtp_username: "",
@@ -2323,8 +1906,42 @@
2323 1906 email_tpl_admin_payment_subject: "",
2324 1907 email_tpl_admin_payment_body: "",
2325 1908 email_tpl_admin_cancellation_subject: "",
2326 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: "",
2327 1944 customer_registration: true,
2328 1945 customer_fields: ["name", "email", "phone", "address"],
2329 1946 require_email_verification: false,
2330 1947 customer_account_page: "/my-account",
@@ -2335,9 +1952,10 @@
2335 1952 auto_approve_reviews: false,
2336 1953 review_moderation: true,
2337 1954 min_rating: 1,
2338 1955 allow_anonymous_reviews: false,
2339 - review_reminder_days: 7,
1956 + review_reminder_days: 3,
1957 + review_reminder_max_age_days: 14,
2340 1958 enable_tax: true,
2341 1959 tax_name: __("Tax", "yatra"),
2342 1960 tax_rate: 10,
2343 1961 tax_inclusive: false,
@@ -2360,8 +1978,12 @@
2360 1978 facebook_pixel: "",
2361 1979 recaptcha_enabled: false,
2362 1980 recaptcha_site_key: "",
2363 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,
2364 1986 trip_base: "trip",
2365 1987 destination_base: "destination",
2366 1988 activity_base: "activity",
2367 1989 trip_category_base: "trip-category",
@@ -2366,8 +1988,10 @@
2366 1988 activity_base: "activity",
2367 1989 trip_category_base: "trip-category",
2368 1990 booking_base: "book",
2369 1991 enable_wishlist: false,
1992 + show_sold_out: true,
1993 + availability_horizon_months: 12,
2370 1994 use_booking_page: false,
2371 1995 booking_page_id: 0,
2372 1996 terms_page_id: 0,
2373 1997 privacy_policy_page_id: 0,
@@ -2372,8 +1996,9 @@
2372 1996 terms_page_id: 0,
2373 1997 privacy_policy_page_id: 0,
2374 1998 debug_mode: false,
2375 1999 enable_logging: false,
2000 + delete_data_on_uninstall: false,
2376 2001 cache_enabled: true,
2377 2002 api_key: "",
2378 2003 api_rate_limit: 100,
2379 2004 session_timeout: 3600,
@@ -2607,8 +2232,11 @@
2607 2232 seo_trip_meta_title: "",
2608 2233 seo_trip_meta_description: "",
2609 2234 seo_trip_meta_keywords: "",
2610 2235 seo_trip_meta_image: 0,
2236 + enable_sitemap: true,
2237 + sitemap_types: ["archive", "trip", "destination", "activity", "category"],
2238 + sitemap_noindex_excluded: false,
2611 2239 }),
2612 2240 [],
2613 2241 );
2614 2242
@@ -2636,8 +2264,9 @@
2636 2264 >([]);
2637 2265 const [showApiKey, setShowApiKey] = useState(false);
2638 2266 const [validatingApiKey, setValidatingApiKey] = useState(false);
2639 2267 const [showFacebookToken, setShowFacebookToken] = useState(false);
2268 + const [showGa4ApiSecret, setShowGa4ApiSecret] = useState(false);
2640 2269 const [seoImageUrl, setSeoImageUrl] = useState<string>("");
2641 2270 const [seoImageLoading, setSeoImageLoading] = useState<boolean>(false);
2642 2271 const [seoImageError, setSeoImageError] = useState<string>("");
2643 2272
@@ -2791,13 +2420,13 @@
2791 2420 if (!formData?.ga4_measurement_id) return;
2792 2421
2793 2422 setValidatingMeasurementId(true);
2794 2423 try {
2795 - const response = await apiService.validateGoogleAnalyticsMeasurementId(
2424 + const raw = (await apiService.validateGoogleAnalyticsMeasurementId(
2796 2425 formData.ga4_measurement_id,
2797 - );
2426 + )) as { success?: boolean; message?: string };
2798 2427
2799 - if (response.success) {
2428 + if (raw?.success) {
2800 2429 showToast(
2801 2430 __("Measurement ID validated successfully!", "yatra"),
2802 2431 "success",
2803 2432 );
@@ -2802,9 +2431,9 @@
2802 2431 "success",
2803 2432 );
2804 2433 } else {
2805 2434 showToast(
2806 - response.message || __("Measurement ID validation failed.", "yatra"),
2435 + raw?.message || __("Measurement ID validation failed.", "yatra"),
2807 2436 "error",
2808 2437 );
2809 2438 }
2810 2439 } catch (error: any) {
@@ -2821,18 +2450,18 @@
2821 2450 if (!formData?.ga4_measurement_id || !formData?.ga4_api_secret) return;
2822 2451
2823 2452 setValidatingApiSecret(true);
2824 2453 try {
2825 - const response = await apiService.validateGoogleAnalyticsApiSecret(
2454 + const raw = (await apiService.validateGoogleAnalyticsApiSecret(
2826 2455 formData.ga4_measurement_id,
2827 2456 formData.ga4_api_secret,
2828 - );
2457 + )) as { success?: boolean; message?: string };
2829 2458
2830 - if (response.success) {
2459 + if (raw?.success) {
2831 2460 showToast(__("API Secret validated successfully!", "yatra"), "success");
2832 2461 } else {
2833 2462 showToast(
2834 - response.message || __("API Secret validation failed.", "yatra"),
2463 + raw?.message || __("API Secret validation failed.", "yatra"),
2835 2464 "error",
2836 2465 );
2837 2466 }
2838 2467 } catch (error: any) {
@@ -2961,8 +2590,9 @@
2961 2590 // Use default settings if API fails or returns empty
2962 2591 setFormData(defaultSettings);
2963 2592 isInitializedRef.current = true;
2964 2593 }
2594 + // eslint-disable-next-line react-hooks/exhaustive-deps
2965 2595 }, [settings, isLoading]);
2966 2596
2967 2597 // Fetch SEO image URL when image ID changes with performance optimization
2968 2598 React.useEffect(() => {
@@ -3037,33 +2667,43 @@
3037 2667 controller.abort();
3038 2668 };
3039 2669 }, [formData?.seo_trip_meta_image]);
3040 2670
3041 - // 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);
3042 2681 React.useEffect(() => {
3043 - if (
3044 - gatewayDefinitions &&
3045 - Object.keys(gatewayDefinitions).length > 0 &&
3046 - gatewayOrder.length === 0
3047 - ) {
3048 - // Use saved order if available, otherwise use default order from definitions
3049 - const savedOrder = settings?.gateway_order as string[] | undefined;
3050 - if (savedOrder) {
3051 - // Merge saved order with any new gateways
3052 - const allGateways = Object.keys(gatewayDefinitions);
3053 - const validSavedOrder = savedOrder.filter((id) =>
3054 - allGateways.includes(id),
3055 - );
3056 - const newGateways = allGateways.filter(
3057 - (id) => !validSavedOrder.includes(id),
3058 - );
3059 - setGatewayOrder([...validSavedOrder, ...newGateways]);
3060 - } else {
3061 - setGatewayOrder(Object.keys(gatewayDefinitions));
3062 - }
2682 + if (gatewayOrderInitialized.current) return;
2683 + if (!gatewayDefinitions || Object.keys(gatewayDefinitions).length === 0) {
2684 + return;
3063 2685 }
3064 - }, [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;
3065 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 +
3066 2706 // Single stable onChange handler that reads field name from input's name or id attribute
3067 2707 const handleFieldChange = React.useCallback(
3068 2708 (
3069 2709 e: React.ChangeEvent<
@@ -3075,10 +2715,19 @@
3075 2715
3076 2716 if (e.target.type === "checkbox") {
3077 2717 value = (e.target as HTMLInputElement).checked;
3078 2718 } else if (e.target.type === "number") {
3079 - const numValue = parseFloat(e.target.value);
3080 - 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 + }
3081 2730 } else {
3082 2731 value = e.target.value;
3083 2732 }
3084 2733
@@ -3089,8 +2738,48 @@
3089 2738 },
3090 2739 [],
3091 2740 );
3092 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 +
3093 2782 const handleGatewayConfigChange = React.useCallback(
3094 2783 (gateway: string, field: keyof PaymentGatewayConfig, value: any) => {
3095 2784 setFormData((prev) => {
3096 2785 if (!prev) return prev;
@@ -3252,9 +2941,9 @@
3252 2941 }>;
3253 2942 },
3254 2943 // Used by multiple sections (Booking page, Legal pages, etc.), so keep it available
3255 2944 // whenever the user can manage Yatra settings.
3256 - enabled: can("manage_yatra"),
2945 + enabled: can("yatra_manage_settings"),
3257 2946 });
3258 2947
3259 2948 // Check shortcode on-demand when user selects a page
3260 2949 const checkShortcodeMutation = useMutation({
@@ -3386,8 +3075,13 @@
3386 3075 label: __("General", "yatra"),
3387 3076 icon: Building2,
3388 3077 },
3389 3078 {
3079 + id: "design" as SettingsSection,
3080 + label: __("Design", "yatra"),
3081 + icon: Palette,
3082 + },
3083 + {
3390 3084 id: "booking" as SettingsSection,
3391 3085 label: __("Booking", "yatra"),
3392 3086 icon: Calendar,
3393 3087 },
@@ -3396,12 +3090,36 @@
3396 3090 label: __("Booking Form", "yatra"),
3397 3091 icon: ClipboardList,
3398 3092 },
3399 3093 {
3094 + id: "search" as SettingsSection,
3095 + label: __("Search & Listing", "yatra"),
3096 + icon: SlidersHorizontal,
3097 + },
3098 + {
3400 3099 id: "payment" as SettingsSection,
3401 3100 label: __("Payment", "yatra"),
3402 3101 icon: DollarSign,
3403 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 + : []),
3404 3122 {
3405 3123 id: "customer" as SettingsSection,
3406 3124 label: __("Customer", "yatra"),
3407 3125 icon: Users,
@@ -3436,8 +3154,13 @@
3436 3154 id: "advanced" as SettingsSection,
3437 3155 label: __("Advanced", "yatra"),
3438 3156 icon: Shield,
3439 3157 },
3158 + {
3159 + id: "uninstall" as SettingsSection,
3160 + label: __("Uninstall", "yatra"),
3161 + icon: Trash2,
3162 + },
3440 3163 ];
3441 3164
3442 3165 // Helper component for section divider
3443 3166 const SectionDivider = ({ title }: { title: string }) => (
@@ -3600,29 +3323,61 @@
3600 3323 <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
3601 3324 <FormField
3602 3325 id="timezone"
3603 3326 label={__("Timezone", "yatra")}
3604 - 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 + )}
3605 3331 >
3606 - <Select
3607 - id="timezone"
3608 - value={formData.timezone}
3609 - name="timezone"
3610 - onChange={handleFieldChange}
3611 - >
3612 - <option value="UTC">{__("UTC", "yatra")}</option>
3613 - <option value="America/New_York">
3614 - {__("America/New_York", "yatra")}
3615 - </option>
3616 - <option value="Europe/London">
3617 - {__("Europe/London", "yatra")}
3618 - </option>
3619 - <option value="Asia/Kathmandu">
3620 - {__("Asia/Kathmandu", "yatra")}
3621 - </option>
3622 - <option value="Asia/Dubai">Asia/Dubai</option>
3623 - <option value="Asia/Kolkata">Asia/Kolkata</option>
3624 - </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>
3625 3380 </FormField>
3626 3381
3627 3382 <FormField
3628 3383 id="date_format"
@@ -3730,8 +3485,246 @@
3730 3485 </div>
3731 3486 </div>
3732 3487 );
3733 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 +
3734 3727 case "booking_form":
3735 3728 return (
3736 3729 <BookingFormBuilder formData={formData} setFormData={setFormData} />
3737 3730 );
@@ -3869,8 +3862,218 @@
3869 3862 </div>
3870 3863 </ProFeature>
3871 3864 </div>
3872 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 +
3873 4076 {/* Auto-confirm Pay Later Bookings - Available in Free version */}
3874 4077 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
3875 4078 <input
3876 4079 type="checkbox"
@@ -4234,8 +4437,117 @@
4234 4437 return null;
4235 4438 }
4236 4439 }
4237 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 +
4238 4550 // Render checkbox fields differently
4239 4551 if (field.type === "checkbox") {
4240 4552 return (
4241 4553 <div
@@ -4516,8 +4828,53 @@
4516 4828 </div>
4517 4829 );
4518 4830 }
4519 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 +
4520 4877 // Render text, password, number, email inputs
4521 4878 return (
4522 4879 <div key={field.id} className="space-y-2">
4523 4880 <FormField
@@ -4539,12 +4896,21 @@
4539 4896 onChange={(e) =>
4540 4897 handleGatewayConfigChange(
4541 4898 gatewayId,
4542 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.
4543 4907 field.type === "number"
4544 - ? parseFloat(
4545 - e.target.value,
4546 - ) || 0
4908 + ? e.target.value === ""
4909 + ? ""
4910 + : parseFloat(
4911 + e.target.value,
4912 + ) || 0
4547 4913 : e.target.value,
4548 4914 )
4549 4915 }
4550 4916 placeholder={field.placeholder}
@@ -4607,57 +4973,162 @@
4607 4973 </div>
4608 4974 </>
4609 4975 );
4610 4976
4611 - // Continue with other sections... (I'll add the most important ones)
4612 - case "booking":
4977 + case "search":
4613 4978 return (
4614 4979 <div className="space-y-6">
4615 - <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>
4616 5079 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
4617 5080 <input
4618 5081 type="checkbox"
4619 - id="booking_confirmation"
4620 - checked={formData.booking_confirmation}
4621 - name="booking_confirmation"
5082 + id="collapse_filters_on_mobile"
5083 + checked={!!formData.collapse_filters_on_mobile}
5084 + name="collapse_filters_on_mobile"
4622 5085 onChange={handleFieldChange}
4623 5086 className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
4624 5087 />
4625 5088 <div className="flex-1">
4626 5089 <Label
4627 - htmlFor="booking_confirmation"
5090 + htmlFor="collapse_filters_on_mobile"
4628 5091 className="font-medium cursor-pointer"
4629 5092 >
4630 - {__("Enable Booking Confirmation", "yatra")}
5093 + {__("Collapse filters by default on mobile", "yatra")}
4631 5094 </Label>
4632 5095 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
4633 5096 {__(
4634 - "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.",
4635 5098 "yatra",
4636 5099 )}
4637 5100 </p>
4638 5101 </div>
4639 5102 </div>
5103 + </div>
5104 + </div>
5105 + );
4640 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">
4641 5112 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
4642 5113 <input
4643 5114 type="checkbox"
4644 - id="auto_confirm_bookings"
4645 - checked={formData.auto_confirm_bookings}
4646 - name="auto_confirm_bookings"
5115 + id="booking_confirmation"
5116 + checked={formData.booking_confirmation}
5117 + name="booking_confirmation"
4647 5118 onChange={handleFieldChange}
4648 5119 className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
4649 5120 />
4650 5121 <div className="flex-1">
4651 5122 <Label
4652 - htmlFor="auto_confirm_bookings"
5123 + htmlFor="booking_confirmation"
4653 5124 className="font-medium cursor-pointer"
4654 5125 >
4655 - {__("Auto-Confirm Bookings", "yatra")}
5126 + {__("Send Booking Confirmation Email", "yatra")}
4656 5127 </Label>
4657 5128 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
4658 5129 {__(
4659 - "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.",
4660 5131 "yatra",
4661 5132 )}
4662 5133 </p>
4663 5134 </div>
@@ -4662,8 +5133,85 @@
4662 5133 </p>
4663 5134 </div>
4664 5135 </div>
4665 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 +
4666 5214 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
4667 5215 <input
4668 5216 type="checkbox"
4669 5217 id="require_login"
@@ -4712,11 +5260,94 @@
4712 5260 </p>
4713 5261 </div>
4714 5262 </div>
4715 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 + */}
4716 5273 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
4717 5274 <input
4718 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"
4719 5350 id="allow_waitlist"
4720 5351 checked={formData.allow_waitlist}
4721 5352 name="allow_waitlist"
4722 5353 onChange={handleFieldChange}
@@ -4761,80 +5392,65 @@
4761 5392 )}
4762 5393 </p>
4763 5394 </div>
4764 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>
4765 5430 </div>
4766 5431
4767 - <SectionDivider
4768 - title={__("Cancellation & Refund Policy", "yatra")}
4769 - />
5432 + {/*
5433 + Cancellation & Refund Policy section removed.
4770 5434
4771 - <div className="space-y-4">
4772 - <FormField
4773 - id="cancellation_policy"
4774 - label={__("Cancellation Policy", "yatra")}
4775 - description={__(
4776 - "What happens when a booking is cancelled",
4777 - "yatra",
4778 - )}
4779 - >
4780 - <Select
4781 - id="cancellation_policy"
4782 - value={formData.cancellation_policy}
4783 - name="cancellation_policy"
4784 - onChange={handleFieldChange}
4785 - >
4786 - <option value="no_refund">{__("No Refund", "yatra")}</option>
4787 - <option value="full_refund">
4788 - {__("Full Refund", "yatra")}
4789 - </option>
4790 - <option value="partial_refund">
4791 - {__("Partial Refund", "yatra")}
4792 - </option>
4793 - </Select>
4794 - </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.
4795 5444
4796 - <FormField
4797 - id="cancellation_days"
4798 - label={__("Cancellation Days Before Departure", "yatra")}
4799 - description={__(
4800 - "Number of days before departure when cancellation is allowed",
4801 - "yatra",
4802 - )}
4803 - >
4804 - <Input
4805 - id="cancellation_days"
4806 - type="number"
4807 - value={formData.cancellation_days}
4808 - name="cancellation_days"
4809 - onChange={handleFieldChange}
4810 - min="0"
4811 - />
4812 - </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 + */}
4813 5452
4814 - <FormField
4815 - id="refund_policy"
4816 - label={__("Refund Policy", "yatra")}
4817 - description={__(
4818 - "Detailed refund policy description shown to customers",
4819 - "yatra",
4820 - )}
4821 - >
4822 - <textarea
4823 - id="refund_policy"
4824 - value={formData.refund_policy}
4825 - name="refund_policy"
4826 - onChange={handleFieldChange}
4827 - rows={4}
4828 - 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"
4829 - placeholder={__(
4830 - "Enter your refund policy details...",
4831 - "yatra",
4832 - )}
4833 - />
4834 - </FormField>
4835 - </div>
4836 -
4837 5453 <SectionDivider title={__("Booking Expiry & Reminders", "yatra")} />
4838 5454
4839 5455 <div className="space-y-4">
4840 5456 <FormField
@@ -4875,8 +5491,188 @@
4875 5491 </div>
4876 5492 </div>
4877 5493 );
4878 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 +
4879 5675 case "customer":
4880 5676 return (
4881 5677 <div className="space-y-6">
4882 5678 <div className="space-y-4">
@@ -5143,9 +5939,9 @@
5143 5939 <FormField
5144 5940 id="review_reminder_days"
5145 5941 label={__("Review Reminder Days", "yatra")}
5146 5942 description={__(
5147 - "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.",
5148 5944 "yatra",
5149 5945 )}
5150 5946 >
5151 5947 <Input
@@ -5156,8 +5952,26 @@
5156 5952 onChange={handleFieldChange}
5157 5953 min="0"
5158 5954 />
5159 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>
5160 5974 </>
5161 5975 )}
5162 5976 </div>
5163 5977 </div>
@@ -6345,37 +7159,46 @@
6345 7159 {/* Google Analytics 4 Enhanced - Pro Feature */}
6346 7160 <SectionDivider
6347 7161 title={__("Google Analytics 4 Enhanced", "yatra")}
6348 7162 />
6349 - <Card>
6350 - <CardHeader className="flex flex-row items-center justify-between pb-2">
6351 - <CardTitle className="text-lg flex items-center gap-2">
6352 - <svg
6353 - className="w-5 h-5 text-orange-500"
6354 - viewBox="0 0 24 24"
6355 - fill="currentColor"
6356 - >
6357 - <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" />
6358 - </svg>
6359 - {__("Google Analytics 4 Enhanced", "yatra")}
6360 - <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>
6361 7176 </CardTitle>
6362 - {(window as any).yatraAdmin?.googleAnalytics?.connected ? (
6363 - <div className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400">
6364 - <CheckCircle className="w-4 h-4" />
6365 - {__("Connected", "yatra")}
6366 - </div>
6367 - ) : (
6368 - <div className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
6369 - <XCircle className="w-4 h-4" />
6370 - {__("Not Connected", "yatra")}
6371 - </div>
6372 - )}
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 + })()}
6373 7196 </CardHeader>
6374 - <CardContent className="space-y-4">
6375 - <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">
6376 7199 {__(
6377 - "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.",
6378 7201 "yatra",
6379 7202 )}
6380 7203 </p>
6381 7204
@@ -6391,206 +7214,114 @@
6391 7214 isModuleEnabled={
6392 7215 (window as any).yatraAdmin?.showGoogleAnalyticsSettingsUI
6393 7216 }
6394 7217 >
6395 - <div className="space-y-4">
6396 - <FormField
6397 - id="ga4_measurement_id"
6398 - label={__("Measurement ID", "yatra")}
6399 - description={
6400 - <>
6401 - {__("Get your Measurement ID from", "yatra")}{" "}
6402 - <a
6403 - href="https://analytics.google.com/analytics/web/"
6404 - target="_blank"
6405 - rel="noopener noreferrer"
6406 - className="text-blue-600 hover:text-blue-500 dark:text-blue-400 underline"
6407 - >
6408 - {__("Google Analytics", "yatra")}
6409 - </a>{" "}
6410 - {__("(Admin > Data Streams)", "yatra")}
6411 - </>
6412 - }
6413 - actionButton={
6414 - <Button
6415 - type="button"
6416 - variant="outline"
6417 - size="sm"
6418 - onClick={() => validateMeasurementId()}
6419 - disabled={
6420 - !formData.ga4_measurement_id ||
6421 - validatingMeasurementId
6422 - }
6423 - className="shrink-0"
6424 - >
6425 - {validatingMeasurementId ? (
6426 - <>
6427 - <svg
6428 - className="animate-spin -ml-1 mr-2 h-4 w-4"
6429 - fill="none"
6430 - viewBox="0 0 24 24"
6431 - >
6432 - <circle
6433 - className="opacity-25"
6434 - cx="12"
6435 - cy="12"
6436 - r="10"
6437 - stroke="currentColor"
6438 - strokeWidth="4"
6439 - ></circle>
6440 - <path
6441 - className="opacity-75"
6442 - fill="currentColor"
6443 - 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"
6444 - ></path>
6445 - </svg>
6446 - {__("Validating...", "yatra")}
6447 - </>
6448 - ) : (
6449 - <>
6450 - <svg
6451 - className="-ml-1 mr-2 h-4 w-4"
6452 - fill="none"
6453 - viewBox="0 0 24 24"
6454 - stroke="currentColor"
6455 - >
6456 - <path
6457 - strokeLinecap="round"
6458 - strokeLinejoin="round"
6459 - strokeWidth={2}
6460 - d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
6461 - />
6462 - </svg>
6463 - {__("Validate", "yatra")}
6464 - </>
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",
6465 7229 )}
6466 - </Button>
6467 - }
6468 - >
6469 - <Input
6470 - id="ga4_measurement_id"
6471 - value={formData.ga4_measurement_id || ""}
6472 - name="ga4_measurement_id"
6473 - onChange={handleFieldChange}
6474 - placeholder="G-XXXXXXXXXX"
6475 - />
6476 - </FormField>
6477 -
6478 - <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
6479 - <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
6480 - <input
6481 - type="checkbox"
6482 - id="ga4_track_view_item"
6483 - checked={formData.ga4_track_view_item ?? false}
6484 - name="ga4_track_view_item"
6485 - onChange={handleFieldChange}
6486 - className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
6487 - />
6488 - <Label
6489 - htmlFor="ga4_track_view_item"
6490 - className="text-sm cursor-pointer"
6491 - >
6492 - {__("Track View Item", "yatra")}
6493 - </Label>
7230 + </p>
6494 7231 </div>
6495 -
6496 - <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
6497 - <input
6498 - type="checkbox"
6499 - id="ga4_track_add_to_cart"
6500 - checked={formData.ga4_track_add_to_cart ?? false}
6501 - name="ga4_track_add_to_cart"
6502 - onChange={handleFieldChange}
6503 - className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
6504 - />
6505 - <Label
6506 - htmlFor="ga4_track_add_to_cart"
6507 - className="text-sm cursor-pointer"
6508 - >
6509 - {__("Track Add to Cart", "yatra")}
6510 - </Label>
6511 - </div>
6512 -
6513 - <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
6514 - <input
6515 - type="checkbox"
6516 - id="ga4_track_begin_checkout"
6517 - checked={formData.ga4_track_begin_checkout ?? false}
6518 - name="ga4_track_begin_checkout"
6519 - onChange={handleFieldChange}
6520 - className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
6521 - />
6522 - <Label
6523 - htmlFor="ga4_track_begin_checkout"
6524 - className="text-sm cursor-pointer"
6525 - >
6526 - {__("Track Begin Checkout", "yatra")}
6527 - </Label>
6528 - </div>
6529 -
6530 - <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
6531 - <input
6532 - type="checkbox"
6533 - id="ga4_track_purchase"
6534 - checked={formData.ga4_track_purchase ?? false}
6535 - name="ga4_track_purchase"
6536 - onChange={handleFieldChange}
6537 - className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
6538 - />
6539 - <Label
6540 - htmlFor="ga4_track_purchase"
6541 - className="text-sm cursor-pointer"
6542 - >
6543 - {__("Track Purchase", "yatra")}
6544 - </Label>
6545 - </div>
6546 - </div>
6547 - <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
6548 - <input
6549 - type="checkbox"
6550 - id="ga4_use_measurement_protocol"
6551 - checked={formData.ga4_use_measurement_protocol ?? false}
6552 - name="ga4_use_measurement_protocol"
6553 - onChange={handleFieldChange}
6554 - className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
6555 - />
6556 - <Label
6557 - htmlFor="ga4_use_measurement_protocol"
6558 - className="text-sm cursor-pointer"
6559 - >
6560 - {__("Use Measurement Protocol", "yatra")}
6561 - </Label>
6562 - </div>
6563 -
6564 - {formData.ga4_use_measurement_protocol && (
6565 7232 <FormField
6566 - id="ga4_api_secret"
6567 - label={__("API Secret", "yatra")}
6568 - description={__(
6569 - "Generate API secret from Google Analytics Admin > Data Streams > Measurement Protocol",
6570 - "yatra",
6571 - )}
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 + }
6572 7304 actionButton={
6573 7305 <Button
6574 7306 type="button"
6575 7307 variant="outline"
6576 7308 size="sm"
6577 - onClick={() => validateApiSecret()}
7309 + onClick={() => validateMeasurementId()}
6578 7310 disabled={
6579 - !formData.ga4_api_secret ||
6580 - !formData.ga4_measurement_id ||
6581 - validatingApiSecret
7311 + !formData.ga4_measurement_id?.trim() ||
7312 + validatingMeasurementId
6582 7313 }
6583 7314 className="shrink-0"
6584 7315 >
6585 - {validatingApiSecret ? (
7316 + {validatingMeasurementId ? (
6586 7317 <>
6587 - <Loader2 className="w-4 h-4 animate-spin" />
7318 + <Loader2 className="mr-2 h-4 w-4 animate-spin" />
6588 7319 {__("Validating...", "yatra")}
6589 7320 </>
6590 7321 ) : (
6591 7322 <>
6592 - <CheckCircle className="w-4 h-4" />
7323 + <Check className="mr-2 h-4 w-4" />
6593 7324 {__("Validate", "yatra")}
6594 7325 </>
6595 7326 )}
6596 7327 </Button>
@@ -6596,56 +7327,375 @@
6596 7327 </Button>
6597 7328 }
6598 7329 >
6599 7330 <Input
6600 - id="ga4_api_secret"
6601 - value={formData.ga4_api_secret || ""}
6602 - name="ga4_api_secret"
7331 + id="ga4_measurement_id"
7332 + value={formData.ga4_measurement_id || ""}
7333 + name="ga4_measurement_id"
6603 7334 onChange={handleFieldChange}
6604 - placeholder="Your API secret"
7335 + placeholder="G-XXXXXXXXXX"
7336 + className="font-mono text-sm"
7337 + spellCheck={false}
7338 + autoComplete="off"
6605 7339 />
6606 7340 </FormField>
6607 - )}
6608 - </div>
6609 - </ProFeature>
7341 + </div>
6610 7342
6611 - {/* Google Analytics Monitoring Notice */}
6612 - {(window as any).yatraAdmin?.googleAnalytics?.measurementId && (
6613 - <div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
6614 - <div className="flex items-center gap-2">
6615 - <svg
6616 - className="w-4 h-4 text-blue-600 dark:text-blue-400"
6617 - fill="none"
6618 - stroke="currentColor"
6619 - viewBox="0 0 24 24"
6620 - >
6621 - <path
6622 - strokeLinecap="round"
6623 - strokeLinejoin="round"
6624 - strokeWidth={2}
6625 - d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
6626 - />
6627 - </svg>
6628 - <div className="text-sm">
6629 - <span className="text-blue-800 dark:text-blue-300 font-medium">
6630 - {__("Google Analytics Monitoring:", "yatra")}
6631 - </span>
6632 - <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">
6633 7350 {__(
6634 - "View event statistics and activity in the ",
7351 + "Choose which standard ecommerce events Yatra should emit to GA4 on the storefront and booking flow.",
6635 7352 "yatra",
6636 7353 )}
6637 - <a
6638 - href={`${(window as any).yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=reports`}
6639 - 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"
6640 7485 >
6641 - {__("Reports page", "yatra")}
6642 - </a>
6643 - {__(" → Google Analytics 4 tab.", "yatra")}
6644 - </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>
6645 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 + )}
6646 7667 </div>
6647 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>
6648 7698 )}
6649 7699 </CardContent>
6650 7700 </Card>
6651 7701
@@ -6736,8 +7786,93 @@
6736 7786 onChange={handleFieldChange}
6737 7787 placeholder={__("Enter secret key", "yatra")}
6738 7788 />
6739 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>
6740 7875 </>
6741 7876 )}
6742 7877 </div>
6743 7878 </div>
@@ -7431,11 +8566,235 @@
7431 8566 </div>
7432 8567 </FormField>
7433 8568 </div>
7434 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>
7435 8734 </div>
7436 8735 );
7437 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 +
7438 8797 case "advanced":
7439 8798 return (
7440 8799 <div className="space-y-6">
7441 8800 <div className="space-y-4">
@@ -7619,14 +8978,21 @@
7619 8978 </p>
7620 8979 )}
7621 8980
7622 8981 {!usageLoading && !usageStatus && (
7623 - <p className="text-sm text-amber-600">
7624 - {__(
7625 - "Could not load this section. You may need permission to manage Yatra.",
7626 - "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>
7627 8993 )}
7628 - </p>
8994 + </div>
7629 8995 )}
7630 8996
7631 8997 {!usageLoading && usageStatus && (
7632 8998 <div className="space-y-4">
@@ -7827,9 +9193,9 @@
7827 9193 </Button>
7828 9194 }
7829 9195 />
7830 9196
7831 - <ConditionalRender capability="manage_yatra">
9197 + <ConditionalRender capability="yatra_manage_settings">
7832 9198 <div className="grid grid-cols-1 lg:grid-cols-4 gap-3">
7833 9199 {/* Left Sidebar */}
7834 9200 <div className="lg:col-span-1">
7835 9201 <Card>