PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
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 +968 -1048 3.0.7 → 3.0.16 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}
@@ -685,46 +737,10 @@
685 737 | "seo"
686 738 | "advanced";
687 739
688 740 // 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 -
714 -interface FormSectionConfig {
715 - title: string;
716 - description: string;
717 - enabled?: boolean;
718 - fields: FormFieldConfig[];
719 -}
720 -
721 -interface BookingFormConfig {
722 - contact_form: FormSectionConfig;
723 - emergency_contact_form: FormSectionConfig;
724 - traveler_form: FormSectionConfig;
725 -}
726 -
741 +// Booking form builder types live in components/settings/booking-form-types.ts
742 +// (shared with the per-trip Conditions popup).
727 743 interface PaymentGatewayConfig {
728 744 enabled: boolean;
729 745 icon?: string;
730 746 title?: string;
@@ -784,8 +800,10 @@
784 800 /** Hex brand color for public trip/booking/listing UI (buttons, links, accents) */
785 801 frontend_primary_color: string;
786 802 /** CSS max width for Yatra containers; empty = follow theme / theme.json */
787 803 frontend_container_max_width: string;
804 + /** Trip listing card density: "standard" | "compact_mobile" | "compact_all" */
805 + frontend_listing_card_layout: string;
788 806
789 807 // Google Calendar Settings
790 808 google_calendar_client_id?: string;
791 809 google_calendar_client_secret?: string;
@@ -800,13 +818,15 @@
800 818 search_show_destination: boolean;
801 819 search_show_activities: boolean;
802 820 search_show_duration: boolean;
803 821 search_show_budget: boolean;
822 + search_show_date: boolean;
804 823 collapse_filters_on_mobile: boolean;
805 824
806 825 // Booking Settings
807 826 booking_confirmation: boolean;
808 827 auto_confirm_bookings: boolean;
828 + auto_confirm_mode: "none" | "online" | "all";
809 829 require_login: boolean;
810 830 allow_guest_checkout: boolean;
811 831 require_guest_email_verification: boolean;
812 832 booking_expiry_hours: number;
@@ -830,8 +850,10 @@
830 850 scheduled_payment_days: number;
831 851 scheduled_payment_installments: number;
832 852 scheduled_payment_interval: number;
833 853 scheduled_payment_reminder_days: number;
854 + balance_anchor: string;
855 + balance_due_days: number;
834 856 gateway_configs: Record<string, PaymentGatewayConfig>;
835 857 gateway_order?: string[];
836 858
837 859 // Pricing Settings
@@ -857,8 +879,9 @@
857 879 email_template_admin_payment: boolean;
858 880 email_template_admin_cancellation: boolean;
859 881 email_template_trip_consent: boolean;
860 882 email_template_customer_verification: boolean;
883 + email_template_guest_verification: boolean;
861 884 email_template_booking_completed: boolean;
862 885 email_template_booking_expired_customer: boolean;
863 886 email_template_admin_booking_expired: boolean;
864 887 email_template_scheduled_payment_reminder: boolean;
@@ -896,8 +919,10 @@
896 919 email_tpl_trip_consent_subject: string;
897 920 email_tpl_trip_consent_body: string;
898 921 email_tpl_customer_verification_subject: string;
899 922 email_tpl_customer_verification_body: string;
923 + email_tpl_guest_verification_subject: string;
924 + email_tpl_guest_verification_body: string;
900 925 email_tpl_booking_completed_subject: string;
901 926 email_tpl_booking_completed_body: string;
902 927 email_tpl_booking_expired_customer_subject: string;
903 928 email_tpl_booking_expired_customer_body: string;
@@ -934,8 +959,12 @@
934 959 allow_customer_reviews: boolean;
935 960 customer_dashboard_enabled: boolean;
936 961 /** Wishlist / saved trips (Yatra Pro); toggled in Customer settings */
937 962 enable_wishlist?: boolean;
963 + /** Keep sold-out departure dates visible on the storefront (default true) */
964 + show_sold_out?: boolean;
965 + /** How many months ahead customers can see and book (1–36, default 12) */
966 + availability_horizon_months?: number;
938 967
939 968 // Review Settings
940 969 enable_reviews: boolean;
941 970 require_booking: boolean;
@@ -978,8 +1007,12 @@
978 1007 facebook_pixel: string;
979 1008 recaptcha_enabled: boolean;
980 1009 recaptcha_site_key: string;
981 1010 recaptcha_secret_key: string;
1011 + recaptcha_score_threshold: number;
1012 + recaptcha_protect_enquiry: boolean;
1013 + recaptcha_protect_booking: boolean;
1014 + recaptcha_protect_registration: boolean;
982 1015
983 1016 // Mailchimp Integration (Pro)
984 1017 mailchimp_api_key?: string;
985 1018 mailchimp_list_id?: string;
@@ -1053,8 +1086,11 @@
1053 1086 seo_trip_meta_title: string;
1054 1087 seo_trip_meta_description: string;
1055 1088 seo_trip_meta_keywords: string;
1056 1089 seo_trip_meta_image: number;
1090 + enable_sitemap: boolean;
1091 + sitemap_types: string[];
1092 + sitemap_noindex_excluded: boolean;
1057 1093 }
1058 1094
1059 1095 // Form Builder Component
1060 1096 type BookingFormSubTab =
@@ -1089,11 +1125,17 @@
1089 1125 }) => {
1090 1126 const [activeFormTab, setActiveFormTab] =
1091 1127 useState<BookingFormSubTab>(getInitialFormSubTab);
1092 1128
1093 - // Check if Dynamic Form Field module is enabled via modules API
1129 + // Check if Dynamic Form Field module is enabled via modules API.
1130 + // NOTE: the key must differ from useModulesQuery()'s ["modules"] — this
1131 + // query caches the RAW { success, data } envelope, and the Modules page
1132 + // reads the same cache entry expecting a plain array. Sharing the key made
1133 + // Modules crash with "modules.forEach is not a function" after visiting
1134 + // Settings → Booking Form. The "modules" prefix keeps it invalidated by the
1135 + // module toggles.
1094 1136 const { data: modulesData } = useQuery({
1095 - queryKey: ["modules"],
1137 + queryKey: ["modules", "raw-envelope"],
1096 1138 queryFn: async () => {
1097 1139 const response = await apiService.getModules();
1098 1140 return response;
1099 1141 },
@@ -1115,8 +1157,27 @@
1115 1157 // Discount Stacking tab visibility earlier this session.
1116 1158 return !!window.yatraAdmin?.dynamicFormFieldEnabled;
1117 1159 }, [modulesData]);
1118 1160
1161 + // Per-trip "Conditions" popup for the active form. Needs the module on AND a
1162 + // Pro build that resolves conditions at checkout (older Pro builds set the
1163 + // module flag but not this one — offering the popup there would save rules
1164 + // that never apply). Target names (trips / categories) are only fetched when
1165 + // there is something to label.
1166 + const conditionsSupported =
1167 + isDynamicFormFieldEnabled &&
1168 + !!window.yatraAdmin?.bookingFormConditionsEnabled;
1169 + const [conditionsOpen, setConditionsOpen] = useState(false);
1170 + const hasAnyConditions = React.useMemo(() => {
1171 + const cfg = formData?.booking_form_config;
1172 + return !!cfg && Object.values(cfg).some(
1173 + (s: any) => Array.isArray(s?.conditions) && s.conditions.length > 0,
1174 + );
1175 + }, [formData?.booking_form_config]);
1176 + const { options: conditionTargetOptions } = useConditionTargets(
1177 + conditionsSupported && hasAnyConditions,
1178 + );
1179 +
1119 1180 // Save sub-tab to localStorage when it changes
1120 1181 const handleSubTabChange = (tab: BookingFormSubTab) => {
1121 1182 setActiveFormTab(tab);
1122 1183 if (typeof window !== "undefined") {
@@ -1122,66 +1183,15 @@
1122 1183 if (typeof window !== "undefined") {
1123 1184 localStorage.setItem("yatra_settings_booking_form_subtab", tab);
1124 1185 }
1125 1186 };
1126 - const [editingField, setEditingField] = useState<string | null>(null);
1127 - const [showAddField, setShowAddField] = useState(false);
1128 - const [newField, setNewField] = useState<Partial<FormFieldConfig>>({
1129 - id: "",
1130 - type: "text",
1131 - label: "",
1132 - placeholder: "",
1133 - required: false,
1134 - enabled: true,
1135 - width: "full",
1136 - options: [],
1137 - });
1187 + // Traveler-section "Applies to" choices (only shown on the Traveler tab).
1188 + const appliesToOptions: { value: "all" | "lead"; label: string }[] = [
1189 + { value: "all", label: __("All travelers", "yatra") },
1190 + { value: "lead", label: __("Lead traveler only", "yatra") },
1191 + ];
1138 1192
1139 - // Delete confirmation state
1140 - const [deleteConfirm, setDeleteConfirm] = useState<{
1141 - isOpen: boolean;
1142 - fieldId: string | null;
1143 - fieldLabel: string;
1144 - }>({
1145 - isOpen: false,
1146 - fieldId: null,
1147 - fieldLabel: "",
1148 - });
1149 1193
1150 - // Drag and drop state
1151 - const [draggedFieldId, setDraggedFieldId] = useState<string | null>(null);
1152 - const [dragOverFieldId, setDragOverFieldId] = useState<string | null>(null);
1153 -
1154 - // Helper to generate ID from label
1155 - const generateIdFromLabel = (label: string): string => {
1156 - return label
1157 - .toLowerCase()
1158 - .replace(/\s+/g, "_")
1159 - .replace(/[^a-z0-9_]/g, "");
1160 - };
1161 -
1162 - // Helper to sanitize ID input
1163 - const sanitizeId = (id: string): string => {
1164 - return id
1165 - .toLowerCase()
1166 - .replace(/\s+/g, "_")
1167 - .replace(/[^a-z0-9_]/g, "");
1168 - };
1169 -
1170 - // Handle label change with auto ID generation
1171 - const handleNewFieldLabelChange = (label: string) => {
1172 - const autoId = generateIdFromLabel(label);
1173 - setNewField((prev) => ({
1174 - ...prev,
1175 - label,
1176 - // Only auto-generate ID if it hasn't been manually edited
1177 - id:
1178 - prev.id === "" || prev.id === generateIdFromLabel(prev.label || "")
1179 - ? autoId
1180 - : prev.id,
1181 - }));
1182 - };
1183 -
1184 1194 const formTabs = [
1185 1195 {
1186 1196 id: "contact_form" as const,
1187 1197 label: __("Contact Form", "yatra"),
@@ -1263,183 +1273,9 @@
1263 1273 };
1264 1274 });
1265 1275 };
1266 1276
1267 - const updateField = (fieldId: string, updates: Partial<FormFieldConfig>) => {
1268 - const currentConfig = getCurrentFormConfig();
1269 - const updatedFields = currentConfig.fields.map((field) =>
1270 - field.id === fieldId ? { ...field, ...updates } : field,
1271 - );
1272 - updateFormConfig({ fields: updatedFields });
1273 - };
1274 1277
1275 - const toggleFieldEnabled = (fieldId: string) => {
1276 - const currentConfig = getCurrentFormConfig();
1277 - const field = currentConfig.fields.find((f) => f.id === fieldId);
1278 - // Locked fields cannot be disabled
1279 - if (field && !field.locked) {
1280 - updateField(fieldId, { enabled: !field.enabled });
1281 - }
1282 - };
1283 -
1284 - const toggleFieldRequired = (fieldId: string) => {
1285 - const currentConfig = getCurrentFormConfig();
1286 - const field = currentConfig.fields.find((f) => f.id === fieldId);
1287 - if (field) {
1288 - updateField(fieldId, { required: !field.required });
1289 - }
1290 - };
1291 -
1292 - const moveField = (fieldId: string, direction: "up" | "down") => {
1293 - const currentConfig = getCurrentFormConfig();
1294 - const fields = [...currentConfig.fields];
1295 - const index = fields.findIndex((f) => f.id === fieldId);
1296 -
1297 - if (direction === "up" && index > 0) {
1298 - [fields[index - 1], fields[index]] = [fields[index], fields[index - 1]];
1299 - } else if (direction === "down" && index < fields.length - 1) {
1300 - [fields[index], fields[index + 1]] = [fields[index + 1], fields[index]];
1301 - }
1302 -
1303 - // Update order values
1304 - fields.forEach((field, i) => {
1305 - field.order = i + 1;
1306 - });
1307 -
1308 - updateFormConfig({ fields });
1309 - };
1310 -
1311 - // Drag and Drop handlers
1312 - const handleDragStart = (e: React.DragEvent, fieldId: string) => {
1313 - setDraggedFieldId(fieldId);
1314 - e.dataTransfer.effectAllowed = "move";
1315 - };
1316 -
1317 - const handleDragOver = (e: React.DragEvent, fieldId: string) => {
1318 - e.preventDefault();
1319 - e.dataTransfer.dropEffect = "move";
1320 - if (fieldId !== draggedFieldId) {
1321 - setDragOverFieldId(fieldId);
1322 - }
1323 - };
1324 -
1325 - const handleDragLeave = () => {
1326 - setDragOverFieldId(null);
1327 - };
1328 -
1329 - const handleDrop = (e: React.DragEvent, targetFieldId: string) => {
1330 - e.preventDefault();
1331 - if (!draggedFieldId || draggedFieldId === targetFieldId) {
1332 - setDraggedFieldId(null);
1333 - setDragOverFieldId(null);
1334 - return;
1335 - }
1336 -
1337 - const currentConfig = getCurrentFormConfig();
1338 - const fields = [...currentConfig.fields];
1339 - const draggedIndex = fields.findIndex((f) => f.id === draggedFieldId);
1340 - const targetIndex = fields.findIndex((f) => f.id === targetFieldId);
1341 -
1342 - if (draggedIndex !== -1 && targetIndex !== -1) {
1343 - const [draggedField] = fields.splice(draggedIndex, 1);
1344 - fields.splice(targetIndex, 0, draggedField);
1345 -
1346 - // Update order values
1347 - fields.forEach((field, i) => {
1348 - field.order = i + 1;
1349 - });
1350 -
1351 - updateFormConfig({ fields });
1352 - }
1353 -
1354 - setDraggedFieldId(null);
1355 - setDragOverFieldId(null);
1356 - };
1357 -
1358 - const handleDragEnd = () => {
1359 - setDraggedFieldId(null);
1360 - setDragOverFieldId(null);
1361 - };
1362 -
1363 - const deleteField = (fieldId: string) => {
1364 - const currentConfig = getCurrentFormConfig();
1365 - const updatedFields = currentConfig.fields.filter((f) => f.id !== fieldId);
1366 - updatedFields.forEach((field, i) => {
1367 - field.order = i + 1;
1368 - });
1369 - updateFormConfig({ fields: updatedFields });
1370 - setDeleteConfirm({ isOpen: false, fieldId: null, fieldLabel: "" });
1371 - };
1372 -
1373 - const addNewField = () => {
1374 - const isTextBlock = newField.type === "text_block";
1375 - const currentConfig = getCurrentFormConfig();
1376 -
1377 - let fieldId: string;
1378 - if (isTextBlock) {
1379 - // Display-only block: the admin supplies only content — no label or id.
1380 - // Generate a unique internal id so it can be ordered/stored like any field.
1381 - if (!newField.content || !newField.content.trim()) return;
1382 - let n = 1;
1383 - while (currentConfig.fields.some((f) => f.id === `text_block_${n}`)) n++;
1384 - fieldId = `text_block_${n}`;
1385 - } else {
1386 - if (!newField.label || !newField.id) return;
1387 - fieldId = sanitizeId(newField.id);
1388 - }
1389 -
1390 - // Check if ID already exists
1391 - if (currentConfig.fields.some((f) => f.id === fieldId)) {
1392 - // TODO: Replace with toast notification when refactoring this nested component
1393 - window.alert(
1394 - "A field with this ID already exists. Please use a different ID.",
1395 - );
1396 - return;
1397 - }
1398 -
1399 - const newFieldConfig: FormFieldConfig = {
1400 - id: fieldId,
1401 - type: (newField.type as FormFieldConfig["type"]) || "text",
1402 - label: newField.label || "",
1403 - placeholder: newField.placeholder || "",
1404 - required: newField.required || false,
1405 - enabled: true,
1406 - order: currentConfig.fields.length + 1,
1407 - width: (newField.width as FormFieldConfig["width"]) || "full",
1408 - };
1409 -
1410 - // Add options if field type is select
1411 - if (
1412 - newField.type === "select" &&
1413 - newField.options &&
1414 - newField.options.length > 0
1415 - ) {
1416 - newFieldConfig.options = newField.options.filter(
1417 - (opt) => opt.value && opt.label,
1418 - );
1419 - }
1420 -
1421 - // Text block: keep its display content; it can never be a required input.
1422 - if (isTextBlock) {
1423 - newFieldConfig.content = newField.content || "";
1424 - newFieldConfig.required = false;
1425 - }
1426 -
1427 - updateFormConfig({ fields: [...currentConfig.fields, newFieldConfig] });
1428 - setNewField({
1429 - id: "",
1430 - type: "text",
1431 - label: "",
1432 - placeholder: "",
1433 - required: false,
1434 - enabled: true,
1435 - width: "full",
1436 - options: [],
1437 - content: "",
1438 - });
1439 - setShowAddField(false);
1440 - };
1441 -
1442 1278 const currentConfig = getCurrentFormConfig();
1443 1279
1444 1280 return (
1445 1281 <div className="space-y-6">
@@ -1488,11 +1324,35 @@
1488 1324
1489 1325 {/* Form Section Settings */}
1490 1326 <Card>
1491 1327 <CardHeader>
1492 - <CardTitle className="text-base">
1493 - {__("Form Section Settings", "yatra")}
1494 - </CardTitle>
1328 + <div className="flex flex-wrap items-center justify-between gap-2">
1329 + <CardTitle className="text-base">
1330 + {__("Form Section Settings", "yatra")}
1331 + </CardTitle>
1332 + {conditionsSupported && (
1333 + <Button
1334 + type="button"
1335 + variant="outline"
1336 + size="sm"
1337 + onClick={() => setConditionsOpen(true)}
1338 + 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"
1339 + title={__(
1340 + "Give some trips their own version of this form",
1341 + "yatra",
1342 + )}
1343 + data-testid="open-conditions"
1344 + >
1345 + <SlidersHorizontal className="w-4 h-4 mr-1.5" />
1346 + {__("Conditions", "yatra")}
1347 + {(currentConfig.conditions?.length ?? 0) > 0 && (
1348 + <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">
1349 + {currentConfig.conditions?.length}
1350 + </span>
1351 + )}
1352 + </Button>
1353 + )}
1354 + </div>
1495 1355 </CardHeader>
1496 1356 <CardContent className="space-y-4">
1497 1357 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
1498 1358 <input
@@ -1539,728 +1399,85 @@
1539 1399 className="mt-1"
1540 1400 />
1541 1401 </div>
1542 1402 </div>
1543 - </CardContent>
1544 - </Card>
1545 1403
1546 - {/* Form Fields */}
1547 - <Card>
1548 - <CardHeader className="flex flex-row items-center justify-between">
1549 - <CardTitle className="text-base">
1550 - {__("Form Fields", "yatra")}
1551 - </CardTitle>
1552 - <Button
1553 - variant="outline"
1554 - size="sm"
1555 - onClick={() => setShowAddField(!showAddField)}
1556 - className="flex items-center gap-2"
1557 - >
1558 - <Plus className="w-4 h-4" />
1559 - {__("Add Field", "yatra")}
1560 - </Button>
1561 - </CardHeader>
1562 - <CardContent className="space-y-3">
1563 - {/* Add New Field Form */}
1564 - {showAddField && (
1565 - <div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 mb-4">
1566 - <h4 className="font-medium text-sm mb-3">
1567 - {__("Add New Field", "yatra")}
1568 - </h4>
1569 - <div className="grid grid-cols-1 md:grid-cols-5 gap-3">
1570 - <div>
1571 - <Label className="text-xs">
1572 - {__("Field Type", "yatra")}
1573 - </Label>
1574 - <Select
1575 - value={newField.type || "text"}
1576 - onChange={(e) =>
1577 - setNewField((prev) => ({
1578 - ...prev,
1579 - type: e.target.value as FormFieldConfig["type"],
1580 - }))
1581 - }
1582 - className="mt-1"
1404 + {/* Per-trip conditions summary (Pro) */}
1405 + {conditionsSupported &&
1406 + (currentConfig.conditions?.length ?? 0) > 0 && (
1407 + <div className="space-y-2" data-testid="conditions-summary">
1408 + {currentConfig.conditions?.map((c, i) => (
1409 + <div
1410 + key={c.id || i}
1411 + 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"
1583 1412 >
1584 - {fieldTypes.map((type) => (
1585 - <option key={type.value} value={type.value}>
1586 - {type.label}
1587 - </option>
1588 - ))}
1589 - </Select>
1590 - </div>
1591 - {newField.type !== "text_block" && (
1592 - <div>
1593 - <Label className="text-xs">
1594 - {__("Label", "yatra")} *
1595 - </Label>
1596 - <Input
1597 - value={newField.label || ""}
1598 - onChange={(e) =>
1599 - handleNewFieldLabelChange(e.target.value)
1600 - }
1601 - placeholder="Field label"
1602 - className="mt-1"
1603 - />
1604 - </div>
1605 - )}
1606 - {newField.type !== "text_block" && (
1607 - <div>
1608 - <Label className="text-xs">
1609 - {__("Field ID", "yatra")} *
1610 - </Label>
1611 - <Input
1612 - value={newField.id || ""}
1613 - onChange={(e) =>
1614 - setNewField((prev) => ({
1615 - ...prev,
1616 - id: sanitizeId(e.target.value),
1617 - }))
1618 - }
1619 - placeholder="field_id"
1620 - className="mt-1 font-mono text-xs"
1621 - />
1622 - <p className="text-[10px] text-gray-400 mt-0.5">
1623 - {__("Lowercase, no spaces", "yatra")}
1624 - </p>
1625 - </div>
1626 - )}
1627 - {newField.type !== "text_block" && (
1628 - <div>
1629 - <Label className="text-xs">
1630 - {__("Placeholder", "yatra")}
1631 - </Label>
1632 - <Input
1633 - value={newField.placeholder || ""}
1634 - onChange={(e) =>
1635 - setNewField((prev) => ({
1636 - ...prev,
1637 - placeholder: e.target.value,
1638 - }))
1639 - }
1640 - placeholder="Placeholder text"
1641 - className="mt-1"
1642 - />
1643 - </div>
1644 - )}
1645 - <div>
1646 - <Label className="text-xs">{__("Width", "yatra")}</Label>
1647 - <Select
1648 - value={newField.width || "full"}
1649 - onChange={(e) =>
1650 - setNewField((prev) => ({
1651 - ...prev,
1652 - width: e.target.value as FormFieldConfig["width"],
1653 - }))
1654 - }
1655 - className="mt-1"
1656 - >
1657 - {widthOptions.map((w) => (
1658 - <option key={w.value} value={w.value}>
1659 - {w.label}
1660 - </option>
1661 - ))}
1662 - </Select>
1663 - </div>
1664 - </div>
1665 - {/* Content editor for text blocks (display-only) */}
1666 - {newField.type === "text_block" && (
1667 - <div className="mt-3">
1668 - <Label className="text-xs font-medium">
1669 - {__("Content", "yatra")}
1670 - </Label>
1671 - <textarea
1672 - value={newField.content || ""}
1673 - onChange={(e) =>
1674 - setNewField((prev) => ({
1675 - ...prev,
1676 - content: e.target.value,
1677 - }))
1678 - }
1679 - rows={4}
1680 - placeholder={__(
1681 - "Text shown to customers between fields. Basic HTML (bold, links, lists) is allowed.",
1682 - "yatra",
1683 - )}
1684 - 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"
1685 - />
1686 - <p className="text-[10px] text-gray-400 mt-0.5">
1687 - {__(
1688 - "Display only — this is not an input and is not submitted by the customer.",
1689 - "yatra",
1690 - )}
1691 - </p>
1692 - </div>
1693 - )}
1694 -
1695 - {/* Options editor for select fields */}
1696 - {newField.type === "select" && (
1697 - <div className="mt-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-blue-100 dark:border-blue-900">
1698 - <div className="flex items-center justify-between mb-2">
1699 - <Label className="text-xs font-medium">
1700 - {__("Dropdown Options", "yatra")}
1701 - </Label>
1413 + <span className="text-gray-500">{__("On", "yatra")}</span>
1414 + <span className="font-medium">
1415 + {describeTargets(c.targets, conditionTargetOptions) ||
1416 + __("(no trips selected)", "yatra")}
1417 + </span>
1418 + <span className="text-gray-500">
1419 + {sprintf(
1420 + __("→ “%1$s” · %2$d fields", "yatra"),
1421 + c.title,
1422 + c.fields.filter((f) => f.enabled !== false).length,
1423 + )}
1424 + </span>
1425 + <span className="flex-1" />
1702 1426 <Button
1703 1427 type="button"
1704 1428 variant="ghost"
1705 1429 size="sm"
1706 - onClick={() => {
1707 - const currentOptions = newField.options || [];
1708 - setNewField((prev) => ({
1709 - ...prev,
1710 - options: [
1711 - ...currentOptions,
1712 - { value: "", label: "" },
1713 - ],
1714 - }));
1715 - }}
1716 - className="h-6 text-xs"
1430 + onClick={() => setConditionsOpen(true)}
1717 1431 >
1718 - <Plus className="w-3 h-3 mr-1" />
1719 - {__("Add Option", "yatra")}
1432 + {__("Edit", "yatra")}
1720 1433 </Button>
1721 1434 </div>
1722 - <div className="space-y-2">
1723 - {(newField.options || []).map((option, optIndex) => (
1724 - <div key={optIndex} className="flex items-center gap-2">
1725 - <Input
1726 - value={option.value}
1727 - onChange={(e) => {
1728 - const newOptions = [...(newField.options || [])];
1729 - newOptions[optIndex] = {
1730 - ...newOptions[optIndex],
1731 - value: e.target.value,
1732 - };
1733 - setNewField((prev) => ({
1734 - ...prev,
1735 - options: newOptions,
1736 - }));
1737 - }}
1738 - placeholder="Value (e.g., option1)"
1739 - className="text-xs flex-1"
1740 - />
1741 - <Input
1742 - value={option.label}
1743 - onChange={(e) => {
1744 - const newOptions = [...(newField.options || [])];
1745 - newOptions[optIndex] = {
1746 - ...newOptions[optIndex],
1747 - label: e.target.value,
1748 - };
1749 - setNewField((prev) => ({
1750 - ...prev,
1751 - options: newOptions,
1752 - }));
1753 - }}
1754 - placeholder="Label (e.g., Option 1)"
1755 - className="text-xs flex-1"
1756 - />
1757 - <button
1758 - type="button"
1759 - onClick={() => {
1760 - const newOptions = (
1761 - newField.options || []
1762 - ).filter((_, i) => i !== optIndex);
1763 - setNewField((prev) => ({
1764 - ...prev,
1765 - options: newOptions,
1766 - }));
1767 - }}
1768 - className="p-1 text-gray-400 hover:text-red-500"
1769 - >
1770 - <Trash2 className="w-3 h-3" />
1771 - </button>
1772 - </div>
1773 - ))}
1774 - {(!newField.options || newField.options.length === 0) && (
1775 - <p className="text-xs text-gray-400 italic">
1776 - {__(
1777 - 'Click "Add Option" to add dropdown choices.',
1778 - "yatra",
1779 - )}
1780 - </p>
1781 - )}
1782 - </div>
1435 + ))}
1436 + <div className="flex flex-wrap items-center gap-2 px-3 py-2 rounded-md bg-gray-50 dark:bg-gray-800 text-sm">
1437 + <span className="font-medium">
1438 + {__("All other trips", "yatra")}
1439 + </span>
1440 + <span className="text-gray-500">
1441 + {__("use this form (below)", "yatra")}
1442 + </span>
1783 1443 </div>
1784 - )}
1785 -
1786 - <div className="flex items-center gap-4 mt-3">
1787 - {newField.type !== "text_block" && (
1788 - <label className="flex items-center gap-2 text-sm">
1789 - <input
1790 - type="checkbox"
1791 - checked={newField.required || false}
1792 - onChange={(e) =>
1793 - setNewField((prev) => ({
1794 - ...prev,
1795 - required: e.target.checked,
1796 - }))
1797 - }
1798 - className="w-4 h-4 rounded border-gray-300 text-blue-600"
1799 - />
1800 - {__("Required", "yatra")}
1801 - </label>
1802 - )}
1803 - <div className="flex-1"></div>
1804 - <Button
1805 - variant="ghost"
1806 - size="sm"
1807 - onClick={() => setShowAddField(false)}
1808 - >
1809 - {__("Cancel", "yatra")}
1810 - </Button>
1811 - <Button
1812 - size="sm"
1813 - onClick={addNewField}
1814 - disabled={
1815 - newField.type === "text_block"
1816 - ? !newField.content || !newField.content.trim()
1817 - : !newField.id || !newField.label
1818 - }
1819 - >
1820 - {__("Add Field", "yatra")}
1821 - </Button>
1822 1444 </div>
1823 - </div>
1824 - )}
1825 -
1826 - {/* Field List */}
1827 - {currentConfig.fields?.length === 0 ? (
1828 - <div className="text-center py-8 text-gray-500 dark:text-gray-400">
1829 - <p>
1830 - {__(
1831 - 'No fields configured. Click "Add Field" to get started.',
1832 - "yatra",
1833 - )}
1834 - </p>
1835 - </div>
1836 - ) : (
1837 - <div className="space-y-2">
1838 - {currentConfig.fields?.map((field, index) => (
1839 - <div
1840 - key={field.id}
1841 - draggable
1842 - onDragStart={(e) => handleDragStart(e, field.id)}
1843 - onDragOver={(e) => handleDragOver(e, field.id)}
1844 - onDragLeave={handleDragLeave}
1845 - onDrop={(e) => handleDrop(e, field.id)}
1846 - onDragEnd={handleDragEnd}
1847 - className={`flex items-center gap-3 p-3 rounded-lg border transition-all cursor-grab active:cursor-grabbing ${
1848 - draggedFieldId === field.id
1849 - ? "opacity-50 border-dashed"
1850 - : ""
1851 - } ${
1852 - dragOverFieldId === field.id
1853 - ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
1854 - : ""
1855 - } ${
1856 - field.enabled
1857 - ? "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
1858 - : "bg-gray-50 dark:bg-gray-900 border-gray-100 dark:border-gray-800 opacity-60"
1859 - }`}
1860 - >
1861 - {/* Drag Handle & Order */}
1862 - <div className="flex items-center gap-1">
1863 - <GripVertical className="w-4 h-4 text-gray-400" />
1864 - <div className="flex flex-col gap-0.5">
1865 - <button
1866 - type="button"
1867 - onClick={() => moveField(field.id, "up")}
1868 - disabled={index === 0}
1869 - className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
1870 - >
1871 - <ArrowUp className="w-3 h-3" />
1872 - </button>
1873 - <button
1874 - type="button"
1875 - onClick={() => moveField(field.id, "down")}
1876 - disabled={index === currentConfig.fields.length - 1}
1877 - className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
1878 - >
1879 - <ArrowDown className="w-3 h-3" />
1880 - </button>
1881 - </div>
1882 - </div>
1883 -
1884 - {/* Field Info */}
1885 - <div className="flex-1 min-w-0">
1886 - {editingField === field.id ? (
1887 - <div className="space-y-3">
1888 - <div className="grid grid-cols-4 gap-2">
1889 - {field.type !== "text_block" && (
1890 - <>
1891 - <Input
1892 - value={field.label}
1893 - onChange={(e) =>
1894 - updateField(field.id, {
1895 - label: e.target.value,
1896 - })
1897 - }
1898 - placeholder="Label"
1899 - className="text-sm"
1900 - />
1901 - <Input
1902 - value={field.placeholder}
1903 - onChange={(e) =>
1904 - updateField(field.id, {
1905 - placeholder: e.target.value,
1906 - })
1907 - }
1908 - placeholder="Placeholder"
1909 - className="text-sm"
1910 - />
1911 - </>
1912 - )}
1913 - <Select
1914 - value={field.type}
1915 - onChange={(e) =>
1916 - !field.locked &&
1917 - updateField(field.id, {
1918 - type: e.target
1919 - .value as FormFieldConfig["type"],
1920 - })
1921 - }
1922 - disabled={field.locked}
1923 - title={
1924 - field.locked
1925 - ? "Locked fields cannot change type"
1926 - : undefined
1927 - }
1928 - className="text-sm"
1929 - >
1930 - {fieldTypes.map((type) => (
1931 - <option key={type.value} value={type.value}>
1932 - {type.label}
1933 - </option>
1934 - ))}
1935 - </Select>
1936 - <Select
1937 - value={field.width}
1938 - onChange={(e) =>
1939 - updateField(field.id, {
1940 - width: e.target
1941 - .value as FormFieldConfig["width"],
1942 - })
1943 - }
1944 - className="text-sm"
1945 - >
1946 - {widthOptions.map((w) => (
1947 - <option key={w.value} value={w.value}>
1948 - {w.label}
1949 - </option>
1950 - ))}
1951 - </Select>
1952 - </div>
1953 -
1954 - {/* Content editor for text blocks (display-only) */}
1955 - {field.type === "text_block" && (
1956 - <div>
1957 - <Label className="text-xs font-medium">
1958 - {__("Content", "yatra")}
1959 - </Label>
1960 - <textarea
1961 - value={field.content || ""}
1962 - onChange={(e) =>
1963 - updateField(field.id, {
1964 - content: e.target.value,
1965 - })
1966 - }
1967 - rows={4}
1968 - placeholder={__(
1969 - "Text shown to customers between fields. Basic HTML (bold, links, lists) is allowed.",
1970 - "yatra",
1971 - )}
1972 - 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"
1973 - />
1974 - <p className="text-[10px] text-gray-400 mt-0.5">
1975 - {__(
1976 - "Display only — this is not an input and is not submitted by the customer.",
1977 - "yatra",
1978 - )}
1979 - </p>
1980 - </div>
1981 - )}
1982 -
1983 - {/* Field ID - Below other fields (input fields only) */}
1984 - {field.type !== "text_block" && (
1985 - <div className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-900 rounded">
1986 - <Label className="text-xs text-gray-500 whitespace-nowrap">
1987 - {__("Field ID:", "yatra")}
1988 - </Label>
1989 - <Input
1990 - value={field.id}
1991 - onChange={(e) => {
1992 - if (!field.locked) {
1993 - updateField(field.id, {
1994 - id: sanitizeId(e.target.value),
1995 - });
1996 - }
1997 - }}
1998 - placeholder="field_id"
1999 - className="text-sm font-mono flex-1 max-w-xs"
2000 - disabled={field.locked}
2001 - title={
2002 - field.locked
2003 - ? "Locked fields cannot change ID"
2004 - : "Field ID (lowercase, no spaces)"
2005 - }
2006 - />
2007 - {field.locked && (
2008 - <span className="text-xs text-amber-600">
2009 - {__("(locked)", "yatra")}
2010 - </span>
2011 - )}
2012 - </div>
2013 - )}
2014 -
2015 - {/* Dropdown Options Editor */}
2016 - {field.type === "select" && (
2017 - <div className="p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
2018 - <div className="flex items-center justify-between mb-2">
2019 - <Label className="text-xs font-medium">
2020 - {__("Dropdown Options", "yatra")}
2021 - </Label>
2022 - <Button
2023 - type="button"
2024 - variant="ghost"
2025 - size="sm"
2026 - onClick={() => {
2027 - const newOptions = [
2028 - ...(field.options || []),
2029 - { value: "", label: "" },
2030 - ];
2031 - updateField(field.id, {
2032 - options: newOptions,
2033 - });
2034 - }}
2035 - className="h-6 text-xs"
2036 - >
2037 - <Plus className="w-3 h-3 mr-1" />
2038 - {__("Add Option", "yatra")}
2039 - </Button>
2040 - </div>
2041 - <div className="space-y-2">
2042 - {(field.options || []).map(
2043 - (option, optIndex) => (
2044 - <div
2045 - key={optIndex}
2046 - className="flex items-center gap-2"
2047 - >
2048 - <Input
2049 - value={option.value}
2050 - onChange={(e) => {
2051 - const newOptions = [
2052 - ...(field.options || []),
2053 - ];
2054 - newOptions[optIndex] = {
2055 - ...newOptions[optIndex],
2056 - value: e.target.value,
2057 - };
2058 - updateField(field.id, {
2059 - options: newOptions,
2060 - });
2061 - }}
2062 - placeholder="Value (e.g., spouse)"
2063 - className="text-xs flex-1"
2064 - />
2065 - <Input
2066 - value={option.label}
2067 - onChange={(e) => {
2068 - const newOptions = [
2069 - ...(field.options || []),
2070 - ];
2071 - newOptions[optIndex] = {
2072 - ...newOptions[optIndex],
2073 - label: e.target.value,
2074 - };
2075 - updateField(field.id, {
2076 - options: newOptions,
2077 - });
2078 - }}
2079 - placeholder="Label (e.g., Spouse/Partner)"
2080 - className="text-xs flex-1"
2081 - />
2082 - <button
2083 - type="button"
2084 - onClick={() => {
2085 - const newOptions = (
2086 - field.options || []
2087 - ).filter((_, i) => i !== optIndex);
2088 - updateField(field.id, {
2089 - options: newOptions,
2090 - });
2091 - }}
2092 - className="p-1 text-gray-400 hover:text-red-500"
2093 - >
2094 - <Trash2 className="w-3 h-3" />
2095 - </button>
2096 - </div>
2097 - ),
2098 - )}
2099 - {(!field.options ||
2100 - field.options.length === 0) && (
2101 - <p className="text-xs text-gray-400 italic">
2102 - {__(
2103 - 'No options. Click "Add Option" to add dropdown choices.',
2104 - "yatra",
2105 - )}
2106 - </p>
2107 - )}
2108 - </div>
2109 - </div>
2110 - )}
2111 - </div>
2112 - ) : (
2113 - <div className="flex items-center gap-3 flex-wrap">
2114 - <span className="font-medium text-sm">
2115 - {field.type === "text_block"
2116 - ? __("Text Block", "yatra")
2117 - : field.label}
2118 - </span>
2119 - {field.type !== "text_block" && (
2120 - <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">
2121 - {field.id}
2122 - </code>
2123 - )}
2124 - <span className="text-xs text-gray-400 dark:text-gray-500 px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">
2125 - {fieldTypes.find((t) => t.value === field.type)
2126 - ?.label || field.type}
2127 - </span>
2128 - <span className="text-xs text-gray-400 dark:text-gray-500">
2129 - {widthOptions.find((w) => w.value === field.width)
2130 - ?.label || "Full"}
2131 - </span>
2132 - {field.type === "text_block" && field.content && (
2133 - <span className="text-xs text-gray-500 dark:text-gray-400 italic truncate max-w-[16rem]">
2134 - "{field.content.replace(/<[^>]*>/g, "").slice(0, 60)}
2135 - "
2136 - </span>
2137 - )}
2138 - {field.type === "select" &&
2139 - field.options &&
2140 - field.options.length > 0 && (
2141 - <span className="text-xs text-blue-500 dark:text-blue-400">
2142 - ({field.options.length}{" "}
2143 - {field.options.length === 1
2144 - ? "option"
2145 - : "options"}
2146 - )
2147 - </span>
2148 - )}
2149 - {field.required && (
2150 - <span className="text-xs text-red-500 font-medium">
2151 - {__("Required", "yatra")}
2152 - </span>
2153 - )}
2154 - {field.locked && (
2155 - <span
2156 - 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"
2157 - title="This field is protected and cannot be deleted"
2158 - >
2159 - <Lock className="w-3 h-3" />
2160 - Locked
2161 - </span>
2162 - )}
2163 - </div>
2164 - )}
2165 - </div>
2166 -
2167 - {/* Actions */}
2168 - <div className="flex items-center gap-1">
2169 - {field.type !== "text_block" && (
2170 - <button
2171 - type="button"
2172 - onClick={() =>
2173 - !field.locked && toggleFieldRequired(field.id)
2174 - }
2175 - disabled={field.locked}
2176 - 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"}`}
2177 - title={
2178 - field.locked
2179 - ? "This field is required and cannot be changed"
2180 - : field.required
2181 - ? "Make optional"
2182 - : "Make required"
2183 - }
2184 - >
2185 - <Star
2186 - className="w-4 h-4"
2187 - fill={field.required ? "currentColor" : "none"}
2188 - />
2189 - </button>
2190 - )}
2191 - {/* Show enable/disable toggle only for non-locked fields */}
2192 - {!field.locked && (
2193 - <button
2194 - type="button"
2195 - onClick={() => toggleFieldEnabled(field.id)}
2196 - className={`p-1.5 rounded ${field.enabled ? "text-green-500" : "text-gray-400"}`}
2197 - title={
2198 - field.enabled ? "Disable field" : "Enable field"
2199 - }
2200 - >
2201 - {field.enabled ? (
2202 - <Eye className="w-4 h-4" />
2203 - ) : (
2204 - <EyeOff className="w-4 h-4" />
2205 - )}
2206 - </button>
2207 - )}
2208 - <button
2209 - type="button"
2210 - onClick={() =>
2211 - setEditingField(
2212 - editingField === field.id ? null : field.id,
2213 - )
2214 - }
2215 - className="p-1.5 rounded text-gray-400 hover:text-blue-500"
2216 - title="Edit field"
2217 - >
2218 - <Edit2 className="w-4 h-4" />
2219 - </button>
2220 - {/* Show delete button only for non-locked fields */}
2221 - {!field.locked && (
2222 - <button
2223 - type="button"
2224 - onClick={() =>
2225 - setDeleteConfirm({
2226 - isOpen: true,
2227 - fieldId: field.id,
2228 - fieldLabel: field.label,
2229 - })
2230 - }
2231 - className="p-1.5 rounded text-gray-400 hover:text-red-500"
2232 - title="Delete field"
2233 - >
2234 - <Trash2 className="w-4 h-4" />
2235 - </button>
2236 - )}
2237 - </div>
2238 - </div>
2239 - ))}
2240 - </div>
2241 - )}
1445 + )}
2242 1446 </CardContent>
2243 1447 </Card>
2244 1448
2245 - {/* Delete Confirmation Dialog */}
2246 - <ConfirmationDialog
2247 - isOpen={deleteConfirm.isOpen}
2248 - onClose={() =>
2249 - setDeleteConfirm({ isOpen: false, fieldId: null, fieldLabel: "" })
2250 - }
2251 - onConfirm={() => {
2252 - if (deleteConfirm.fieldId) {
2253 - deleteField(deleteConfirm.fieldId);
1449 + {conditionsSupported && (
1450 + <BookingFormConditionsModal
1451 + isOpen={conditionsOpen}
1452 + onClose={() => setConditionsOpen(false)}
1453 + formType={activeFormTab}
1454 + formLabel={
1455 + formTabs.find((t) => t.id === activeFormTab)?.label || ""
2254 1456 }
2255 - }}
2256 - title={__("Delete Field", "yatra")}
2257 - message={`Are you sure you want to delete the field "${deleteConfirm.fieldLabel}"? This action cannot be undone.`}
2258 - confirmText={__("Delete", "yatra")}
2259 - cancelText={__("Cancel", "yatra")}
2260 - variant="danger"
1457 + section={currentConfig}
1458 + onSave={(conditions) =>
1459 + updateFormConfig({
1460 + conditions: conditions.length > 0 ? conditions : undefined,
1461 + })
1462 + }
1463 + fieldTypes={fieldTypes}
1464 + widthOptions={widthOptions}
1465 + appliesToOptions={appliesToOptions}
1466 + />
1467 + )}
1468 +
1469 + {/* Form Fields — shared builder (also used by the Conditions popup) */}
1470 + <BookingFormFieldsEditor
1471 + formType={activeFormTab}
1472 + fields={currentConfig.fields}
1473 + onChange={(fields) => updateFormConfig({ fields })}
1474 + fieldTypes={fieldTypes}
1475 + widthOptions={widthOptions}
1476 + appliesToOptions={appliesToOptions}
2261 1477 />
2262 1478
1479 +
2263 1480 {/* Preview */}
2264 1481 <Card>
2265 1482 <CardHeader>
2266 1483 <CardTitle className="text-base">
@@ -2518,8 +1735,9 @@
2518 1735 date_format: "Y-m-d",
2519 1736 time_format: "H:i",
2520 1737 frontend_primary_color: "#3b82f6",
2521 1738 frontend_container_max_width: "",
1739 + frontend_listing_card_layout: "standard",
2522 1740 // Search & Listing — defaults match current behaviour (all fields shown,
2523 1741 // mobile filters expanded) so existing sites are unchanged.
2524 1742 search_show_keyword: true,
2525 1743 search_show_destination: true,
@@ -2525,11 +1743,13 @@
2525 1743 search_show_destination: true,
2526 1744 search_show_activities: true,
2527 1745 search_show_duration: true,
2528 1746 search_show_budget: true,
1747 + search_show_date: false,
2529 1748 collapse_filters_on_mobile: false,
2530 1749 booking_confirmation: true,
2531 1750 auto_confirm_bookings: false,
1751 + auto_confirm_mode: "online",
2532 1752 require_login: false,
2533 1753 allow_guest_checkout: true,
2534 1754 require_guest_email_verification: false,
2535 1755 booking_expiry_hours: 24,
@@ -2553,8 +1773,10 @@
2553 1773 scheduled_payment_installments: 1,
2554 1774 discount_stacking_mode: "both",
2555 1775 scheduled_payment_interval: 30,
2556 1776 scheduled_payment_reminder_days: 3,
1777 + balance_anchor: "booking",
1778 + balance_due_days: 14,
2557 1779 gateway_configs: {
2558 1780 stripe: {
2559 1781 enabled: true,
2560 1782 title: "Stripe",
@@ -2644,8 +1866,9 @@
2644 1866 email_template_admin_payment: true,
2645 1867 email_template_admin_cancellation: true,
2646 1868 email_template_trip_consent: true,
2647 1869 email_template_customer_verification: true,
1870 + email_template_guest_verification: true,
2648 1871 email_template_booking_completed: true,
2649 1872 email_template_booking_expired_customer: true,
2650 1873 email_template_admin_booking_expired: true,
2651 1874 email_template_scheduled_payment_reminder: true,
@@ -2682,8 +1905,10 @@
2682 1905 email_tpl_trip_consent_subject: "",
2683 1906 email_tpl_trip_consent_body: "",
2684 1907 email_tpl_customer_verification_subject: "",
2685 1908 email_tpl_customer_verification_body: "",
1909 + email_tpl_guest_verification_subject: "",
1910 + email_tpl_guest_verification_body: "",
2686 1911 email_tpl_booking_completed_subject: "",
2687 1912 email_tpl_booking_completed_body: "",
2688 1913 email_tpl_booking_expired_customer_subject: "",
2689 1914 email_tpl_booking_expired_customer_body: "",
@@ -2747,8 +1972,12 @@
2747 1972 facebook_pixel: "",
2748 1973 recaptcha_enabled: false,
2749 1974 recaptcha_site_key: "",
2750 1975 recaptcha_secret_key: "",
1976 + recaptcha_score_threshold: 0.5,
1977 + recaptcha_protect_enquiry: false,
1978 + recaptcha_protect_booking: false,
1979 + recaptcha_protect_registration: false,
2751 1980 trip_base: "trip",
2752 1981 destination_base: "destination",
2753 1982 activity_base: "activity",
2754 1983 trip_category_base: "trip-category",
@@ -2753,8 +1982,10 @@
2753 1982 activity_base: "activity",
2754 1983 trip_category_base: "trip-category",
2755 1984 booking_base: "book",
2756 1985 enable_wishlist: false,
1986 + show_sold_out: true,
1987 + availability_horizon_months: 12,
2757 1988 use_booking_page: false,
2758 1989 booking_page_id: 0,
2759 1990 terms_page_id: 0,
2760 1991 privacy_policy_page_id: 0,
@@ -2994,8 +2225,11 @@
2994 2225 seo_trip_meta_title: "",
2995 2226 seo_trip_meta_description: "",
2996 2227 seo_trip_meta_keywords: "",
2997 2228 seo_trip_meta_image: 0,
2229 + enable_sitemap: true,
2230 + sitemap_types: ["archive", "trip", "destination", "activity", "category"],
2231 + sitemap_noindex_excluded: false,
2998 2232 }),
2999 2233 [],
3000 2234 );
3001 2235
@@ -3349,9 +2583,9 @@
3349 2583 // Use default settings if API fails or returns empty
3350 2584 setFormData(defaultSettings);
3351 2585 isInitializedRef.current = true;
3352 2586 }
3353 - // eslint-disable-next-line react-hooks/exhaustive-deps
2587 + // eslint-disable-next-line react-hooks/exhaustive-deps
3354 2588 }, [settings, isLoading]);
3355 2589
3356 2590 // Fetch SEO image URL when image ID changes with performance optimization
3357 2591 React.useEffect(() => {
@@ -3426,33 +2660,43 @@
3426 2660 controller.abort();
3427 2661 };
3428 2662 }, [formData?.seo_trip_meta_image]);
3429 2663
3430 - // Initialize gateway order when gateway definitions are loaded
2664 + // Initialize gateway order ONCE, and only after BOTH the gateway definitions
2665 + // AND the settings have loaded. These are two independent queries. The old
2666 + // guard (`gatewayOrder.length === 0`) initialized as soon as the definitions
2667 + // arrived — if that happened before `settings` resolved, `savedOrder` was
2668 + // still undefined, so it fell back to the DEFAULT order; the length guard then
2669 + // permanently shadowed the saved order once it arrived. That was the "payment
2670 + // option order is not saved" bug: the order persisted fine in the DB but the
2671 + // UI reverted to default on reload. Waiting for `settings` to resolve, tracked
2672 + // by a run-once ref, fixes it without clobbering in-progress drag reordering.
2673 + const gatewayOrderInitialized = React.useRef(false);
3431 2674 React.useEffect(() => {
3432 - if (
3433 - gatewayDefinitions &&
3434 - Object.keys(gatewayDefinitions).length > 0 &&
3435 - gatewayOrder.length === 0
3436 - ) {
3437 - // Use saved order if available, otherwise use default order from definitions
3438 - const savedOrder = settings?.gateway_order as string[] | undefined;
3439 - if (savedOrder) {
3440 - // Merge saved order with any new gateways
3441 - const allGateways = Object.keys(gatewayDefinitions);
3442 - const validSavedOrder = savedOrder.filter((id) =>
3443 - allGateways.includes(id),
3444 - );
3445 - const newGateways = allGateways.filter(
3446 - (id) => !validSavedOrder.includes(id),
3447 - );
3448 - setGatewayOrder([...validSavedOrder, ...newGateways]);
3449 - } else {
3450 - setGatewayOrder(Object.keys(gatewayDefinitions));
3451 - }
2675 + if (gatewayOrderInitialized.current) return;
2676 + if (!gatewayDefinitions || Object.keys(gatewayDefinitions).length === 0) {
2677 + return;
3452 2678 }
3453 - }, [gatewayDefinitions, settings?.gateway_order, gatewayOrder.length]);
2679 + // Wait for the settings query to resolve so the saved order is available.
2680 + if (settings === undefined) return;
3454 2681
2682 + const allGateways = Object.keys(gatewayDefinitions);
2683 + const savedOrder = settings?.gateway_order as string[] | undefined;
2684 + if (Array.isArray(savedOrder) && savedOrder.length > 0) {
2685 + // Keep the saved order, appending any gateways added since it was saved.
2686 + const validSavedOrder = savedOrder.filter((id) =>
2687 + allGateways.includes(id),
2688 + );
2689 + const newGateways = allGateways.filter(
2690 + (id) => !validSavedOrder.includes(id),
2691 + );
2692 + setGatewayOrder([...validSavedOrder, ...newGateways]);
2693 + } else {
2694 + setGatewayOrder(allGateways);
2695 + }
2696 + gatewayOrderInitialized.current = true;
2697 + }, [gatewayDefinitions, settings]);
2698 +
3455 2699 // Single stable onChange handler that reads field name from input's name or id attribute
3456 2700 const handleFieldChange = React.useCallback(
3457 2701 (
3458 2702 e: React.ChangeEvent<
@@ -3464,10 +2708,19 @@
3464 2708
3465 2709 if (e.target.type === "checkbox") {
3466 2710 value = (e.target as HTMLInputElement).checked;
3467 2711 } else if (e.target.type === "number") {
3468 - const numValue = parseFloat(e.target.value);
3469 - value = isNaN(numValue) ? 0 : numValue;
2712 + // Keep an empty field empty while editing so the user can clear the
2713 + // last character and retype. Previously empty coerced to 0, which
2714 + // "stuck" and blocked re-entry (deleting the last digit snapped a value
2715 + // back). The empty string is coerced to a number server-side on save.
2716 + const raw = e.target.value;
2717 + if (raw === "") {
2718 + value = "";
2719 + } else {
2720 + const numValue = parseFloat(raw);
2721 + value = isNaN(numValue) ? 0 : numValue;
2722 + }
3470 2723 } else {
3471 2724 value = e.target.value;
3472 2725 }
3473 2726
@@ -4310,8 +3563,152 @@
4310 3563 spellCheck={false}
4311 3564 autoComplete="off"
4312 3565 />
4313 3566 </FormField>
3567 +
3568 + <FormField
3569 + id="frontend_listing_card_layout"
3570 + label={__("Listing card layout", "yatra")}
3571 + description={__(
3572 + "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.",
3573 + "yatra",
3574 + )}
3575 + >
3576 + <div
3577 + role="radiogroup"
3578 + aria-label={__("Listing card layout", "yatra")}
3579 + className="grid grid-cols-1 sm:grid-cols-3 gap-3 max-w-2xl"
3580 + >
3581 + {(
3582 + [
3583 + {
3584 + value: "standard",
3585 + label: __("Standard", "yatra"),
3586 + sub: __("Full card (default)", "yatra"),
3587 + svg: (
3588 + <svg
3589 + viewBox="0 0 160 100"
3590 + className="w-full h-full"
3591 + fill="none"
3592 + >
3593 + <rect x="46" y="6" width="68" height="88" rx="6" fill="#ffffff" stroke="#cbd5e1" strokeWidth="2" />
3594 + <rect x="52" y="12" width="56" height="30" rx="3" fill="#60a5fa" />
3595 + <rect x="52" y="49" width="44" height="5" rx="2.5" fill="#94a3b8" />
3596 + <rect x="52" y="59" width="34" height="3" rx="1.5" fill="#cbd5e1" />
3597 + <rect x="52" y="66" width="50" height="3" rx="1.5" fill="#e2e8f0" />
3598 + <rect x="52" y="73" width="50" height="3" rx="1.5" fill="#e2e8f0" />
3599 + <rect x="52" y="82" width="18" height="6" rx="2" fill="#475569" />
3600 + <rect x="86" y="81" width="22" height="8" rx="3" fill="#3b82f6" />
3601 + </svg>
3602 + ),
3603 + },
3604 + {
3605 + value: "compact_mobile",
3606 + label: __("Compact — mobile", "yatra"),
3607 + sub: __("Dense on phones · desktop unchanged", "yatra"),
3608 + svg: (
3609 + <svg
3610 + viewBox="0 0 160 100"
3611 + className="w-full h-full"
3612 + fill="none"
3613 + >
3614 + <rect x="50" y="4" width="60" height="92" rx="9" fill="#ffffff" stroke="#cbd5e1" strokeWidth="2" />
3615 + <rect x="72" y="8" width="16" height="3" rx="1.5" fill="#e2e8f0" />
3616 + {[0, 26, 52].map((dy) => (
3617 + <g key={dy} transform={`translate(0,${dy})`}>
3618 + <rect x="56" y="16" width="48" height="22" rx="3" fill="#f1f5f9" />
3619 + <rect x="59" y="19" width="16" height="16" rx="2" fill="#60a5fa" />
3620 + <rect x="79" y="21" width="20" height="3" rx="1.5" fill="#94a3b8" />
3621 + <rect x="79" y="27" width="12" height="2.5" rx="1.25" fill="#cbd5e1" />
3622 + <rect x="79" y="32" width="9" height="3" rx="1.5" fill="#475569" />
3623 + <rect x="93" y="31.5" width="6" height="4" rx="1.5" fill="#3b82f6" />
3624 + </g>
3625 + ))}
3626 + </svg>
3627 + ),
3628 + },
3629 + {
3630 + value: "compact_all",
3631 + label: __("Compact — everywhere", "yatra"),
3632 + sub: __("Dense on mobile & desktop", "yatra"),
3633 + svg: (
3634 + <svg
3635 + viewBox="0 0 160 100"
3636 + className="w-full h-full"
3637 + fill="none"
3638 + >
3639 + <rect x="10" y="12" width="98" height="58" rx="4" fill="#ffffff" stroke="#cbd5e1" strokeWidth="2" />
3640 + <rect x="51" y="70" width="16" height="8" fill="#cbd5e1" />
3641 + <rect x="39" y="78" width="40" height="4" rx="2" fill="#cbd5e1" />
3642 + {[0, 24].map((dy) => (
3643 + <g key={dy} transform={`translate(0,${dy})`}>
3644 + <rect x="16" y="18" width="86" height="20" rx="3" fill="#f1f5f9" />
3645 + <rect x="19" y="21" width="14" height="14" rx="2" fill="#60a5fa" />
3646 + <rect x="37" y="23" width="34" height="3" rx="1.5" fill="#94a3b8" />
3647 + <rect x="37" y="29" width="20" height="2.5" rx="1.25" fill="#cbd5e1" />
3648 + <rect x="86" y="27" width="12" height="6" rx="2" fill="#3b82f6" />
3649 + </g>
3650 + ))}
3651 + <rect x="120" y="26" width="30" height="62" rx="5" fill="#ffffff" stroke="#cbd5e1" strokeWidth="2" />
3652 + {[0, 18, 36].map((dy) => (
3653 + <g key={dy} transform={`translate(0,${dy})`}>
3654 + <rect x="124" y="32" width="22" height="14" rx="2" fill="#f1f5f9" />
3655 + <rect x="126" y="34" width="10" height="10" rx="1.5" fill="#60a5fa" />
3656 + <rect x="138" y="36" width="7" height="2" rx="1" fill="#94a3b8" />
3657 + <rect x="138" y="40" width="5" height="2" rx="1" fill="#cbd5e1" />
3658 + </g>
3659 + ))}
3660 + </svg>
3661 + ),
3662 + },
3663 + ] as const
3664 + ).map((opt) => {
3665 + const selected =
3666 + (formData.frontend_listing_card_layout || "standard") ===
3667 + opt.value;
3668 + return (
3669 + <button
3670 + key={opt.value}
3671 + type="button"
3672 + role="radio"
3673 + aria-checked={selected}
3674 + onClick={() =>
3675 + handleFieldChange({
3676 + target: {
3677 + name: "frontend_listing_card_layout",
3678 + id: "frontend_listing_card_layout",
3679 + value: opt.value,
3680 + type: "text",
3681 + },
3682 + } as React.ChangeEvent<HTMLInputElement>)
3683 + }
3684 + className={`relative rounded-lg border-2 p-2.5 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${
3685 + selected
3686 + ? "border-blue-600 ring-1 ring-blue-600 bg-blue-50/60 dark:bg-blue-900/20"
3687 + : "border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
3688 + }`}
3689 + >
3690 + <div className="aspect-[8/5] w-full overflow-hidden rounded-md bg-gray-50 dark:bg-gray-800/60">
3691 + {opt.svg}
3692 + </div>
3693 + <div className="mt-2 text-sm font-medium text-gray-900 dark:text-white">
3694 + {opt.label}
3695 + </div>
3696 + <div className="text-xs text-gray-500 dark:text-gray-400">
3697 + {opt.sub}
3698 + </div>
3699 + {selected && (
3700 + <span className="absolute top-2 right-2 inline-flex h-5 w-5 items-center justify-center rounded-full bg-blue-600 text-white">
3701 + <svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor">
3702 + <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" />
3703 + </svg>
3704 + </span>
3705 + )}
3706 + </button>
3707 + );
3708 + })}
3709 + </div>
3710 + </FormField>
4314 3711 </div>
4315 3712 </div>
4316 3713 );
4317 3714
@@ -4506,8 +3903,53 @@
4506 3903
4507 3904 {formData.enable_scheduled_payments && (
4508 3905 <>
4509 3906 <FormField
3907 + id="balance_anchor"
3908 + label={__("Balance schedule", "yatra")}
3909 + description={__(
3910 + "Whether the remaining balance is scheduled a fixed number of days after the deposit, or relative to the tour start date.",
3911 + "yatra",
3912 + )}
3913 + >
3914 + <Select
3915 + id="balance_anchor"
3916 + name="balance_anchor"
3917 + value={formData.balance_anchor}
3918 + onChange={handleFieldChange}
3919 + >
3920 + <option value="booking">
3921 + {__("A fixed number of days after the deposit", "yatra")}
3922 + </option>
3923 + <option value="tour">
3924 + {__("A number of days before the tour date", "yatra")}
3925 + </option>
3926 + </Select>
3927 + </FormField>
3928 +
3929 + {formData.balance_anchor === "tour" && (
3930 + <FormField
3931 + id="balance_due_days"
3932 + label={__("Days before tour", "yatra")}
3933 + description={__(
3934 + "The balance is collected this many days before the tour starts. Bookings made inside this window must pay in full up front.",
3935 + "yatra",
3936 + )}
3937 + >
3938 + <Input
3939 + id="balance_due_days"
3940 + type="number"
3941 + min={1}
3942 + max={365}
3943 + value={formData.balance_due_days}
3944 + name="balance_due_days"
3945 + onChange={handleFieldChange}
3946 + className="max-w-xs"
3947 + />
3948 + </FormField>
3949 + )}
3950 +
3951 + <FormField
4510 3952 id="scheduled_payment_type"
4511 3953 label={__("Schedule type", "yatra")}
4512 3954 description={__(
4513 3955 "Single final charge on a date, or multiple installments.",
@@ -4528,27 +3970,29 @@
4528 3970 </option>
4529 3971 </Select>
4530 3972 </FormField>
4531 3973
4532 - <FormField
4533 - id="scheduled_payment_days"
4534 - label={__("Days until first charge", "yatra")}
4535 - description={__(
4536 - "How many days after the initial payment to schedule the first balance collection.",
4537 - "yatra",
4538 - )}
4539 - >
4540 - <Input
3974 + {formData.balance_anchor !== "tour" && (
3975 + <FormField
4541 3976 id="scheduled_payment_days"
4542 - type="number"
4543 - min={0}
4544 - max={365}
4545 - value={formData.scheduled_payment_days}
4546 - name="scheduled_payment_days"
4547 - onChange={handleFieldChange}
4548 - className="max-w-xs"
4549 - />
4550 - </FormField>
3977 + label={__("Days until first charge", "yatra")}
3978 + description={__(
3979 + "How many days after the initial payment to schedule the first balance collection.",
3980 + "yatra",
3981 + )}
3982 + >
3983 + <Input
3984 + id="scheduled_payment_days"
3985 + type="number"
3986 + min={0}
3987 + max={365}
3988 + value={formData.scheduled_payment_days}
3989 + name="scheduled_payment_days"
3990 + onChange={handleFieldChange}
3991 + className="max-w-xs"
3992 + />
3993 + </FormField>
3994 + )}
4551 3995
4552 3996 {formData.scheduled_payment_type === "installments" && (
4553 3997 <>
4554 3998 <FormField
@@ -4981,8 +4425,117 @@
4981 4425 return null;
4982 4426 }
4983 4427 }
4984 4428
4429 + // Read-only webhook endpoint URL for THIS
4430 + // site, with a copy button + setup steps.
4431 + // The operator pastes this into the gateway
4432 + // dashboard; it is display-only (never saved).
4433 + if (field.type === "webhook_url") {
4434 + const webhookUrl = String(
4435 + field.default || config[field.id] || "",
4436 + );
4437 + const stripeSetupUrl =
4438 + formData.payment_test_mode
4439 + ? field.help_url_test ||
4440 + field.help_url_live
4441 + : field.help_url_live ||
4442 + field.help_url_test;
4443 + return (
4444 + <FormField
4445 + key={field.id}
4446 + id={`${gatewayId}_${field.id}`}
4447 + label={field.label}
4448 + >
4449 + <div className="flex gap-2">
4450 + <Input
4451 + readOnly
4452 + value={webhookUrl}
4453 + onFocus={(e) =>
4454 + e.currentTarget.select()
4455 + }
4456 + className="font-mono text-xs"
4457 + />
4458 + <Button
4459 + type="button"
4460 + variant="outline"
4461 + size="sm"
4462 + onClick={(e) => {
4463 + const btn = e.currentTarget;
4464 + const input =
4465 + btn.parentElement?.querySelector(
4466 + "input",
4467 + );
4468 + let copied = false;
4469 + // execCommand path works even on
4470 + // non-HTTPS admin origins where the
4471 + // async Clipboard API is unavailable.
4472 + try {
4473 + if (input) {
4474 + input.focus();
4475 + input.select();
4476 + input.setSelectionRange(
4477 + 0,
4478 + webhookUrl.length,
4479 + );
4480 + copied =
4481 + document.execCommand(
4482 + "copy",
4483 + );
4484 + }
4485 + } catch {
4486 + /* ignore */
4487 + }
4488 + if (
4489 + navigator.clipboard?.writeText
4490 + ) {
4491 + navigator.clipboard
4492 + .writeText(webhookUrl)
4493 + .catch(() => {});
4494 + copied = true;
4495 + }
4496 + const orig = __(
4497 + "Copy",
4498 + "yatra",
4499 + );
4500 + btn.textContent = copied
4501 + ? __("Copied!", "yatra")
4502 + : __(
4503 + "Select & copy",
4504 + "yatra",
4505 + );
4506 + window.setTimeout(() => {
4507 + btn.textContent = orig;
4508 + }, 1500);
4509 + }}
4510 + >
4511 + {__("Copy", "yatra")}
4512 + </Button>
4513 + </div>
4514 + {field.description && (
4515 + <p className="mt-1.5 text-xs text-gray-500 dark:text-gray-400 leading-relaxed">
4516 + {field.description}
4517 + </p>
4518 + )}
4519 + {stripeSetupUrl && (
4520 + <a
4521 + href={stripeSetupUrl}
4522 + target="_blank"
4523 + rel="noopener noreferrer"
4524 + 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"
4525 + >
4526 + {field.help_text ||
4527 + __(
4528 + "Open Stripe → Webhooks",
4529 + "yatra",
4530 + )}{" "}
4531 + →
4532 + </a>
4533 + )}
4534 + </FormField>
4535 + );
4536 + }
4537 +
4985 4538 // Render checkbox fields differently
4986 4539 if (field.type === "checkbox") {
4987 4540 return (
4988 4541 <div
@@ -5270,9 +4823,12 @@
5270 4823 const roValue = String(
5271 4824 config[field.id] ?? field.default ?? "",
5272 4825 );
5273 4826 return (
5274 - <div key={field.id} className="space-y-2">
4827 + <div
4828 + key={field.id}
4829 + className="space-y-2"
4830 + >
5275 4831 <FormField
5276 4832 id={`${gatewayId}_${field.id}`}
5277 4833 label={field.label}
5278 4834 description={field.description}
@@ -5328,12 +4884,21 @@
5328 4884 onChange={(e) =>
5329 4885 handleGatewayConfigChange(
5330 4886 gatewayId,
5331 4887 field.id,
4888 + // Keep an empty field empty while
4889 + // editing so the last character can
4890 + // be cleared and retyped. Coercing
4891 + // "" to 0 (parseFloat("") || 0) made
4892 + // the field snap back and blocked
4893 + // re-entry. Numbers are coerced on
4894 + // save; "" persists harmlessly.
5332 4895 field.type === "number"
5333 - ? parseFloat(
5334 - e.target.value,
5335 - ) || 0
4896 + ? e.target.value === ""
4897 + ? ""
4898 + : parseFloat(
4899 + e.target.value,
4900 + ) || 0
5336 4901 : e.target.value,
5337 4902 )
5338 4903 }
5339 4904 placeholder={field.placeholder}
@@ -5451,8 +5016,16 @@
5451 5016 "Dropdown to filter trips by price range.",
5452 5017 "yatra",
5453 5018 ),
5454 5019 },
5020 + {
5021 + key: "search_show_date" as const,
5022 + label: __("Date", "yatra"),
5023 + description: __(
5024 + "Date picker to find trips with a departure on a specific date.",
5025 + "yatra",
5026 + ),
5027 + },
5455 5028 ].map((row) => (
5456 5029 <div
5457 5030 key={row.key}
5458 5031 className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"
@@ -5548,31 +5121,83 @@
5548 5121 </p>
5549 5122 </div>
5550 5123 </div>
5551 5124
5552 - <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
5553 - <input
5554 - type="checkbox"
5555 - id="auto_confirm_bookings"
5556 - checked={formData.auto_confirm_bookings}
5557 - name="auto_confirm_bookings"
5558 - onChange={handleFieldChange}
5559 - className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
5560 - />
5561 - <div className="flex-1">
5562 - <Label
5563 - htmlFor="auto_confirm_bookings"
5564 - className="font-medium cursor-pointer"
5565 - >
5566 - {__("Auto-Confirm Bookings", "yatra")}
5567 - </Label>
5568 - <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
5569 - {__(
5570 - "Automatically confirm bookings without manual approval",
5571 - "yatra",
5572 - )}
5573 - </p>
5125 + <div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
5126 + <div className="font-medium">
5127 + {__("Auto-Confirm Bookings", "yatra")}
5574 5128 </div>
5129 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 mb-2">
5130 + {__(
5131 + "Choose when a booking is automatically set to Confirmed. Offline methods (bank transfer, pay later) stay pending until you confirm them.",
5132 + "yatra",
5133 + )}
5134 + </p>
5135 + <div role="radiogroup" className="flex flex-col gap-2">
5136 + {[
5137 + {
5138 + value: "none",
5139 + label: __("Don't auto-confirm", "yatra"),
5140 + sub: __(
5141 + "Every booking stays Pending until you confirm it manually.",
5142 + "yatra",
5143 + ),
5144 + },
5145 + {
5146 + value: "online",
5147 + label: __("Auto-confirm online payments only", "yatra"),
5148 + sub: __(
5149 + "When an online payment (Stripe, PayPal, Razorpay…) pays the balance in full, the booking is confirmed. Deposits/partial payments and offline methods stay Pending.",
5150 + "yatra",
5151 + ),
5152 + },
5153 + {
5154 + value: "all",
5155 + label: __("Auto-confirm all bookings", "yatra"),
5156 + sub: __(
5157 + "Every booking is confirmed at checkout, whether or not it was paid.",
5158 + "yatra",
5159 + ),
5160 + },
5161 + ].map((opt) => (
5162 + <label
5163 + key={opt.value}
5164 + htmlFor={`auto_confirm_mode_${opt.value}`}
5165 + className="flex items-start gap-2 cursor-pointer"
5166 + >
5167 + <input
5168 + type="radio"
5169 + id={`auto_confirm_mode_${opt.value}`}
5170 + name="auto_confirm_mode"
5171 + value={opt.value}
5172 + checked={
5173 + (formData.auto_confirm_mode || "none") === opt.value
5174 + }
5175 + onChange={() =>
5176 + setFormData((prev) =>
5177 + prev
5178 + ? {
5179 + ...prev,
5180 + auto_confirm_mode:
5181 + opt.value as SettingsData["auto_confirm_mode"],
5182 + // Keep the legacy boolean in sync for any
5183 + // external reader (all → true, otherwise false).
5184 + auto_confirm_bookings: opt.value === "all",
5185 + }
5186 + : prev,
5187 + )
5188 + }
5189 + className="mt-0.5 w-4 h-4 border-gray-300 text-blue-600 focus:ring-blue-500"
5190 + />
5191 + <div className="flex-1">
5192 + <span className="font-medium text-sm">{opt.label}</span>
5193 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
5194 + {opt.sub}
5195 + </p>
5196 + </div>
5197 + </label>
5198 + ))}
5199 + </div>
5575 5200 </div>
5576 5201
5577 5202 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
5578 5203 <input
@@ -5663,8 +5288,54 @@
5663 5288
5664 5289 <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
5665 5290 <input
5666 5291 type="checkbox"
5292 + id="show_sold_out"
5293 + checked={formData.show_sold_out !== false}
5294 + name="show_sold_out"
5295 + onChange={handleFieldChange}
5296 + className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
5297 + />
5298 + <div className="flex-1">
5299 + <Label
5300 + htmlFor="show_sold_out"
5301 + className="font-medium cursor-pointer"
5302 + >
5303 + {__("Show sold-out dates", "yatra")}
5304 + </Label>
5305 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
5306 + {__(
5307 + "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.",
5308 + "yatra",
5309 + )}
5310 + </p>
5311 + </div>
5312 + </div>
5313 +
5314 + <div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
5315 + <FormField
5316 + id="availability_horizon_months"
5317 + label={__("Booking horizon (months)", "yatra")}
5318 + description={__(
5319 + "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.",
5320 + "yatra",
5321 + )}
5322 + >
5323 + <Input
5324 + id="availability_horizon_months"
5325 + type="number"
5326 + value={formData.availability_horizon_months ?? 12}
5327 + name="availability_horizon_months"
5328 + onChange={handleFieldChange}
5329 + min="1"
5330 + max="36"
5331 + />
5332 + </FormField>
5333 + </div>
5334 +
5335 + <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
5336 + <input
5337 + type="checkbox"
5667 5338 id="allow_waitlist"
5668 5339 checked={formData.allow_waitlist}
5669 5340 name="allow_waitlist"
5670 5341 onChange={handleFieldChange}
@@ -8085,8 +7756,93 @@
8085 7756 onChange={handleFieldChange}
8086 7757 placeholder={__("Enter secret key", "yatra")}
8087 7758 />
8088 7759 </FormField>
7760 +
7761 + <p className="text-xs text-gray-500 dark:text-gray-400">
7762 + {__(
7763 + "Uses reCAPTCHA v3 (invisible, score-based). Create v3 keys at google.com/recaptcha and paste them above.",
7764 + "yatra",
7765 + )}
7766 + </p>
7767 +
7768 + <FormField
7769 + id="recaptcha_score_threshold"
7770 + label={__("Score Threshold", "yatra")}
7771 + description={__(
7772 + "Minimum v3 score (0.0–1.0) to accept a submission. Lower is more lenient, higher is stricter. Default 0.5.",
7773 + "yatra",
7774 + )}
7775 + >
7776 + <Input
7777 + id="recaptcha_score_threshold"
7778 + type="number"
7779 + min="0"
7780 + max="1"
7781 + step="0.1"
7782 + value={String(formData.recaptcha_score_threshold ?? 0.5)}
7783 + name="recaptcha_score_threshold"
7784 + onChange={(e) =>
7785 + setFormData((prev) =>
7786 + prev
7787 + ? {
7788 + ...prev,
7789 + recaptcha_score_threshold: parseFloat(
7790 + e.target.value,
7791 + ),
7792 + }
7793 + : prev,
7794 + )
7795 + }
7796 + />
7797 + </FormField>
7798 +
7799 + <div className="pt-2">
7800 + <Label className="font-medium">
7801 + {__("Protect these forms", "yatra")}
7802 + </Label>
7803 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 mb-2">
7804 + {__(
7805 + "Choose which forms reCAPTCHA should verify. Booking protection is off by default to avoid blocking a legitimate payment on a low score.",
7806 + "yatra",
7807 + )}
7808 + </p>
7809 + <div className="space-y-2">
7810 + {[
7811 + {
7812 + key: "recaptcha_protect_enquiry",
7813 + label: __("Enquiry form", "yatra"),
7814 + },
7815 + {
7816 + key: "recaptcha_protect_booking",
7817 + label: __("Booking / checkout form", "yatra"),
7818 + },
7819 + {
7820 + key: "recaptcha_protect_registration",
7821 + label: __("Customer registration", "yatra"),
7822 + },
7823 + ].map((f) => (
7824 + <label
7825 + key={f.key}
7826 + className="flex items-center gap-2 cursor-pointer"
7827 + >
7828 + <input
7829 + type="checkbox"
7830 + id={f.key}
7831 + name={f.key}
7832 + checked={Boolean(
7833 + (formData as unknown as Record<string, unknown>)[
7834 + f.key
7835 + ],
7836 + )}
7837 + onChange={handleFieldChange}
7838 + className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
7839 + />
7840 + <span className="text-sm">{f.label}</span>
7841 + </label>
7842 + ))}
7843 + </div>
7844 + </div>
8089 7845 </>
8090 7846 )}
8091 7847 </div>
8092 7848 </div>
@@ -8778,8 +8534,172 @@
8778 8534 </div>
8779 8535 )}
8780 8536 </div>
8781 8537 </FormField>
8538 + </div>
8539 + </div>
8540 +
8541 + <div>
8542 + <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">
8543 + {__("Sitemap", "yatra")}
8544 + </h3>
8545 + <p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
8546 + {__(
8547 + "Yatra publishes one sitemap of all trips, destinations, activities and categories so search engines can find them. It is advertised in robots.txt and linked into Yoast, Rank Math, AIOSEO and WordPress core sitemaps automatically.",
8548 + "yatra",
8549 + )}
8550 + </p>
8551 + <div className="space-y-4">
8552 + <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
8553 + <input
8554 + type="checkbox"
8555 + id="enable_sitemap"
8556 + checked={formData.enable_sitemap}
8557 + name="enable_sitemap"
8558 + onChange={handleFieldChange}
8559 + className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
8560 + />
8561 + <div className="flex-1">
8562 + <Label
8563 + htmlFor="enable_sitemap"
8564 + className="font-medium cursor-pointer"
8565 + >
8566 + {__("Enable Sitemap", "yatra")}
8567 + </Label>
8568 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
8569 + {__(
8570 + "Generate /yatra-sitemap.xml and advertise it to search engines.",
8571 + "yatra",
8572 + )}
8573 + </p>
8574 + </div>
8575 + </div>
8576 + {formData.enable_sitemap && (
8577 + <div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
8578 + <Label className="font-medium">
8579 + {__("Sitemap URL", "yatra")}
8580 + </Label>
8581 + <div className="mt-2 flex items-center gap-2">
8582 + <a
8583 + href={
8584 + (window as { yatraAdmin?: { sitemapUrl?: string } })
8585 + .yatraAdmin?.sitemapUrl || "#"
8586 + }
8587 + target="_blank"
8588 + rel="noopener noreferrer"
8589 + className="flex-1 break-all text-sm text-blue-600 dark:text-blue-400 underline"
8590 + >
8591 + {(window as { yatraAdmin?: { sitemapUrl?: string } })
8592 + .yatraAdmin?.sitemapUrl || ""}
8593 + </a>
8594 + <button
8595 + type="button"
8596 + onClick={() =>
8597 + navigator.clipboard?.writeText(
8598 + (window as { yatraAdmin?: { sitemapUrl?: string } })
8599 + .yatraAdmin?.sitemapUrl || "",
8600 + )
8601 + }
8602 + className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
8603 + >
8604 + {__("Copy", "yatra")}
8605 + </button>
8606 + </div>
8607 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
8608 + {__(
8609 + "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.",
8610 + "yatra",
8611 + )}
8612 +
8613 + {formData.enable_sitemap && (
8614 + <div className="p-3 border border-gray-200 dark:border-gray-700 rounded-md space-y-3">
8615 + <div>
8616 + <Label className="font-medium">
8617 + {__("Content in the sitemap", "yatra")}
8618 + </Label>
8619 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
8620 + {__(
8621 + "Uncheck a type to keep it out of /yatra-sitemap.xml.",
8622 + "yatra",
8623 + )}
8624 + </p>
8625 + </div>
8626 + <div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
8627 + {[
8628 + { key: "trip", label: __("Trips", "yatra") },
8629 + { key: "destination", label: __("Destinations", "yatra") },
8630 + { key: "activity", label: __("Activities", "yatra") },
8631 + { key: "category", label: __("Trip categories", "yatra") },
8632 + { key: "archive", label: __("Listing pages", "yatra") },
8633 + ].map((t) => {
8634 + const selected = formData.sitemap_types ?? [];
8635 + const checked = selected.includes(t.key);
8636 + return (
8637 + <label
8638 + key={t.key}
8639 + className="flex items-center gap-2 text-sm cursor-pointer"
8640 + >
8641 + <input
8642 + type="checkbox"
8643 + checked={checked}
8644 + onChange={() =>
8645 + setFormData((prev) => {
8646 + if (!prev) return prev;
8647 + return {
8648 + ...prev,
8649 + sitemap_types: checked
8650 + ? (prev.sitemap_types ?? []).filter(
8651 + (x: string) => x !== t.key,
8652 + )
8653 + : [...(prev.sitemap_types ?? []), t.key],
8654 + };
8655 + })
8656 + }
8657 + className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
8658 + />
8659 + <span>{t.label}</span>
8660 + </label>
8661 + );
8662 + })}
8663 + </div>
8664 +
8665 + <div className="flex items-start gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
8666 + <input
8667 + type="checkbox"
8668 + id="sitemap_noindex_excluded"
8669 + name="sitemap_noindex_excluded"
8670 + checked={formData.sitemap_noindex_excluded}
8671 + onChange={(e) =>
8672 + setFormData((prev) => {
8673 + if (!prev) return prev;
8674 + return {
8675 + ...prev,
8676 + sitemap_noindex_excluded: e.target.checked,
8677 + };
8678 + })
8679 + }
8680 + className="mt-0.5 w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
8681 + />
8682 + <div className="flex-1">
8683 + <Label
8684 + htmlFor="sitemap_noindex_excluded"
8685 + className="font-medium cursor-pointer"
8686 + >
8687 + {__("Also add noindex to excluded types", "yatra")}
8688 + </Label>
8689 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
8690 + {__(
8691 + "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.",
8692 + "yatra",
8693 + )}
8694 + </p>
8695 + </div>
8696 + </div>
8697 + </div>
8698 + )}
8699 + </p>
8700 + </div>
8701 + )}
8782 8702 </div>
8783 8703 </div>
8784 8704 </div>
8785 8705 );