| @@ -14,8 +14,9 @@ | ||
| 14 | 14 | Calendar, |
| 15 | 15 | DollarSign, |
| 16 | 16 | Mail, |
| 17 | 17 | MapPin, |
| 18 | + SlidersHorizontal, | |
| 18 | 19 | Users, |
| 19 | 20 | Star, |
| 20 | 21 | Receipt, |
| 21 | 22 | Globe, |
| @@ -46,9 +47,9 @@ | ||
| 46 | 47 | BarChart3, |
| 47 | 48 | Palette, |
| 48 | 49 | Percent, |
| 49 | 50 | } from "lucide-react"; |
| 50 | -import { __ } from "../lib/i18n"; | |
| 51 | +import { __, sprintf } from "../lib/i18n"; | |
| 51 | 52 | import { prepareWordPressMediaFrameOpen } from "../lib/wp-media-open"; |
| 52 | 53 | import { usePermissions } from "../hooks/usePermissions"; |
| 53 | 54 | import { useToast } from "../components/ui/toast"; |
| 54 | 55 | import { navigateMenu } from "../hooks/useNavigate"; |
| @@ -91,8 +92,20 @@ | ||
| 91 | 92 | } from "../lib/frontend-permalink-urls"; |
| 92 | 93 | import { getCurrencyOptions } from "../data/currencies"; |
| 93 | 94 | import { SearchableSelect } from "../components/ui/searchable-select"; |
| 94 | 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"; | |
| 95 | 108 | |
| 96 | 109 | // Multiple Taxes Editor Component |
| 97 | 110 | const MultipleTaxesEditor = React.memo( |
| 98 | 111 | ({ |
| @@ -226,8 +239,11 @@ | ||
| 226 | 239 | }> = ({ formData, setFormData }) => { |
| 227 | 240 | const yatraAdmin = (window as any).yatraAdmin || {}; |
| 228 | 241 | const gcSettings = yatraAdmin.googleCalendar || {}; |
| 229 | 242 | const siteUrl = yatraAdmin.siteUrl || ""; |
| 243 | + // Full URL to the dedicated Google Calendar dashboard subpage so the user can | |
| 244 | + // reach it from the UI (not just via the OAuth redirect / a direct link). | |
| 245 | + const gcDashboardUrl = `${yatraAdmin.adminUrl || "admin.php"}?page=yatra&subpage=google-calendar`; | |
| 230 | 246 | |
| 231 | 247 | // Use state from parent formData if available, otherwise use from yatraAdmin |
| 232 | 248 | const [clientId, setClientId] = useState( |
| 233 | 249 | formData.google_calendar_client_id || gcSettings.client_id || "", |
| @@ -245,8 +261,10 @@ | ||
| 245 | 261 | formData.google_calendar_connected || gcSettings.connected || false, |
| 246 | 262 | ); |
| 247 | 263 | const [connecting, setConnecting] = useState(false); |
| 248 | 264 | const [syncing, setSyncing] = useState(false); |
| 265 | + const [savingCalendar, setSavingCalendar] = useState(false); | |
| 266 | + const { showToast } = useToast(); | |
| 249 | 267 | |
| 250 | 268 | const redirectUri = |
| 251 | 269 | yatraAdmin.googleCalendarRedirectUri || |
| 252 | 270 | gcSettings.redirect_uri || |
| @@ -260,8 +278,13 @@ | ||
| 260 | 278 | const allSettings = { |
| 261 | 279 | google_calendar_client_id: clientId, |
| 262 | 280 | google_calendar_client_secret: clientSecret, |
| 263 | 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, | |
| 264 | 287 | }; |
| 265 | 288 | |
| 266 | 289 | // Update formData with all settings |
| 267 | 290 | setFormData((prev) => { |
| @@ -275,10 +298,10 @@ | ||
| 275 | 298 | |
| 276 | 299 | // Update formData when any setting changes |
| 277 | 300 | useEffect(() => { |
| 278 | 301 | syncSettingsToFormData(); |
| 279 | - // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 280 | - }, [clientId, clientSecret]); | |
| 302 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 303 | + }, [clientId, clientSecret, calendarId, calendarName]); | |
| 281 | 304 | |
| 282 | 305 | const handleConnect = async () => { |
| 283 | 306 | setConnecting(true); |
| 284 | 307 | try { |
| @@ -310,10 +333,14 @@ | ||
| 310 | 333 | // Update settings state |
| 311 | 334 | setConnected(false); |
| 312 | 335 | setCalendarId(""); |
| 313 | 336 | setCalendarName(""); |
| 337 | + showToast(__("Disconnected from Google Calendar.", "yatra"), "success"); | |
| 314 | 338 | } catch (error) { |
| 315 | - console.error("Failed to disconnect:", error); | |
| 339 | + showToast( | |
| 340 | + __("Failed to disconnect. Please try again.", "yatra"), | |
| 341 | + "error", | |
| 342 | + ); | |
| 316 | 343 | } |
| 317 | 344 | }; |
| 318 | 345 | |
| 319 | 346 | const handleSync = async () => { |
| @@ -318,18 +345,52 @@ | ||
| 318 | 345 | |
| 319 | 346 | const handleSync = async () => { |
| 320 | 347 | setSyncing(true); |
| 321 | 348 | try { |
| 322 | - await apiClient.post("/google-calendar/sync-all"); | |
| 323 | - // No need to update lastSync state directly as it's derived from formData | |
| 324 | - // The API response will be handled by the backend and reflected in the next load | |
| 349 | + const res: any = await apiClient.post("/google-calendar/sync-all"); | |
| 350 | + if (res && res.success === false) { | |
| 351 | + showToast( | |
| 352 | + res.message || __("Failed to sync bookings.", "yatra"), | |
| 353 | + "error", | |
| 354 | + ); | |
| 355 | + } else { | |
| 356 | + showToast( | |
| 357 | + (res && res.message) || | |
| 358 | + __("Bookings synced to Google Calendar.", "yatra"), | |
| 359 | + "success", | |
| 360 | + ); | |
| 361 | + } | |
| 325 | 362 | } catch (error) { |
| 326 | - console.error("Failed to sync:", error); | |
| 363 | + showToast( | |
| 364 | + __("Failed to sync bookings. Please try again.", "yatra"), | |
| 365 | + "error", | |
| 366 | + ); | |
| 327 | 367 | } finally { |
| 328 | 368 | setSyncing(false); |
| 329 | 369 | } |
| 330 | 370 | }; |
| 331 | 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 | + | |
| 332 | 393 | return ( |
| 333 | 394 | <div className="space-y-6"> |
| 334 | 395 | {/* Settings Card */} |
| 335 | 396 | <Card> |
| @@ -507,9 +568,9 @@ | ||
| 507 | 568 | <h4 className="font-medium text-sm text-green-800 dark:text-green-400 flex items-center gap-2"> |
| 508 | 569 | <CheckCircle className="w-4 h-4" /> |
| 509 | 570 | {__("Connected Calendar", "yatra")} |
| 510 | 571 | </h4> |
| 511 | - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| 572 | + <div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:items-end"> | |
| 512 | 573 | <FormField |
| 513 | 574 | id="gc_calendar_id" |
| 514 | 575 | label={__("Calendar ID", "yatra")} |
| 515 | 576 | description={__( |
| @@ -551,20 +612,42 @@ | ||
| 551 | 612 | ) : ( |
| 552 | 613 | __("Never synced", "yatra") |
| 553 | 614 | )} |
| 554 | 615 | </p> |
| 555 | - <Button | |
| 556 | - variant="outline" | |
| 557 | - size="sm" | |
| 558 | - onClick={handleSync} | |
| 559 | - disabled={syncing} | |
| 560 | - className="flex items-center gap-2" | |
| 561 | - > | |
| 562 | - <RefreshCw | |
| 563 | - className={`w-4 h-4 ${syncing ? "animate-spin" : ""}`} | |
| 564 | - /> | |
| 565 | - {__("Sync Now", "yatra")} | |
| 566 | - </Button> | |
| 616 | + <div className="flex items-center gap-2"> | |
| 617 | + <a | |
| 618 | + href={gcDashboardUrl} | |
| 619 | + className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-green-300 dark:border-green-700 text-green-800 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/40" | |
| 620 | + > | |
| 621 | + <Calendar className="w-4 h-4" /> | |
| 622 | + {__("Open dashboard", "yatra")} | |
| 623 | + </a> | |
| 624 | + <Button | |
| 625 | + size="sm" | |
| 626 | + onClick={handleSaveCalendar} | |
| 627 | + disabled={savingCalendar} | |
| 628 | + className="flex items-center gap-2" | |
| 629 | + > | |
| 630 | + {savingCalendar ? ( | |
| 631 | + <RefreshCw className="w-4 h-4 animate-spin" /> | |
| 632 | + ) : ( | |
| 633 | + <CheckCircle className="w-4 h-4" /> | |
| 634 | + )} | |
| 635 | + {__("Save Calendar", "yatra")} | |
| 636 | + </Button> | |
| 637 | + <Button | |
| 638 | + variant="outline" | |
| 639 | + size="sm" | |
| 640 | + onClick={handleSync} | |
| 641 | + disabled={syncing} | |
| 642 | + className="flex items-center gap-2" | |
| 643 | + > | |
| 644 | + <RefreshCw | |
| 645 | + className={`w-4 h-4 ${syncing ? "animate-spin" : ""}`} | |
| 646 | + /> | |
| 647 | + {__("Sync Now", "yatra")} | |
| 648 | + </Button> | |
| 649 | + </div> | |
| 567 | 650 | </div> |
| 568 | 651 | </div> |
| 569 | 652 | )} |
| 570 | 653 | |
| @@ -570,8 +653,15 @@ | ||
| 570 | 653 | |
| 571 | 654 | <div className="flex flex-wrap justify-between items-center gap-3 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700"> |
| 572 | 655 | <div className="flex flex-wrap gap-3"> |
| 573 | 656 | <a |
| 657 | + href={gcDashboardUrl} | |
| 658 | + className="flex items-center gap-1 text-blue-600 hover:text-blue-500 dark:text-blue-400 text-sm font-medium" | |
| 659 | + > | |
| 660 | + <Calendar className="w-3.5 h-3.5" /> | |
| 661 | + {__("Open Google Calendar dashboard", "yatra")} | |
| 662 | + </a> | |
| 663 | + <a | |
| 574 | 664 | href="https://console.cloud.google.com/apis/credentials" |
| 575 | 665 | target="_blank" |
| 576 | 666 | rel="noopener noreferrer" |
| 577 | 667 | className="flex items-center gap-1 text-blue-600 hover:text-blue-500 dark:text-blue-400 text-sm" |
| @@ -636,8 +726,9 @@ | ||
| 636 | 726 | | "booking_form" |
| 637 | 727 | | "payment" |
| 638 | 728 | | "pricing" |
| 639 | 729 | | "trip" |
| 730 | + | "search" | |
| 640 | 731 | | "customer" |
| 641 | 732 | | "review" |
| 642 | 733 | | "tax" |
| 643 | 734 | | "currency" |
| @@ -643,47 +734,14 @@ | ||
| 643 | 734 | | "currency" |
| 644 | 735 | | "integration" |
| 645 | 736 | | "permalink" |
| 646 | 737 | | "seo" |
| 647 | - | "advanced"; | |
| 738 | + | "advanced" | |
| 739 | + | "uninstall"; | |
| 648 | 740 | |
| 649 | 741 | // Form Builder Types |
| 650 | -interface FormFieldConfig { | |
| 651 | - id: string; | |
| 652 | - type: | |
| 653 | - | "text" | |
| 654 | - | "email" | |
| 655 | - | "tel" | |
| 656 | - | "date" | |
| 657 | - | "select" | |
| 658 | - | "country" | |
| 659 | - | "textarea" | |
| 660 | - | "checkbox" | |
| 661 | - | "number"; | |
| 662 | - label: string; | |
| 663 | - placeholder: string; | |
| 664 | - required: boolean; | |
| 665 | - enabled: boolean; | |
| 666 | - order: number; | |
| 667 | - width: "full" | "half" | "third"; | |
| 668 | - section?: string; | |
| 669 | - options?: { value: string; label: string }[]; | |
| 670 | - locked?: boolean; // If true, field cannot be deleted and required cannot be changed | |
| 671 | -} | |
| 672 | - | |
| 673 | -interface FormSectionConfig { | |
| 674 | - title: string; | |
| 675 | - description: string; | |
| 676 | - enabled?: boolean; | |
| 677 | - fields: FormFieldConfig[]; | |
| 678 | -} | |
| 679 | - | |
| 680 | -interface BookingFormConfig { | |
| 681 | - contact_form: FormSectionConfig; | |
| 682 | - emergency_contact_form: FormSectionConfig; | |
| 683 | - traveler_form: FormSectionConfig; | |
| 684 | -} | |
| 685 | - | |
| 742 | +// Booking form builder types live in components/settings/booking-form-types.ts | |
| 743 | +// (shared with the per-trip Conditions popup). | |
| 686 | 744 | interface PaymentGatewayConfig { |
| 687 | 745 | enabled: boolean; |
| 688 | 746 | icon?: string; |
| 689 | 747 | title?: string; |
| @@ -743,8 +801,10 @@ | ||
| 743 | 801 | /** Hex brand color for public trip/booking/listing UI (buttons, links, accents) */ |
| 744 | 802 | frontend_primary_color: string; |
| 745 | 803 | /** CSS max width for Yatra containers; empty = follow theme / theme.json */ |
| 746 | 804 | frontend_container_max_width: string; |
| 805 | + /** Trip listing card density: "standard" | "compact_mobile" | "compact_all" */ | |
| 806 | + frontend_listing_card_layout: string; | |
| 747 | 807 | |
| 748 | 808 | // Google Calendar Settings |
| 749 | 809 | google_calendar_client_id?: string; |
| 750 | 810 | google_calendar_client_secret?: string; |
| @@ -753,11 +813,21 @@ | ||
| 753 | 813 | google_calendar_connected?: boolean; |
| 754 | 814 | google_calendar_last_sync?: string | null; |
| 755 | 815 | google_calendar_enabled?: boolean; |
| 756 | 816 | |
| 817 | + // Search & Listing storefront UX | |
| 818 | + search_show_keyword: boolean; | |
| 819 | + search_show_destination: boolean; | |
| 820 | + search_show_activities: boolean; | |
| 821 | + search_show_duration: boolean; | |
| 822 | + search_show_budget: boolean; | |
| 823 | + search_show_date: boolean; | |
| 824 | + collapse_filters_on_mobile: boolean; | |
| 825 | + | |
| 757 | 826 | // Booking Settings |
| 758 | 827 | booking_confirmation: boolean; |
| 759 | 828 | auto_confirm_bookings: boolean; |
| 829 | + auto_confirm_mode: "none" | "online" | "all"; | |
| 760 | 830 | require_login: boolean; |
| 761 | 831 | allow_guest_checkout: boolean; |
| 762 | 832 | require_guest_email_verification: boolean; |
| 763 | 833 | booking_expiry_hours: number; |
| @@ -781,8 +851,10 @@ | ||
| 781 | 851 | scheduled_payment_days: number; |
| 782 | 852 | scheduled_payment_installments: number; |
| 783 | 853 | scheduled_payment_interval: number; |
| 784 | 854 | scheduled_payment_reminder_days: number; |
| 855 | + balance_anchor: string; | |
| 856 | + balance_due_days: number; | |
| 785 | 857 | gateway_configs: Record<string, PaymentGatewayConfig>; |
| 786 | 858 | gateway_order?: string[]; |
| 787 | 859 | |
| 788 | 860 | // Pricing Settings |
| @@ -808,8 +880,9 @@ | ||
| 808 | 880 | email_template_admin_payment: boolean; |
| 809 | 881 | email_template_admin_cancellation: boolean; |
| 810 | 882 | email_template_trip_consent: boolean; |
| 811 | 883 | email_template_customer_verification: boolean; |
| 884 | + email_template_guest_verification: boolean; | |
| 812 | 885 | email_template_booking_completed: boolean; |
| 813 | 886 | email_template_booking_expired_customer: boolean; |
| 814 | 887 | email_template_admin_booking_expired: boolean; |
| 815 | 888 | email_template_scheduled_payment_reminder: boolean; |
| @@ -847,8 +920,10 @@ | ||
| 847 | 920 | email_tpl_trip_consent_subject: string; |
| 848 | 921 | email_tpl_trip_consent_body: string; |
| 849 | 922 | email_tpl_customer_verification_subject: string; |
| 850 | 923 | email_tpl_customer_verification_body: string; |
| 924 | + email_tpl_guest_verification_subject: string; | |
| 925 | + email_tpl_guest_verification_body: string; | |
| 851 | 926 | email_tpl_booking_completed_subject: string; |
| 852 | 927 | email_tpl_booking_completed_body: string; |
| 853 | 928 | email_tpl_booking_expired_customer_subject: string; |
| 854 | 929 | email_tpl_booking_expired_customer_body: string; |
| @@ -885,8 +960,12 @@ | ||
| 885 | 960 | allow_customer_reviews: boolean; |
| 886 | 961 | customer_dashboard_enabled: boolean; |
| 887 | 962 | /** Wishlist / saved trips (Yatra Pro); toggled in Customer settings */ |
| 888 | 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; | |
| 889 | 968 | |
| 890 | 969 | // Review Settings |
| 891 | 970 | enable_reviews: boolean; |
| 892 | 971 | require_booking: boolean; |
| @@ -894,8 +973,9 @@ | ||
| 894 | 973 | review_moderation: boolean; |
| 895 | 974 | min_rating: number; |
| 896 | 975 | allow_anonymous_reviews: boolean; |
| 897 | 976 | review_reminder_days: number; |
| 977 | + review_reminder_max_age_days: number; | |
| 898 | 978 | |
| 899 | 979 | // Tax Settings |
| 900 | 980 | enable_tax: boolean; |
| 901 | 981 | tax_name: string; |
| @@ -929,8 +1009,12 @@ | ||
| 929 | 1009 | facebook_pixel: string; |
| 930 | 1010 | recaptcha_enabled: boolean; |
| 931 | 1011 | recaptcha_site_key: string; |
| 932 | 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; | |
| 933 | 1017 | |
| 934 | 1018 | // Mailchimp Integration (Pro) |
| 935 | 1019 | mailchimp_api_key?: string; |
| 936 | 1020 | mailchimp_list_id?: string; |
| @@ -991,8 +1075,9 @@ | ||
| 991 | 1075 | |
| 992 | 1076 | // Advanced Settings |
| 993 | 1077 | debug_mode: boolean; |
| 994 | 1078 | enable_logging: boolean; |
| 1079 | + delete_data_on_uninstall: boolean; | |
| 995 | 1080 | cache_enabled: boolean; |
| 996 | 1081 | api_key: string; |
| 997 | 1082 | api_rate_limit: number; |
| 998 | 1083 | session_timeout: number; |
| @@ -1004,8 +1089,11 @@ | ||
| 1004 | 1089 | seo_trip_meta_title: string; |
| 1005 | 1090 | seo_trip_meta_description: string; |
| 1006 | 1091 | seo_trip_meta_keywords: string; |
| 1007 | 1092 | seo_trip_meta_image: number; |
| 1093 | + enable_sitemap: boolean; | |
| 1094 | + sitemap_types: string[]; | |
| 1095 | + sitemap_noindex_excluded: boolean; | |
| 1008 | 1096 | } |
| 1009 | 1097 | |
| 1010 | 1098 | // Form Builder Component |
| 1011 | 1099 | type BookingFormSubTab = |
| @@ -1040,11 +1128,17 @@ | ||
| 1040 | 1128 | }) => { |
| 1041 | 1129 | const [activeFormTab, setActiveFormTab] = |
| 1042 | 1130 | useState<BookingFormSubTab>(getInitialFormSubTab); |
| 1043 | 1131 | |
| 1044 | - // 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. | |
| 1045 | 1139 | const { data: modulesData } = useQuery({ |
| 1046 | - queryKey: ["modules"], | |
| 1140 | + queryKey: ["modules", "raw-envelope"], | |
| 1047 | 1141 | queryFn: async () => { |
| 1048 | 1142 | const response = await apiService.getModules(); |
| 1049 | 1143 | return response; |
| 1050 | 1144 | }, |
| @@ -1066,8 +1160,27 @@ | ||
| 1066 | 1160 | // Discount Stacking tab visibility earlier this session. |
| 1067 | 1161 | return !!window.yatraAdmin?.dynamicFormFieldEnabled; |
| 1068 | 1162 | }, [modulesData]); |
| 1069 | 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 | + | |
| 1070 | 1183 | // Save sub-tab to localStorage when it changes |
| 1071 | 1184 | const handleSubTabChange = (tab: BookingFormSubTab) => { |
| 1072 | 1185 | setActiveFormTab(tab); |
| 1073 | 1186 | if (typeof window !== "undefined") { |
| @@ -1073,66 +1186,15 @@ | ||
| 1073 | 1186 | if (typeof window !== "undefined") { |
| 1074 | 1187 | localStorage.setItem("yatra_settings_booking_form_subtab", tab); |
| 1075 | 1188 | } |
| 1076 | 1189 | }; |
| 1077 | - const [editingField, setEditingField] = useState<string | null>(null); | |
| 1078 | - const [showAddField, setShowAddField] = useState(false); | |
| 1079 | - const [newField, setNewField] = useState<Partial<FormFieldConfig>>({ | |
| 1080 | - id: "", | |
| 1081 | - type: "text", | |
| 1082 | - label: "", | |
| 1083 | - placeholder: "", | |
| 1084 | - required: false, | |
| 1085 | - enabled: true, | |
| 1086 | - width: "full", | |
| 1087 | - options: [], | |
| 1088 | - }); | |
| 1190 | + // Traveler-section "Applies to" choices (only shown on the Traveler tab). | |
| 1191 | + const appliesToOptions: { value: "all" | "lead"; label: string }[] = [ | |
| 1192 | + { value: "all", label: __("All travelers", "yatra") }, | |
| 1193 | + { value: "lead", label: __("Lead traveler only", "yatra") }, | |
| 1194 | + ]; | |
| 1089 | 1195 | |
| 1090 | - // Delete confirmation state | |
| 1091 | - const [deleteConfirm, setDeleteConfirm] = useState<{ | |
| 1092 | - isOpen: boolean; | |
| 1093 | - fieldId: string | null; | |
| 1094 | - fieldLabel: string; | |
| 1095 | - }>({ | |
| 1096 | - isOpen: false, | |
| 1097 | - fieldId: null, | |
| 1098 | - fieldLabel: "", | |
| 1099 | - }); | |
| 1100 | 1196 | |
| 1101 | - // Drag and drop state | |
| 1102 | - const [draggedFieldId, setDraggedFieldId] = useState<string | null>(null); | |
| 1103 | - const [dragOverFieldId, setDragOverFieldId] = useState<string | null>(null); | |
| 1104 | - | |
| 1105 | - // Helper to generate ID from label | |
| 1106 | - const generateIdFromLabel = (label: string): string => { | |
| 1107 | - return label | |
| 1108 | - .toLowerCase() | |
| 1109 | - .replace(/\s+/g, "_") | |
| 1110 | - .replace(/[^a-z0-9_]/g, ""); | |
| 1111 | - }; | |
| 1112 | - | |
| 1113 | - // Helper to sanitize ID input | |
| 1114 | - const sanitizeId = (id: string): string => { | |
| 1115 | - return id | |
| 1116 | - .toLowerCase() | |
| 1117 | - .replace(/\s+/g, "_") | |
| 1118 | - .replace(/[^a-z0-9_]/g, ""); | |
| 1119 | - }; | |
| 1120 | - | |
| 1121 | - // Handle label change with auto ID generation | |
| 1122 | - const handleNewFieldLabelChange = (label: string) => { | |
| 1123 | - const autoId = generateIdFromLabel(label); | |
| 1124 | - setNewField((prev) => ({ | |
| 1125 | - ...prev, | |
| 1126 | - label, | |
| 1127 | - // Only auto-generate ID if it hasn't been manually edited | |
| 1128 | - id: | |
| 1129 | - prev.id === "" || prev.id === generateIdFromLabel(prev.label || "") | |
| 1130 | - ? autoId | |
| 1131 | - : prev.id, | |
| 1132 | - })); | |
| 1133 | - }; | |
| 1134 | - | |
| 1135 | 1197 | const formTabs = [ |
| 1136 | 1198 | { |
| 1137 | 1199 | id: "contact_form" as const, |
| 1138 | 1200 | label: __("Contact Form", "yatra"), |
| @@ -1150,17 +1212,18 @@ | ||
| 1150 | 1212 | }, |
| 1151 | 1213 | ]; |
| 1152 | 1214 | |
| 1153 | 1215 | const fieldTypes = [ |
| 1154 | - { value: "text", label: "Text" }, | |
| 1155 | - { value: "email", label: "Email" }, | |
| 1156 | - { value: "tel", label: "Phone" }, | |
| 1157 | - { value: "date", label: "Date" }, | |
| 1158 | - { value: "select", label: "Dropdown" }, | |
| 1159 | - { value: "country", label: "Country Selector" }, | |
| 1160 | - { value: "textarea", label: "Text Area" }, | |
| 1161 | - { value: "number", label: "Number" }, | |
| 1162 | - { value: "checkbox", label: "Checkbox" }, | |
| 1216 | + { value: "text", label: __("Text", "yatra") }, | |
| 1217 | + { value: "email", label: __("Email", "yatra") }, | |
| 1218 | + { value: "tel", label: __("Phone", "yatra") }, | |
| 1219 | + { value: "date", label: __("Date", "yatra") }, | |
| 1220 | + { value: "select", label: __("Dropdown", "yatra") }, | |
| 1221 | + { value: "country", label: __("Country Selector", "yatra") }, | |
| 1222 | + { value: "textarea", label: __("Text Area", "yatra") }, | |
| 1223 | + { value: "number", label: __("Number", "yatra") }, | |
| 1224 | + { value: "checkbox", label: __("Checkbox", "yatra") }, | |
| 1225 | + { value: "text_block", label: __("Text Block (display only)", "yatra") }, | |
| 1163 | 1226 | ]; |
| 1164 | 1227 | |
| 1165 | 1228 | const widthOptions = [ |
| 1166 | 1229 | { value: "full", label: "Full Width" }, |
| @@ -1178,8 +1241,27 @@ | ||
| 1178 | 1241 | } |
| 1179 | 1242 | ); |
| 1180 | 1243 | }; |
| 1181 | 1244 | |
| 1245 | + // Booking confirmation, the customer account and the voucher all need an email. | |
| 1246 | + // Warn the operator when neither the Contact nor the Traveler form will capture | |
| 1247 | + // one (section disabled, or its email field disabled) — checkout enforces the | |
| 1248 | + // same rule (lead-traveler email is used as a fallback when Contact is off). | |
| 1249 | + const bookingFormCapturesEmail = (() => { | |
| 1250 | + const cfg = formData?.booking_form_config; | |
| 1251 | + if (!cfg) return true; | |
| 1252 | + const sectionHasEnabledEmail = (section?: FormSectionConfig): boolean => { | |
| 1253 | + if (!section || section.enabled === false) return false; | |
| 1254 | + return (section.fields || []).some( | |
| 1255 | + (f) => (f.type === "email" || f.id === "email") && f.enabled !== false, | |
| 1256 | + ); | |
| 1257 | + }; | |
| 1258 | + return ( | |
| 1259 | + sectionHasEnabledEmail(cfg.contact_form) || | |
| 1260 | + sectionHasEnabledEmail(cfg.traveler_form) | |
| 1261 | + ); | |
| 1262 | + })(); | |
| 1263 | + | |
| 1182 | 1264 | const updateFormConfig = (updates: Partial<FormSectionConfig>) => { |
| 1183 | 1265 | setFormData((prev) => { |
| 1184 | 1266 | if (!prev) return prev; |
| 1185 | 1267 | return { |
| @@ -1194,165 +1276,9 @@ | ||
| 1194 | 1276 | }; |
| 1195 | 1277 | }); |
| 1196 | 1278 | }; |
| 1197 | 1279 | |
| 1198 | - const updateField = (fieldId: string, updates: Partial<FormFieldConfig>) => { | |
| 1199 | - const currentConfig = getCurrentFormConfig(); | |
| 1200 | - const updatedFields = currentConfig.fields.map((field) => | |
| 1201 | - field.id === fieldId ? { ...field, ...updates } : field, | |
| 1202 | - ); | |
| 1203 | - updateFormConfig({ fields: updatedFields }); | |
| 1204 | - }; | |
| 1205 | 1280 | |
| 1206 | - const toggleFieldEnabled = (fieldId: string) => { | |
| 1207 | - const currentConfig = getCurrentFormConfig(); | |
| 1208 | - const field = currentConfig.fields.find((f) => f.id === fieldId); | |
| 1209 | - // Locked fields cannot be disabled | |
| 1210 | - if (field && !field.locked) { | |
| 1211 | - updateField(fieldId, { enabled: !field.enabled }); | |
| 1212 | - } | |
| 1213 | - }; | |
| 1214 | - | |
| 1215 | - const toggleFieldRequired = (fieldId: string) => { | |
| 1216 | - const currentConfig = getCurrentFormConfig(); | |
| 1217 | - const field = currentConfig.fields.find((f) => f.id === fieldId); | |
| 1218 | - if (field) { | |
| 1219 | - updateField(fieldId, { required: !field.required }); | |
| 1220 | - } | |
| 1221 | - }; | |
| 1222 | - | |
| 1223 | - const moveField = (fieldId: string, direction: "up" | "down") => { | |
| 1224 | - const currentConfig = getCurrentFormConfig(); | |
| 1225 | - const fields = [...currentConfig.fields]; | |
| 1226 | - const index = fields.findIndex((f) => f.id === fieldId); | |
| 1227 | - | |
| 1228 | - if (direction === "up" && index > 0) { | |
| 1229 | - [fields[index - 1], fields[index]] = [fields[index], fields[index - 1]]; | |
| 1230 | - } else if (direction === "down" && index < fields.length - 1) { | |
| 1231 | - [fields[index], fields[index + 1]] = [fields[index + 1], fields[index]]; | |
| 1232 | - } | |
| 1233 | - | |
| 1234 | - // Update order values | |
| 1235 | - fields.forEach((field, i) => { | |
| 1236 | - field.order = i + 1; | |
| 1237 | - }); | |
| 1238 | - | |
| 1239 | - updateFormConfig({ fields }); | |
| 1240 | - }; | |
| 1241 | - | |
| 1242 | - // Drag and Drop handlers | |
| 1243 | - const handleDragStart = (e: React.DragEvent, fieldId: string) => { | |
| 1244 | - setDraggedFieldId(fieldId); | |
| 1245 | - e.dataTransfer.effectAllowed = "move"; | |
| 1246 | - }; | |
| 1247 | - | |
| 1248 | - const handleDragOver = (e: React.DragEvent, fieldId: string) => { | |
| 1249 | - e.preventDefault(); | |
| 1250 | - e.dataTransfer.dropEffect = "move"; | |
| 1251 | - if (fieldId !== draggedFieldId) { | |
| 1252 | - setDragOverFieldId(fieldId); | |
| 1253 | - } | |
| 1254 | - }; | |
| 1255 | - | |
| 1256 | - const handleDragLeave = () => { | |
| 1257 | - setDragOverFieldId(null); | |
| 1258 | - }; | |
| 1259 | - | |
| 1260 | - const handleDrop = (e: React.DragEvent, targetFieldId: string) => { | |
| 1261 | - e.preventDefault(); | |
| 1262 | - if (!draggedFieldId || draggedFieldId === targetFieldId) { | |
| 1263 | - setDraggedFieldId(null); | |
| 1264 | - setDragOverFieldId(null); | |
| 1265 | - return; | |
| 1266 | - } | |
| 1267 | - | |
| 1268 | - const currentConfig = getCurrentFormConfig(); | |
| 1269 | - const fields = [...currentConfig.fields]; | |
| 1270 | - const draggedIndex = fields.findIndex((f) => f.id === draggedFieldId); | |
| 1271 | - const targetIndex = fields.findIndex((f) => f.id === targetFieldId); | |
| 1272 | - | |
| 1273 | - if (draggedIndex !== -1 && targetIndex !== -1) { | |
| 1274 | - const [draggedField] = fields.splice(draggedIndex, 1); | |
| 1275 | - fields.splice(targetIndex, 0, draggedField); | |
| 1276 | - | |
| 1277 | - // Update order values | |
| 1278 | - fields.forEach((field, i) => { | |
| 1279 | - field.order = i + 1; | |
| 1280 | - }); | |
| 1281 | - | |
| 1282 | - updateFormConfig({ fields }); | |
| 1283 | - } | |
| 1284 | - | |
| 1285 | - setDraggedFieldId(null); | |
| 1286 | - setDragOverFieldId(null); | |
| 1287 | - }; | |
| 1288 | - | |
| 1289 | - const handleDragEnd = () => { | |
| 1290 | - setDraggedFieldId(null); | |
| 1291 | - setDragOverFieldId(null); | |
| 1292 | - }; | |
| 1293 | - | |
| 1294 | - const deleteField = (fieldId: string) => { | |
| 1295 | - const currentConfig = getCurrentFormConfig(); | |
| 1296 | - const updatedFields = currentConfig.fields.filter((f) => f.id !== fieldId); | |
| 1297 | - updatedFields.forEach((field, i) => { | |
| 1298 | - field.order = i + 1; | |
| 1299 | - }); | |
| 1300 | - updateFormConfig({ fields: updatedFields }); | |
| 1301 | - setDeleteConfirm({ isOpen: false, fieldId: null, fieldLabel: "" }); | |
| 1302 | - }; | |
| 1303 | - | |
| 1304 | - const addNewField = () => { | |
| 1305 | - if (!newField.label || !newField.id) return; | |
| 1306 | - | |
| 1307 | - const currentConfig = getCurrentFormConfig(); | |
| 1308 | - const fieldId = sanitizeId(newField.id); | |
| 1309 | - | |
| 1310 | - // Check if ID already exists | |
| 1311 | - if (currentConfig.fields.some((f) => f.id === fieldId)) { | |
| 1312 | - // TODO: Replace with toast notification when refactoring this nested component | |
| 1313 | - window.alert( | |
| 1314 | - "A field with this ID already exists. Please use a different ID.", | |
| 1315 | - ); | |
| 1316 | - return; | |
| 1317 | - } | |
| 1318 | - | |
| 1319 | - const newFieldConfig: FormFieldConfig = { | |
| 1320 | - id: fieldId, | |
| 1321 | - type: (newField.type as FormFieldConfig["type"]) || "text", | |
| 1322 | - label: newField.label || "", | |
| 1323 | - placeholder: newField.placeholder || "", | |
| 1324 | - required: newField.required || false, | |
| 1325 | - enabled: true, | |
| 1326 | - order: currentConfig.fields.length + 1, | |
| 1327 | - width: (newField.width as FormFieldConfig["width"]) || "full", | |
| 1328 | - }; | |
| 1329 | - | |
| 1330 | - // Add options if field type is select | |
| 1331 | - if ( | |
| 1332 | - newField.type === "select" && | |
| 1333 | - newField.options && | |
| 1334 | - newField.options.length > 0 | |
| 1335 | - ) { | |
| 1336 | - newFieldConfig.options = newField.options.filter( | |
| 1337 | - (opt) => opt.value && opt.label, | |
| 1338 | - ); | |
| 1339 | - } | |
| 1340 | - | |
| 1341 | - updateFormConfig({ fields: [...currentConfig.fields, newFieldConfig] }); | |
| 1342 | - setNewField({ | |
| 1343 | - id: "", | |
| 1344 | - type: "text", | |
| 1345 | - label: "", | |
| 1346 | - placeholder: "", | |
| 1347 | - required: false, | |
| 1348 | - enabled: true, | |
| 1349 | - width: "full", | |
| 1350 | - options: [], | |
| 1351 | - }); | |
| 1352 | - setShowAddField(false); | |
| 1353 | - }; | |
| 1354 | - | |
| 1355 | 1281 | const currentConfig = getCurrentFormConfig(); |
| 1356 | 1282 | |
| 1357 | 1283 | return ( |
| 1358 | 1284 | <div className="space-y-6"> |
| @@ -1385,14 +1311,51 @@ | ||
| 1385 | 1311 | ))} |
| 1386 | 1312 | </nav> |
| 1387 | 1313 | </div> |
| 1388 | 1314 | |
| 1315 | + {/* No-email warning — booking confirmation/account/voucher all need one */} | |
| 1316 | + {!bookingFormCapturesEmail && ( | |
| 1317 | + <div className="flex items-start gap-2 p-3 rounded-md border border-red-300 bg-red-50 dark:border-red-700 dark:bg-red-900/20 text-sm text-red-800 dark:text-red-300"> | |
| 1318 | + <span className="font-semibold">⚠</span> | |
| 1319 | + <span> | |
| 1320 | + {__( | |
| 1321 | + "No form is set to collect an email address. An email is required for booking confirmation, the customer account and the voucher. Please enable an email field on the Contact or Traveler form — otherwise customers can't complete checkout.", | |
| 1322 | + "yatra", | |
| 1323 | + )} | |
| 1324 | + </span> | |
| 1325 | + </div> | |
| 1326 | + )} | |
| 1327 | + | |
| 1389 | 1328 | {/* Form Section Settings */} |
| 1390 | 1329 | <Card> |
| 1391 | 1330 | <CardHeader> |
| 1392 | - <CardTitle className="text-base"> | |
| 1393 | - {__("Form Section Settings", "yatra")} | |
| 1394 | - </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> | |
| 1395 | 1358 | </CardHeader> |
| 1396 | 1359 | <CardContent className="space-y-4"> |
| 1397 | 1360 | <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> |
| 1398 | 1361 | <input |
| @@ -1439,628 +1402,85 @@ | ||
| 1439 | 1402 | className="mt-1" |
| 1440 | 1403 | /> |
| 1441 | 1404 | </div> |
| 1442 | 1405 | </div> |
| 1443 | - </CardContent> | |
| 1444 | - </Card> | |
| 1445 | 1406 | |
| 1446 | - {/* Form Fields */} | |
| 1447 | - <Card> | |
| 1448 | - <CardHeader className="flex flex-row items-center justify-between"> | |
| 1449 | - <CardTitle className="text-base"> | |
| 1450 | - {__("Form Fields", "yatra")} | |
| 1451 | - </CardTitle> | |
| 1452 | - <Button | |
| 1453 | - variant="outline" | |
| 1454 | - size="sm" | |
| 1455 | - onClick={() => setShowAddField(!showAddField)} | |
| 1456 | - className="flex items-center gap-2" | |
| 1457 | - > | |
| 1458 | - <Plus className="w-4 h-4" /> | |
| 1459 | - {__("Add Field", "yatra")} | |
| 1460 | - </Button> | |
| 1461 | - </CardHeader> | |
| 1462 | - <CardContent className="space-y-3"> | |
| 1463 | - {/* Add New Field Form */} | |
| 1464 | - {showAddField && ( | |
| 1465 | - <div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 mb-4"> | |
| 1466 | - <h4 className="font-medium text-sm mb-3"> | |
| 1467 | - {__("Add New Field", "yatra")} | |
| 1468 | - </h4> | |
| 1469 | - <div className="grid grid-cols-1 md:grid-cols-5 gap-3"> | |
| 1470 | - <div> | |
| 1471 | - <Label className="text-xs"> | |
| 1472 | - {__("Field Type", "yatra")} | |
| 1473 | - </Label> | |
| 1474 | - <Select | |
| 1475 | - value={newField.type || "text"} | |
| 1476 | - onChange={(e) => | |
| 1477 | - setNewField((prev) => ({ | |
| 1478 | - ...prev, | |
| 1479 | - type: e.target.value as FormFieldConfig["type"], | |
| 1480 | - })) | |
| 1481 | - } | |
| 1482 | - 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" | |
| 1483 | 1415 | > |
| 1484 | - {fieldTypes.map((type) => ( | |
| 1485 | - <option key={type.value} value={type.value}> | |
| 1486 | - {type.label} | |
| 1487 | - </option> | |
| 1488 | - ))} | |
| 1489 | - </Select> | |
| 1490 | - </div> | |
| 1491 | - <div> | |
| 1492 | - <Label className="text-xs">{__("Label", "yatra")} *</Label> | |
| 1493 | - <Input | |
| 1494 | - value={newField.label || ""} | |
| 1495 | - onChange={(e) => | |
| 1496 | - handleNewFieldLabelChange(e.target.value) | |
| 1497 | - } | |
| 1498 | - placeholder="Field label" | |
| 1499 | - className="mt-1" | |
| 1500 | - /> | |
| 1501 | - </div> | |
| 1502 | - <div> | |
| 1503 | - <Label className="text-xs"> | |
| 1504 | - {__("Field ID", "yatra")} * | |
| 1505 | - </Label> | |
| 1506 | - <Input | |
| 1507 | - value={newField.id || ""} | |
| 1508 | - onChange={(e) => | |
| 1509 | - setNewField((prev) => ({ | |
| 1510 | - ...prev, | |
| 1511 | - id: sanitizeId(e.target.value), | |
| 1512 | - })) | |
| 1513 | - } | |
| 1514 | - placeholder="field_id" | |
| 1515 | - className="mt-1 font-mono text-xs" | |
| 1516 | - /> | |
| 1517 | - <p className="text-[10px] text-gray-400 mt-0.5"> | |
| 1518 | - {__("Lowercase, no spaces", "yatra")} | |
| 1519 | - </p> | |
| 1520 | - </div> | |
| 1521 | - <div> | |
| 1522 | - <Label className="text-xs"> | |
| 1523 | - {__("Placeholder", "yatra")} | |
| 1524 | - </Label> | |
| 1525 | - <Input | |
| 1526 | - value={newField.placeholder || ""} | |
| 1527 | - onChange={(e) => | |
| 1528 | - setNewField((prev) => ({ | |
| 1529 | - ...prev, | |
| 1530 | - placeholder: e.target.value, | |
| 1531 | - })) | |
| 1532 | - } | |
| 1533 | - placeholder="Placeholder text" | |
| 1534 | - className="mt-1" | |
| 1535 | - /> | |
| 1536 | - </div> | |
| 1537 | - <div> | |
| 1538 | - <Label className="text-xs">{__("Width", "yatra")}</Label> | |
| 1539 | - <Select | |
| 1540 | - value={newField.width || "full"} | |
| 1541 | - onChange={(e) => | |
| 1542 | - setNewField((prev) => ({ | |
| 1543 | - ...prev, | |
| 1544 | - width: e.target.value as FormFieldConfig["width"], | |
| 1545 | - })) | |
| 1546 | - } | |
| 1547 | - className="mt-1" | |
| 1548 | - > | |
| 1549 | - {widthOptions.map((w) => ( | |
| 1550 | - <option key={w.value} value={w.value}> | |
| 1551 | - {w.label} | |
| 1552 | - </option> | |
| 1553 | - ))} | |
| 1554 | - </Select> | |
| 1555 | - </div> | |
| 1556 | - </div> | |
| 1557 | - {/* Options editor for select fields */} | |
| 1558 | - {newField.type === "select" && ( | |
| 1559 | - <div className="mt-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-blue-100 dark:border-blue-900"> | |
| 1560 | - <div className="flex items-center justify-between mb-2"> | |
| 1561 | - <Label className="text-xs font-medium"> | |
| 1562 | - {__("Dropdown Options", "yatra")} | |
| 1563 | - </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" /> | |
| 1564 | 1429 | <Button |
| 1565 | 1430 | type="button" |
| 1566 | 1431 | variant="ghost" |
| 1567 | 1432 | size="sm" |
| 1568 | - onClick={() => { | |
| 1569 | - const currentOptions = newField.options || []; | |
| 1570 | - setNewField((prev) => ({ | |
| 1571 | - ...prev, | |
| 1572 | - options: [ | |
| 1573 | - ...currentOptions, | |
| 1574 | - { value: "", label: "" }, | |
| 1575 | - ], | |
| 1576 | - })); | |
| 1577 | - }} | |
| 1578 | - className="h-6 text-xs" | |
| 1433 | + onClick={() => setConditionsOpen(true)} | |
| 1579 | 1434 | > |
| 1580 | - <Plus className="w-3 h-3 mr-1" /> | |
| 1581 | - {__("Add Option", "yatra")} | |
| 1435 | + {__("Edit", "yatra")} | |
| 1582 | 1436 | </Button> |
| 1583 | 1437 | </div> |
| 1584 | - <div className="space-y-2"> | |
| 1585 | - {(newField.options || []).map((option, optIndex) => ( | |
| 1586 | - <div key={optIndex} className="flex items-center gap-2"> | |
| 1587 | - <Input | |
| 1588 | - value={option.value} | |
| 1589 | - onChange={(e) => { | |
| 1590 | - const newOptions = [...(newField.options || [])]; | |
| 1591 | - newOptions[optIndex] = { | |
| 1592 | - ...newOptions[optIndex], | |
| 1593 | - value: e.target.value, | |
| 1594 | - }; | |
| 1595 | - setNewField((prev) => ({ | |
| 1596 | - ...prev, | |
| 1597 | - options: newOptions, | |
| 1598 | - })); | |
| 1599 | - }} | |
| 1600 | - placeholder="Value (e.g., option1)" | |
| 1601 | - className="text-xs flex-1" | |
| 1602 | - /> | |
| 1603 | - <Input | |
| 1604 | - value={option.label} | |
| 1605 | - onChange={(e) => { | |
| 1606 | - const newOptions = [...(newField.options || [])]; | |
| 1607 | - newOptions[optIndex] = { | |
| 1608 | - ...newOptions[optIndex], | |
| 1609 | - label: e.target.value, | |
| 1610 | - }; | |
| 1611 | - setNewField((prev) => ({ | |
| 1612 | - ...prev, | |
| 1613 | - options: newOptions, | |
| 1614 | - })); | |
| 1615 | - }} | |
| 1616 | - placeholder="Label (e.g., Option 1)" | |
| 1617 | - className="text-xs flex-1" | |
| 1618 | - /> | |
| 1619 | - <button | |
| 1620 | - type="button" | |
| 1621 | - onClick={() => { | |
| 1622 | - const newOptions = ( | |
| 1623 | - newField.options || [] | |
| 1624 | - ).filter((_, i) => i !== optIndex); | |
| 1625 | - setNewField((prev) => ({ | |
| 1626 | - ...prev, | |
| 1627 | - options: newOptions, | |
| 1628 | - })); | |
| 1629 | - }} | |
| 1630 | - className="p-1 text-gray-400 hover:text-red-500" | |
| 1631 | - > | |
| 1632 | - <Trash2 className="w-3 h-3" /> | |
| 1633 | - </button> | |
| 1634 | - </div> | |
| 1635 | - ))} | |
| 1636 | - {(!newField.options || newField.options.length === 0) && ( | |
| 1637 | - <p className="text-xs text-gray-400 italic"> | |
| 1638 | - {__( | |
| 1639 | - 'Click "Add Option" to add dropdown choices.', | |
| 1640 | - "yatra", | |
| 1641 | - )} | |
| 1642 | - </p> | |
| 1643 | - )} | |
| 1644 | - </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> | |
| 1645 | 1446 | </div> |
| 1646 | - )} | |
| 1647 | - | |
| 1648 | - <div className="flex items-center gap-4 mt-3"> | |
| 1649 | - <label className="flex items-center gap-2 text-sm"> | |
| 1650 | - <input | |
| 1651 | - type="checkbox" | |
| 1652 | - checked={newField.required || false} | |
| 1653 | - onChange={(e) => | |
| 1654 | - setNewField((prev) => ({ | |
| 1655 | - ...prev, | |
| 1656 | - required: e.target.checked, | |
| 1657 | - })) | |
| 1658 | - } | |
| 1659 | - className="w-4 h-4 rounded border-gray-300 text-blue-600" | |
| 1660 | - /> | |
| 1661 | - {__("Required", "yatra")} | |
| 1662 | - </label> | |
| 1663 | - <div className="flex-1"></div> | |
| 1664 | - <Button | |
| 1665 | - variant="ghost" | |
| 1666 | - size="sm" | |
| 1667 | - onClick={() => setShowAddField(false)} | |
| 1668 | - > | |
| 1669 | - {__("Cancel", "yatra")} | |
| 1670 | - </Button> | |
| 1671 | - <Button | |
| 1672 | - size="sm" | |
| 1673 | - onClick={addNewField} | |
| 1674 | - disabled={!newField.label || !newField.id} | |
| 1675 | - > | |
| 1676 | - {__("Add Field", "yatra")} | |
| 1677 | - </Button> | |
| 1678 | 1447 | </div> |
| 1679 | - </div> | |
| 1680 | - )} | |
| 1681 | - | |
| 1682 | - {/* Field List */} | |
| 1683 | - {currentConfig.fields?.length === 0 ? ( | |
| 1684 | - <div className="text-center py-8 text-gray-500 dark:text-gray-400"> | |
| 1685 | - <p> | |
| 1686 | - {__( | |
| 1687 | - 'No fields configured. Click "Add Field" to get started.', | |
| 1688 | - "yatra", | |
| 1689 | - )} | |
| 1690 | - </p> | |
| 1691 | - </div> | |
| 1692 | - ) : ( | |
| 1693 | - <div className="space-y-2"> | |
| 1694 | - {currentConfig.fields?.map((field, index) => ( | |
| 1695 | - <div | |
| 1696 | - key={field.id} | |
| 1697 | - draggable | |
| 1698 | - onDragStart={(e) => handleDragStart(e, field.id)} | |
| 1699 | - onDragOver={(e) => handleDragOver(e, field.id)} | |
| 1700 | - onDragLeave={handleDragLeave} | |
| 1701 | - onDrop={(e) => handleDrop(e, field.id)} | |
| 1702 | - onDragEnd={handleDragEnd} | |
| 1703 | - className={`flex items-center gap-3 p-3 rounded-lg border transition-all cursor-grab active:cursor-grabbing ${ | |
| 1704 | - draggedFieldId === field.id | |
| 1705 | - ? "opacity-50 border-dashed" | |
| 1706 | - : "" | |
| 1707 | - } ${ | |
| 1708 | - dragOverFieldId === field.id | |
| 1709 | - ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20" | |
| 1710 | - : "" | |
| 1711 | - } ${ | |
| 1712 | - field.enabled | |
| 1713 | - ? "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700" | |
| 1714 | - : "bg-gray-50 dark:bg-gray-900 border-gray-100 dark:border-gray-800 opacity-60" | |
| 1715 | - }`} | |
| 1716 | - > | |
| 1717 | - {/* Drag Handle & Order */} | |
| 1718 | - <div className="flex items-center gap-1"> | |
| 1719 | - <GripVertical className="w-4 h-4 text-gray-400" /> | |
| 1720 | - <div className="flex flex-col gap-0.5"> | |
| 1721 | - <button | |
| 1722 | - type="button" | |
| 1723 | - onClick={() => moveField(field.id, "up")} | |
| 1724 | - disabled={index === 0} | |
| 1725 | - className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed" | |
| 1726 | - > | |
| 1727 | - <ArrowUp className="w-3 h-3" /> | |
| 1728 | - </button> | |
| 1729 | - <button | |
| 1730 | - type="button" | |
| 1731 | - onClick={() => moveField(field.id, "down")} | |
| 1732 | - disabled={index === currentConfig.fields.length - 1} | |
| 1733 | - className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed" | |
| 1734 | - > | |
| 1735 | - <ArrowDown className="w-3 h-3" /> | |
| 1736 | - </button> | |
| 1737 | - </div> | |
| 1738 | - </div> | |
| 1739 | - | |
| 1740 | - {/* Field Info */} | |
| 1741 | - <div className="flex-1 min-w-0"> | |
| 1742 | - {editingField === field.id ? ( | |
| 1743 | - <div className="space-y-3"> | |
| 1744 | - <div className="grid grid-cols-4 gap-2"> | |
| 1745 | - <Input | |
| 1746 | - value={field.label} | |
| 1747 | - onChange={(e) => | |
| 1748 | - updateField(field.id, { label: e.target.value }) | |
| 1749 | - } | |
| 1750 | - placeholder="Label" | |
| 1751 | - className="text-sm" | |
| 1752 | - /> | |
| 1753 | - <Input | |
| 1754 | - value={field.placeholder} | |
| 1755 | - onChange={(e) => | |
| 1756 | - updateField(field.id, { | |
| 1757 | - placeholder: e.target.value, | |
| 1758 | - }) | |
| 1759 | - } | |
| 1760 | - placeholder="Placeholder" | |
| 1761 | - className="text-sm" | |
| 1762 | - /> | |
| 1763 | - <Select | |
| 1764 | - value={field.type} | |
| 1765 | - onChange={(e) => | |
| 1766 | - updateField(field.id, { | |
| 1767 | - type: e.target | |
| 1768 | - .value as FormFieldConfig["type"], | |
| 1769 | - }) | |
| 1770 | - } | |
| 1771 | - className="text-sm" | |
| 1772 | - > | |
| 1773 | - {fieldTypes.map((type) => ( | |
| 1774 | - <option key={type.value} value={type.value}> | |
| 1775 | - {type.label} | |
| 1776 | - </option> | |
| 1777 | - ))} | |
| 1778 | - </Select> | |
| 1779 | - <Select | |
| 1780 | - value={field.width} | |
| 1781 | - onChange={(e) => | |
| 1782 | - updateField(field.id, { | |
| 1783 | - width: e.target | |
| 1784 | - .value as FormFieldConfig["width"], | |
| 1785 | - }) | |
| 1786 | - } | |
| 1787 | - className="text-sm" | |
| 1788 | - > | |
| 1789 | - {widthOptions.map((w) => ( | |
| 1790 | - <option key={w.value} value={w.value}> | |
| 1791 | - {w.label} | |
| 1792 | - </option> | |
| 1793 | - ))} | |
| 1794 | - </Select> | |
| 1795 | - </div> | |
| 1796 | - | |
| 1797 | - {/* Field ID - Below other fields */} | |
| 1798 | - <div className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-900 rounded"> | |
| 1799 | - <Label className="text-xs text-gray-500 whitespace-nowrap"> | |
| 1800 | - {__("Field ID:", "yatra")} | |
| 1801 | - </Label> | |
| 1802 | - <Input | |
| 1803 | - value={field.id} | |
| 1804 | - onChange={(e) => { | |
| 1805 | - if (!field.locked) { | |
| 1806 | - updateField(field.id, { | |
| 1807 | - id: sanitizeId(e.target.value), | |
| 1808 | - }); | |
| 1809 | - } | |
| 1810 | - }} | |
| 1811 | - placeholder="field_id" | |
| 1812 | - className="text-sm font-mono flex-1 max-w-xs" | |
| 1813 | - disabled={field.locked} | |
| 1814 | - title={ | |
| 1815 | - field.locked | |
| 1816 | - ? "Locked fields cannot change ID" | |
| 1817 | - : "Field ID (lowercase, no spaces)" | |
| 1818 | - } | |
| 1819 | - /> | |
| 1820 | - {field.locked && ( | |
| 1821 | - <span className="text-xs text-amber-600"> | |
| 1822 | - {__("(locked)", "yatra")} | |
| 1823 | - </span> | |
| 1824 | - )} | |
| 1825 | - </div> | |
| 1826 | - | |
| 1827 | - {/* Dropdown Options Editor */} | |
| 1828 | - {field.type === "select" && ( | |
| 1829 | - <div className="p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700"> | |
| 1830 | - <div className="flex items-center justify-between mb-2"> | |
| 1831 | - <Label className="text-xs font-medium"> | |
| 1832 | - {__("Dropdown Options", "yatra")} | |
| 1833 | - </Label> | |
| 1834 | - <Button | |
| 1835 | - type="button" | |
| 1836 | - variant="ghost" | |
| 1837 | - size="sm" | |
| 1838 | - onClick={() => { | |
| 1839 | - const newOptions = [ | |
| 1840 | - ...(field.options || []), | |
| 1841 | - { value: "", label: "" }, | |
| 1842 | - ]; | |
| 1843 | - updateField(field.id, { | |
| 1844 | - options: newOptions, | |
| 1845 | - }); | |
| 1846 | - }} | |
| 1847 | - className="h-6 text-xs" | |
| 1848 | - > | |
| 1849 | - <Plus className="w-3 h-3 mr-1" /> | |
| 1850 | - {__("Add Option", "yatra")} | |
| 1851 | - </Button> | |
| 1852 | - </div> | |
| 1853 | - <div className="space-y-2"> | |
| 1854 | - {(field.options || []).map( | |
| 1855 | - (option, optIndex) => ( | |
| 1856 | - <div | |
| 1857 | - key={optIndex} | |
| 1858 | - className="flex items-center gap-2" | |
| 1859 | - > | |
| 1860 | - <Input | |
| 1861 | - value={option.value} | |
| 1862 | - onChange={(e) => { | |
| 1863 | - const newOptions = [ | |
| 1864 | - ...(field.options || []), | |
| 1865 | - ]; | |
| 1866 | - newOptions[optIndex] = { | |
| 1867 | - ...newOptions[optIndex], | |
| 1868 | - value: e.target.value, | |
| 1869 | - }; | |
| 1870 | - updateField(field.id, { | |
| 1871 | - options: newOptions, | |
| 1872 | - }); | |
| 1873 | - }} | |
| 1874 | - placeholder="Value (e.g., spouse)" | |
| 1875 | - className="text-xs flex-1" | |
| 1876 | - /> | |
| 1877 | - <Input | |
| 1878 | - value={option.label} | |
| 1879 | - onChange={(e) => { | |
| 1880 | - const newOptions = [ | |
| 1881 | - ...(field.options || []), | |
| 1882 | - ]; | |
| 1883 | - newOptions[optIndex] = { | |
| 1884 | - ...newOptions[optIndex], | |
| 1885 | - label: e.target.value, | |
| 1886 | - }; | |
| 1887 | - updateField(field.id, { | |
| 1888 | - options: newOptions, | |
| 1889 | - }); | |
| 1890 | - }} | |
| 1891 | - placeholder="Label (e.g., Spouse/Partner)" | |
| 1892 | - className="text-xs flex-1" | |
| 1893 | - /> | |
| 1894 | - <button | |
| 1895 | - type="button" | |
| 1896 | - onClick={() => { | |
| 1897 | - const newOptions = ( | |
| 1898 | - field.options || [] | |
| 1899 | - ).filter((_, i) => i !== optIndex); | |
| 1900 | - updateField(field.id, { | |
| 1901 | - options: newOptions, | |
| 1902 | - }); | |
| 1903 | - }} | |
| 1904 | - className="p-1 text-gray-400 hover:text-red-500" | |
| 1905 | - > | |
| 1906 | - <Trash2 className="w-3 h-3" /> | |
| 1907 | - </button> | |
| 1908 | - </div> | |
| 1909 | - ), | |
| 1910 | - )} | |
| 1911 | - {(!field.options || | |
| 1912 | - field.options.length === 0) && ( | |
| 1913 | - <p className="text-xs text-gray-400 italic"> | |
| 1914 | - {__( | |
| 1915 | - 'No options. Click "Add Option" to add dropdown choices.', | |
| 1916 | - "yatra", | |
| 1917 | - )} | |
| 1918 | - </p> | |
| 1919 | - )} | |
| 1920 | - </div> | |
| 1921 | - </div> | |
| 1922 | - )} | |
| 1923 | - </div> | |
| 1924 | - ) : ( | |
| 1925 | - <div className="flex items-center gap-3 flex-wrap"> | |
| 1926 | - <span className="font-medium text-sm"> | |
| 1927 | - {field.label} | |
| 1928 | - </span> | |
| 1929 | - <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"> | |
| 1930 | - {field.id} | |
| 1931 | - </code> | |
| 1932 | - <span className="text-xs text-gray-400 dark:text-gray-500 px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded"> | |
| 1933 | - {fieldTypes.find((t) => t.value === field.type) | |
| 1934 | - ?.label || field.type} | |
| 1935 | - </span> | |
| 1936 | - <span className="text-xs text-gray-400 dark:text-gray-500"> | |
| 1937 | - {widthOptions.find((w) => w.value === field.width) | |
| 1938 | - ?.label || "Full"} | |
| 1939 | - </span> | |
| 1940 | - {field.type === "select" && | |
| 1941 | - field.options && | |
| 1942 | - field.options.length > 0 && ( | |
| 1943 | - <span className="text-xs text-blue-500 dark:text-blue-400"> | |
| 1944 | - ({field.options.length}{" "} | |
| 1945 | - {field.options.length === 1 | |
| 1946 | - ? "option" | |
| 1947 | - : "options"} | |
| 1948 | - ) | |
| 1949 | - </span> | |
| 1950 | - )} | |
| 1951 | - {field.required && ( | |
| 1952 | - <span className="text-xs text-red-500 font-medium"> | |
| 1953 | - {__("Required", "yatra")} | |
| 1954 | - </span> | |
| 1955 | - )} | |
| 1956 | - {field.locked && ( | |
| 1957 | - <span | |
| 1958 | - 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" | |
| 1959 | - title="This field is protected and cannot be deleted" | |
| 1960 | - > | |
| 1961 | - <Lock className="w-3 h-3" /> | |
| 1962 | - Locked | |
| 1963 | - </span> | |
| 1964 | - )} | |
| 1965 | - </div> | |
| 1966 | - )} | |
| 1967 | - </div> | |
| 1968 | - | |
| 1969 | - {/* Actions */} | |
| 1970 | - <div className="flex items-center gap-1"> | |
| 1971 | - <button | |
| 1972 | - type="button" | |
| 1973 | - onClick={() => | |
| 1974 | - !field.locked && toggleFieldRequired(field.id) | |
| 1975 | - } | |
| 1976 | - disabled={field.locked} | |
| 1977 | - 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"}`} | |
| 1978 | - title={ | |
| 1979 | - field.locked | |
| 1980 | - ? "This field is required and cannot be changed" | |
| 1981 | - : field.required | |
| 1982 | - ? "Make optional" | |
| 1983 | - : "Make required" | |
| 1984 | - } | |
| 1985 | - > | |
| 1986 | - <Star | |
| 1987 | - className="w-4 h-4" | |
| 1988 | - fill={field.required ? "currentColor" : "none"} | |
| 1989 | - /> | |
| 1990 | - </button> | |
| 1991 | - {/* Show enable/disable toggle only for non-locked fields */} | |
| 1992 | - {!field.locked && ( | |
| 1993 | - <button | |
| 1994 | - type="button" | |
| 1995 | - onClick={() => toggleFieldEnabled(field.id)} | |
| 1996 | - className={`p-1.5 rounded ${field.enabled ? "text-green-500" : "text-gray-400"}`} | |
| 1997 | - title={ | |
| 1998 | - field.enabled ? "Disable field" : "Enable field" | |
| 1999 | - } | |
| 2000 | - > | |
| 2001 | - {field.enabled ? ( | |
| 2002 | - <Eye className="w-4 h-4" /> | |
| 2003 | - ) : ( | |
| 2004 | - <EyeOff className="w-4 h-4" /> | |
| 2005 | - )} | |
| 2006 | - </button> | |
| 2007 | - )} | |
| 2008 | - <button | |
| 2009 | - type="button" | |
| 2010 | - onClick={() => | |
| 2011 | - setEditingField( | |
| 2012 | - editingField === field.id ? null : field.id, | |
| 2013 | - ) | |
| 2014 | - } | |
| 2015 | - className="p-1.5 rounded text-gray-400 hover:text-blue-500" | |
| 2016 | - title="Edit field" | |
| 2017 | - > | |
| 2018 | - <Edit2 className="w-4 h-4" /> | |
| 2019 | - </button> | |
| 2020 | - {/* Show delete button only for non-locked fields */} | |
| 2021 | - {!field.locked && ( | |
| 2022 | - <button | |
| 2023 | - type="button" | |
| 2024 | - onClick={() => | |
| 2025 | - setDeleteConfirm({ | |
| 2026 | - isOpen: true, | |
| 2027 | - fieldId: field.id, | |
| 2028 | - fieldLabel: field.label, | |
| 2029 | - }) | |
| 2030 | - } | |
| 2031 | - className="p-1.5 rounded text-gray-400 hover:text-red-500" | |
| 2032 | - title="Delete field" | |
| 2033 | - > | |
| 2034 | - <Trash2 className="w-4 h-4" /> | |
| 2035 | - </button> | |
| 2036 | - )} | |
| 2037 | - </div> | |
| 2038 | - </div> | |
| 2039 | - ))} | |
| 2040 | - </div> | |
| 2041 | - )} | |
| 1448 | + )} | |
| 2042 | 1449 | </CardContent> |
| 2043 | 1450 | </Card> |
| 2044 | 1451 | |
| 2045 | - {/* Delete Confirmation Dialog */} | |
| 2046 | - <ConfirmationDialog | |
| 2047 | - isOpen={deleteConfirm.isOpen} | |
| 2048 | - onClose={() => | |
| 2049 | - setDeleteConfirm({ isOpen: false, fieldId: null, fieldLabel: "" }) | |
| 2050 | - } | |
| 2051 | - onConfirm={() => { | |
| 2052 | - if (deleteConfirm.fieldId) { | |
| 2053 | - 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 || "" | |
| 2054 | 1459 | } |
| 2055 | - }} | |
| 2056 | - title={__("Delete Field", "yatra")} | |
| 2057 | - message={`Are you sure you want to delete the field "${deleteConfirm.fieldLabel}"? This action cannot be undone.`} | |
| 2058 | - confirmText={__("Delete", "yatra")} | |
| 2059 | - cancelText={__("Cancel", "yatra")} | |
| 2060 | - 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} | |
| 2061 | 1480 | /> |
| 2062 | 1481 | |
| 1482 | + | |
| 2063 | 1483 | {/* Preview */} |
| 2064 | 1484 | <Card> |
| 2065 | 1485 | <CardHeader> |
| 2066 | 1486 | <CardTitle className="text-base"> |
| @@ -2088,32 +1508,43 @@ | ||
| 2088 | 1508 | ? "col-span-1" |
| 2089 | 1509 | : "col-span-1" |
| 2090 | 1510 | } |
| 2091 | 1511 | > |
| 2092 | - <Label className="text-sm"> | |
| 2093 | - {field.label} | |
| 2094 | - {field.required && ( | |
| 2095 | - <span className="text-red-500 ml-1">*</span> | |
| 2096 | - )} | |
| 2097 | - </Label> | |
| 2098 | - {field.type === "select" || field.type === "country" ? ( | |
| 2099 | - <Select disabled className="mt-1 w-full"> | |
| 2100 | - <option>{field.placeholder || "Select..."}</option> | |
| 2101 | - </Select> | |
| 2102 | - ) : field.type === "textarea" ? ( | |
| 2103 | - <textarea | |
| 2104 | - disabled | |
| 2105 | - placeholder={field.placeholder} | |
| 2106 | - className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-md bg-white dark:bg-gray-700 text-sm" | |
| 2107 | - rows={2} | |
| 2108 | - /> | |
| 1512 | + {field.type === "text_block" ? ( | |
| 1513 | + <div className="yatra-form-text-block text-sm text-gray-600 dark:text-gray-300 whitespace-pre-line"> | |
| 1514 | + {(field.content || "").replace(/<[^>]*>/g, "")} | |
| 1515 | + </div> | |
| 2109 | 1516 | ) : ( |
| 2110 | - <Input | |
| 2111 | - disabled | |
| 2112 | - type={field.type} | |
| 2113 | - placeholder={field.placeholder} | |
| 2114 | - className="mt-1" | |
| 2115 | - /> | |
| 1517 | + <> | |
| 1518 | + <Label className="text-sm"> | |
| 1519 | + {field.label} | |
| 1520 | + {field.required && ( | |
| 1521 | + <span className="text-red-500 ml-1">*</span> | |
| 1522 | + )} | |
| 1523 | + </Label> | |
| 1524 | + {field.type === "select" || | |
| 1525 | + field.type === "country" ? ( | |
| 1526 | + <Select disabled className="mt-1 w-full"> | |
| 1527 | + <option> | |
| 1528 | + {field.placeholder || "Select..."} | |
| 1529 | + </option> | |
| 1530 | + </Select> | |
| 1531 | + ) : field.type === "textarea" ? ( | |
| 1532 | + <textarea | |
| 1533 | + disabled | |
| 1534 | + placeholder={field.placeholder} | |
| 1535 | + className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-md bg-white dark:bg-gray-700 text-sm" | |
| 1536 | + rows={2} | |
| 1537 | + /> | |
| 1538 | + ) : ( | |
| 1539 | + <Input | |
| 1540 | + disabled | |
| 1541 | + type={field.type} | |
| 1542 | + placeholder={field.placeholder} | |
| 1543 | + className="mt-1" | |
| 1544 | + /> | |
| 1545 | + )} | |
| 1546 | + </> | |
| 2116 | 1547 | )} |
| 2117 | 1548 | </div> |
| 2118 | 1549 | ))} |
| 2119 | 1550 | </div> |
| @@ -2131,9 +1562,34 @@ | ||
| 2131 | 1562 | const { showToast } = useToast(); |
| 2132 | 1563 | |
| 2133 | 1564 | // Get active section from localStorage or default to 'general' |
| 2134 | 1565 | const getInitialActiveSection = (): SettingsSection => { |
| 1566 | + const VALID_SECTIONS = [ | |
| 1567 | + "general", | |
| 1568 | + "design", | |
| 1569 | + "booking", | |
| 1570 | + "booking_form", | |
| 1571 | + "payment", | |
| 1572 | + "trip", | |
| 1573 | + "customer", | |
| 1574 | + "review", | |
| 1575 | + "tax", | |
| 1576 | + "currency", | |
| 1577 | + "integration", | |
| 1578 | + "permalink", | |
| 1579 | + "seo", | |
| 1580 | + "advanced", | |
| 1581 | + "uninstall", | |
| 1582 | + ]; | |
| 2135 | 1583 | if (typeof window !== "undefined") { |
| 1584 | + // Allow deep-linking a section via ?section=… (e.g. the Google Calendar | |
| 1585 | + // dashboard's "Back to Settings → Integration" link). | |
| 1586 | + const fromUrl = new URLSearchParams(window.location.search).get( | |
| 1587 | + "section", | |
| 1588 | + ); | |
| 1589 | + if (fromUrl && VALID_SECTIONS.includes(fromUrl)) { | |
| 1590 | + return fromUrl as SettingsSection; | |
| 1591 | + } | |
| 2136 | 1592 | const saved = localStorage.getItem("yatra_settings_active_section"); |
| 2137 | 1593 | if (saved === "email" || saved === "notification") { |
| 2138 | 1594 | return "general"; |
| 2139 | 1595 | } |
| @@ -2153,8 +1609,9 @@ | ||
| 2153 | 1609 | "integration", |
| 2154 | 1610 | "permalink", |
| 2155 | 1611 | "seo", |
| 2156 | 1612 | "advanced", |
| 1613 | + "uninstall", | |
| 2157 | 1614 | ].includes(saved) |
| 2158 | 1615 | ) { |
| 2159 | 1616 | return saved as SettingsSection; |
| 2160 | 1617 | } |
| @@ -2269,9 +1726,9 @@ | ||
| 2269 | 1726 | // Default settings fallback |
| 2270 | 1727 | const defaultSettings: SettingsData = useMemo<SettingsData>( |
| 2271 | 1728 | () => ({ |
| 2272 | 1729 | company_name: "Yatra Travel Agency", |
| 2273 | - company_email: "[email protected]", | |
| 1730 | + company_email: "[email protected]", | |
| 2274 | 1731 | company_phone: "+1-234-567-8900", |
| 2275 | 1732 | company_address: "123 Travel Street", |
| 2276 | 1733 | company_city: "Kathmandu", |
| 2277 | 1734 | company_state: "Bagmati", |
| @@ -2276,9 +1733,9 @@ | ||
| 2276 | 1733 | company_city: "Kathmandu", |
| 2277 | 1734 | company_state: "Bagmati", |
| 2278 | 1735 | company_country: "Nepal", |
| 2279 | 1736 | company_zip: "44600", |
| 2280 | - company_website: "https://yatra.com", | |
| 1737 | + company_website: "https://wpyatra.com", | |
| 2281 | 1738 | company_logo: "", |
| 2282 | 1739 | timezone: "Asia/Kathmandu", |
| 2283 | 1740 | date_format: "Y-m-d", |
| 2284 | 1741 | time_format: "H:i", |
| @@ -2283,10 +1740,21 @@ | ||
| 2283 | 1740 | date_format: "Y-m-d", |
| 2284 | 1741 | time_format: "H:i", |
| 2285 | 1742 | frontend_primary_color: "#3b82f6", |
| 2286 | 1743 | frontend_container_max_width: "", |
| 1744 | + frontend_listing_card_layout: "standard", | |
| 1745 | + // Search & Listing — defaults match current behaviour (all fields shown, | |
| 1746 | + // mobile filters expanded) so existing sites are unchanged. | |
| 1747 | + search_show_keyword: true, | |
| 1748 | + search_show_destination: true, | |
| 1749 | + search_show_activities: true, | |
| 1750 | + search_show_duration: true, | |
| 1751 | + search_show_budget: true, | |
| 1752 | + search_show_date: false, | |
| 1753 | + collapse_filters_on_mobile: false, | |
| 2287 | 1754 | booking_confirmation: true, |
| 2288 | 1755 | auto_confirm_bookings: false, |
| 1756 | + auto_confirm_mode: "online", | |
| 2289 | 1757 | require_login: false, |
| 2290 | 1758 | allow_guest_checkout: true, |
| 2291 | 1759 | require_guest_email_verification: false, |
| 2292 | 1760 | booking_expiry_hours: 24, |
| @@ -2310,8 +1778,10 @@ | ||
| 2310 | 1778 | scheduled_payment_installments: 1, |
| 2311 | 1779 | discount_stacking_mode: "both", |
| 2312 | 1780 | scheduled_payment_interval: 30, |
| 2313 | 1781 | scheduled_payment_reminder_days: 3, |
| 1782 | + balance_anchor: "booking", | |
| 1783 | + balance_due_days: 14, | |
| 2314 | 1784 | gateway_configs: { |
| 2315 | 1785 | stripe: { |
| 2316 | 1786 | enabled: true, |
| 2317 | 1787 | title: "Stripe", |
| @@ -2389,10 +1859,10 @@ | ||
| 2389 | 1859 | "bank_transfer", |
| 2390 | 1860 | "esewa", |
| 2391 | 1861 | "khalti", |
| 2392 | 1862 | ], |
| 2393 | - admin_email: "[email protected]", | |
| 2394 | - from_email: "[email protected]", | |
| 1863 | + admin_email: "[email protected]", | |
| 1864 | + from_email: "[email protected]", | |
| 2395 | 1865 | from_name: "Yatra Travel", |
| 2396 | 1866 | email_template_booking: true, |
| 2397 | 1867 | email_template_confirmation: true, |
| 2398 | 1868 | email_template_cancellation: true, |
| @@ -2401,8 +1871,9 @@ | ||
| 2401 | 1871 | email_template_admin_payment: true, |
| 2402 | 1872 | email_template_admin_cancellation: true, |
| 2403 | 1873 | email_template_trip_consent: true, |
| 2404 | 1874 | email_template_customer_verification: true, |
| 1875 | + email_template_guest_verification: true, | |
| 2405 | 1876 | email_template_booking_completed: true, |
| 2406 | 1877 | email_template_booking_expired_customer: true, |
| 2407 | 1878 | email_template_admin_booking_expired: true, |
| 2408 | 1879 | email_template_scheduled_payment_reminder: true, |
| @@ -2411,9 +1882,9 @@ | ||
| 2411 | 1882 | email_template_admin_scheduled_payment_failed: true, |
| 2412 | 1883 | email_template_enquiry_received: true, |
| 2413 | 1884 | email_template_enquiry_admin: true, |
| 2414 | 1885 | email_template_enquiry_response: true, |
| 2415 | - email_template_review_request: true, | |
| 1886 | + email_template_review_request: false, | |
| 2416 | 1887 | email_template_abandoned_booking_recovery_first: true, |
| 2417 | 1888 | email_template_abandoned_booking_recovery_second: true, |
| 2418 | 1889 | email_template_abandoned_booking_recovery_final: true, |
| 2419 | 1890 | smtp_enabled: false, |
| @@ -2439,8 +1910,10 @@ | ||
| 2439 | 1910 | email_tpl_trip_consent_subject: "", |
| 2440 | 1911 | email_tpl_trip_consent_body: "", |
| 2441 | 1912 | email_tpl_customer_verification_subject: "", |
| 2442 | 1913 | email_tpl_customer_verification_body: "", |
| 1914 | + email_tpl_guest_verification_subject: "", | |
| 1915 | + email_tpl_guest_verification_body: "", | |
| 2443 | 1916 | email_tpl_booking_completed_subject: "", |
| 2444 | 1917 | email_tpl_booking_completed_body: "", |
| 2445 | 1918 | email_tpl_booking_expired_customer_subject: "", |
| 2446 | 1919 | email_tpl_booking_expired_customer_body: "", |
| @@ -2479,9 +1952,10 @@ | ||
| 2479 | 1952 | auto_approve_reviews: false, |
| 2480 | 1953 | review_moderation: true, |
| 2481 | 1954 | min_rating: 1, |
| 2482 | 1955 | allow_anonymous_reviews: false, |
| 2483 | - review_reminder_days: 7, | |
| 1956 | + review_reminder_days: 3, | |
| 1957 | + review_reminder_max_age_days: 14, | |
| 2484 | 1958 | enable_tax: true, |
| 2485 | 1959 | tax_name: __("Tax", "yatra"), |
| 2486 | 1960 | tax_rate: 10, |
| 2487 | 1961 | tax_inclusive: false, |
| @@ -2504,8 +1978,12 @@ | ||
| 2504 | 1978 | facebook_pixel: "", |
| 2505 | 1979 | recaptcha_enabled: false, |
| 2506 | 1980 | recaptcha_site_key: "", |
| 2507 | 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, | |
| 2508 | 1986 | trip_base: "trip", |
| 2509 | 1987 | destination_base: "destination", |
| 2510 | 1988 | activity_base: "activity", |
| 2511 | 1989 | trip_category_base: "trip-category", |
| @@ -2510,8 +1988,10 @@ | ||
| 2510 | 1988 | activity_base: "activity", |
| 2511 | 1989 | trip_category_base: "trip-category", |
| 2512 | 1990 | booking_base: "book", |
| 2513 | 1991 | enable_wishlist: false, |
| 1992 | + show_sold_out: true, | |
| 1993 | + availability_horizon_months: 12, | |
| 2514 | 1994 | use_booking_page: false, |
| 2515 | 1995 | booking_page_id: 0, |
| 2516 | 1996 | terms_page_id: 0, |
| 2517 | 1997 | privacy_policy_page_id: 0, |
| @@ -2516,8 +1996,9 @@ | ||
| 2516 | 1996 | terms_page_id: 0, |
| 2517 | 1997 | privacy_policy_page_id: 0, |
| 2518 | 1998 | debug_mode: false, |
| 2519 | 1999 | enable_logging: false, |
| 2000 | + delete_data_on_uninstall: false, | |
| 2520 | 2001 | cache_enabled: true, |
| 2521 | 2002 | api_key: "", |
| 2522 | 2003 | api_rate_limit: 100, |
| 2523 | 2004 | session_timeout: 3600, |
| @@ -2751,8 +2232,11 @@ | ||
| 2751 | 2232 | seo_trip_meta_title: "", |
| 2752 | 2233 | seo_trip_meta_description: "", |
| 2753 | 2234 | seo_trip_meta_keywords: "", |
| 2754 | 2235 | seo_trip_meta_image: 0, |
| 2236 | + enable_sitemap: true, | |
| 2237 | + sitemap_types: ["archive", "trip", "destination", "activity", "category"], | |
| 2238 | + sitemap_noindex_excluded: false, | |
| 2755 | 2239 | }), |
| 2756 | 2240 | [], |
| 2757 | 2241 | ); |
| 2758 | 2242 | |
| @@ -3106,9 +2590,9 @@ | ||
| 3106 | 2590 | // Use default settings if API fails or returns empty |
| 3107 | 2591 | setFormData(defaultSettings); |
| 3108 | 2592 | isInitializedRef.current = true; |
| 3109 | 2593 | } |
| 3110 | - // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 2594 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 3111 | 2595 | }, [settings, isLoading]); |
| 3112 | 2596 | |
| 3113 | 2597 | // Fetch SEO image URL when image ID changes with performance optimization |
| 3114 | 2598 | React.useEffect(() => { |
| @@ -3183,33 +2667,43 @@ | ||
| 3183 | 2667 | controller.abort(); |
| 3184 | 2668 | }; |
| 3185 | 2669 | }, [formData?.seo_trip_meta_image]); |
| 3186 | 2670 | |
| 3187 | - // 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); | |
| 3188 | 2681 | React.useEffect(() => { |
| 3189 | - if ( | |
| 3190 | - gatewayDefinitions && | |
| 3191 | - Object.keys(gatewayDefinitions).length > 0 && | |
| 3192 | - gatewayOrder.length === 0 | |
| 3193 | - ) { | |
| 3194 | - // Use saved order if available, otherwise use default order from definitions | |
| 3195 | - const savedOrder = settings?.gateway_order as string[] | undefined; | |
| 3196 | - if (savedOrder) { | |
| 3197 | - // Merge saved order with any new gateways | |
| 3198 | - const allGateways = Object.keys(gatewayDefinitions); | |
| 3199 | - const validSavedOrder = savedOrder.filter((id) => | |
| 3200 | - allGateways.includes(id), | |
| 3201 | - ); | |
| 3202 | - const newGateways = allGateways.filter( | |
| 3203 | - (id) => !validSavedOrder.includes(id), | |
| 3204 | - ); | |
| 3205 | - setGatewayOrder([...validSavedOrder, ...newGateways]); | |
| 3206 | - } else { | |
| 3207 | - setGatewayOrder(Object.keys(gatewayDefinitions)); | |
| 3208 | - } | |
| 2682 | + if (gatewayOrderInitialized.current) return; | |
| 2683 | + if (!gatewayDefinitions || Object.keys(gatewayDefinitions).length === 0) { | |
| 2684 | + return; | |
| 3209 | 2685 | } |
| 3210 | - }, [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; | |
| 3211 | 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 | + | |
| 3212 | 2706 | // Single stable onChange handler that reads field name from input's name or id attribute |
| 3213 | 2707 | const handleFieldChange = React.useCallback( |
| 3214 | 2708 | ( |
| 3215 | 2709 | e: React.ChangeEvent< |
| @@ -3221,10 +2715,19 @@ | ||
| 3221 | 2715 | |
| 3222 | 2716 | if (e.target.type === "checkbox") { |
| 3223 | 2717 | value = (e.target as HTMLInputElement).checked; |
| 3224 | 2718 | } else if (e.target.type === "number") { |
| 3225 | - const numValue = parseFloat(e.target.value); | |
| 3226 | - 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 | + } | |
| 3227 | 2730 | } else { |
| 3228 | 2731 | value = e.target.value; |
| 3229 | 2732 | } |
| 3230 | 2733 | |
| @@ -3587,8 +3090,13 @@ | ||
| 3587 | 3090 | label: __("Booking Form", "yatra"), |
| 3588 | 3091 | icon: ClipboardList, |
| 3589 | 3092 | }, |
| 3590 | 3093 | { |
| 3094 | + id: "search" as SettingsSection, | |
| 3095 | + label: __("Search & Listing", "yatra"), | |
| 3096 | + icon: SlidersHorizontal, | |
| 3097 | + }, | |
| 3098 | + { | |
| 3591 | 3099 | id: "payment" as SettingsSection, |
| 3592 | 3100 | label: __("Payment", "yatra"), |
| 3593 | 3101 | icon: DollarSign, |
| 3594 | 3102 | }, |
| @@ -3646,8 +3154,13 @@ | ||
| 3646 | 3154 | id: "advanced" as SettingsSection, |
| 3647 | 3155 | label: __("Advanced", "yatra"), |
| 3648 | 3156 | icon: Shield, |
| 3649 | 3157 | }, |
| 3158 | + { | |
| 3159 | + id: "uninstall" as SettingsSection, | |
| 3160 | + label: __("Uninstall", "yatra"), | |
| 3161 | + icon: Trash2, | |
| 3162 | + }, | |
| 3650 | 3163 | ]; |
| 3651 | 3164 | |
| 3652 | 3165 | // Helper component for section divider |
| 3653 | 3166 | const SectionDivider = ({ title }: { title: string }) => ( |
| @@ -4062,8 +3575,152 @@ | ||
| 4062 | 3575 | spellCheck={false} |
| 4063 | 3576 | autoComplete="off" |
| 4064 | 3577 | /> |
| 4065 | 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> | |
| 4066 | 3723 | </div> |
| 4067 | 3724 | </div> |
| 4068 | 3725 | ); |
| 4069 | 3726 | |
| @@ -4258,8 +3915,53 @@ | ||
| 4258 | 3915 | |
| 4259 | 3916 | {formData.enable_scheduled_payments && ( |
| 4260 | 3917 | <> |
| 4261 | 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 | |
| 4262 | 3964 | id="scheduled_payment_type" |
| 4263 | 3965 | label={__("Schedule type", "yatra")} |
| 4264 | 3966 | description={__( |
| 4265 | 3967 | "Single final charge on a date, or multiple installments.", |
| @@ -4280,27 +3982,29 @@ | ||
| 4280 | 3982 | </option> |
| 4281 | 3983 | </Select> |
| 4282 | 3984 | </FormField> |
| 4283 | 3985 | |
| 4284 | - <FormField | |
| 4285 | - id="scheduled_payment_days" | |
| 4286 | - label={__("Days until first charge", "yatra")} | |
| 4287 | - description={__( | |
| 4288 | - "How many days after the initial payment to schedule the first balance collection.", | |
| 4289 | - "yatra", | |
| 4290 | - )} | |
| 4291 | - > | |
| 4292 | - <Input | |
| 3986 | + {formData.balance_anchor !== "tour" && ( | |
| 3987 | + <FormField | |
| 4293 | 3988 | id="scheduled_payment_days" |
| 4294 | - type="number" | |
| 4295 | - min={0} | |
| 4296 | - max={365} | |
| 4297 | - value={formData.scheduled_payment_days} | |
| 4298 | - name="scheduled_payment_days" | |
| 4299 | - onChange={handleFieldChange} | |
| 4300 | - className="max-w-xs" | |
| 4301 | - /> | |
| 4302 | - </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 | + )} | |
| 4303 | 4007 | |
| 4304 | 4008 | {formData.scheduled_payment_type === "installments" && ( |
| 4305 | 4009 | <> |
| 4306 | 4010 | <FormField |
| @@ -4733,8 +4437,117 @@ | ||
| 4733 | 4437 | return null; |
| 4734 | 4438 | } |
| 4735 | 4439 | } |
| 4736 | 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 | + | |
| 4737 | 4550 | // Render checkbox fields differently |
| 4738 | 4551 | if (field.type === "checkbox") { |
| 4739 | 4552 | return ( |
| 4740 | 4553 | <div |
| @@ -5015,8 +4828,53 @@ | ||
| 5015 | 4828 | </div> |
| 5016 | 4829 | ); |
| 5017 | 4830 | } |
| 5018 | 4831 | |
| 4832 | + // Read-only display field (e.g. PayPal webhook URL): show the | |
| 4833 | + // value with a Copy button; not editable, never saved. | |
| 4834 | + if (field.readonly) { | |
| 4835 | + const roValue = String( | |
| 4836 | + config[field.id] ?? field.default ?? "", | |
| 4837 | + ); | |
| 4838 | + return ( | |
| 4839 | + <div | |
| 4840 | + key={field.id} | |
| 4841 | + className="space-y-2" | |
| 4842 | + > | |
| 4843 | + <FormField | |
| 4844 | + id={`${gatewayId}_${field.id}`} | |
| 4845 | + label={field.label} | |
| 4846 | + description={field.description} | |
| 4847 | + > | |
| 4848 | + <div className="flex items-center gap-2"> | |
| 4849 | + <Input | |
| 4850 | + id={`${gatewayId}_${field.id}`} | |
| 4851 | + value={roValue} | |
| 4852 | + readOnly | |
| 4853 | + className="bg-gray-50 dark:bg-gray-900 font-mono text-sm" | |
| 4854 | + /> | |
| 4855 | + <Button | |
| 4856 | + variant="outline" | |
| 4857 | + size="sm" | |
| 4858 | + onClick={() => | |
| 4859 | + navigator.clipboard.writeText( | |
| 4860 | + roValue, | |
| 4861 | + ) | |
| 4862 | + } | |
| 4863 | + > | |
| 4864 | + {__("Copy", "yatra")} | |
| 4865 | + </Button> | |
| 4866 | + </div> | |
| 4867 | + </FormField> | |
| 4868 | + {field.help_text && ( | |
| 4869 | + <p className="text-xs text-blue-600 dark:text-blue-400"> | |
| 4870 | + {field.help_text} | |
| 4871 | + </p> | |
| 4872 | + )} | |
| 4873 | + </div> | |
| 4874 | + ); | |
| 4875 | + } | |
| 4876 | + | |
| 5019 | 4877 | // Render text, password, number, email inputs |
| 5020 | 4878 | return ( |
| 5021 | 4879 | <div key={field.id} className="space-y-2"> |
| 5022 | 4880 | <FormField |
| @@ -5038,12 +4896,21 @@ | ||
| 5038 | 4896 | onChange={(e) => |
| 5039 | 4897 | handleGatewayConfigChange( |
| 5040 | 4898 | gatewayId, |
| 5041 | 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. | |
| 5042 | 4907 | field.type === "number" |
| 5043 | - ? parseFloat( | |
| 5044 | - e.target.value, | |
| 5045 | - ) || 0 | |
| 4908 | + ? e.target.value === "" | |
| 4909 | + ? "" | |
| 4910 | + : parseFloat( | |
| 4911 | + e.target.value, | |
| 4912 | + ) || 0 | |
| 5046 | 4913 | : e.target.value, |
| 5047 | 4914 | ) |
| 5048 | 4915 | } |
| 5049 | 4916 | placeholder={field.placeholder} |
| @@ -5106,57 +4973,162 @@ | ||
| 5106 | 4973 | </div> |
| 5107 | 4974 | </> |
| 5108 | 4975 | ); |
| 5109 | 4976 | |
| 5110 | - // Continue with other sections... (I'll add the most important ones) | |
| 5111 | - case "booking": | |
| 4977 | + case "search": | |
| 5112 | 4978 | return ( |
| 5113 | 4979 | <div className="space-y-6"> |
| 5114 | - <div className="space-y-4"> | |
| 4980 | + <div> | |
| 4981 | + <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1"> | |
| 4982 | + {__("Search Bar Fields", "yatra")} | |
| 4983 | + </h3> | |
| 4984 | + <p className="text-xs text-gray-500 dark:text-gray-400 mb-4"> | |
| 4985 | + {__( | |
| 4986 | + "Choose which fields appear in the trip search bar. Turn off the ones you don't need for a cleaner search.", | |
| 4987 | + "yatra", | |
| 4988 | + )} | |
| 4989 | + </p> | |
| 4990 | + <div className="space-y-4"> | |
| 4991 | + {[ | |
| 4992 | + { | |
| 4993 | + key: "search_show_keyword" as const, | |
| 4994 | + label: __("Keyword search", "yatra"), | |
| 4995 | + description: __( | |
| 4996 | + "Free-text field to search by trip name or keyword.", | |
| 4997 | + "yatra", | |
| 4998 | + ), | |
| 4999 | + }, | |
| 5000 | + { | |
| 5001 | + key: "search_show_destination" as const, | |
| 5002 | + label: __("Destination", "yatra"), | |
| 5003 | + description: __( | |
| 5004 | + "Dropdown to filter trips by destination.", | |
| 5005 | + "yatra", | |
| 5006 | + ), | |
| 5007 | + }, | |
| 5008 | + { | |
| 5009 | + key: "search_show_activities" as const, | |
| 5010 | + label: __("Activities", "yatra"), | |
| 5011 | + description: __( | |
| 5012 | + "Dropdown to filter trips by activity.", | |
| 5013 | + "yatra", | |
| 5014 | + ), | |
| 5015 | + }, | |
| 5016 | + { | |
| 5017 | + key: "search_show_duration" as const, | |
| 5018 | + label: __("Duration", "yatra"), | |
| 5019 | + description: __( | |
| 5020 | + "Range slider to filter trips by number of days.", | |
| 5021 | + "yatra", | |
| 5022 | + ), | |
| 5023 | + }, | |
| 5024 | + { | |
| 5025 | + key: "search_show_budget" as const, | |
| 5026 | + label: __("Budget", "yatra"), | |
| 5027 | + description: __( | |
| 5028 | + "Dropdown to filter trips by price range.", | |
| 5029 | + "yatra", | |
| 5030 | + ), | |
| 5031 | + }, | |
| 5032 | + { | |
| 5033 | + key: "search_show_date" as const, | |
| 5034 | + label: __("Date", "yatra"), | |
| 5035 | + description: __( | |
| 5036 | + "Date picker to find trips with a departure on a specific date.", | |
| 5037 | + "yatra", | |
| 5038 | + ), | |
| 5039 | + }, | |
| 5040 | + ].map((row) => ( | |
| 5041 | + <div | |
| 5042 | + key={row.key} | |
| 5043 | + className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md" | |
| 5044 | + > | |
| 5045 | + <input | |
| 5046 | + type="checkbox" | |
| 5047 | + id={row.key} | |
| 5048 | + checked={!!formData[row.key]} | |
| 5049 | + name={row.key} | |
| 5050 | + onChange={handleFieldChange} | |
| 5051 | + className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" | |
| 5052 | + /> | |
| 5053 | + <div className="flex-1"> | |
| 5054 | + <Label | |
| 5055 | + htmlFor={row.key} | |
| 5056 | + className="font-medium cursor-pointer" | |
| 5057 | + > | |
| 5058 | + {row.label} | |
| 5059 | + </Label> | |
| 5060 | + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> | |
| 5061 | + {row.description} | |
| 5062 | + </p> | |
| 5063 | + </div> | |
| 5064 | + </div> | |
| 5065 | + ))} | |
| 5066 | + </div> | |
| 5067 | + </div> | |
| 5068 | + | |
| 5069 | + <div> | |
| 5070 | + <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1"> | |
| 5071 | + {__("Mobile Filters", "yatra")} | |
| 5072 | + </h3> | |
| 5073 | + <p className="text-xs text-gray-500 dark:text-gray-400 mb-4"> | |
| 5074 | + {__( | |
| 5075 | + "Controls the filter sidebar on the trip listing page on mobile devices.", | |
| 5076 | + "yatra", | |
| 5077 | + )} | |
| 5078 | + </p> | |
| 5115 | 5079 | <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> |
| 5116 | 5080 | <input |
| 5117 | 5081 | type="checkbox" |
| 5118 | - id="booking_confirmation" | |
| 5119 | - checked={formData.booking_confirmation} | |
| 5120 | - name="booking_confirmation" | |
| 5082 | + id="collapse_filters_on_mobile" | |
| 5083 | + checked={!!formData.collapse_filters_on_mobile} | |
| 5084 | + name="collapse_filters_on_mobile" | |
| 5121 | 5085 | onChange={handleFieldChange} |
| 5122 | 5086 | className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" |
| 5123 | 5087 | /> |
| 5124 | 5088 | <div className="flex-1"> |
| 5125 | 5089 | <Label |
| 5126 | - htmlFor="booking_confirmation" | |
| 5090 | + htmlFor="collapse_filters_on_mobile" | |
| 5127 | 5091 | className="font-medium cursor-pointer" |
| 5128 | 5092 | > |
| 5129 | - {__("Send Booking Confirmation Email", "yatra")} | |
| 5093 | + {__("Collapse filters by default on mobile", "yatra")} | |
| 5130 | 5094 | </Label> |
| 5131 | 5095 | <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> |
| 5132 | 5096 | {__( |
| 5133 | - "Send a confirmation email to the customer immediately after a successful booking. The post-checkout page is always shown; this toggle only controls the email.", | |
| 5097 | + "On mobile, show filter sections collapsed so trip listings are visible right away. Visitors tap a section to expand it. Desktop is unaffected.", | |
| 5134 | 5098 | "yatra", |
| 5135 | 5099 | )} |
| 5136 | 5100 | </p> |
| 5137 | 5101 | </div> |
| 5138 | 5102 | </div> |
| 5103 | + </div> | |
| 5104 | + </div> | |
| 5105 | + ); | |
| 5139 | 5106 | |
| 5107 | + // Continue with other sections... (I'll add the most important ones) | |
| 5108 | + case "booking": | |
| 5109 | + return ( | |
| 5110 | + <div className="space-y-6"> | |
| 5111 | + <div className="space-y-4"> | |
| 5140 | 5112 | <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> |
| 5141 | 5113 | <input |
| 5142 | 5114 | type="checkbox" |
| 5143 | - id="auto_confirm_bookings" | |
| 5144 | - checked={formData.auto_confirm_bookings} | |
| 5145 | - name="auto_confirm_bookings" | |
| 5115 | + id="booking_confirmation" | |
| 5116 | + checked={formData.booking_confirmation} | |
| 5117 | + name="booking_confirmation" | |
| 5146 | 5118 | onChange={handleFieldChange} |
| 5147 | 5119 | className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" |
| 5148 | 5120 | /> |
| 5149 | 5121 | <div className="flex-1"> |
| 5150 | 5122 | <Label |
| 5151 | - htmlFor="auto_confirm_bookings" | |
| 5123 | + htmlFor="booking_confirmation" | |
| 5152 | 5124 | className="font-medium cursor-pointer" |
| 5153 | 5125 | > |
| 5154 | - {__("Auto-Confirm Bookings", "yatra")} | |
| 5126 | + {__("Send Booking Confirmation Email", "yatra")} | |
| 5155 | 5127 | </Label> |
| 5156 | 5128 | <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> |
| 5157 | 5129 | {__( |
| 5158 | - "Automatically confirm bookings without manual approval", | |
| 5130 | + "Send a confirmation email to the customer immediately after a successful booking. The post-checkout page is always shown; this toggle only controls the email.", | |
| 5159 | 5131 | "yatra", |
| 5160 | 5132 | )} |
| 5161 | 5133 | </p> |
| 5162 | 5134 | </div> |
| @@ -5161,8 +5133,85 @@ | ||
| 5161 | 5133 | </p> |
| 5162 | 5134 | </div> |
| 5163 | 5135 | </div> |
| 5164 | 5136 | |
| 5137 | + <div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> | |
| 5138 | + <div className="font-medium"> | |
| 5139 | + {__("Auto-Confirm Bookings", "yatra")} | |
| 5140 | + </div> | |
| 5141 | + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 mb-2"> | |
| 5142 | + {__( | |
| 5143 | + "Choose when a booking is automatically set to Confirmed. Offline methods (bank transfer, pay later) stay pending until you confirm them.", | |
| 5144 | + "yatra", | |
| 5145 | + )} | |
| 5146 | + </p> | |
| 5147 | + <div role="radiogroup" className="flex flex-col gap-2"> | |
| 5148 | + {[ | |
| 5149 | + { | |
| 5150 | + value: "none", | |
| 5151 | + label: __("Don't auto-confirm", "yatra"), | |
| 5152 | + sub: __( | |
| 5153 | + "Every booking stays Pending until you confirm it manually.", | |
| 5154 | + "yatra", | |
| 5155 | + ), | |
| 5156 | + }, | |
| 5157 | + { | |
| 5158 | + value: "online", | |
| 5159 | + label: __("Auto-confirm online payments only", "yatra"), | |
| 5160 | + sub: __( | |
| 5161 | + "When an online payment (Stripe, PayPal, Razorpay…) pays the balance in full, the booking is confirmed. Deposits/partial payments and offline methods stay Pending.", | |
| 5162 | + "yatra", | |
| 5163 | + ), | |
| 5164 | + }, | |
| 5165 | + { | |
| 5166 | + value: "all", | |
| 5167 | + label: __("Auto-confirm all bookings", "yatra"), | |
| 5168 | + sub: __( | |
| 5169 | + "Every booking is confirmed at checkout, whether or not it was paid.", | |
| 5170 | + "yatra", | |
| 5171 | + ), | |
| 5172 | + }, | |
| 5173 | + ].map((opt) => ( | |
| 5174 | + <label | |
| 5175 | + key={opt.value} | |
| 5176 | + htmlFor={`auto_confirm_mode_${opt.value}`} | |
| 5177 | + className="flex items-start gap-2 cursor-pointer" | |
| 5178 | + > | |
| 5179 | + <input | |
| 5180 | + type="radio" | |
| 5181 | + id={`auto_confirm_mode_${opt.value}`} | |
| 5182 | + name="auto_confirm_mode" | |
| 5183 | + value={opt.value} | |
| 5184 | + checked={ | |
| 5185 | + (formData.auto_confirm_mode || "none") === opt.value | |
| 5186 | + } | |
| 5187 | + onChange={() => | |
| 5188 | + setFormData((prev) => | |
| 5189 | + prev | |
| 5190 | + ? { | |
| 5191 | + ...prev, | |
| 5192 | + auto_confirm_mode: | |
| 5193 | + opt.value as SettingsData["auto_confirm_mode"], | |
| 5194 | + // Keep the legacy boolean in sync for any | |
| 5195 | + // external reader (all → true, otherwise false). | |
| 5196 | + auto_confirm_bookings: opt.value === "all", | |
| 5197 | + } | |
| 5198 | + : prev, | |
| 5199 | + ) | |
| 5200 | + } | |
| 5201 | + className="mt-0.5 w-4 h-4 border-gray-300 text-blue-600 focus:ring-blue-500" | |
| 5202 | + /> | |
| 5203 | + <div className="flex-1"> | |
| 5204 | + <span className="font-medium text-sm">{opt.label}</span> | |
| 5205 | + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> | |
| 5206 | + {opt.sub} | |
| 5207 | + </p> | |
| 5208 | + </div> | |
| 5209 | + </label> | |
| 5210 | + ))} | |
| 5211 | + </div> | |
| 5212 | + </div> | |
| 5213 | + | |
| 5165 | 5214 | <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> |
| 5166 | 5215 | <input |
| 5167 | 5216 | type="checkbox" |
| 5168 | 5217 | id="require_login" |
| @@ -5251,8 +5300,54 @@ | ||
| 5251 | 5300 | |
| 5252 | 5301 | <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> |
| 5253 | 5302 | <input |
| 5254 | 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" | |
| 5255 | 5350 | id="allow_waitlist" |
| 5256 | 5351 | checked={formData.allow_waitlist} |
| 5257 | 5352 | name="allow_waitlist" |
| 5258 | 5353 | onChange={handleFieldChange} |
| @@ -5844,9 +5939,9 @@ | ||
| 5844 | 5939 | <FormField |
| 5845 | 5940 | id="review_reminder_days" |
| 5846 | 5941 | label={__("Review Reminder Days", "yatra")} |
| 5847 | 5942 | description={__( |
| 5848 | - "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.", | |
| 5849 | 5944 | "yatra", |
| 5850 | 5945 | )} |
| 5851 | 5946 | > |
| 5852 | 5947 | <Input |
| @@ -5857,8 +5952,26 @@ | ||
| 5857 | 5952 | onChange={handleFieldChange} |
| 5858 | 5953 | min="0" |
| 5859 | 5954 | /> |
| 5860 | 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> | |
| 5861 | 5974 | </> |
| 5862 | 5975 | )} |
| 5863 | 5976 | </div> |
| 5864 | 5977 | </div> |
| @@ -7673,8 +7786,93 @@ | ||
| 7673 | 7786 | onChange={handleFieldChange} |
| 7674 | 7787 | placeholder={__("Enter secret key", "yatra")} |
| 7675 | 7788 | /> |
| 7676 | 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> | |
| 7677 | 7875 | </> |
| 7678 | 7876 | )} |
| 7679 | 7877 | </div> |
| 7680 | 7878 | </div> |
| @@ -8367,8 +8565,232 @@ | ||
| 8367 | 8565 | )} |
| 8368 | 8566 | </div> |
| 8369 | 8567 | </FormField> |
| 8370 | 8568 | </div> |
| 8569 | + </div> | |
| 8570 | + | |
| 8571 | + <div> | |
| 8572 | + <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-4"> | |
| 8573 | + {__("Sitemap", "yatra")} | |
| 8574 | + </h3> | |
| 8575 | + <p className="text-xs text-gray-500 dark:text-gray-400 mb-4"> | |
| 8576 | + {__( | |
| 8577 | + "Yatra publishes one sitemap of all trips, destinations, activities and categories so search engines can find them. It is advertised in robots.txt and linked into Yoast, Rank Math, AIOSEO and WordPress core sitemaps automatically.", | |
| 8578 | + "yatra", | |
| 8579 | + )} | |
| 8580 | + </p> | |
| 8581 | + <div className="space-y-4"> | |
| 8582 | + <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> | |
| 8583 | + <input | |
| 8584 | + type="checkbox" | |
| 8585 | + id="enable_sitemap" | |
| 8586 | + checked={formData.enable_sitemap} | |
| 8587 | + name="enable_sitemap" | |
| 8588 | + onChange={handleFieldChange} | |
| 8589 | + className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" | |
| 8590 | + /> | |
| 8591 | + <div className="flex-1"> | |
| 8592 | + <Label | |
| 8593 | + htmlFor="enable_sitemap" | |
| 8594 | + className="font-medium cursor-pointer" | |
| 8595 | + > | |
| 8596 | + {__("Enable Sitemap", "yatra")} | |
| 8597 | + </Label> | |
| 8598 | + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> | |
| 8599 | + {__( | |
| 8600 | + "Generate /yatra-sitemap.xml and advertise it to search engines.", | |
| 8601 | + "yatra", | |
| 8602 | + )} | |
| 8603 | + </p> | |
| 8604 | + </div> | |
| 8605 | + </div> | |
| 8606 | + {formData.enable_sitemap && ( | |
| 8607 | + <div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> | |
| 8608 | + <Label className="font-medium"> | |
| 8609 | + {__("Sitemap URL", "yatra")} | |
| 8610 | + </Label> | |
| 8611 | + <div className="mt-2 flex items-center gap-2"> | |
| 8612 | + <a | |
| 8613 | + href={ | |
| 8614 | + (window as { yatraAdmin?: { sitemapUrl?: string } }) | |
| 8615 | + .yatraAdmin?.sitemapUrl || "#" | |
| 8616 | + } | |
| 8617 | + target="_blank" | |
| 8618 | + rel="noopener noreferrer" | |
| 8619 | + className="flex-1 break-all text-sm text-blue-600 dark:text-blue-400 underline" | |
| 8620 | + > | |
| 8621 | + {(window as { yatraAdmin?: { sitemapUrl?: string } }) | |
| 8622 | + .yatraAdmin?.sitemapUrl || ""} | |
| 8623 | + </a> | |
| 8624 | + <button | |
| 8625 | + type="button" | |
| 8626 | + onClick={() => | |
| 8627 | + navigator.clipboard?.writeText( | |
| 8628 | + (window as { yatraAdmin?: { sitemapUrl?: string } }) | |
| 8629 | + .yatraAdmin?.sitemapUrl || "", | |
| 8630 | + ) | |
| 8631 | + } | |
| 8632 | + className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700" | |
| 8633 | + > | |
| 8634 | + {__("Copy", "yatra")} | |
| 8635 | + </button> | |
| 8636 | + </div> | |
| 8637 | + <p className="text-xs text-gray-500 dark:text-gray-400 mt-2"> | |
| 8638 | + {__( | |
| 8639 | + "Optional: submit this URL in Google Search Console or Bing Webmaster Tools. It is already listed in robots.txt, so search engines find it automatically.", | |
| 8640 | + "yatra", | |
| 8641 | + )} | |
| 8642 | + | |
| 8643 | + {formData.enable_sitemap && ( | |
| 8644 | + <div className="p-3 border border-gray-200 dark:border-gray-700 rounded-md space-y-3"> | |
| 8645 | + <div> | |
| 8646 | + <Label className="font-medium"> | |
| 8647 | + {__("Content in the sitemap", "yatra")} | |
| 8648 | + </Label> | |
| 8649 | + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> | |
| 8650 | + {__( | |
| 8651 | + "Uncheck a type to keep it out of /yatra-sitemap.xml.", | |
| 8652 | + "yatra", | |
| 8653 | + )} | |
| 8654 | + </p> | |
| 8655 | + </div> | |
| 8656 | + <div className="grid grid-cols-2 sm:grid-cols-3 gap-2"> | |
| 8657 | + {[ | |
| 8658 | + { key: "trip", label: __("Trips", "yatra") }, | |
| 8659 | + { key: "destination", label: __("Destinations", "yatra") }, | |
| 8660 | + { key: "activity", label: __("Activities", "yatra") }, | |
| 8661 | + { key: "category", label: __("Trip categories", "yatra") }, | |
| 8662 | + { key: "archive", label: __("Listing pages", "yatra") }, | |
| 8663 | + ].map((t) => { | |
| 8664 | + const selected = formData.sitemap_types ?? []; | |
| 8665 | + const checked = selected.includes(t.key); | |
| 8666 | + return ( | |
| 8667 | + <label | |
| 8668 | + key={t.key} | |
| 8669 | + className="flex items-center gap-2 text-sm cursor-pointer" | |
| 8670 | + > | |
| 8671 | + <input | |
| 8672 | + type="checkbox" | |
| 8673 | + checked={checked} | |
| 8674 | + onChange={() => | |
| 8675 | + setFormData((prev) => { | |
| 8676 | + if (!prev) return prev; | |
| 8677 | + return { | |
| 8678 | + ...prev, | |
| 8679 | + sitemap_types: checked | |
| 8680 | + ? (prev.sitemap_types ?? []).filter( | |
| 8681 | + (x: string) => x !== t.key, | |
| 8682 | + ) | |
| 8683 | + : [...(prev.sitemap_types ?? []), t.key], | |
| 8684 | + }; | |
| 8685 | + }) | |
| 8686 | + } | |
| 8687 | + className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" | |
| 8688 | + /> | |
| 8689 | + <span>{t.label}</span> | |
| 8690 | + </label> | |
| 8691 | + ); | |
| 8692 | + })} | |
| 8693 | + </div> | |
| 8694 | + | |
| 8695 | + <div className="flex items-start gap-2 pt-2 border-t border-gray-200 dark:border-gray-700"> | |
| 8696 | + <input | |
| 8697 | + type="checkbox" | |
| 8698 | + id="sitemap_noindex_excluded" | |
| 8699 | + name="sitemap_noindex_excluded" | |
| 8700 | + checked={formData.sitemap_noindex_excluded} | |
| 8701 | + onChange={(e) => | |
| 8702 | + setFormData((prev) => { | |
| 8703 | + if (!prev) return prev; | |
| 8704 | + return { | |
| 8705 | + ...prev, | |
| 8706 | + sitemap_noindex_excluded: e.target.checked, | |
| 8707 | + }; | |
| 8708 | + }) | |
| 8709 | + } | |
| 8710 | + className="mt-0.5 w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" | |
| 8711 | + /> | |
| 8712 | + <div className="flex-1"> | |
| 8713 | + <Label | |
| 8714 | + htmlFor="sitemap_noindex_excluded" | |
| 8715 | + className="font-medium cursor-pointer" | |
| 8716 | + > | |
| 8717 | + {__("Also add noindex to excluded types", "yatra")} | |
| 8718 | + </Label> | |
| 8719 | + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> | |
| 8720 | + {__( | |
| 8721 | + "Leaving a type out of the sitemap does not stop Google indexing it. Turn this on to send noindex on those pages as well — they will drop out of search results, so only do this for content you do not want found.", | |
| 8722 | + "yatra", | |
| 8723 | + )} | |
| 8724 | + </p> | |
| 8725 | + </div> | |
| 8726 | + </div> | |
| 8727 | + </div> | |
| 8728 | + )} | |
| 8729 | + </p> | |
| 8730 | + </div> | |
| 8731 | + )} | |
| 8732 | + </div> | |
| 8733 | + </div> | |
| 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 | + )} | |
| 8371 | 8793 | </div> |
| 8372 | 8794 | </div> |
| 8373 | 8795 | ); |
| 8374 | 8796 | |