/** * Settings Page * Comprehensive settings page for trip booking software * Left sidebar navigation + Right side form fields with detailed configurations */ import React, { useState, useEffect, useMemo } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Settings as SettingsIcon, Save, Loader2, Building2, Calendar, DollarSign, Mail, MapPin, SlidersHorizontal, Users, Star, Receipt, Globe, Plug, Shield, Info, ChevronDown, GripVertical, ArrowUp, ArrowDown, ClipboardList, Plus, Trash2, Eye, EyeOff, Edit2, Lock, CheckCircle, XCircle, AlertCircle, RefreshCw, ExternalLink, ChevronRight, Check, Image, X, TrendingUp, BarChart3, Palette, Percent, } from "lucide-react"; import { __ } from "../lib/i18n"; import { prepareWordPressMediaFrameOpen } from "../lib/wp-media-open"; import { usePermissions } from "../hooks/usePermissions"; import { useToast } from "../components/ui/toast"; import { navigateMenu } from "../hooks/useNavigate"; import { apiClient, apiService } from "../lib/api-client"; import { fetchBookingPageShortcodeStatus, fetchPaymentGatewayDefinitions, fetchSettings, fetchWordPressPages, postFlushRewriteRules, postInsertBookingShortcode, saveSettings, } from "../api/settings-api"; import { fetchUsageTrackingStatus, postUsageTrackingSettings, postUsageTrackingSend, type UsageTrackingStatus, } from "../api/usage-tracking-api"; import { Button } from "../components/ui/button"; import { ProFeature, ProBadge } from "../components/ProFeature"; import { PremiumUpgradeDialog } from "../components/modules/PremiumUpgradeDialog"; import { Input } from "../components/ui/input"; import { Select } from "../components/ui/select"; import { Label } from "../components/ui/label"; import { PageHeader } from "../components/common/PageHeader"; import { Card, CardContent, CardHeader, CardTitle, CardFooter, } from "../components/ui/card"; import { ConditionalRender } from "../components/ui/conditional-render"; import { ConfirmationDialog } from "../components/ui/confirmation-dialog"; import { buildYatraAccountViewUrl, buildYatraListingPublicUrl, isWordPressPlainPermalink, } from "../lib/frontend-permalink-urls"; import { getCurrencyOptions } from "../data/currencies"; import { SearchableSelect } from "../components/ui/searchable-select"; import { MultiSelect, MultiSelectOption } from "../components/ui/multi-select"; // Multiple Taxes Editor Component const MultipleTaxesEditor = React.memo( ({ taxes, onChange, }: { taxes: Array<{ name: string; rate: number }>; onChange: (taxes: Array<{ name: string; rate: number }>) => void; }) => { const removeTax = (index: number) => { onChange(taxes.filter((_, i) => i !== index)); }; const updateTax = ( index: number, field: "name" | "rate", value: string | number, ) => { const updated = [...taxes]; if (field === "rate") { updated[index].rate = parseFloat(value as string) || 0; } else { updated[index].name = value as string; } onChange(updated); }; return (
{/* Tax Cards */}
{taxes .filter((tax) => tax.name && tax.name.trim() !== "") .map((tax, index) => (
updateTax(index, "name", e.target.value)} placeholder={__("Tax name (e.g., VAT, GST)", "yatra")} className="w-full" />
updateTax(index, "rate", e.target.value) } min="0" max="100" step="0.01" placeholder={__("Rate", "yatra")} className="w-full" /> %
))}
); }, ); // Helper component for form field with description - MUST be outside component to prevent remounts const FormField = React.memo( ({ id, label, description, required = false, actionButton, children, }: { id: string; label: string; description?: React.ReactNode; required?: boolean; actionButton?: React.ReactNode; children: React.ReactNode; }) => (
{description && (

{description}

)} {actionButton ? (
{children}
{actionButton}
) : ( children )}
), ); FormField.displayName = "FormField"; // Google Calendar Integration Section - inline component to avoid lazy loading issues const GoogleCalendarIntegrationSection: React.FC<{ formData: SettingsData; setFormData: React.Dispatch>; }> = ({ formData, setFormData }) => { const yatraAdmin = (window as any).yatraAdmin || {}; const gcSettings = yatraAdmin.googleCalendar || {}; const siteUrl = yatraAdmin.siteUrl || ""; // Full URL to the dedicated Google Calendar dashboard subpage so the user can // reach it from the UI (not just via the OAuth redirect / a direct link). const gcDashboardUrl = `${yatraAdmin.adminUrl || "admin.php"}?page=yatra&subpage=google-calendar`; // Use state from parent formData if available, otherwise use from yatraAdmin const [clientId, setClientId] = useState( formData.google_calendar_client_id || gcSettings.client_id || "", ); const [clientSecret, setClientSecret] = useState( formData.google_calendar_client_secret || gcSettings.client_secret || "", ); const [calendarId, setCalendarId] = useState( formData.google_calendar_calendar_id || gcSettings.calendar_id || "", ); const [calendarName, setCalendarName] = useState( formData.google_calendar_calendar_name || gcSettings.calendar_name || "", ); const [connected, setConnected] = useState( formData.google_calendar_connected || gcSettings.connected || false, ); const [connecting, setConnecting] = useState(false); const [syncing, setSyncing] = useState(false); const [savingCalendar, setSavingCalendar] = useState(false); const { showToast } = useToast(); const redirectUri = yatraAdmin.googleCalendarRedirectUri || gcSettings.redirect_uri || `${siteUrl}/wp-json/yatra/v1/google-calendar/callback`; const lastSync = formData.google_calendar_last_sync || gcSettings.last_sync || null; // Dynamic mapping of settings to formData const syncSettingsToFormData = () => { // Get all available settings from the backend const allSettings = { google_calendar_client_id: clientId, google_calendar_client_secret: clientSecret, google_calendar_enabled: true, // Enable by default when settings are provided // Persist the target calendar through the MAIN page Save too (the option // name matches what the sync path reads), so the user's natural action — // clicking the page's Save button — keeps the chosen calendar. google_calendar_calendar_id: calendarId, google_calendar_calendar_name: calendarName, }; // Update formData with all settings setFormData((prev) => { if (!prev) return prev; return { ...prev, ...allSettings, }; }); }; // Update formData when any setting changes useEffect(() => { syncSettingsToFormData(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [clientId, clientSecret, calendarId, calendarName]); const handleConnect = async () => { setConnecting(true); try { const response = await apiClient.post("/google-calendar/connect"); if (response.data?.auth_url) { window.open(response.data.auth_url, "_blank"); } } catch (error) { console.error("Failed to connect:", error); } finally { setConnecting(false); } }; const handleDisconnect = async () => { if ( !confirm( __( "Are you sure you want to disconnect from Google Calendar?", "yatra", ), ) ) { return; } try { await apiClient.post("/google-calendar/disconnect"); // Update settings state setConnected(false); setCalendarId(""); setCalendarName(""); showToast(__("Disconnected from Google Calendar.", "yatra"), "success"); } catch (error) { showToast( __("Failed to disconnect. Please try again.", "yatra"), "error", ); } }; const handleSync = async () => { setSyncing(true); try { const res: any = await apiClient.post("/google-calendar/sync-all"); if (res && res.success === false) { showToast( res.message || __("Failed to sync bookings.", "yatra"), "error", ); } else { showToast( (res && res.message) || __("Bookings synced to Google Calendar.", "yatra"), "success", ); } } catch (error) { showToast( __("Failed to sync bookings. Please try again.", "yatra"), "error", ); } finally { setSyncing(false); } }; // Persist the target calendar to the Google Calendar module endpoint (the same // store the sync path reads), so the value survives a page reload. A blank // Calendar ID is allowed and resolves to the primary calendar at sync time. const handleSaveCalendar = async () => { setSavingCalendar(true); try { await apiClient.post("/google-calendar/settings", { calendar_id: calendarId.trim(), calendar_name: calendarName.trim(), }); showToast(__("Calendar settings saved.", "yatra"), "success"); } catch (error) { showToast( __("Failed to save calendar settings. Please try again.", "yatra"), "error", ); } finally { setSavingCalendar(false); } }; return (
{/* Settings Card */} {__("Google Calendar Settings", "yatra")}
{connected ? (
{__("Connected", "yatra")}
) : (
{__("Not Connected", "yatra")}
)}

{__( "Connect your Google Calendar to automatically sync bookings and departures. Events will be created for each booking with trip details, traveler information, and departure dates.", "yatra", )}

{__("Create an OAuth client in", "yatra")}{" "} {__("Google Cloud Credentials", "yatra")} {__(", then paste the OAuth 2.0 Client ID here.", "yatra")}{" "} {__("Example:", "yatra")}{" "} 123...apps.googleusercontent.com } > setClientId(e.target.value)} placeholder="123456789-xxxxx.apps.googleusercontent.com" /> {__( "Copy the Client Secret from the same OAuth client in", "yatra", )}{" "} {__("Google Cloud Credentials", "yatra")} {__(".", "yatra")} {__("Keep this private.", "yatra")} } > setClientSecret(e.target.value)} placeholder="GOCSPX-xxxxxxxxxxxxxxxxxx" />
{__( 'Add this to your OAuth client under "Authorized redirect URIs" in', "yatra", )}{" "} {__("Google Cloud Credentials", "yatra")} {__(".", "yatra")} {__("Make sure the", "yatra")}{" "} {__("Google Calendar API", "yatra")} {" "} {__("is enabled.", "yatra")} } >

{__( "OAuth scopes required (add these on the Google OAuth consent screen):", "yatra", )}{" "} {__("OAuth Consent Screen", "yatra")}

https://www.googleapis.com/auth/calendar
https://www.googleapis.com/auth/calendar.events
{connected && (

{__("Connected Calendar", "yatra")}

setCalendarId(e.target.value)} placeholder="primary" /> setCalendarName(e.target.value)} placeholder={__("My Booking Calendar", "yatra")} />

{lastSync ? ( <> {__("Last sync:", "yatra")}{" "} {new Date(lastSync).toLocaleString()} ) : ( __("Never synced", "yatra") )}

{__("Open dashboard", "yatra")}
)}
{connected ? ( ) : ( )}
); }; type SettingsSection = | "general" | "design" | "booking" | "booking_form" | "payment" | "pricing" | "trip" | "search" | "customer" | "review" | "tax" | "currency" | "integration" | "permalink" | "seo" | "advanced"; // Form Builder Types interface FormFieldConfig { id: string; type: | "text" | "email" | "tel" | "date" | "select" | "country" | "textarea" | "checkbox" | "number" | "text_block"; label: string; placeholder: string; required: boolean; enabled: boolean; order: number; width: "full" | "half" | "third"; section?: string; options?: { value: string; label: string }[]; content?: string; // For text_block: the display-only content (safe HTML) locked?: boolean; // If true, field cannot be deleted and required cannot be changed /** * Traveler-section only: which travelers this field is shown to. * "all" (default) = every traveler; "lead" = the lead traveler (Traveler 1) only. * Absent is treated as "all" for backward compatibility. */ applies_to?: "all" | "lead"; /** * Phone (tel) fields only: show the international country-code selector * (flag + dial code) on the input. Absent is treated as `true` (on) for * backward compatibility, so existing forms keep the widget without a re-save. */ show_country_code?: boolean; } interface FormSectionConfig { title: string; description: string; enabled?: boolean; fields: FormFieldConfig[]; } interface BookingFormConfig { contact_form: FormSectionConfig; emergency_contact_form: FormSectionConfig; traveler_form: FormSectionConfig; } interface PaymentGatewayConfig { enabled: boolean; icon?: string; title?: string; description?: string; [key: string]: any; } interface GatewayField { id: string; type: string; label: string; description?: string; placeholder?: string; default?: any; options?: Record; condition?: string; show_when?: Record; // Conditional display based on other field values min?: number; max?: number; readonly?: boolean; help_text?: string; help_url?: string; help_url_test?: string; help_url_live?: string; } interface GatewayDefinition { id: string; title: string; description: string; icon: string; sandbox_url?: string; is_offline: boolean; supports: string[]; fields: GatewayField[]; config: PaymentGatewayConfig; enabled: boolean; is_premium?: boolean; requires_pro?: boolean; } interface SettingsData { // General Settings company_name: string; company_email: string; company_phone: string; company_address: string; company_city: string; company_state: string; company_country: string; company_zip: string; company_website: string; company_logo: string; timezone: string; date_format: string; time_format: string; /** Hex brand color for public trip/booking/listing UI (buttons, links, accents) */ frontend_primary_color: string; /** CSS max width for Yatra containers; empty = follow theme / theme.json */ frontend_container_max_width: string; // Google Calendar Settings google_calendar_client_id?: string; google_calendar_client_secret?: string; google_calendar_calendar_id?: string; google_calendar_calendar_name?: string; google_calendar_connected?: boolean; google_calendar_last_sync?: string | null; google_calendar_enabled?: boolean; // Search & Listing storefront UX search_show_keyword: boolean; search_show_destination: boolean; search_show_activities: boolean; search_show_duration: boolean; search_show_budget: boolean; collapse_filters_on_mobile: boolean; // Booking Settings booking_confirmation: boolean; auto_confirm_bookings: boolean; require_login: boolean; allow_guest_checkout: boolean; require_guest_email_verification: boolean; booking_expiry_hours: number; booking_reminder_days: number; allow_waitlist: boolean; waitlist_auto_confirm: boolean; date_picker_as_dropdown: boolean; // Payment Settings currency: string; payment_test_mode: boolean; payment_gateways: string[]; payment_methods: string[]; partial_payment: boolean; partial_payment_percentage: number; deposit_required: boolean; deposit_percentage: number; auto_confirm_pay_later: boolean; enable_scheduled_payments: boolean; scheduled_payment_type: string; scheduled_payment_days: number; scheduled_payment_installments: number; scheduled_payment_interval: number; scheduled_payment_reminder_days: number; gateway_configs: Record; gateway_order?: string[]; // Pricing Settings // Controls how Advanced Discount + Dynamic Pricing combine on the same // booking. Surfaced in Settings → Pricing only when BOTH modules are // enabled; backend enforcement is also gated by both modules being on, // so a stale 'discount_only' value on a site that disabled DP is inert. discount_stacking_mode?: | "both" | "discount_only" | "dynamic_pricing_only" | "best_for_customer"; // Email Settings admin_email: string; from_email: string; from_name: string; email_template_booking: boolean; email_template_confirmation: boolean; email_template_cancellation: boolean; email_template_reminder: boolean; email_template_admin_new_booking: boolean; email_template_admin_payment: boolean; email_template_admin_cancellation: boolean; email_template_trip_consent: boolean; email_template_customer_verification: boolean; email_template_guest_verification: boolean; email_template_booking_completed: boolean; email_template_booking_expired_customer: boolean; email_template_admin_booking_expired: boolean; email_template_scheduled_payment_reminder: boolean; email_template_scheduled_payment_succeeded: boolean; email_template_scheduled_payment_failed: boolean; email_template_admin_scheduled_payment_failed: boolean; email_template_enquiry_received: boolean; email_template_enquiry_admin: boolean; email_template_enquiry_response: boolean; email_template_review_request: boolean; email_template_abandoned_booking_recovery_first: boolean; email_template_abandoned_booking_recovery_second: boolean; email_template_abandoned_booking_recovery_final: boolean; smtp_enabled: boolean; smtp_host: string; smtp_port: number; smtp_username: string; smtp_password: string; smtp_encryption: string; email_tpl_booking_subject: string; email_tpl_booking_body: string; email_tpl_payment_subject: string; email_tpl_payment_body: string; email_tpl_cancellation_subject: string; email_tpl_cancellation_body: string; email_tpl_reminder_subject: string; email_tpl_reminder_body: string; email_tpl_admin_booking_subject: string; email_tpl_admin_booking_body: string; email_tpl_admin_payment_subject: string; email_tpl_admin_payment_body: string; email_tpl_admin_cancellation_subject: string; email_tpl_admin_cancellation_body: string; email_tpl_trip_consent_subject: string; email_tpl_trip_consent_body: string; email_tpl_customer_verification_subject: string; email_tpl_customer_verification_body: string; email_tpl_guest_verification_subject: string; email_tpl_guest_verification_body: string; email_tpl_booking_completed_subject: string; email_tpl_booking_completed_body: string; email_tpl_booking_expired_customer_subject: string; email_tpl_booking_expired_customer_body: string; email_tpl_admin_booking_expired_subject: string; email_tpl_admin_booking_expired_body: string; email_tpl_scheduled_payment_reminder_subject: string; email_tpl_scheduled_payment_reminder_body: string; email_tpl_scheduled_payment_succeeded_subject: string; email_tpl_scheduled_payment_succeeded_body: string; email_tpl_scheduled_payment_failed_subject: string; email_tpl_scheduled_payment_failed_body: string; email_tpl_admin_scheduled_payment_failed_subject: string; email_tpl_admin_scheduled_payment_failed_body: string; email_tpl_enquiry_received_subject: string; email_tpl_enquiry_received_body: string; email_tpl_enquiry_admin_subject: string; email_tpl_enquiry_admin_body: string; email_tpl_enquiry_response_subject: string; email_tpl_enquiry_response_body: string; email_tpl_review_request_subject: string; email_tpl_review_request_body: string; email_tpl_abandoned_booking_recovery_first_subject: string; email_tpl_abandoned_booking_recovery_first_body: string; email_tpl_abandoned_booking_recovery_second_subject: string; email_tpl_abandoned_booking_recovery_second_body: string; email_tpl_abandoned_booking_recovery_final_subject: string; email_tpl_abandoned_booking_recovery_final_body: string; // Customer Settings customer_registration: boolean; customer_fields: string[]; require_email_verification: boolean; customer_account_page: string; allow_customer_reviews: boolean; customer_dashboard_enabled: boolean; /** Wishlist / saved trips (Yatra Pro); toggled in Customer settings */ enable_wishlist?: boolean; /** Keep sold-out departure dates visible on the storefront (default true) */ show_sold_out?: boolean; // Review Settings enable_reviews: boolean; require_booking: boolean; auto_approve_reviews: boolean; review_moderation: boolean; min_rating: number; allow_anonymous_reviews: boolean; review_reminder_days: number; // Tax Settings enable_tax: boolean; tax_name: string; tax_rate: number; tax_inclusive: boolean; vat_number: string; tax_by_country: boolean; tax_rates: Record; multiple_taxes_enabled: boolean; multiple_taxes: Array<{ name: string; rate: number }>; multiple_taxes_by_country: Record< string, Array<{ name: string; rate: number }> >; // Currency Settings default_currency: string; multi_currency: boolean; currency_position: string; currency_decimals: number; thousand_separator: string; decimal_separator: string; // Notification Settings (SMS; booking emails use Email → Templates) sms_notifications: boolean; sms_provider: string; sms_api_key: string; // Integration Settings google_analytics: string; facebook_pixel: string; recaptcha_enabled: boolean; recaptcha_site_key: string; recaptcha_secret_key: string; recaptcha_score_threshold: number; recaptcha_protect_enquiry: boolean; recaptcha_protect_booking: boolean; recaptcha_protect_registration: boolean; // Mailchimp Integration (Pro) mailchimp_api_key?: string; mailchimp_list_id?: string; mailchimp_list_name?: string; mailchimp_sync_on_booking?: boolean; mailchimp_sync_on_payment?: boolean; mailchimp_double_optin?: boolean; mailchimp_add_tags?: boolean; mailchimp_default_tags?: string[]; mailchimp_field_mapping?: Record; // Facebook Pixel Enhanced (Pro) facebook_pixel_id?: string; fb_track_view_content?: boolean; fb_track_initiate_checkout?: boolean; fb_track_purchase?: boolean; fb_track_add_to_cart?: boolean; fb_use_conversions_api?: boolean; facebook_access_token?: string; fb_test_event_code?: string; fb_event_config?: Record< string, { enabled: boolean; custom_params?: string[] } >; fb_parameter_mapping?: Record; // Google Analytics 4 Enhanced (Pro) ga4_measurement_id?: string; ga4_track_view_item?: boolean; ga4_track_add_to_cart?: boolean; ga4_track_begin_checkout?: boolean; ga4_track_purchase?: boolean; ga4_use_measurement_protocol?: boolean; ga4_api_secret?: string; ga4_debug_mode?: boolean; ga4_custom_dimensions?: Array<{ name: string; yatra_field: string; scope?: string; }>; ga4_event_config?: Record< string, { enabled: boolean; custom_params?: string[] } >; // Permalink Settings trip_base: string; destination_base: string; activity_base: string; trip_category_base: string; booking_base: string; // Booking Page Settings use_booking_page: boolean; booking_page_id: number; terms_page_id: number; privacy_policy_page_id: number; // Advanced Settings debug_mode: boolean; enable_logging: boolean; cache_enabled: boolean; api_key: string; api_rate_limit: number; session_timeout: number; // Booking Form Builder booking_form_config: BookingFormConfig; // SEO Settings seo_trip_meta_title: string; seo_trip_meta_description: string; seo_trip_meta_keywords: string; seo_trip_meta_image: number; enable_sitemap: boolean; } // Form Builder Component type BookingFormSubTab = | "contact_form" | "emergency_contact_form" | "traveler_form"; interface BookingFormBuilderProps { formData: SettingsData; setFormData: React.Dispatch>; } // Get initial sub-tab from localStorage const getInitialFormSubTab = (): BookingFormSubTab => { if (typeof window !== "undefined") { const saved = localStorage.getItem("yatra_settings_booking_form_subtab"); if ( saved && ["contact_form", "emergency_contact_form", "traveler_form"].includes( saved, ) ) { return saved as BookingFormSubTab; } } return "contact_form"; }; const BookingFormBuilder: React.FC = ({ formData, setFormData, }) => { const [activeFormTab, setActiveFormTab] = useState(getInitialFormSubTab); // Check if Dynamic Form Field module is enabled via modules API const { data: modulesData } = useQuery({ queryKey: ["modules"], queryFn: async () => { const response = await apiService.getModules(); return response; }, staleTime: 30000, // Cache for 30 seconds }); // Check if module is enabled from API response or fallback to yatraAdmin const isDynamicFormFieldEnabled = React.useMemo(() => { if (modulesData?.data) { const module = modulesData.data.find( (m: any) => m.slug === "dynamic_form_field", ); return module?.enabled === true && module?.is_available === true; } // Fallback to yatraAdmin if modules API hasn't loaded yet. // Truthy check (not `=== true`) because wp_localize_script // serialises every scalar to a string before exposing it to JS — // true becomes "1", false becomes "". Same gotcha that bit the // Discount Stacking tab visibility earlier this session. return !!window.yatraAdmin?.dynamicFormFieldEnabled; }, [modulesData]); // Save sub-tab to localStorage when it changes const handleSubTabChange = (tab: BookingFormSubTab) => { setActiveFormTab(tab); if (typeof window !== "undefined") { localStorage.setItem("yatra_settings_booking_form_subtab", tab); } }; const [editingField, setEditingField] = useState(null); // Draft for the Field ID input while editing an existing field. The field's // `id` is its React key AND the value `editingField` tracks, so mutating it on // every keystroke remounts the row (focus loss) and breaks the open-editor // match (editor closes). We edit a local draft and commit once on blur/Enter. const [fieldIdDraft, setFieldIdDraft] = useState(null); // Clear any pending Field ID draft whenever the edited field changes // (open / close / switch) so a draft can never leak onto another field. useEffect(() => { setFieldIdDraft(null); }, [editingField]); const [showAddField, setShowAddField] = useState(false); const [newField, setNewField] = useState>({ id: "", type: "text", label: "", placeholder: "", required: false, enabled: true, width: "full", options: [], applies_to: "all", }); // Traveler-section "Applies to" choices (only shown on the Traveler tab). const appliesToOptions: { value: "all" | "lead"; label: string }[] = [ { value: "all", label: __("All travelers", "yatra") }, { value: "lead", label: __("Lead traveler only", "yatra") }, ]; // Delete confirmation state const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; fieldId: string | null; fieldLabel: string; }>({ isOpen: false, fieldId: null, fieldLabel: "", }); // Drag and drop state const [draggedFieldId, setDraggedFieldId] = useState(null); const [dragOverFieldId, setDragOverFieldId] = useState(null); // Helper to generate ID from label const generateIdFromLabel = (label: string): string => { return label .toLowerCase() .replace(/\s+/g, "_") .replace(/[^a-z0-9_]/g, ""); }; // Helper to sanitize ID input const sanitizeId = (id: string): string => { return id .toLowerCase() .replace(/\s+/g, "_") .replace(/[^a-z0-9_]/g, ""); }; // Handle label change with auto ID generation const handleNewFieldLabelChange = (label: string) => { const autoId = generateIdFromLabel(label); setNewField((prev) => ({ ...prev, label, // Only auto-generate ID if it hasn't been manually edited id: prev.id === "" || prev.id === generateIdFromLabel(prev.label || "") ? autoId : prev.id, })); }; const formTabs = [ { id: "contact_form" as const, label: __("Contact Form", "yatra"), description: "Lead traveler contact details", }, { id: "emergency_contact_form" as const, label: __("Emergency Contact", "yatra"), description: "Emergency contact information", }, { id: "traveler_form" as const, label: __("Traveler Form", "yatra"), description: "Individual traveler details", }, ]; const fieldTypes = [ { value: "text", label: __("Text", "yatra") }, { value: "email", label: __("Email", "yatra") }, { value: "tel", label: __("Phone", "yatra") }, { value: "date", label: __("Date", "yatra") }, { value: "select", label: __("Dropdown", "yatra") }, { value: "country", label: __("Country Selector", "yatra") }, { value: "textarea", label: __("Text Area", "yatra") }, { value: "number", label: __("Number", "yatra") }, { value: "checkbox", label: __("Checkbox", "yatra") }, { value: "text_block", label: __("Text Block (display only)", "yatra") }, ]; const widthOptions = [ { value: "full", label: "Full Width" }, { value: "half", label: "Half Width" }, { value: "third", label: "One Third" }, ]; const getCurrentFormConfig = () => { return ( formData?.booking_form_config?.[activeFormTab] || { title: "", description: "", enabled: true, fields: [], } ); }; // Booking confirmation, the customer account and the voucher all need an email. // Warn the operator when neither the Contact nor the Traveler form will capture // one (section disabled, or its email field disabled) — checkout enforces the // same rule (lead-traveler email is used as a fallback when Contact is off). const bookingFormCapturesEmail = (() => { const cfg = formData?.booking_form_config; if (!cfg) return true; const sectionHasEnabledEmail = (section?: FormSectionConfig): boolean => { if (!section || section.enabled === false) return false; return (section.fields || []).some( (f) => (f.type === "email" || f.id === "email") && f.enabled !== false, ); }; return ( sectionHasEnabledEmail(cfg.contact_form) || sectionHasEnabledEmail(cfg.traveler_form) ); })(); const updateFormConfig = (updates: Partial) => { setFormData((prev) => { if (!prev) return prev; return { ...prev, booking_form_config: { ...prev.booking_form_config, [activeFormTab]: { ...prev.booking_form_config[activeFormTab], ...updates, }, }, }; }); }; const updateField = (fieldId: string, updates: Partial) => { const currentConfig = getCurrentFormConfig(); const updatedFields = currentConfig.fields.map((field) => field.id === fieldId ? { ...field, ...updates } : field, ); updateFormConfig({ fields: updatedFields }); }; const toggleFieldEnabled = (fieldId: string) => { const currentConfig = getCurrentFormConfig(); const field = currentConfig.fields.find((f) => f.id === fieldId); // Locked fields cannot be disabled if (field && !field.locked) { updateField(fieldId, { enabled: !field.enabled }); } }; const toggleFieldRequired = (fieldId: string) => { const currentConfig = getCurrentFormConfig(); const field = currentConfig.fields.find((f) => f.id === fieldId); if (field) { updateField(fieldId, { required: !field.required }); } }; const moveField = (fieldId: string, direction: "up" | "down") => { const currentConfig = getCurrentFormConfig(); const fields = [...currentConfig.fields]; const index = fields.findIndex((f) => f.id === fieldId); if (direction === "up" && index > 0) { [fields[index - 1], fields[index]] = [fields[index], fields[index - 1]]; } else if (direction === "down" && index < fields.length - 1) { [fields[index], fields[index + 1]] = [fields[index + 1], fields[index]]; } // Update order values fields.forEach((field, i) => { field.order = i + 1; }); updateFormConfig({ fields }); }; // Drag and Drop handlers const handleDragStart = (e: React.DragEvent, fieldId: string) => { setDraggedFieldId(fieldId); e.dataTransfer.effectAllowed = "move"; }; const handleDragOver = (e: React.DragEvent, fieldId: string) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; if (fieldId !== draggedFieldId) { setDragOverFieldId(fieldId); } }; const handleDragLeave = () => { setDragOverFieldId(null); }; const handleDrop = (e: React.DragEvent, targetFieldId: string) => { e.preventDefault(); if (!draggedFieldId || draggedFieldId === targetFieldId) { setDraggedFieldId(null); setDragOverFieldId(null); return; } const currentConfig = getCurrentFormConfig(); const fields = [...currentConfig.fields]; const draggedIndex = fields.findIndex((f) => f.id === draggedFieldId); const targetIndex = fields.findIndex((f) => f.id === targetFieldId); if (draggedIndex !== -1 && targetIndex !== -1) { const [draggedField] = fields.splice(draggedIndex, 1); fields.splice(targetIndex, 0, draggedField); // Update order values fields.forEach((field, i) => { field.order = i + 1; }); updateFormConfig({ fields }); } setDraggedFieldId(null); setDragOverFieldId(null); }; const handleDragEnd = () => { setDraggedFieldId(null); setDragOverFieldId(null); }; const deleteField = (fieldId: string) => { const currentConfig = getCurrentFormConfig(); const updatedFields = currentConfig.fields.filter((f) => f.id !== fieldId); updatedFields.forEach((field, i) => { field.order = i + 1; }); updateFormConfig({ fields: updatedFields }); setDeleteConfirm({ isOpen: false, fieldId: null, fieldLabel: "" }); }; const addNewField = () => { const isTextBlock = newField.type === "text_block"; const currentConfig = getCurrentFormConfig(); let fieldId: string; if (isTextBlock) { // Display-only block: the admin supplies only content — no label or id. // Generate a unique internal id so it can be ordered/stored like any field. if (!newField.content || !newField.content.trim()) return; let n = 1; while (currentConfig.fields.some((f) => f.id === `text_block_${n}`)) n++; fieldId = `text_block_${n}`; } else { if (!newField.label || !newField.id) return; fieldId = sanitizeId(newField.id); } // Check if ID already exists if (currentConfig.fields.some((f) => f.id === fieldId)) { // TODO: Replace with toast notification when refactoring this nested component window.alert( "A field with this ID already exists. Please use a different ID.", ); return; } const newFieldConfig: FormFieldConfig = { id: fieldId, type: (newField.type as FormFieldConfig["type"]) || "text", label: newField.label || "", placeholder: newField.placeholder || "", required: newField.required || false, enabled: true, order: currentConfig.fields.length + 1, width: (newField.width as FormFieldConfig["width"]) || "full", // Per-traveler targeting only applies to the Traveler section. ...(activeFormTab === "traveler_form" ? { applies_to: newField.applies_to || "all" } : {}), // Phone fields carry the country-code toggle (default on). ...(newField.type === "tel" ? { show_country_code: newField.show_country_code !== false } : {}), }; // Add options if field type is select if ( newField.type === "select" && newField.options && newField.options.length > 0 ) { newFieldConfig.options = newField.options.filter( (opt) => opt.value && opt.label, ); } // Text block: keep its display content; it can never be a required input. if (isTextBlock) { newFieldConfig.content = newField.content || ""; newFieldConfig.required = false; } updateFormConfig({ fields: [...currentConfig.fields, newFieldConfig] }); setNewField({ id: "", type: "text", label: "", placeholder: "", required: false, enabled: true, width: "full", options: [], content: "", }); setShowAddField(false); }; const currentConfig = getCurrentFormConfig(); return (
{/* Form Type Tabs */}
{/* No-email warning — booking confirmation/account/voucher all need one */} {!bookingFormCapturesEmail && (
{__( "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.", "yatra", )}
)} {/* Form Section Settings */} {__("Form Section Settings", "yatra")}
updateFormConfig({ enabled: e.target.checked }) } className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
updateFormConfig({ title: e.target.value })} placeholder="Enter section title" className="mt-1" />
updateFormConfig({ description: e.target.value }) } placeholder="Enter description" className="mt-1" />
{/* Form Fields */} {__("Form Fields", "yatra")} {/* Add New Field Form */} {showAddField && (

{__("Add New Field", "yatra")}

{newField.type !== "text_block" && (
handleNewFieldLabelChange(e.target.value) } placeholder="Field label" className="mt-1" />
)} {newField.type !== "text_block" && (
setNewField((prev) => ({ ...prev, id: sanitizeId(e.target.value), })) } placeholder="field_id" className="mt-1 font-mono text-xs" />

{__("Lowercase, no spaces", "yatra")}

)} {newField.type !== "text_block" && (
setNewField((prev) => ({ ...prev, placeholder: e.target.value, })) } placeholder="Placeholder text" className="mt-1" />
)}
{activeFormTab === "traveler_form" && (
)}
{/* Content editor for text blocks (display-only) */} {newField.type === "text_block" && (