PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 All 85 releases
← All changes | resources/js/pages/Settings.tsx +972 -1116 3.0.8 → 3.0.17 View file →
@@ -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