| @@ -47,9 +47,9 @@ | ||
| 47 | 47 | BarChart3, |
| 48 | 48 | Palette, |
| 49 | 49 | Percent, |
| 50 | 50 | } from "lucide-react"; |
| 51 | -import { __ } from "../lib/i18n"; | |
| 51 | +import { __, sprintf } from "../lib/i18n"; | |
| 52 | 52 | import { prepareWordPressMediaFrameOpen } from "../lib/wp-media-open"; |
| 53 | 53 | import { usePermissions } from "../hooks/usePermissions"; |
| 54 | 54 | import { useToast } from "../components/ui/toast"; |
| 55 | 55 | import { navigateMenu } from "../hooks/useNavigate"; |
| @@ -92,8 +92,20 @@ | ||
| 92 | 92 | } from "../lib/frontend-permalink-urls"; |
| 93 | 93 | import { getCurrencyOptions } from "../data/currencies"; |
| 94 | 94 | import { SearchableSelect } from "../components/ui/searchable-select"; |
| 95 | 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"; | |
| 96 | 108 | |
| 97 | 109 | // Multiple Taxes Editor Component |
| 98 | 110 | const MultipleTaxesEditor = React.memo( |
| 99 | 111 | ({ |
| @@ -249,8 +261,9 @@ | ||
| 249 | 261 | formData.google_calendar_connected || gcSettings.connected || false, |
| 250 | 262 | ); |
| 251 | 263 | const [connecting, setConnecting] = useState(false); |
| 252 | 264 | const [syncing, setSyncing] = useState(false); |
| 265 | + const [savingCalendar, setSavingCalendar] = useState(false); | |
| 253 | 266 | const { showToast } = useToast(); |
| 254 | 267 | |
| 255 | 268 | const redirectUri = |
| 256 | 269 | yatraAdmin.googleCalendarRedirectUri || |
| @@ -265,8 +278,13 @@ | ||
| 265 | 278 | const allSettings = { |
| 266 | 279 | google_calendar_client_id: clientId, |
| 267 | 280 | google_calendar_client_secret: clientSecret, |
| 268 | 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, | |
| 269 | 287 | }; |
| 270 | 288 | |
| 271 | 289 | // Update formData with all settings |
| 272 | 290 | setFormData((prev) => { |
| @@ -280,10 +298,10 @@ | ||
| 280 | 298 | |
| 281 | 299 | // Update formData when any setting changes |
| 282 | 300 | useEffect(() => { |
| 283 | 301 | syncSettingsToFormData(); |
| 284 | - // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 285 | - }, [clientId, clientSecret]); | |
| 302 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 303 | + }, [clientId, clientSecret, calendarId, calendarName]); | |
| 286 | 304 | |
| 287 | 305 | const handleConnect = async () => { |
| 288 | 306 | setConnecting(true); |
| 289 | 307 | try { |
| @@ -350,8 +368,29 @@ | ||
| 350 | 368 | setSyncing(false); |
| 351 | 369 | } |
| 352 | 370 | }; |
| 353 | 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 | + | |
| 354 | 393 | return ( |
| 355 | 394 | <div className="space-y-6"> |
| 356 | 395 | {/* Settings Card */} |
| 357 | 396 | <Card> |
| @@ -582,8 +621,21 @@ | ||
| 582 | 621 | <Calendar className="w-4 h-4" /> |
| 583 | 622 | {__("Open dashboard", "yatra")} |
| 584 | 623 | </a> |
| 585 | 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 | |
| 586 | 638 | variant="outline" |
| 587 | 639 | size="sm" |
| 588 | 640 | onClick={handleSync} |
| 589 | 641 | disabled={syncing} |
| @@ -682,55 +734,14 @@ | ||
| 682 | 734 | | "currency" |
| 683 | 735 | | "integration" |
| 684 | 736 | | "permalink" |
| 685 | 737 | | "seo" |
| 686 | - | "advanced"; | |
| 738 | + | "advanced" | |
| 739 | + | "uninstall"; | |
| 687 | 740 | |
| 688 | 741 | // Form Builder Types |
| 689 | -interface FormFieldConfig { | |
| 690 | - id: string; | |
| 691 | - type: | |
| 692 | - | "text" | |
| 693 | - | "email" | |
| 694 | - | "tel" | |
| 695 | - | "date" | |
| 696 | - | "select" | |
| 697 | - | "country" | |
| 698 | - | "textarea" | |
| 699 | - | "checkbox" | |
| 700 | - | "number" | |
| 701 | - | "text_block"; | |
| 702 | - label: string; | |
| 703 | - placeholder: string; | |
| 704 | - required: boolean; | |
| 705 | - enabled: boolean; | |
| 706 | - order: number; | |
| 707 | - width: "full" | "half" | "third"; | |
| 708 | - section?: string; | |
| 709 | - options?: { value: string; label: string }[]; | |
| 710 | - content?: string; // For text_block: the display-only content (safe HTML) | |
| 711 | - locked?: boolean; // If true, field cannot be deleted and required cannot be changed | |
| 712 | - /** | |
| 713 | - * Traveler-section only: which travelers this field is shown to. | |
| 714 | - * "all" (default) = every traveler; "lead" = the lead traveler (Traveler 1) only. | |
| 715 | - * Absent is treated as "all" for backward compatibility. | |
| 716 | - */ | |
| 717 | - applies_to?: "all" | "lead"; | |
| 718 | -} | |
| 719 | - | |
| 720 | -interface FormSectionConfig { | |
| 721 | - title: string; | |
| 722 | - description: string; | |
| 723 | - enabled?: boolean; | |
| 724 | - fields: FormFieldConfig[]; | |
| 725 | -} | |
| 726 | - | |
| 727 | -interface BookingFormConfig { | |
| 728 | - contact_form: FormSectionConfig; | |
| 729 | - emergency_contact_form: FormSectionConfig; | |
| 730 | - traveler_form: FormSectionConfig; | |
| 731 | -} | |
| 732 | - | |
| 742 | +// Booking form builder types live in components/settings/booking-form-types.ts | |
| 743 | +// (shared with the per-trip Conditions popup). | |
| 733 | 744 | interface PaymentGatewayConfig { |
| 734 | 745 | enabled: boolean; |
| 735 | 746 | icon?: string; |
| 736 | 747 | title?: string; |
| @@ -790,8 +801,10 @@ | ||
| 790 | 801 | /** Hex brand color for public trip/booking/listing UI (buttons, links, accents) */ |
| 791 | 802 | frontend_primary_color: string; |
| 792 | 803 | /** CSS max width for Yatra containers; empty = follow theme / theme.json */ |
| 793 | 804 | frontend_container_max_width: string; |
| 805 | + /** Trip listing card density: "standard" | "compact_mobile" | "compact_all" */ | |
| 806 | + frontend_listing_card_layout: string; | |
| 794 | 807 | |
| 795 | 808 | // Google Calendar Settings |
| 796 | 809 | google_calendar_client_id?: string; |
| 797 | 810 | google_calendar_client_secret?: string; |
| @@ -806,13 +819,15 @@ | ||
| 806 | 819 | search_show_destination: boolean; |
| 807 | 820 | search_show_activities: boolean; |
| 808 | 821 | search_show_duration: boolean; |
| 809 | 822 | search_show_budget: boolean; |
| 823 | + search_show_date: boolean; | |
| 810 | 824 | collapse_filters_on_mobile: boolean; |
| 811 | 825 | |
| 812 | 826 | // Booking Settings |
| 813 | 827 | booking_confirmation: boolean; |
| 814 | 828 | auto_confirm_bookings: boolean; |
| 829 | + auto_confirm_mode: "none" | "online" | "all"; | |
| 815 | 830 | require_login: boolean; |
| 816 | 831 | allow_guest_checkout: boolean; |
| 817 | 832 | require_guest_email_verification: boolean; |
| 818 | 833 | booking_expiry_hours: number; |
| @@ -836,8 +851,10 @@ | ||
| 836 | 851 | scheduled_payment_days: number; |
| 837 | 852 | scheduled_payment_installments: number; |
| 838 | 853 | scheduled_payment_interval: number; |
| 839 | 854 | scheduled_payment_reminder_days: number; |
| 855 | + balance_anchor: string; | |
| 856 | + balance_due_days: number; | |
| 840 | 857 | gateway_configs: Record<string, PaymentGatewayConfig>; |
| 841 | 858 | gateway_order?: string[]; |
| 842 | 859 | |
| 843 | 860 | // Pricing Settings |
| @@ -943,8 +960,12 @@ | ||
| 943 | 960 | allow_customer_reviews: boolean; |
| 944 | 961 | customer_dashboard_enabled: boolean; |
| 945 | 962 | /** Wishlist / saved trips (Yatra Pro); toggled in Customer settings */ |
| 946 | 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; | |
| 947 | 968 | |
| 948 | 969 | // Review Settings |
| 949 | 970 | enable_reviews: boolean; |
| 950 | 971 | require_booking: boolean; |
| @@ -952,8 +973,9 @@ | ||
| 952 | 973 | review_moderation: boolean; |
| 953 | 974 | min_rating: number; |
| 954 | 975 | allow_anonymous_reviews: boolean; |
| 955 | 976 | review_reminder_days: number; |
| 977 | + review_reminder_max_age_days: number; | |
| 956 | 978 | |
| 957 | 979 | // Tax Settings |
| 958 | 980 | enable_tax: boolean; |
| 959 | 981 | tax_name: string; |
| @@ -987,8 +1009,12 @@ | ||
| 987 | 1009 | facebook_pixel: string; |
| 988 | 1010 | recaptcha_enabled: boolean; |
| 989 | 1011 | recaptcha_site_key: string; |
| 990 | 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; | |
| 991 | 1017 | |
| 992 | 1018 | // Mailchimp Integration (Pro) |
| 993 | 1019 | mailchimp_api_key?: string; |
| 994 | 1020 | mailchimp_list_id?: string; |
| @@ -1049,8 +1075,9 @@ | ||
| 1049 | 1075 | |
| 1050 | 1076 | // Advanced Settings |
| 1051 | 1077 | debug_mode: boolean; |
| 1052 | 1078 | enable_logging: boolean; |
| 1079 | + delete_data_on_uninstall: boolean; | |
| 1053 | 1080 | cache_enabled: boolean; |
| 1054 | 1081 | api_key: string; |
| 1055 | 1082 | api_rate_limit: number; |
| 1056 | 1083 | session_timeout: number; |
| @@ -1063,8 +1090,10 @@ | ||
| 1063 | 1090 | seo_trip_meta_description: string; |
| 1064 | 1091 | seo_trip_meta_keywords: string; |
| 1065 | 1092 | seo_trip_meta_image: number; |
| 1066 | 1093 | enable_sitemap: boolean; |
| 1094 | + sitemap_types: string[]; | |
| 1095 | + sitemap_noindex_excluded: boolean; | |
| 1067 | 1096 | } |
| 1068 | 1097 | |
| 1069 | 1098 | // Form Builder Component |
| 1070 | 1099 | type BookingFormSubTab = |
| @@ -1099,11 +1128,17 @@ | ||
| 1099 | 1128 | }) => { |
| 1100 | 1129 | const [activeFormTab, setActiveFormTab] = |
| 1101 | 1130 | useState<BookingFormSubTab>(getInitialFormSubTab); |
| 1102 | 1131 | |
| 1103 | - // 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. | |
| 1104 | 1139 | const { data: modulesData } = useQuery({ |
| 1105 | - queryKey: ["modules"], | |
| 1140 | + queryKey: ["modules", "raw-envelope"], | |
| 1106 | 1141 | queryFn: async () => { |
| 1107 | 1142 | const response = await apiService.getModules(); |
| 1108 | 1143 | return response; |
| 1109 | 1144 | }, |
| @@ -1125,8 +1160,27 @@ | ||
| 1125 | 1160 | // Discount Stacking tab visibility earlier this session. |
| 1126 | 1161 | return !!window.yatraAdmin?.dynamicFormFieldEnabled; |
| 1127 | 1162 | }, [modulesData]); |
| 1128 | 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 | + | |
| 1129 | 1183 | // Save sub-tab to localStorage when it changes |
| 1130 | 1184 | const handleSubTabChange = (tab: BookingFormSubTab) => { |
| 1131 | 1185 | setActiveFormTab(tab); |
| 1132 | 1186 | if (typeof window !== "undefined") { |
| @@ -1132,22 +1186,8 @@ | ||
| 1132 | 1186 | if (typeof window !== "undefined") { |
| 1133 | 1187 | localStorage.setItem("yatra_settings_booking_form_subtab", tab); |
| 1134 | 1188 | } |
| 1135 | 1189 | }; |
| 1136 | - const [editingField, setEditingField] = useState<string | null>(null); | |
| 1137 | - const [showAddField, setShowAddField] = useState(false); | |
| 1138 | - const [newField, setNewField] = useState<Partial<FormFieldConfig>>({ | |
| 1139 | - id: "", | |
| 1140 | - type: "text", | |
| 1141 | - label: "", | |
| 1142 | - placeholder: "", | |
| 1143 | - required: false, | |
| 1144 | - enabled: true, | |
| 1145 | - width: "full", | |
| 1146 | - options: [], | |
| 1147 | - applies_to: "all", | |
| 1148 | - }); | |
| 1149 | - | |
| 1150 | 1190 | // Traveler-section "Applies to" choices (only shown on the Traveler tab). |
| 1151 | 1191 | const appliesToOptions: { value: "all" | "lead"; label: string }[] = [ |
| 1152 | 1192 | { value: "all", label: __("All travelers", "yatra") }, |
| 1153 | 1193 | { value: "lead", label: __("Lead traveler only", "yatra") }, |
| @@ -1152,53 +1192,9 @@ | ||
| 1152 | 1192 | { value: "all", label: __("All travelers", "yatra") }, |
| 1153 | 1193 | { value: "lead", label: __("Lead traveler only", "yatra") }, |
| 1154 | 1194 | ]; |
| 1155 | 1195 | |
| 1156 | - // Delete confirmation state | |
| 1157 | - const [deleteConfirm, setDeleteConfirm] = useState<{ | |
| 1158 | - isOpen: boolean; | |
| 1159 | - fieldId: string | null; | |
| 1160 | - fieldLabel: string; | |
| 1161 | - }>({ | |
| 1162 | - isOpen: false, | |
| 1163 | - fieldId: null, | |
| 1164 | - fieldLabel: "", | |
| 1165 | - }); | |
| 1166 | 1196 | |
| 1167 | - // Drag and drop state | |
| 1168 | - const [draggedFieldId, setDraggedFieldId] = useState<string | null>(null); | |
| 1169 | - const [dragOverFieldId, setDragOverFieldId] = useState<string | null>(null); | |
| 1170 | - | |
| 1171 | - // Helper to generate ID from label | |
| 1172 | - const generateIdFromLabel = (label: string): string => { | |
| 1173 | - return label | |
| 1174 | - .toLowerCase() | |
| 1175 | - .replace(/\s+/g, "_") | |
| 1176 | - .replace(/[^a-z0-9_]/g, ""); | |
| 1177 | - }; | |
| 1178 | - | |
| 1179 | - // Helper to sanitize ID input | |
| 1180 | - const sanitizeId = (id: string): string => { | |
| 1181 | - return id | |
| 1182 | - .toLowerCase() | |
| 1183 | - .replace(/\s+/g, "_") | |
| 1184 | - .replace(/[^a-z0-9_]/g, ""); | |
| 1185 | - }; | |
| 1186 | - | |
| 1187 | - // Handle label change with auto ID generation | |
| 1188 | - const handleNewFieldLabelChange = (label: string) => { | |
| 1189 | - const autoId = generateIdFromLabel(label); | |
| 1190 | - setNewField((prev) => ({ | |
| 1191 | - ...prev, | |
| 1192 | - label, | |
| 1193 | - // Only auto-generate ID if it hasn't been manually edited | |
| 1194 | - id: | |
| 1195 | - prev.id === "" || prev.id === generateIdFromLabel(prev.label || "") | |
| 1196 | - ? autoId | |
| 1197 | - : prev.id, | |
| 1198 | - })); | |
| 1199 | - }; | |
| 1200 | - | |
| 1201 | 1197 | const formTabs = [ |
| 1202 | 1198 | { |
| 1203 | 1199 | id: "contact_form" as const, |
| 1204 | 1200 | label: __("Contact Form", "yatra"), |
| @@ -1280,187 +1276,9 @@ | ||
| 1280 | 1276 | }; |
| 1281 | 1277 | }); |
| 1282 | 1278 | }; |
| 1283 | 1279 | |
| 1284 | - const updateField = (fieldId: string, updates: Partial<FormFieldConfig>) => { | |
| 1285 | - const currentConfig = getCurrentFormConfig(); | |
| 1286 | - const updatedFields = currentConfig.fields.map((field) => | |
| 1287 | - field.id === fieldId ? { ...field, ...updates } : field, | |
| 1288 | - ); | |
| 1289 | - updateFormConfig({ fields: updatedFields }); | |
| 1290 | - }; | |
| 1291 | 1280 | |
| 1292 | - const toggleFieldEnabled = (fieldId: string) => { | |
| 1293 | - const currentConfig = getCurrentFormConfig(); | |
| 1294 | - const field = currentConfig.fields.find((f) => f.id === fieldId); | |
| 1295 | - // Locked fields cannot be disabled | |
| 1296 | - if (field && !field.locked) { | |
| 1297 | - updateField(fieldId, { enabled: !field.enabled }); | |
| 1298 | - } | |
| 1299 | - }; | |
| 1300 | - | |
| 1301 | - const toggleFieldRequired = (fieldId: string) => { | |
| 1302 | - const currentConfig = getCurrentFormConfig(); | |
| 1303 | - const field = currentConfig.fields.find((f) => f.id === fieldId); | |
| 1304 | - if (field) { | |
| 1305 | - updateField(fieldId, { required: !field.required }); | |
| 1306 | - } | |
| 1307 | - }; | |
| 1308 | - | |
| 1309 | - const moveField = (fieldId: string, direction: "up" | "down") => { | |
| 1310 | - const currentConfig = getCurrentFormConfig(); | |
| 1311 | - const fields = [...currentConfig.fields]; | |
| 1312 | - const index = fields.findIndex((f) => f.id === fieldId); | |
| 1313 | - | |
| 1314 | - if (direction === "up" && index > 0) { | |
| 1315 | - [fields[index - 1], fields[index]] = [fields[index], fields[index - 1]]; | |
| 1316 | - } else if (direction === "down" && index < fields.length - 1) { | |
| 1317 | - [fields[index], fields[index + 1]] = [fields[index + 1], fields[index]]; | |
| 1318 | - } | |
| 1319 | - | |
| 1320 | - // Update order values | |
| 1321 | - fields.forEach((field, i) => { | |
| 1322 | - field.order = i + 1; | |
| 1323 | - }); | |
| 1324 | - | |
| 1325 | - updateFormConfig({ fields }); | |
| 1326 | - }; | |
| 1327 | - | |
| 1328 | - // Drag and Drop handlers | |
| 1329 | - const handleDragStart = (e: React.DragEvent, fieldId: string) => { | |
| 1330 | - setDraggedFieldId(fieldId); | |
| 1331 | - e.dataTransfer.effectAllowed = "move"; | |
| 1332 | - }; | |
| 1333 | - | |
| 1334 | - const handleDragOver = (e: React.DragEvent, fieldId: string) => { | |
| 1335 | - e.preventDefault(); | |
| 1336 | - e.dataTransfer.dropEffect = "move"; | |
| 1337 | - if (fieldId !== draggedFieldId) { | |
| 1338 | - setDragOverFieldId(fieldId); | |
| 1339 | - } | |
| 1340 | - }; | |
| 1341 | - | |
| 1342 | - const handleDragLeave = () => { | |
| 1343 | - setDragOverFieldId(null); | |
| 1344 | - }; | |
| 1345 | - | |
| 1346 | - const handleDrop = (e: React.DragEvent, targetFieldId: string) => { | |
| 1347 | - e.preventDefault(); | |
| 1348 | - if (!draggedFieldId || draggedFieldId === targetFieldId) { | |
| 1349 | - setDraggedFieldId(null); | |
| 1350 | - setDragOverFieldId(null); | |
| 1351 | - return; | |
| 1352 | - } | |
| 1353 | - | |
| 1354 | - const currentConfig = getCurrentFormConfig(); | |
| 1355 | - const fields = [...currentConfig.fields]; | |
| 1356 | - const draggedIndex = fields.findIndex((f) => f.id === draggedFieldId); | |
| 1357 | - const targetIndex = fields.findIndex((f) => f.id === targetFieldId); | |
| 1358 | - | |
| 1359 | - if (draggedIndex !== -1 && targetIndex !== -1) { | |
| 1360 | - const [draggedField] = fields.splice(draggedIndex, 1); | |
| 1361 | - fields.splice(targetIndex, 0, draggedField); | |
| 1362 | - | |
| 1363 | - // Update order values | |
| 1364 | - fields.forEach((field, i) => { | |
| 1365 | - field.order = i + 1; | |
| 1366 | - }); | |
| 1367 | - | |
| 1368 | - updateFormConfig({ fields }); | |
| 1369 | - } | |
| 1370 | - | |
| 1371 | - setDraggedFieldId(null); | |
| 1372 | - setDragOverFieldId(null); | |
| 1373 | - }; | |
| 1374 | - | |
| 1375 | - const handleDragEnd = () => { | |
| 1376 | - setDraggedFieldId(null); | |
| 1377 | - setDragOverFieldId(null); | |
| 1378 | - }; | |
| 1379 | - | |
| 1380 | - const deleteField = (fieldId: string) => { | |
| 1381 | - const currentConfig = getCurrentFormConfig(); | |
| 1382 | - const updatedFields = currentConfig.fields.filter((f) => f.id !== fieldId); | |
| 1383 | - updatedFields.forEach((field, i) => { | |
| 1384 | - field.order = i + 1; | |
| 1385 | - }); | |
| 1386 | - updateFormConfig({ fields: updatedFields }); | |
| 1387 | - setDeleteConfirm({ isOpen: false, fieldId: null, fieldLabel: "" }); | |
| 1388 | - }; | |
| 1389 | - | |
| 1390 | - const addNewField = () => { | |
| 1391 | - const isTextBlock = newField.type === "text_block"; | |
| 1392 | - const currentConfig = getCurrentFormConfig(); | |
| 1393 | - | |
| 1394 | - let fieldId: string; | |
| 1395 | - if (isTextBlock) { | |
| 1396 | - // Display-only block: the admin supplies only content — no label or id. | |
| 1397 | - // Generate a unique internal id so it can be ordered/stored like any field. | |
| 1398 | - if (!newField.content || !newField.content.trim()) return; | |
| 1399 | - let n = 1; | |
| 1400 | - while (currentConfig.fields.some((f) => f.id === `text_block_${n}`)) n++; | |
| 1401 | - fieldId = `text_block_${n}`; | |
| 1402 | - } else { | |
| 1403 | - if (!newField.label || !newField.id) return; | |
| 1404 | - fieldId = sanitizeId(newField.id); | |
| 1405 | - } | |
| 1406 | - | |
| 1407 | - // Check if ID already exists | |
| 1408 | - if (currentConfig.fields.some((f) => f.id === fieldId)) { | |
| 1409 | - // TODO: Replace with toast notification when refactoring this nested component | |
| 1410 | - window.alert( | |
| 1411 | - "A field with this ID already exists. Please use a different ID.", | |
| 1412 | - ); | |
| 1413 | - return; | |
| 1414 | - } | |
| 1415 | - | |
| 1416 | - const newFieldConfig: FormFieldConfig = { | |
| 1417 | - id: fieldId, | |
| 1418 | - type: (newField.type as FormFieldConfig["type"]) || "text", | |
| 1419 | - label: newField.label || "", | |
| 1420 | - placeholder: newField.placeholder || "", | |
| 1421 | - required: newField.required || false, | |
| 1422 | - enabled: true, | |
| 1423 | - order: currentConfig.fields.length + 1, | |
| 1424 | - width: (newField.width as FormFieldConfig["width"]) || "full", | |
| 1425 | - // Per-traveler targeting only applies to the Traveler section. | |
| 1426 | - ...(activeFormTab === "traveler_form" | |
| 1427 | - ? { applies_to: newField.applies_to || "all" } | |
| 1428 | - : {}), | |
| 1429 | - }; | |
| 1430 | - | |
| 1431 | - // Add options if field type is select | |
| 1432 | - if ( | |
| 1433 | - newField.type === "select" && | |
| 1434 | - newField.options && | |
| 1435 | - newField.options.length > 0 | |
| 1436 | - ) { | |
| 1437 | - newFieldConfig.options = newField.options.filter( | |
| 1438 | - (opt) => opt.value && opt.label, | |
| 1439 | - ); | |
| 1440 | - } | |
| 1441 | - | |
| 1442 | - // Text block: keep its display content; it can never be a required input. | |
| 1443 | - if (isTextBlock) { | |
| 1444 | - newFieldConfig.content = newField.content || ""; | |
| 1445 | - newFieldConfig.required = false; | |
| 1446 | - } | |
| 1447 | - | |
| 1448 | - updateFormConfig({ fields: [...currentConfig.fields, newFieldConfig] }); | |
| 1449 | - setNewField({ | |
| 1450 | - id: "", | |
| 1451 | - type: "text", | |
| 1452 | - label: "", | |
| 1453 | - placeholder: "", | |
| 1454 | - required: false, | |
| 1455 | - enabled: true, | |
| 1456 | - width: "full", | |
| 1457 | - options: [], | |
| 1458 | - content: "", | |
| 1459 | - }); | |
| 1460 | - setShowAddField(false); | |
| 1461 | - }; | |
| 1462 | - | |
| 1463 | 1281 | const currentConfig = getCurrentFormConfig(); |
| 1464 | 1282 | |
| 1465 | 1283 | return ( |
| 1466 | 1284 | <div className="space-y-6"> |
| @@ -1509,11 +1327,35 @@ | ||
| 1509 | 1327 | |
| 1510 | 1328 | {/* Form Section Settings */} |
| 1511 | 1329 | <Card> |
| 1512 | 1330 | <CardHeader> |
| 1513 | - <CardTitle className="text-base"> | |
| 1514 | - {__("Form Section Settings", "yatra")} | |
| 1515 | - </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> | |
| 1516 | 1358 | </CardHeader> |
| 1517 | 1359 | <CardContent className="space-y-4"> |
| 1518 | 1360 | <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> |
| 1519 | 1361 | <input |
| @@ -1560,780 +1402,85 @@ | ||
| 1560 | 1402 | className="mt-1" |
| 1561 | 1403 | /> |
| 1562 | 1404 | </div> |
| 1563 | 1405 | </div> |
| 1564 | - </CardContent> | |
| 1565 | - </Card> | |
| 1566 | 1406 | |
| 1567 | - {/* Form Fields */} | |
| 1568 | - <Card> | |
| 1569 | - <CardHeader className="flex flex-row items-center justify-between"> | |
| 1570 | - <CardTitle className="text-base"> | |
| 1571 | - {__("Form Fields", "yatra")} | |
| 1572 | - </CardTitle> | |
| 1573 | - <Button | |
| 1574 | - variant="outline" | |
| 1575 | - size="sm" | |
| 1576 | - onClick={() => setShowAddField(!showAddField)} | |
| 1577 | - className="flex items-center gap-2" | |
| 1578 | - > | |
| 1579 | - <Plus className="w-4 h-4" /> | |
| 1580 | - {__("Add Field", "yatra")} | |
| 1581 | - </Button> | |
| 1582 | - </CardHeader> | |
| 1583 | - <CardContent className="space-y-3"> | |
| 1584 | - {/* Add New Field Form */} | |
| 1585 | - {showAddField && ( | |
| 1586 | - <div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 mb-4"> | |
| 1587 | - <h4 className="font-medium text-sm mb-3"> | |
| 1588 | - {__("Add New Field", "yatra")} | |
| 1589 | - </h4> | |
| 1590 | - <div className="grid grid-cols-1 md:grid-cols-5 gap-3"> | |
| 1591 | - <div> | |
| 1592 | - <Label className="text-xs"> | |
| 1593 | - {__("Field Type", "yatra")} | |
| 1594 | - </Label> | |
| 1595 | - <Select | |
| 1596 | - value={newField.type || "text"} | |
| 1597 | - onChange={(e) => | |
| 1598 | - setNewField((prev) => ({ | |
| 1599 | - ...prev, | |
| 1600 | - type: e.target.value as FormFieldConfig["type"], | |
| 1601 | - })) | |
| 1602 | - } | |
| 1603 | - 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" | |
| 1604 | 1415 | > |
| 1605 | - {fieldTypes.map((type) => ( | |
| 1606 | - <option key={type.value} value={type.value}> | |
| 1607 | - {type.label} | |
| 1608 | - </option> | |
| 1609 | - ))} | |
| 1610 | - </Select> | |
| 1611 | - </div> | |
| 1612 | - {newField.type !== "text_block" && ( | |
| 1613 | - <div> | |
| 1614 | - <Label className="text-xs"> | |
| 1615 | - {__("Label", "yatra")} * | |
| 1616 | - </Label> | |
| 1617 | - <Input | |
| 1618 | - value={newField.label || ""} | |
| 1619 | - onChange={(e) => | |
| 1620 | - handleNewFieldLabelChange(e.target.value) | |
| 1621 | - } | |
| 1622 | - placeholder="Field label" | |
| 1623 | - className="mt-1" | |
| 1624 | - /> | |
| 1625 | - </div> | |
| 1626 | - )} | |
| 1627 | - {newField.type !== "text_block" && ( | |
| 1628 | - <div> | |
| 1629 | - <Label className="text-xs"> | |
| 1630 | - {__("Field ID", "yatra")} * | |
| 1631 | - </Label> | |
| 1632 | - <Input | |
| 1633 | - value={newField.id || ""} | |
| 1634 | - onChange={(e) => | |
| 1635 | - setNewField((prev) => ({ | |
| 1636 | - ...prev, | |
| 1637 | - id: sanitizeId(e.target.value), | |
| 1638 | - })) | |
| 1639 | - } | |
| 1640 | - placeholder="field_id" | |
| 1641 | - className="mt-1 font-mono text-xs" | |
| 1642 | - /> | |
| 1643 | - <p className="text-[10px] text-gray-400 mt-0.5"> | |
| 1644 | - {__("Lowercase, no spaces", "yatra")} | |
| 1645 | - </p> | |
| 1646 | - </div> | |
| 1647 | - )} | |
| 1648 | - {newField.type !== "text_block" && ( | |
| 1649 | - <div> | |
| 1650 | - <Label className="text-xs"> | |
| 1651 | - {__("Placeholder", "yatra")} | |
| 1652 | - </Label> | |
| 1653 | - <Input | |
| 1654 | - value={newField.placeholder || ""} | |
| 1655 | - onChange={(e) => | |
| 1656 | - setNewField((prev) => ({ | |
| 1657 | - ...prev, | |
| 1658 | - placeholder: e.target.value, | |
| 1659 | - })) | |
| 1660 | - } | |
| 1661 | - placeholder="Placeholder text" | |
| 1662 | - className="mt-1" | |
| 1663 | - /> | |
| 1664 | - </div> | |
| 1665 | - )} | |
| 1666 | - <div> | |
| 1667 | - <Label className="text-xs">{__("Width", "yatra")}</Label> | |
| 1668 | - <Select | |
| 1669 | - value={newField.width || "full"} | |
| 1670 | - onChange={(e) => | |
| 1671 | - setNewField((prev) => ({ | |
| 1672 | - ...prev, | |
| 1673 | - width: e.target.value as FormFieldConfig["width"], | |
| 1674 | - })) | |
| 1675 | - } | |
| 1676 | - className="mt-1" | |
| 1677 | - > | |
| 1678 | - {widthOptions.map((w) => ( | |
| 1679 | - <option key={w.value} value={w.value}> | |
| 1680 | - {w.label} | |
| 1681 | - </option> | |
| 1682 | - ))} | |
| 1683 | - </Select> | |
| 1684 | - </div> | |
| 1685 | - {activeFormTab === "traveler_form" && ( | |
| 1686 | - <div> | |
| 1687 | - <Label className="text-xs"> | |
| 1688 | - {__("Applies to", "yatra")} | |
| 1689 | - </Label> | |
| 1690 | - <Select | |
| 1691 | - value={newField.applies_to || "all"} | |
| 1692 | - onChange={(e) => | |
| 1693 | - setNewField((prev) => ({ | |
| 1694 | - ...prev, | |
| 1695 | - applies_to: e.target | |
| 1696 | - .value as FormFieldConfig["applies_to"], | |
| 1697 | - })) | |
| 1698 | - } | |
| 1699 | - className="mt-1" | |
| 1700 | - > | |
| 1701 | - {appliesToOptions.map((a) => ( | |
| 1702 | - <option key={a.value} value={a.value}> | |
| 1703 | - {a.label} | |
| 1704 | - </option> | |
| 1705 | - ))} | |
| 1706 | - </Select> | |
| 1707 | - </div> | |
| 1708 | - )} | |
| 1709 | - </div> | |
| 1710 | - {/* Content editor for text blocks (display-only) */} | |
| 1711 | - {newField.type === "text_block" && ( | |
| 1712 | - <div className="mt-3"> | |
| 1713 | - <Label className="text-xs font-medium"> | |
| 1714 | - {__("Content", "yatra")} | |
| 1715 | - </Label> | |
| 1716 | - <textarea | |
| 1717 | - value={newField.content || ""} | |
| 1718 | - onChange={(e) => | |
| 1719 | - setNewField((prev) => ({ | |
| 1720 | - ...prev, | |
| 1721 | - content: e.target.value, | |
| 1722 | - })) | |
| 1723 | - } | |
| 1724 | - rows={4} | |
| 1725 | - placeholder={__( | |
| 1726 | - "Text shown to customers between fields. Basic HTML (bold, links, lists) is allowed.", | |
| 1727 | - "yatra", | |
| 1728 | - )} | |
| 1729 | - className="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm" | |
| 1730 | - /> | |
| 1731 | - <p className="text-[10px] text-gray-400 mt-0.5"> | |
| 1732 | - {__( | |
| 1733 | - "Display only — this is not an input and is not submitted by the customer.", | |
| 1734 | - "yatra", | |
| 1735 | - )} | |
| 1736 | - </p> | |
| 1737 | - </div> | |
| 1738 | - )} | |
| 1739 | - | |
| 1740 | - {/* Options editor for select fields */} | |
| 1741 | - {newField.type === "select" && ( | |
| 1742 | - <div className="mt-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-blue-100 dark:border-blue-900"> | |
| 1743 | - <div className="flex items-center justify-between mb-2"> | |
| 1744 | - <Label className="text-xs font-medium"> | |
| 1745 | - {__("Dropdown Options", "yatra")} | |
| 1746 | - </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" /> | |
| 1747 | 1429 | <Button |
| 1748 | 1430 | type="button" |
| 1749 | 1431 | variant="ghost" |
| 1750 | 1432 | size="sm" |
| 1751 | - onClick={() => { | |
| 1752 | - const currentOptions = newField.options || []; | |
| 1753 | - setNewField((prev) => ({ | |
| 1754 | - ...prev, | |
| 1755 | - options: [ | |
| 1756 | - ...currentOptions, | |
| 1757 | - { value: "", label: "" }, | |
| 1758 | - ], | |
| 1759 | - })); | |
| 1760 | - }} | |
| 1761 | - className="h-6 text-xs" | |
| 1433 | + onClick={() => setConditionsOpen(true)} | |
| 1762 | 1434 | > |
| 1763 | - <Plus className="w-3 h-3 mr-1" /> | |
| 1764 | - {__("Add Option", "yatra")} | |
| 1435 | + {__("Edit", "yatra")} | |
| 1765 | 1436 | </Button> |
| 1766 | 1437 | </div> |
| 1767 | - <div className="space-y-2"> | |
| 1768 | - {(newField.options || []).map((option, optIndex) => ( | |
| 1769 | - <div key={optIndex} className="flex items-center gap-2"> | |
| 1770 | - <Input | |
| 1771 | - value={option.value} | |
| 1772 | - onChange={(e) => { | |
| 1773 | - const newOptions = [...(newField.options || [])]; | |
| 1774 | - newOptions[optIndex] = { | |
| 1775 | - ...newOptions[optIndex], | |
| 1776 | - value: e.target.value, | |
| 1777 | - }; | |
| 1778 | - setNewField((prev) => ({ | |
| 1779 | - ...prev, | |
| 1780 | - options: newOptions, | |
| 1781 | - })); | |
| 1782 | - }} | |
| 1783 | - placeholder="Value (e.g., option1)" | |
| 1784 | - className="text-xs flex-1" | |
| 1785 | - /> | |
| 1786 | - <Input | |
| 1787 | - value={option.label} | |
| 1788 | - onChange={(e) => { | |
| 1789 | - const newOptions = [...(newField.options || [])]; | |
| 1790 | - newOptions[optIndex] = { | |
| 1791 | - ...newOptions[optIndex], | |
| 1792 | - label: e.target.value, | |
| 1793 | - }; | |
| 1794 | - setNewField((prev) => ({ | |
| 1795 | - ...prev, | |
| 1796 | - options: newOptions, | |
| 1797 | - })); | |
| 1798 | - }} | |
| 1799 | - placeholder="Label (e.g., Option 1)" | |
| 1800 | - className="text-xs flex-1" | |
| 1801 | - /> | |
| 1802 | - <button | |
| 1803 | - type="button" | |
| 1804 | - onClick={() => { | |
| 1805 | - const newOptions = ( | |
| 1806 | - newField.options || [] | |
| 1807 | - ).filter((_, i) => i !== optIndex); | |
| 1808 | - setNewField((prev) => ({ | |
| 1809 | - ...prev, | |
| 1810 | - options: newOptions, | |
| 1811 | - })); | |
| 1812 | - }} | |
| 1813 | - className="p-1 text-gray-400 hover:text-red-500" | |
| 1814 | - > | |
| 1815 | - <Trash2 className="w-3 h-3" /> | |
| 1816 | - </button> | |
| 1817 | - </div> | |
| 1818 | - ))} | |
| 1819 | - {(!newField.options || newField.options.length === 0) && ( | |
| 1820 | - <p className="text-xs text-gray-400 italic"> | |
| 1821 | - {__( | |
| 1822 | - 'Click "Add Option" to add dropdown choices.', | |
| 1823 | - "yatra", | |
| 1824 | - )} | |
| 1825 | - </p> | |
| 1826 | - )} | |
| 1827 | - </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> | |
| 1828 | 1446 | </div> |
| 1829 | - )} | |
| 1830 | - | |
| 1831 | - <div className="flex items-center gap-4 mt-3"> | |
| 1832 | - {newField.type !== "text_block" && ( | |
| 1833 | - <label className="flex items-center gap-2 text-sm"> | |
| 1834 | - <input | |
| 1835 | - type="checkbox" | |
| 1836 | - checked={newField.required || false} | |
| 1837 | - onChange={(e) => | |
| 1838 | - setNewField((prev) => ({ | |
| 1839 | - ...prev, | |
| 1840 | - required: e.target.checked, | |
| 1841 | - })) | |
| 1842 | - } | |
| 1843 | - className="w-4 h-4 rounded border-gray-300 text-blue-600" | |
| 1844 | - /> | |
| 1845 | - {__("Required", "yatra")} | |
| 1846 | - </label> | |
| 1847 | - )} | |
| 1848 | - <div className="flex-1"></div> | |
| 1849 | - <Button | |
| 1850 | - variant="ghost" | |
| 1851 | - size="sm" | |
| 1852 | - onClick={() => setShowAddField(false)} | |
| 1853 | - > | |
| 1854 | - {__("Cancel", "yatra")} | |
| 1855 | - </Button> | |
| 1856 | - <Button | |
| 1857 | - size="sm" | |
| 1858 | - onClick={addNewField} | |
| 1859 | - disabled={ | |
| 1860 | - newField.type === "text_block" | |
| 1861 | - ? !newField.content || !newField.content.trim() | |
| 1862 | - : !newField.id || !newField.label | |
| 1863 | - } | |
| 1864 | - > | |
| 1865 | - {__("Add Field", "yatra")} | |
| 1866 | - </Button> | |
| 1867 | 1447 | </div> |
| 1868 | - </div> | |
| 1869 | - )} | |
| 1870 | - | |
| 1871 | - {/* Field List */} | |
| 1872 | - {currentConfig.fields?.length === 0 ? ( | |
| 1873 | - <div className="text-center py-8 text-gray-500 dark:text-gray-400"> | |
| 1874 | - <p> | |
| 1875 | - {__( | |
| 1876 | - 'No fields configured. Click "Add Field" to get started.', | |
| 1877 | - "yatra", | |
| 1878 | - )} | |
| 1879 | - </p> | |
| 1880 | - </div> | |
| 1881 | - ) : ( | |
| 1882 | - <div className="space-y-2"> | |
| 1883 | - {currentConfig.fields?.map((field, index) => ( | |
| 1884 | - <div | |
| 1885 | - key={field.id} | |
| 1886 | - draggable | |
| 1887 | - onDragStart={(e) => handleDragStart(e, field.id)} | |
| 1888 | - onDragOver={(e) => handleDragOver(e, field.id)} | |
| 1889 | - onDragLeave={handleDragLeave} | |
| 1890 | - onDrop={(e) => handleDrop(e, field.id)} | |
| 1891 | - onDragEnd={handleDragEnd} | |
| 1892 | - className={`flex items-center gap-3 p-3 rounded-lg border transition-all cursor-grab active:cursor-grabbing ${ | |
| 1893 | - draggedFieldId === field.id | |
| 1894 | - ? "opacity-50 border-dashed" | |
| 1895 | - : "" | |
| 1896 | - } ${ | |
| 1897 | - dragOverFieldId === field.id | |
| 1898 | - ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20" | |
| 1899 | - : "" | |
| 1900 | - } ${ | |
| 1901 | - field.enabled | |
| 1902 | - ? "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700" | |
| 1903 | - : "bg-gray-50 dark:bg-gray-900 border-gray-100 dark:border-gray-800 opacity-60" | |
| 1904 | - }`} | |
| 1905 | - > | |
| 1906 | - {/* Drag Handle & Order */} | |
| 1907 | - <div className="flex items-center gap-1"> | |
| 1908 | - <GripVertical className="w-4 h-4 text-gray-400" /> | |
| 1909 | - <div className="flex flex-col gap-0.5"> | |
| 1910 | - <button | |
| 1911 | - type="button" | |
| 1912 | - onClick={() => moveField(field.id, "up")} | |
| 1913 | - disabled={index === 0} | |
| 1914 | - className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed" | |
| 1915 | - > | |
| 1916 | - <ArrowUp className="w-3 h-3" /> | |
| 1917 | - </button> | |
| 1918 | - <button | |
| 1919 | - type="button" | |
| 1920 | - onClick={() => moveField(field.id, "down")} | |
| 1921 | - disabled={index === currentConfig.fields.length - 1} | |
| 1922 | - className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed" | |
| 1923 | - > | |
| 1924 | - <ArrowDown className="w-3 h-3" /> | |
| 1925 | - </button> | |
| 1926 | - </div> | |
| 1927 | - </div> | |
| 1928 | - | |
| 1929 | - {/* Field Info */} | |
| 1930 | - <div className="flex-1 min-w-0"> | |
| 1931 | - {editingField === field.id ? ( | |
| 1932 | - <div className="space-y-3"> | |
| 1933 | - <div className="grid grid-cols-4 gap-2"> | |
| 1934 | - {field.type !== "text_block" && ( | |
| 1935 | - <> | |
| 1936 | - <Input | |
| 1937 | - value={field.label} | |
| 1938 | - onChange={(e) => | |
| 1939 | - updateField(field.id, { | |
| 1940 | - label: e.target.value, | |
| 1941 | - }) | |
| 1942 | - } | |
| 1943 | - placeholder="Label" | |
| 1944 | - className="text-sm" | |
| 1945 | - /> | |
| 1946 | - <Input | |
| 1947 | - value={field.placeholder} | |
| 1948 | - onChange={(e) => | |
| 1949 | - updateField(field.id, { | |
| 1950 | - placeholder: e.target.value, | |
| 1951 | - }) | |
| 1952 | - } | |
| 1953 | - placeholder="Placeholder" | |
| 1954 | - className="text-sm" | |
| 1955 | - /> | |
| 1956 | - </> | |
| 1957 | - )} | |
| 1958 | - <Select | |
| 1959 | - value={field.type} | |
| 1960 | - onChange={(e) => | |
| 1961 | - !field.locked && | |
| 1962 | - updateField(field.id, { | |
| 1963 | - type: e.target | |
| 1964 | - .value as FormFieldConfig["type"], | |
| 1965 | - }) | |
| 1966 | - } | |
| 1967 | - disabled={field.locked} | |
| 1968 | - title={ | |
| 1969 | - field.locked | |
| 1970 | - ? "Locked fields cannot change type" | |
| 1971 | - : undefined | |
| 1972 | - } | |
| 1973 | - className="text-sm" | |
| 1974 | - > | |
| 1975 | - {fieldTypes.map((type) => ( | |
| 1976 | - <option key={type.value} value={type.value}> | |
| 1977 | - {type.label} | |
| 1978 | - </option> | |
| 1979 | - ))} | |
| 1980 | - </Select> | |
| 1981 | - <Select | |
| 1982 | - value={field.width} | |
| 1983 | - onChange={(e) => | |
| 1984 | - updateField(field.id, { | |
| 1985 | - width: e.target | |
| 1986 | - .value as FormFieldConfig["width"], | |
| 1987 | - }) | |
| 1988 | - } | |
| 1989 | - className="text-sm" | |
| 1990 | - > | |
| 1991 | - {widthOptions.map((w) => ( | |
| 1992 | - <option key={w.value} value={w.value}> | |
| 1993 | - {w.label} | |
| 1994 | - </option> | |
| 1995 | - ))} | |
| 1996 | - </Select> | |
| 1997 | - {activeFormTab === "traveler_form" && ( | |
| 1998 | - <Select | |
| 1999 | - value={field.applies_to || "all"} | |
| 2000 | - onChange={(e) => | |
| 2001 | - updateField(field.id, { | |
| 2002 | - applies_to: e.target | |
| 2003 | - .value as FormFieldConfig["applies_to"], | |
| 2004 | - }) | |
| 2005 | - } | |
| 2006 | - className="text-sm" | |
| 2007 | - title={__( | |
| 2008 | - "Which travelers see this field", | |
| 2009 | - "yatra", | |
| 2010 | - )} | |
| 2011 | - > | |
| 2012 | - {appliesToOptions.map((a) => ( | |
| 2013 | - <option key={a.value} value={a.value}> | |
| 2014 | - {a.label} | |
| 2015 | - </option> | |
| 2016 | - ))} | |
| 2017 | - </Select> | |
| 2018 | - )} | |
| 2019 | - </div> | |
| 2020 | - | |
| 2021 | - {/* Content editor for text blocks (display-only) */} | |
| 2022 | - {field.type === "text_block" && ( | |
| 2023 | - <div> | |
| 2024 | - <Label className="text-xs font-medium"> | |
| 2025 | - {__("Content", "yatra")} | |
| 2026 | - </Label> | |
| 2027 | - <textarea | |
| 2028 | - value={field.content || ""} | |
| 2029 | - onChange={(e) => | |
| 2030 | - updateField(field.id, { | |
| 2031 | - content: e.target.value, | |
| 2032 | - }) | |
| 2033 | - } | |
| 2034 | - rows={4} | |
| 2035 | - placeholder={__( | |
| 2036 | - "Text shown to customers between fields. Basic HTML (bold, links, lists) is allowed.", | |
| 2037 | - "yatra", | |
| 2038 | - )} | |
| 2039 | - className="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm" | |
| 2040 | - /> | |
| 2041 | - <p className="text-[10px] text-gray-400 mt-0.5"> | |
| 2042 | - {__( | |
| 2043 | - "Display only — this is not an input and is not submitted by the customer.", | |
| 2044 | - "yatra", | |
| 2045 | - )} | |
| 2046 | - </p> | |
| 2047 | - </div> | |
| 2048 | - )} | |
| 2049 | - | |
| 2050 | - {/* Field ID - Below other fields (input fields only) */} | |
| 2051 | - {field.type !== "text_block" && ( | |
| 2052 | - <div className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-900 rounded"> | |
| 2053 | - <Label className="text-xs text-gray-500 whitespace-nowrap"> | |
| 2054 | - {__("Field ID:", "yatra")} | |
| 2055 | - </Label> | |
| 2056 | - <Input | |
| 2057 | - value={field.id} | |
| 2058 | - onChange={(e) => { | |
| 2059 | - if (!field.locked) { | |
| 2060 | - updateField(field.id, { | |
| 2061 | - id: sanitizeId(e.target.value), | |
| 2062 | - }); | |
| 2063 | - } | |
| 2064 | - }} | |
| 2065 | - placeholder="field_id" | |
| 2066 | - className="text-sm font-mono flex-1 max-w-xs" | |
| 2067 | - disabled={field.locked} | |
| 2068 | - title={ | |
| 2069 | - field.locked | |
| 2070 | - ? "Locked fields cannot change ID" | |
| 2071 | - : "Field ID (lowercase, no spaces)" | |
| 2072 | - } | |
| 2073 | - /> | |
| 2074 | - {field.locked && ( | |
| 2075 | - <span className="text-xs text-amber-600"> | |
| 2076 | - {__("(locked)", "yatra")} | |
| 2077 | - </span> | |
| 2078 | - )} | |
| 2079 | - </div> | |
| 2080 | - )} | |
| 2081 | - | |
| 2082 | - {/* Dropdown Options Editor */} | |
| 2083 | - {field.type === "select" && ( | |
| 2084 | - <div className="p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700"> | |
| 2085 | - <div className="flex items-center justify-between mb-2"> | |
| 2086 | - <Label className="text-xs font-medium"> | |
| 2087 | - {__("Dropdown Options", "yatra")} | |
| 2088 | - </Label> | |
| 2089 | - <Button | |
| 2090 | - type="button" | |
| 2091 | - variant="ghost" | |
| 2092 | - size="sm" | |
| 2093 | - onClick={() => { | |
| 2094 | - const newOptions = [ | |
| 2095 | - ...(field.options || []), | |
| 2096 | - { value: "", label: "" }, | |
| 2097 | - ]; | |
| 2098 | - updateField(field.id, { | |
| 2099 | - options: newOptions, | |
| 2100 | - }); | |
| 2101 | - }} | |
| 2102 | - className="h-6 text-xs" | |
| 2103 | - > | |
| 2104 | - <Plus className="w-3 h-3 mr-1" /> | |
| 2105 | - {__("Add Option", "yatra")} | |
| 2106 | - </Button> | |
| 2107 | - </div> | |
| 2108 | - <div className="space-y-2"> | |
| 2109 | - {(field.options || []).map( | |
| 2110 | - (option, optIndex) => ( | |
| 2111 | - <div | |
| 2112 | - key={optIndex} | |
| 2113 | - className="flex items-center gap-2" | |
| 2114 | - > | |
| 2115 | - <Input | |
| 2116 | - value={option.value} | |
| 2117 | - onChange={(e) => { | |
| 2118 | - const newOptions = [ | |
| 2119 | - ...(field.options || []), | |
| 2120 | - ]; | |
| 2121 | - newOptions[optIndex] = { | |
| 2122 | - ...newOptions[optIndex], | |
| 2123 | - value: e.target.value, | |
| 2124 | - }; | |
| 2125 | - updateField(field.id, { | |
| 2126 | - options: newOptions, | |
| 2127 | - }); | |
| 2128 | - }} | |
| 2129 | - placeholder="Value (e.g., spouse)" | |
| 2130 | - className="text-xs flex-1" | |
| 2131 | - /> | |
| 2132 | - <Input | |
| 2133 | - value={option.label} | |
| 2134 | - onChange={(e) => { | |
| 2135 | - const newOptions = [ | |
| 2136 | - ...(field.options || []), | |
| 2137 | - ]; | |
| 2138 | - newOptions[optIndex] = { | |
| 2139 | - ...newOptions[optIndex], | |
| 2140 | - label: e.target.value, | |
| 2141 | - }; | |
| 2142 | - updateField(field.id, { | |
| 2143 | - options: newOptions, | |
| 2144 | - }); | |
| 2145 | - }} | |
| 2146 | - placeholder="Label (e.g., Spouse/Partner)" | |
| 2147 | - className="text-xs flex-1" | |
| 2148 | - /> | |
| 2149 | - <button | |
| 2150 | - type="button" | |
| 2151 | - onClick={() => { | |
| 2152 | - const newOptions = ( | |
| 2153 | - field.options || [] | |
| 2154 | - ).filter((_, i) => i !== optIndex); | |
| 2155 | - updateField(field.id, { | |
| 2156 | - options: newOptions, | |
| 2157 | - }); | |
| 2158 | - }} | |
| 2159 | - className="p-1 text-gray-400 hover:text-red-500" | |
| 2160 | - > | |
| 2161 | - <Trash2 className="w-3 h-3" /> | |
| 2162 | - </button> | |
| 2163 | - </div> | |
| 2164 | - ), | |
| 2165 | - )} | |
| 2166 | - {(!field.options || | |
| 2167 | - field.options.length === 0) && ( | |
| 2168 | - <p className="text-xs text-gray-400 italic"> | |
| 2169 | - {__( | |
| 2170 | - 'No options. Click "Add Option" to add dropdown choices.', | |
| 2171 | - "yatra", | |
| 2172 | - )} | |
| 2173 | - </p> | |
| 2174 | - )} | |
| 2175 | - </div> | |
| 2176 | - </div> | |
| 2177 | - )} | |
| 2178 | - </div> | |
| 2179 | - ) : ( | |
| 2180 | - <div className="flex items-center gap-3 flex-wrap"> | |
| 2181 | - <span className="font-medium text-sm"> | |
| 2182 | - {field.type === "text_block" | |
| 2183 | - ? __("Text Block", "yatra") | |
| 2184 | - : field.label} | |
| 2185 | - </span> | |
| 2186 | - {field.type !== "text_block" && ( | |
| 2187 | - <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"> | |
| 2188 | - {field.id} | |
| 2189 | - </code> | |
| 2190 | - )} | |
| 2191 | - <span className="text-xs text-gray-400 dark:text-gray-500 px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded"> | |
| 2192 | - {fieldTypes.find((t) => t.value === field.type) | |
| 2193 | - ?.label || field.type} | |
| 2194 | - </span> | |
| 2195 | - <span className="text-xs text-gray-400 dark:text-gray-500"> | |
| 2196 | - {widthOptions.find((w) => w.value === field.width) | |
| 2197 | - ?.label || "Full"} | |
| 2198 | - </span> | |
| 2199 | - {field.type === "text_block" && field.content && ( | |
| 2200 | - <span className="text-xs text-gray-500 dark:text-gray-400 italic truncate max-w-[16rem]"> | |
| 2201 | - "{field.content.replace(/<[^>]*>/g, "").slice(0, 60)} | |
| 2202 | - " | |
| 2203 | - </span> | |
| 2204 | - )} | |
| 2205 | - {field.type === "select" && | |
| 2206 | - field.options && | |
| 2207 | - field.options.length > 0 && ( | |
| 2208 | - <span className="text-xs text-blue-500 dark:text-blue-400"> | |
| 2209 | - ({field.options.length}{" "} | |
| 2210 | - {field.options.length === 1 | |
| 2211 | - ? "option" | |
| 2212 | - : "options"} | |
| 2213 | - ) | |
| 2214 | - </span> | |
| 2215 | - )} | |
| 2216 | - {field.required && ( | |
| 2217 | - <span className="text-xs text-red-500 font-medium"> | |
| 2218 | - {__("Required", "yatra")} | |
| 2219 | - </span> | |
| 2220 | - )} | |
| 2221 | - {activeFormTab === "traveler_form" && | |
| 2222 | - field.applies_to === "lead" && ( | |
| 2223 | - <span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 font-medium"> | |
| 2224 | - {__("Lead only", "yatra")} | |
| 2225 | - </span> | |
| 2226 | - )} | |
| 2227 | - {field.locked && ( | |
| 2228 | - <span | |
| 2229 | - 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" | |
| 2230 | - title="This field is protected and cannot be deleted" | |
| 2231 | - > | |
| 2232 | - <Lock className="w-3 h-3" /> | |
| 2233 | - Locked | |
| 2234 | - </span> | |
| 2235 | - )} | |
| 2236 | - </div> | |
| 2237 | - )} | |
| 2238 | - </div> | |
| 2239 | - | |
| 2240 | - {/* Actions */} | |
| 2241 | - <div className="flex items-center gap-1"> | |
| 2242 | - {field.type !== "text_block" && ( | |
| 2243 | - <button | |
| 2244 | - type="button" | |
| 2245 | - onClick={() => | |
| 2246 | - !field.locked && toggleFieldRequired(field.id) | |
| 2247 | - } | |
| 2248 | - disabled={field.locked} | |
| 2249 | - 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"}`} | |
| 2250 | - title={ | |
| 2251 | - field.locked | |
| 2252 | - ? "This field is required and cannot be changed" | |
| 2253 | - : field.required | |
| 2254 | - ? "Make optional" | |
| 2255 | - : "Make required" | |
| 2256 | - } | |
| 2257 | - > | |
| 2258 | - <Star | |
| 2259 | - className="w-4 h-4" | |
| 2260 | - fill={field.required ? "currentColor" : "none"} | |
| 2261 | - /> | |
| 2262 | - </button> | |
| 2263 | - )} | |
| 2264 | - {/* Show enable/disable toggle only for non-locked fields */} | |
| 2265 | - {!field.locked && ( | |
| 2266 | - <button | |
| 2267 | - type="button" | |
| 2268 | - onClick={() => toggleFieldEnabled(field.id)} | |
| 2269 | - className={`p-1.5 rounded ${field.enabled ? "text-green-500" : "text-gray-400"}`} | |
| 2270 | - title={ | |
| 2271 | - field.enabled ? "Disable field" : "Enable field" | |
| 2272 | - } | |
| 2273 | - > | |
| 2274 | - {field.enabled ? ( | |
| 2275 | - <Eye className="w-4 h-4" /> | |
| 2276 | - ) : ( | |
| 2277 | - <EyeOff className="w-4 h-4" /> | |
| 2278 | - )} | |
| 2279 | - </button> | |
| 2280 | - )} | |
| 2281 | - <button | |
| 2282 | - type="button" | |
| 2283 | - onClick={() => | |
| 2284 | - setEditingField( | |
| 2285 | - editingField === field.id ? null : field.id, | |
| 2286 | - ) | |
| 2287 | - } | |
| 2288 | - className="p-1.5 rounded text-gray-400 hover:text-blue-500" | |
| 2289 | - title="Edit field" | |
| 2290 | - > | |
| 2291 | - <Edit2 className="w-4 h-4" /> | |
| 2292 | - </button> | |
| 2293 | - {/* Show delete button only for non-locked fields */} | |
| 2294 | - {!field.locked && ( | |
| 2295 | - <button | |
| 2296 | - type="button" | |
| 2297 | - onClick={() => | |
| 2298 | - setDeleteConfirm({ | |
| 2299 | - isOpen: true, | |
| 2300 | - fieldId: field.id, | |
| 2301 | - fieldLabel: field.label, | |
| 2302 | - }) | |
| 2303 | - } | |
| 2304 | - className="p-1.5 rounded text-gray-400 hover:text-red-500" | |
| 2305 | - title="Delete field" | |
| 2306 | - > | |
| 2307 | - <Trash2 className="w-4 h-4" /> | |
| 2308 | - </button> | |
| 2309 | - )} | |
| 2310 | - </div> | |
| 2311 | - </div> | |
| 2312 | - ))} | |
| 2313 | - </div> | |
| 2314 | - )} | |
| 1448 | + )} | |
| 2315 | 1449 | </CardContent> |
| 2316 | 1450 | </Card> |
| 2317 | 1451 | |
| 2318 | - {/* Delete Confirmation Dialog */} | |
| 2319 | - <ConfirmationDialog | |
| 2320 | - isOpen={deleteConfirm.isOpen} | |
| 2321 | - onClose={() => | |
| 2322 | - setDeleteConfirm({ isOpen: false, fieldId: null, fieldLabel: "" }) | |
| 2323 | - } | |
| 2324 | - onConfirm={() => { | |
| 2325 | - if (deleteConfirm.fieldId) { | |
| 2326 | - 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 || "" | |
| 2327 | 1459 | } |
| 2328 | - }} | |
| 2329 | - title={__("Delete Field", "yatra")} | |
| 2330 | - message={`Are you sure you want to delete the field "${deleteConfirm.fieldLabel}"? This action cannot be undone.`} | |
| 2331 | - confirmText={__("Delete", "yatra")} | |
| 2332 | - cancelText={__("Cancel", "yatra")} | |
| 2333 | - 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} | |
| 2334 | 1480 | /> |
| 2335 | 1481 | |
| 1482 | + | |
| 2336 | 1483 | {/* Preview */} |
| 2337 | 1484 | <Card> |
| 2338 | 1485 | <CardHeader> |
| 2339 | 1486 | <CardTitle className="text-base"> |
| @@ -2430,8 +1577,9 @@ | ||
| 2430 | 1577 | "integration", |
| 2431 | 1578 | "permalink", |
| 2432 | 1579 | "seo", |
| 2433 | 1580 | "advanced", |
| 1581 | + "uninstall", | |
| 2434 | 1582 | ]; |
| 2435 | 1583 | if (typeof window !== "undefined") { |
| 2436 | 1584 | // Allow deep-linking a section via ?section=… (e.g. the Google Calendar |
| 2437 | 1585 | // dashboard's "Back to Settings → Integration" link). |
| @@ -2461,8 +1609,9 @@ | ||
| 2461 | 1609 | "integration", |
| 2462 | 1610 | "permalink", |
| 2463 | 1611 | "seo", |
| 2464 | 1612 | "advanced", |
| 1613 | + "uninstall", | |
| 2465 | 1614 | ].includes(saved) |
| 2466 | 1615 | ) { |
| 2467 | 1616 | return saved as SettingsSection; |
| 2468 | 1617 | } |
| @@ -2591,8 +1740,9 @@ | ||
| 2591 | 1740 | date_format: "Y-m-d", |
| 2592 | 1741 | time_format: "H:i", |
| 2593 | 1742 | frontend_primary_color: "#3b82f6", |
| 2594 | 1743 | frontend_container_max_width: "", |
| 1744 | + frontend_listing_card_layout: "standard", | |
| 2595 | 1745 | // Search & Listing — defaults match current behaviour (all fields shown, |
| 2596 | 1746 | // mobile filters expanded) so existing sites are unchanged. |
| 2597 | 1747 | search_show_keyword: true, |
| 2598 | 1748 | search_show_destination: true, |
| @@ -2598,11 +1748,13 @@ | ||
| 2598 | 1748 | search_show_destination: true, |
| 2599 | 1749 | search_show_activities: true, |
| 2600 | 1750 | search_show_duration: true, |
| 2601 | 1751 | search_show_budget: true, |
| 1752 | + search_show_date: false, | |
| 2602 | 1753 | collapse_filters_on_mobile: false, |
| 2603 | 1754 | booking_confirmation: true, |
| 2604 | 1755 | auto_confirm_bookings: false, |
| 1756 | + auto_confirm_mode: "online", | |
| 2605 | 1757 | require_login: false, |
| 2606 | 1758 | allow_guest_checkout: true, |
| 2607 | 1759 | require_guest_email_verification: false, |
| 2608 | 1760 | booking_expiry_hours: 24, |
| @@ -2626,8 +1778,10 @@ | ||
| 2626 | 1778 | scheduled_payment_installments: 1, |
| 2627 | 1779 | discount_stacking_mode: "both", |
| 2628 | 1780 | scheduled_payment_interval: 30, |
| 2629 | 1781 | scheduled_payment_reminder_days: 3, |
| 1782 | + balance_anchor: "booking", | |
| 1783 | + balance_due_days: 14, | |
| 2630 | 1784 | gateway_configs: { |
| 2631 | 1785 | stripe: { |
| 2632 | 1786 | enabled: true, |
| 2633 | 1787 | title: "Stripe", |
| @@ -2728,9 +1882,9 @@ | ||
| 2728 | 1882 | email_template_admin_scheduled_payment_failed: true, |
| 2729 | 1883 | email_template_enquiry_received: true, |
| 2730 | 1884 | email_template_enquiry_admin: true, |
| 2731 | 1885 | email_template_enquiry_response: true, |
| 2732 | - email_template_review_request: true, | |
| 1886 | + email_template_review_request: false, | |
| 2733 | 1887 | email_template_abandoned_booking_recovery_first: true, |
| 2734 | 1888 | email_template_abandoned_booking_recovery_second: true, |
| 2735 | 1889 | email_template_abandoned_booking_recovery_final: true, |
| 2736 | 1890 | smtp_enabled: false, |
| @@ -2798,9 +1952,10 @@ | ||
| 2798 | 1952 | auto_approve_reviews: false, |
| 2799 | 1953 | review_moderation: true, |
| 2800 | 1954 | min_rating: 1, |
| 2801 | 1955 | allow_anonymous_reviews: false, |
| 2802 | - review_reminder_days: 7, | |
| 1956 | + review_reminder_days: 3, | |
| 1957 | + review_reminder_max_age_days: 14, | |
| 2803 | 1958 | enable_tax: true, |
| 2804 | 1959 | tax_name: __("Tax", "yatra"), |
| 2805 | 1960 | tax_rate: 10, |
| 2806 | 1961 | tax_inclusive: false, |
| @@ -2823,8 +1978,12 @@ | ||
| 2823 | 1978 | facebook_pixel: "", |
| 2824 | 1979 | recaptcha_enabled: false, |
| 2825 | 1980 | recaptcha_site_key: "", |
| 2826 | 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, | |
| 2827 | 1986 | trip_base: "trip", |
| 2828 | 1987 | destination_base: "destination", |
| 2829 | 1988 | activity_base: "activity", |
| 2830 | 1989 | trip_category_base: "trip-category", |
| @@ -2829,8 +1988,10 @@ | ||
| 2829 | 1988 | activity_base: "activity", |
| 2830 | 1989 | trip_category_base: "trip-category", |
| 2831 | 1990 | booking_base: "book", |
| 2832 | 1991 | enable_wishlist: false, |
| 1992 | + show_sold_out: true, | |
| 1993 | + availability_horizon_months: 12, | |
| 2833 | 1994 | use_booking_page: false, |
| 2834 | 1995 | booking_page_id: 0, |
| 2835 | 1996 | terms_page_id: 0, |
| 2836 | 1997 | privacy_policy_page_id: 0, |
| @@ -2835,8 +1996,9 @@ | ||
| 2835 | 1996 | terms_page_id: 0, |
| 2836 | 1997 | privacy_policy_page_id: 0, |
| 2837 | 1998 | debug_mode: false, |
| 2838 | 1999 | enable_logging: false, |
| 2000 | + delete_data_on_uninstall: false, | |
| 2839 | 2001 | cache_enabled: true, |
| 2840 | 2002 | api_key: "", |
| 2841 | 2003 | api_rate_limit: 100, |
| 2842 | 2004 | session_timeout: 3600, |
| @@ -3071,8 +2233,10 @@ | ||
| 3071 | 2233 | seo_trip_meta_description: "", |
| 3072 | 2234 | seo_trip_meta_keywords: "", |
| 3073 | 2235 | seo_trip_meta_image: 0, |
| 3074 | 2236 | enable_sitemap: true, |
| 2237 | + sitemap_types: ["archive", "trip", "destination", "activity", "category"], | |
| 2238 | + sitemap_noindex_excluded: false, | |
| 3075 | 2239 | }), |
| 3076 | 2240 | [], |
| 3077 | 2241 | ); |
| 3078 | 2242 | |
| @@ -3426,9 +2590,9 @@ | ||
| 3426 | 2590 | // Use default settings if API fails or returns empty |
| 3427 | 2591 | setFormData(defaultSettings); |
| 3428 | 2592 | isInitializedRef.current = true; |
| 3429 | 2593 | } |
| 3430 | - // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 2594 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 3431 | 2595 | }, [settings, isLoading]); |
| 3432 | 2596 | |
| 3433 | 2597 | // Fetch SEO image URL when image ID changes with performance optimization |
| 3434 | 2598 | React.useEffect(() => { |
| @@ -3503,33 +2667,43 @@ | ||
| 3503 | 2667 | controller.abort(); |
| 3504 | 2668 | }; |
| 3505 | 2669 | }, [formData?.seo_trip_meta_image]); |
| 3506 | 2670 | |
| 3507 | - // 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); | |
| 3508 | 2681 | React.useEffect(() => { |
| 3509 | - if ( | |
| 3510 | - gatewayDefinitions && | |
| 3511 | - Object.keys(gatewayDefinitions).length > 0 && | |
| 3512 | - gatewayOrder.length === 0 | |
| 3513 | - ) { | |
| 3514 | - // Use saved order if available, otherwise use default order from definitions | |
| 3515 | - const savedOrder = settings?.gateway_order as string[] | undefined; | |
| 3516 | - if (savedOrder) { | |
| 3517 | - // Merge saved order with any new gateways | |
| 3518 | - const allGateways = Object.keys(gatewayDefinitions); | |
| 3519 | - const validSavedOrder = savedOrder.filter((id) => | |
| 3520 | - allGateways.includes(id), | |
| 3521 | - ); | |
| 3522 | - const newGateways = allGateways.filter( | |
| 3523 | - (id) => !validSavedOrder.includes(id), | |
| 3524 | - ); | |
| 3525 | - setGatewayOrder([...validSavedOrder, ...newGateways]); | |
| 3526 | - } else { | |
| 3527 | - setGatewayOrder(Object.keys(gatewayDefinitions)); | |
| 3528 | - } | |
| 2682 | + if (gatewayOrderInitialized.current) return; | |
| 2683 | + if (!gatewayDefinitions || Object.keys(gatewayDefinitions).length === 0) { | |
| 2684 | + return; | |
| 3529 | 2685 | } |
| 3530 | - }, [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; | |
| 3531 | 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 | + | |
| 3532 | 2706 | // Single stable onChange handler that reads field name from input's name or id attribute |
| 3533 | 2707 | const handleFieldChange = React.useCallback( |
| 3534 | 2708 | ( |
| 3535 | 2709 | e: React.ChangeEvent< |
| @@ -3541,10 +2715,19 @@ | ||
| 3541 | 2715 | |
| 3542 | 2716 | if (e.target.type === "checkbox") { |
| 3543 | 2717 | value = (e.target as HTMLInputElement).checked; |
| 3544 | 2718 | } else if (e.target.type === "number") { |
| 3545 | - const numValue = parseFloat(e.target.value); | |
| 3546 | - 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 | + } | |
| 3547 | 2730 | } else { |
| 3548 | 2731 | value = e.target.value; |
| 3549 | 2732 | } |
| 3550 | 2733 | |
| @@ -3971,8 +3154,13 @@ | ||
| 3971 | 3154 | id: "advanced" as SettingsSection, |
| 3972 | 3155 | label: __("Advanced", "yatra"), |
| 3973 | 3156 | icon: Shield, |
| 3974 | 3157 | }, |
| 3158 | + { | |
| 3159 | + id: "uninstall" as SettingsSection, | |
| 3160 | + label: __("Uninstall", "yatra"), | |
| 3161 | + icon: Trash2, | |
| 3162 | + }, | |
| 3975 | 3163 | ]; |
| 3976 | 3164 | |
| 3977 | 3165 | // Helper component for section divider |
| 3978 | 3166 | const SectionDivider = ({ title }: { title: string }) => ( |
| @@ -4387,8 +3575,152 @@ | ||
| 4387 | 3575 | spellCheck={false} |
| 4388 | 3576 | autoComplete="off" |
| 4389 | 3577 | /> |
| 4390 | 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> | |
| 4391 | 3723 | </div> |
| 4392 | 3724 | </div> |
| 4393 | 3725 | ); |
| 4394 | 3726 | |
| @@ -4583,8 +3915,53 @@ | ||
| 4583 | 3915 | |
| 4584 | 3916 | {formData.enable_scheduled_payments && ( |
| 4585 | 3917 | <> |
| 4586 | 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 | |
| 4587 | 3964 | id="scheduled_payment_type" |
| 4588 | 3965 | label={__("Schedule type", "yatra")} |
| 4589 | 3966 | description={__( |
| 4590 | 3967 | "Single final charge on a date, or multiple installments.", |
| @@ -4605,27 +3982,29 @@ | ||
| 4605 | 3982 | </option> |
| 4606 | 3983 | </Select> |
| 4607 | 3984 | </FormField> |
| 4608 | 3985 | |
| 4609 | - <FormField | |
| 4610 | - id="scheduled_payment_days" | |
| 4611 | - label={__("Days until first charge", "yatra")} | |
| 4612 | - description={__( | |
| 4613 | - "How many days after the initial payment to schedule the first balance collection.", | |
| 4614 | - "yatra", | |
| 4615 | - )} | |
| 4616 | - > | |
| 4617 | - <Input | |
| 3986 | + {formData.balance_anchor !== "tour" && ( | |
| 3987 | + <FormField | |
| 4618 | 3988 | id="scheduled_payment_days" |
| 4619 | - type="number" | |
| 4620 | - min={0} | |
| 4621 | - max={365} | |
| 4622 | - value={formData.scheduled_payment_days} | |
| 4623 | - name="scheduled_payment_days" | |
| 4624 | - onChange={handleFieldChange} | |
| 4625 | - className="max-w-xs" | |
| 4626 | - /> | |
| 4627 | - </FormField> | |
| 3989 | + label={__("Days until first charge", "yatra")} | |
| 3990 | + description={__( | |
| 3991 | + "How many days after the initial payment to schedule the first balance collection.", | |
| 3992 | + "yatra", | |
| 3993 | + )} | |
| 3994 | + > | |
| 3995 | + <Input | |
| 3996 | + id="scheduled_payment_days" | |
| 3997 | + type="number" | |
| 3998 | + min={0} | |
| 3999 | + max={365} | |
| 4000 | + value={formData.scheduled_payment_days} | |
| 4001 | + name="scheduled_payment_days" | |
| 4002 | + onChange={handleFieldChange} | |
| 4003 | + className="max-w-xs" | |
| 4004 | + /> | |
| 4005 | + </FormField> | |
| 4006 | + )} | |
| 4628 | 4007 | |
| 4629 | 4008 | {formData.scheduled_payment_type === "installments" && ( |
| 4630 | 4009 | <> |
| 4631 | 4010 | <FormField |
| @@ -5058,8 +4437,117 @@ | ||
| 5058 | 4437 | return null; |
| 5059 | 4438 | } |
| 5060 | 4439 | } |
| 5061 | 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 | + | |
| 5062 | 4550 | // Render checkbox fields differently |
| 5063 | 4551 | if (field.type === "checkbox") { |
| 5064 | 4552 | return ( |
| 5065 | 4553 | <div |
| @@ -5347,9 +4835,12 @@ | ||
| 5347 | 4835 | const roValue = String( |
| 5348 | 4836 | config[field.id] ?? field.default ?? "", |
| 5349 | 4837 | ); |
| 5350 | 4838 | return ( |
| 5351 | - <div key={field.id} className="space-y-2"> | |
| 4839 | + <div | |
| 4840 | + key={field.id} | |
| 4841 | + className="space-y-2" | |
| 4842 | + > | |
| 5352 | 4843 | <FormField |
| 5353 | 4844 | id={`${gatewayId}_${field.id}`} |
| 5354 | 4845 | label={field.label} |
| 5355 | 4846 | description={field.description} |
| @@ -5405,12 +4896,21 @@ | ||
| 5405 | 4896 | onChange={(e) => |
| 5406 | 4897 | handleGatewayConfigChange( |
| 5407 | 4898 | gatewayId, |
| 5408 | 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. | |
| 5409 | 4907 | field.type === "number" |
| 5410 | - ? parseFloat( | |
| 5411 | - e.target.value, | |
| 5412 | - ) || 0 | |
| 4908 | + ? e.target.value === "" | |
| 4909 | + ? "" | |
| 4910 | + : parseFloat( | |
| 4911 | + e.target.value, | |
| 4912 | + ) || 0 | |
| 5413 | 4913 | : e.target.value, |
| 5414 | 4914 | ) |
| 5415 | 4915 | } |
| 5416 | 4916 | placeholder={field.placeholder} |
| @@ -5528,8 +5028,16 @@ | ||
| 5528 | 5028 | "Dropdown to filter trips by price range.", |
| 5529 | 5029 | "yatra", |
| 5530 | 5030 | ), |
| 5531 | 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 | + }, | |
| 5532 | 5040 | ].map((row) => ( |
| 5533 | 5041 | <div |
| 5534 | 5042 | key={row.key} |
| 5535 | 5043 | className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md" |
| @@ -5625,31 +5133,83 @@ | ||
| 5625 | 5133 | </p> |
| 5626 | 5134 | </div> |
| 5627 | 5135 | </div> |
| 5628 | 5136 | |
| 5629 | - <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> | |
| 5630 | - <input | |
| 5631 | - type="checkbox" | |
| 5632 | - id="auto_confirm_bookings" | |
| 5633 | - checked={formData.auto_confirm_bookings} | |
| 5634 | - name="auto_confirm_bookings" | |
| 5635 | - onChange={handleFieldChange} | |
| 5636 | - className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" | |
| 5637 | - /> | |
| 5638 | - <div className="flex-1"> | |
| 5639 | - <Label | |
| 5640 | - htmlFor="auto_confirm_bookings" | |
| 5641 | - className="font-medium cursor-pointer" | |
| 5642 | - > | |
| 5643 | - {__("Auto-Confirm Bookings", "yatra")} | |
| 5644 | - </Label> | |
| 5645 | - <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> | |
| 5646 | - {__( | |
| 5647 | - "Automatically confirm bookings without manual approval", | |
| 5648 | - "yatra", | |
| 5649 | - )} | |
| 5650 | - </p> | |
| 5137 | + <div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> | |
| 5138 | + <div className="font-medium"> | |
| 5139 | + {__("Auto-Confirm Bookings", "yatra")} | |
| 5651 | 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> | |
| 5652 | 5212 | </div> |
| 5653 | 5213 | |
| 5654 | 5214 | <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> |
| 5655 | 5215 | <input |
| @@ -5740,8 +5300,54 @@ | ||
| 5740 | 5300 | |
| 5741 | 5301 | <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> |
| 5742 | 5302 | <input |
| 5743 | 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" | |
| 5744 | 5350 | id="allow_waitlist" |
| 5745 | 5351 | checked={formData.allow_waitlist} |
| 5746 | 5352 | name="allow_waitlist" |
| 5747 | 5353 | onChange={handleFieldChange} |
| @@ -6333,9 +5939,9 @@ | ||
| 6333 | 5939 | <FormField |
| 6334 | 5940 | id="review_reminder_days" |
| 6335 | 5941 | label={__("Review Reminder Days", "yatra")} |
| 6336 | 5942 | description={__( |
| 6337 | - "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.", | |
| 6338 | 5944 | "yatra", |
| 6339 | 5945 | )} |
| 6340 | 5946 | > |
| 6341 | 5947 | <Input |
| @@ -6346,8 +5952,26 @@ | ||
| 6346 | 5952 | onChange={handleFieldChange} |
| 6347 | 5953 | min="0" |
| 6348 | 5954 | /> |
| 6349 | 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> | |
| 6350 | 5974 | </> |
| 6351 | 5975 | )} |
| 6352 | 5976 | </div> |
| 6353 | 5977 | </div> |
| @@ -8162,8 +7786,93 @@ | ||
| 8162 | 7786 | onChange={handleFieldChange} |
| 8163 | 7787 | placeholder={__("Enter secret key", "yatra")} |
| 8164 | 7788 | /> |
| 8165 | 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> | |
| 8166 | 7875 | </> |
| 8167 | 7876 | )} |
| 8168 | 7877 | </div> |
| 8169 | 7878 | </div> |
| @@ -8929,12 +8638,159 @@ | ||
| 8929 | 8638 | {__( |
| 8930 | 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.", |
| 8931 | 8640 | "yatra", |
| 8932 | 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 | + )} | |
| 8933 | 8729 | </p> |
| 8934 | 8730 | </div> |
| 8935 | 8731 | )} |
| 8936 | 8732 | </div> |
| 8733 | + </div> | |
| 8734 | + </div> | |
| 8735 | + ); | |
| 8736 | + | |
| 8737 | + case "uninstall": | |
| 8738 | + return ( | |
| 8739 | + <div className="space-y-6"> | |
| 8740 | + <div className="space-y-4"> | |
| 8741 | + <div className="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-300"> | |
| 8742 | + {__( | |
| 8743 | + "This only applies when Yatra is deleted from the Plugins screen. Deactivating the plugin never removes anything.", | |
| 8744 | + "yatra", | |
| 8745 | + )} | |
| 8746 | + </div> | |
| 8747 | + | |
| 8748 | + <div className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> | |
| 8749 | + <input | |
| 8750 | + type="checkbox" | |
| 8751 | + id="delete_data_on_uninstall" | |
| 8752 | + checked={formData.delete_data_on_uninstall} | |
| 8753 | + name="delete_data_on_uninstall" | |
| 8754 | + onChange={handleFieldChange} | |
| 8755 | + className="mt-1 w-4 h-4 rounded border-gray-300 text-red-600 focus:ring-red-500" | |
| 8756 | + /> | |
| 8757 | + <div className="flex-1"> | |
| 8758 | + <Label | |
| 8759 | + htmlFor="delete_data_on_uninstall" | |
| 8760 | + className="font-medium cursor-pointer" | |
| 8761 | + > | |
| 8762 | + {__("Delete all data on uninstall", "yatra")} | |
| 8763 | + </Label> | |
| 8764 | + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> | |
| 8765 | + {__( | |
| 8766 | + "Permanently remove every Yatra database table, setting and scheduled task when the plugin is deleted. Yatra Pro follows the same switch and removes its own data too.", | |
| 8767 | + "yatra", | |
| 8768 | + )} | |
| 8769 | + </p> | |
| 8770 | + </div> | |
| 8771 | + </div> | |
| 8772 | + | |
| 8773 | + {formData.delete_data_on_uninstall ? ( | |
| 8774 | + <div className="rounded-md border border-red-300 bg-red-50 p-3 dark:border-red-900 dark:bg-red-950/40"> | |
| 8775 | + <p className="text-sm font-semibold text-red-800 dark:text-red-200"> | |
| 8776 | + {__("This cannot be undone", "yatra")} | |
| 8777 | + </p> | |
| 8778 | + <p className="text-xs text-red-700 dark:text-red-300 mt-1"> | |
| 8779 | + {__( | |
| 8780 | + "Trips, bookings, customers, payments, reviews and enquiries are destroyed along with the plugin. Take a backup, or use Tools to export first, if you may want this data again.", | |
| 8781 | + "yatra", | |
| 8782 | + )} | |
| 8783 | + </p> | |
| 8784 | + </div> | |
| 8785 | + ) : ( | |
| 8786 | + <div className="rounded-md border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400"> | |
| 8787 | + {__( | |
| 8788 | + "Your data is kept when Yatra is deleted, so reinstalling restores everything as it was.", | |
| 8789 | + "yatra", | |
| 8790 | + )} | |
| 8791 | + </div> | |
| 8792 | + )} | |
| 8937 | 8793 | </div> |
| 8938 | 8794 | </div> |
| 8939 | 8795 | ); |
| 8940 | 8796 | |