| @@ -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, |
| @@ -44,13 +45,15 @@ | ||
| 44 | 45 | X, |
| 45 | 46 | TrendingUp, |
| 46 | 47 | BarChart3, |
| 47 | 48 | Palette, |
| 49 | + Percent, | |
| 48 | 50 | } from "lucide-react"; |
| 49 | -import { __ } from "../lib/i18n"; | |
| 51 | +import { __, sprintf } from "../lib/i18n"; | |
| 50 | 52 | import { prepareWordPressMediaFrameOpen } from "../lib/wp-media-open"; |
| 51 | 53 | import { usePermissions } from "../hooks/usePermissions"; |
| 52 | 54 | import { useToast } from "../components/ui/toast"; |
| 55 | +import { navigateMenu } from "../hooks/useNavigate"; | |
| 53 | 56 | import { apiClient, apiService } from "../lib/api-client"; |
| 54 | 57 | import { |
| 55 | 58 | fetchBookingPageShortcodeStatus, |
| 56 | 59 | fetchPaymentGatewayDefinitions, |
| @@ -89,8 +92,20 @@ | ||
| 89 | 92 | } from "../lib/frontend-permalink-urls"; |
| 90 | 93 | import { getCurrencyOptions } from "../data/currencies"; |
| 91 | 94 | import { SearchableSelect } from "../components/ui/searchable-select"; |
| 92 | 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"; | |
| 93 | 108 | |
| 94 | 109 | // Multiple Taxes Editor Component |
| 95 | 110 | const MultipleTaxesEditor = React.memo( |
| 96 | 111 | ({ |
| @@ -224,8 +239,11 @@ | ||
| 224 | 239 | }> = ({ formData, setFormData }) => { |
| 225 | 240 | const yatraAdmin = (window as any).yatraAdmin || {}; |
| 226 | 241 | const gcSettings = yatraAdmin.googleCalendar || {}; |
| 227 | 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`; | |
| 228 | 246 | |
| 229 | 247 | // Use state from parent formData if available, otherwise use from yatraAdmin |
| 230 | 248 | const [clientId, setClientId] = useState( |
| 231 | 249 | formData.google_calendar_client_id || gcSettings.client_id || "", |
| @@ -243,8 +261,10 @@ | ||
| 243 | 261 | formData.google_calendar_connected || gcSettings.connected || false, |
| 244 | 262 | ); |
| 245 | 263 | const [connecting, setConnecting] = useState(false); |
| 246 | 264 | const [syncing, setSyncing] = useState(false); |
| 265 | + const [savingCalendar, setSavingCalendar] = useState(false); | |
| 266 | + const { showToast } = useToast(); | |
| 247 | 267 | |
| 248 | 268 | const redirectUri = |
| 249 | 269 | yatraAdmin.googleCalendarRedirectUri || |
| 250 | 270 | gcSettings.redirect_uri || |
| @@ -258,8 +278,13 @@ | ||
| 258 | 278 | const allSettings = { |
| 259 | 279 | google_calendar_client_id: clientId, |
| 260 | 280 | google_calendar_client_secret: clientSecret, |
| 261 | 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, | |
| 262 | 287 | }; |
| 263 | 288 | |
| 264 | 289 | // Update formData with all settings |
| 265 | 290 | setFormData((prev) => { |
| @@ -273,9 +298,10 @@ | ||
| 273 | 298 | |
| 274 | 299 | // Update formData when any setting changes |
| 275 | 300 | useEffect(() => { |
| 276 | 301 | syncSettingsToFormData(); |
| 277 | - }, [clientId, clientSecret]); | |
| 302 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 303 | + }, [clientId, clientSecret, calendarId, calendarName]); | |
| 278 | 304 | |
| 279 | 305 | const handleConnect = async () => { |
| 280 | 306 | setConnecting(true); |
| 281 | 307 | try { |
| @@ -307,10 +333,14 @@ | ||
| 307 | 333 | // Update settings state |
| 308 | 334 | setConnected(false); |
| 309 | 335 | setCalendarId(""); |
| 310 | 336 | setCalendarName(""); |
| 337 | + showToast(__("Disconnected from Google Calendar.", "yatra"), "success"); | |
| 311 | 338 | } catch (error) { |
| 312 | - console.error("Failed to disconnect:", error); | |
| 339 | + showToast( | |
| 340 | + __("Failed to disconnect. Please try again.", "yatra"), | |
| 341 | + "error", | |
| 342 | + ); | |
| 313 | 343 | } |
| 314 | 344 | }; |
| 315 | 345 | |
| 316 | 346 | const handleSync = async () => { |
| @@ -315,18 +345,52 @@ | ||
| 315 | 345 | |
| 316 | 346 | const handleSync = async () => { |
| 317 | 347 | setSyncing(true); |
| 318 | 348 | try { |
| 319 | - await apiClient.post("/google-calendar/sync-all"); | |
| 320 | - // No need to update lastSync state directly as it's derived from formData | |
| 321 | - // 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 | + } | |
| 322 | 362 | } catch (error) { |
| 323 | - console.error("Failed to sync:", error); | |
| 363 | + showToast( | |
| 364 | + __("Failed to sync bookings. Please try again.", "yatra"), | |
| 365 | + "error", | |
| 366 | + ); | |
| 324 | 367 | } finally { |
| 325 | 368 | setSyncing(false); |
| 326 | 369 | } |
| 327 | 370 | }; |
| 328 | 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 | + | |
| 329 | 393 | return ( |
| 330 | 394 | <div className="space-y-6"> |
| 331 | 395 | {/* Settings Card */} |
| 332 | 396 | <Card> |
| @@ -504,9 +568,9 @@ | ||
| 504 | 568 | <h4 className="font-medium text-sm text-green-800 dark:text-green-400 flex items-center gap-2"> |
| 505 | 569 | <CheckCircle className="w-4 h-4" /> |
| 506 | 570 | {__("Connected Calendar", "yatra")} |
| 507 | 571 | </h4> |
| 508 | - <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"> | |
| 509 | 573 | <FormField |
| 510 | 574 | id="gc_calendar_id" |
| 511 | 575 | label={__("Calendar ID", "yatra")} |
| 512 | 576 | description={__( |
| @@ -548,20 +612,42 @@ | ||
| 548 | 612 | ) : ( |
| 549 | 613 | __("Never synced", "yatra") |
| 550 | 614 | )} |
| 551 | 615 | </p> |
| 552 | - <Button | |
| 553 | - variant="outline" | |
| 554 | - size="sm" | |
| 555 | - onClick={handleSync} | |
| 556 | - disabled={syncing} | |
| 557 | - className="flex items-center gap-2" | |
| 558 | - > | |
| 559 | - <RefreshCw | |
| 560 | - className={`w-4 h-4 ${syncing ? "animate-spin" : ""}`} | |
| 561 | - /> | |
| 562 | - {__("Sync Now", "yatra")} | |
| 563 | - </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> | |
| 564 | 650 | </div> |
| 565 | 651 | </div> |
| 566 | 652 | )} |
| 567 | 653 | |
| @@ -567,8 +653,15 @@ | ||
| 567 | 653 | |
| 568 | 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"> |
| 569 | 655 | <div className="flex flex-wrap gap-3"> |
| 570 | 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 | |
| 571 | 664 | href="https://console.cloud.google.com/apis/credentials" |
| 572 | 665 | target="_blank" |
| 573 | 666 | rel="noopener noreferrer" |
| 574 | 667 | className="flex items-center gap-1 text-blue-600 hover:text-blue-500 dark:text-blue-400 text-sm" |
| @@ -631,9 +724,11 @@ | ||
| 631 | 724 | | "design" |
| 632 | 725 | | "booking" |
| 633 | 726 | | "booking_form" |
| 634 | 727 | | "payment" |
| 728 | + | "pricing" | |
| 635 | 729 | | "trip" |
| 730 | + | "search" | |
| 636 | 731 | | "customer" |
| 637 | 732 | | "review" |
| 638 | 733 | | "tax" |
| 639 | 734 | | "currency" |
| @@ -639,47 +734,14 @@ | ||
| 639 | 734 | | "currency" |
| 640 | 735 | | "integration" |
| 641 | 736 | | "permalink" |
| 642 | 737 | | "seo" |
| 643 | - | "advanced"; | |
| 738 | + | "advanced" | |
| 739 | + | "uninstall"; | |
| 644 | 740 | |
| 645 | 741 | // Form Builder Types |
| 646 | -interface FormFieldConfig { | |
| 647 | - id: string; | |
| 648 | - type: | |
| 649 | - | "text" | |
| 650 | - | "email" | |
| 651 | - | "tel" | |
| 652 | - | "date" | |
| 653 | - | "select" | |
| 654 | - | "country" | |
| 655 | - | "textarea" | |
| 656 | - | "checkbox" | |
| 657 | - | "number"; | |
| 658 | - label: string; | |
| 659 | - placeholder: string; | |
| 660 | - required: boolean; | |
| 661 | - enabled: boolean; | |
| 662 | - order: number; | |
| 663 | - width: "full" | "half" | "third"; | |
| 664 | - section?: string; | |
| 665 | - options?: { value: string; label: string }[]; | |
| 666 | - locked?: boolean; // If true, field cannot be deleted and required cannot be changed | |
| 667 | -} | |
| 668 | - | |
| 669 | -interface FormSectionConfig { | |
| 670 | - title: string; | |
| 671 | - description: string; | |
| 672 | - enabled?: boolean; | |
| 673 | - fields: FormFieldConfig[]; | |
| 674 | -} | |
| 675 | - | |
| 676 | -interface BookingFormConfig { | |
| 677 | - contact_form: FormSectionConfig; | |
| 678 | - emergency_contact_form: FormSectionConfig; | |
| 679 | - traveler_form: FormSectionConfig; | |
| 680 | -} | |
| 681 | - | |
| 742 | +// Booking form builder types live in components/settings/booking-form-types.ts | |
| 743 | +// (shared with the per-trip Conditions popup). | |
| 682 | 744 | interface PaymentGatewayConfig { |
| 683 | 745 | enabled: boolean; |
| 684 | 746 | icon?: string; |
| 685 | 747 | title?: string; |
| @@ -739,8 +801,10 @@ | ||
| 739 | 801 | /** Hex brand color for public trip/booking/listing UI (buttons, links, accents) */ |
| 740 | 802 | frontend_primary_color: string; |
| 741 | 803 | /** CSS max width for Yatra containers; empty = follow theme / theme.json */ |
| 742 | 804 | frontend_container_max_width: string; |
| 805 | + /** Trip listing card density: "standard" | "compact_mobile" | "compact_all" */ | |
| 806 | + frontend_listing_card_layout: string; | |
| 743 | 807 | |
| 744 | 808 | // Google Calendar Settings |
| 745 | 809 | google_calendar_client_id?: string; |
| 746 | 810 | google_calendar_client_secret?: string; |
| @@ -749,20 +813,29 @@ | ||
| 749 | 813 | google_calendar_connected?: boolean; |
| 750 | 814 | google_calendar_last_sync?: string | null; |
| 751 | 815 | google_calendar_enabled?: boolean; |
| 752 | 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 | + | |
| 753 | 826 | // Booking Settings |
| 754 | 827 | booking_confirmation: boolean; |
| 755 | 828 | auto_confirm_bookings: boolean; |
| 829 | + auto_confirm_mode: "none" | "online" | "all"; | |
| 756 | 830 | require_login: boolean; |
| 757 | 831 | allow_guest_checkout: boolean; |
| 758 | - cancellation_policy: string; | |
| 759 | - cancellation_days: number; | |
| 760 | - refund_policy: string; | |
| 832 | + require_guest_email_verification: boolean; | |
| 761 | 833 | booking_expiry_hours: number; |
| 762 | 834 | booking_reminder_days: number; |
| 763 | 835 | allow_waitlist: boolean; |
| 764 | 836 | waitlist_auto_confirm: boolean; |
| 837 | + date_picker_as_dropdown: boolean; | |
| 765 | 838 | |
| 766 | 839 | // Payment Settings |
| 767 | 840 | currency: string; |
| 768 | 841 | payment_test_mode: boolean; |
| @@ -778,11 +851,24 @@ | ||
| 778 | 851 | scheduled_payment_days: number; |
| 779 | 852 | scheduled_payment_installments: number; |
| 780 | 853 | scheduled_payment_interval: number; |
| 781 | 854 | scheduled_payment_reminder_days: number; |
| 855 | + balance_anchor: string; | |
| 856 | + balance_due_days: number; | |
| 782 | 857 | gateway_configs: Record<string, PaymentGatewayConfig>; |
| 783 | 858 | gateway_order?: string[]; |
| 784 | 859 | |
| 860 | + // Pricing Settings | |
| 861 | + // Controls how Advanced Discount + Dynamic Pricing combine on the same | |
| 862 | + // booking. Surfaced in Settings → Pricing only when BOTH modules are | |
| 863 | + // enabled; backend enforcement is also gated by both modules being on, | |
| 864 | + // so a stale 'discount_only' value on a site that disabled DP is inert. | |
| 865 | + discount_stacking_mode?: | |
| 866 | + | "both" | |
| 867 | + | "discount_only" | |
| 868 | + | "dynamic_pricing_only" | |
| 869 | + | "best_for_customer"; | |
| 870 | + | |
| 785 | 871 | // Email Settings |
| 786 | 872 | admin_email: string; |
| 787 | 873 | from_email: string; |
| 788 | 874 | from_name: string; |
| @@ -794,8 +880,9 @@ | ||
| 794 | 880 | email_template_admin_payment: boolean; |
| 795 | 881 | email_template_admin_cancellation: boolean; |
| 796 | 882 | email_template_trip_consent: boolean; |
| 797 | 883 | email_template_customer_verification: boolean; |
| 884 | + email_template_guest_verification: boolean; | |
| 798 | 885 | email_template_booking_completed: boolean; |
| 799 | 886 | email_template_booking_expired_customer: boolean; |
| 800 | 887 | email_template_admin_booking_expired: boolean; |
| 801 | 888 | email_template_scheduled_payment_reminder: boolean; |
| @@ -805,9 +892,11 @@ | ||
| 805 | 892 | email_template_enquiry_received: boolean; |
| 806 | 893 | email_template_enquiry_admin: boolean; |
| 807 | 894 | email_template_enquiry_response: boolean; |
| 808 | 895 | email_template_review_request: boolean; |
| 809 | - email_template_abandoned_booking_recovery: boolean; | |
| 896 | + email_template_abandoned_booking_recovery_first: boolean; | |
| 897 | + email_template_abandoned_booking_recovery_second: boolean; | |
| 898 | + email_template_abandoned_booking_recovery_final: boolean; | |
| 810 | 899 | smtp_enabled: boolean; |
| 811 | 900 | smtp_host: string; |
| 812 | 901 | smtp_port: number; |
| 813 | 902 | smtp_username: string; |
| @@ -831,8 +920,10 @@ | ||
| 831 | 920 | email_tpl_trip_consent_subject: string; |
| 832 | 921 | email_tpl_trip_consent_body: string; |
| 833 | 922 | email_tpl_customer_verification_subject: string; |
| 834 | 923 | email_tpl_customer_verification_body: string; |
| 924 | + email_tpl_guest_verification_subject: string; | |
| 925 | + email_tpl_guest_verification_body: string; | |
| 835 | 926 | email_tpl_booking_completed_subject: string; |
| 836 | 927 | email_tpl_booking_completed_body: string; |
| 837 | 928 | email_tpl_booking_expired_customer_subject: string; |
| 838 | 929 | email_tpl_booking_expired_customer_body: string; |
| @@ -853,10 +944,14 @@ | ||
| 853 | 944 | email_tpl_enquiry_response_subject: string; |
| 854 | 945 | email_tpl_enquiry_response_body: string; |
| 855 | 946 | email_tpl_review_request_subject: string; |
| 856 | 947 | email_tpl_review_request_body: string; |
| 857 | - email_tpl_abandoned_booking_recovery_subject: string; | |
| 858 | - email_tpl_abandoned_booking_recovery_body: string; | |
| 948 | + email_tpl_abandoned_booking_recovery_first_subject: string; | |
| 949 | + email_tpl_abandoned_booking_recovery_first_body: string; | |
| 950 | + email_tpl_abandoned_booking_recovery_second_subject: string; | |
| 951 | + email_tpl_abandoned_booking_recovery_second_body: string; | |
| 952 | + email_tpl_abandoned_booking_recovery_final_subject: string; | |
| 953 | + email_tpl_abandoned_booking_recovery_final_body: string; | |
| 859 | 954 | |
| 860 | 955 | // Customer Settings |
| 861 | 956 | customer_registration: boolean; |
| 862 | 957 | customer_fields: string[]; |
| @@ -865,8 +960,12 @@ | ||
| 865 | 960 | allow_customer_reviews: boolean; |
| 866 | 961 | customer_dashboard_enabled: boolean; |
| 867 | 962 | /** Wishlist / saved trips (Yatra Pro); toggled in Customer settings */ |
| 868 | 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; | |
| 869 | 968 | |
| 870 | 969 | // Review Settings |
| 871 | 970 | enable_reviews: boolean; |
| 872 | 971 | require_booking: boolean; |
| @@ -874,8 +973,9 @@ | ||
| 874 | 973 | review_moderation: boolean; |
| 875 | 974 | min_rating: number; |
| 876 | 975 | allow_anonymous_reviews: boolean; |
| 877 | 976 | review_reminder_days: number; |
| 977 | + review_reminder_max_age_days: number; | |
| 878 | 978 | |
| 879 | 979 | // Tax Settings |
| 880 | 980 | enable_tax: boolean; |
| 881 | 981 | tax_name: string; |
| @@ -909,8 +1009,12 @@ | ||
| 909 | 1009 | facebook_pixel: string; |
| 910 | 1010 | recaptcha_enabled: boolean; |
| 911 | 1011 | recaptcha_site_key: string; |
| 912 | 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; | |
| 913 | 1017 | |
| 914 | 1018 | // Mailchimp Integration (Pro) |
| 915 | 1019 | mailchimp_api_key?: string; |
| 916 | 1020 | mailchimp_list_id?: string; |
| @@ -971,8 +1075,9 @@ | ||
| 971 | 1075 | |
| 972 | 1076 | // Advanced Settings |
| 973 | 1077 | debug_mode: boolean; |
| 974 | 1078 | enable_logging: boolean; |
| 1079 | + delete_data_on_uninstall: boolean; | |
| 975 | 1080 | cache_enabled: boolean; |
| 976 | 1081 | api_key: string; |
| 977 | 1082 | api_rate_limit: number; |
| 978 | 1083 | session_timeout: number; |
| @@ -984,8 +1089,11 @@ | ||
| 984 | 1089 | seo_trip_meta_title: string; |
| 985 | 1090 | seo_trip_meta_description: string; |
| 986 | 1091 | seo_trip_meta_keywords: string; |
| 987 | 1092 | seo_trip_meta_image: number; |
| 1093 | + enable_sitemap: boolean; | |
| 1094 | + sitemap_types: string[]; | |
| 1095 | + sitemap_noindex_excluded: boolean; | |
| 988 | 1096 | } |
| 989 | 1097 | |
| 990 | 1098 | // Form Builder Component |
| 991 | 1099 | type BookingFormSubTab = |
| @@ -1020,11 +1128,17 @@ | ||
| 1020 | 1128 | }) => { |
| 1021 | 1129 | const [activeFormTab, setActiveFormTab] = |
| 1022 | 1130 | useState<BookingFormSubTab>(getInitialFormSubTab); |
| 1023 | 1131 | |
| 1024 | - // 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. | |
| 1025 | 1139 | const { data: modulesData } = useQuery({ |
| 1026 | - queryKey: ["modules"], | |
| 1140 | + queryKey: ["modules", "raw-envelope"], | |
| 1027 | 1141 | queryFn: async () => { |
| 1028 | 1142 | const response = await apiService.getModules(); |
| 1029 | 1143 | return response; |
| 1030 | 1144 | }, |
| @@ -1038,12 +1152,35 @@ | ||
| 1038 | 1152 | (m: any) => m.slug === "dynamic_form_field", |
| 1039 | 1153 | ); |
| 1040 | 1154 | return module?.enabled === true && module?.is_available === true; |
| 1041 | 1155 | } |
| 1042 | - // Fallback to yatraAdmin if modules API hasn't loaded yet | |
| 1043 | - return window.yatraAdmin?.dynamicFormFieldEnabled === true; | |
| 1156 | + // Fallback to yatraAdmin if modules API hasn't loaded yet. | |
| 1157 | + // Truthy check (not `=== true`) because wp_localize_script | |
| 1158 | + // serialises every scalar to a string before exposing it to JS — | |
| 1159 | + // true becomes "1", false becomes "". Same gotcha that bit the | |
| 1160 | + // Discount Stacking tab visibility earlier this session. | |
| 1161 | + return !!window.yatraAdmin?.dynamicFormFieldEnabled; | |
| 1044 | 1162 | }, [modulesData]); |
| 1045 | 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 | + | |
| 1046 | 1183 | // Save sub-tab to localStorage when it changes |
| 1047 | 1184 | const handleSubTabChange = (tab: BookingFormSubTab) => { |
| 1048 | 1185 | setActiveFormTab(tab); |
| 1049 | 1186 | if (typeof window !== "undefined") { |
| @@ -1049,66 +1186,15 @@ | ||
| 1049 | 1186 | if (typeof window !== "undefined") { |
| 1050 | 1187 | localStorage.setItem("yatra_settings_booking_form_subtab", tab); |
| 1051 | 1188 | } |
| 1052 | 1189 | }; |
| 1053 | - const [editingField, setEditingField] = useState<string | null>(null); | |
| 1054 | - const [showAddField, setShowAddField] = useState(false); | |
| 1055 | - const [newField, setNewField] = useState<Partial<FormFieldConfig>>({ | |
| 1056 | - id: "", | |
| 1057 | - type: "text", | |
| 1058 | - label: "", | |
| 1059 | - placeholder: "", | |
| 1060 | - required: false, | |
| 1061 | - enabled: true, | |
| 1062 | - width: "full", | |
| 1063 | - options: [], | |
| 1064 | - }); | |
| 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 | + ]; | |
| 1065 | 1195 | |
| 1066 | - // Delete confirmation state | |
| 1067 | - const [deleteConfirm, setDeleteConfirm] = useState<{ | |
| 1068 | - isOpen: boolean; | |
| 1069 | - fieldId: string | null; | |
| 1070 | - fieldLabel: string; | |
| 1071 | - }>({ | |
| 1072 | - isOpen: false, | |
| 1073 | - fieldId: null, | |
| 1074 | - fieldLabel: "", | |
| 1075 | - }); | |
| 1076 | 1196 | |
| 1077 | - // Drag and drop state | |
| 1078 | - const [draggedFieldId, setDraggedFieldId] = useState<string | null>(null); | |
| 1079 | - const [dragOverFieldId, setDragOverFieldId] = useState<string | null>(null); | |
| 1080 | - | |
| 1081 | - // Helper to generate ID from label | |
| 1082 | - const generateIdFromLabel = (label: string): string => { | |
| 1083 | - return label | |
| 1084 | - .toLowerCase() | |
| 1085 | - .replace(/\s+/g, "_") | |
| 1086 | - .replace(/[^a-z0-9_]/g, ""); | |
| 1087 | - }; | |
| 1088 | - | |
| 1089 | - // Helper to sanitize ID input | |
| 1090 | - const sanitizeId = (id: string): string => { | |
| 1091 | - return id | |
| 1092 | - .toLowerCase() | |
| 1093 | - .replace(/\s+/g, "_") | |
| 1094 | - .replace(/[^a-z0-9_]/g, ""); | |
| 1095 | - }; | |
| 1096 | - | |
| 1097 | - // Handle label change with auto ID generation | |
| 1098 | - const handleNewFieldLabelChange = (label: string) => { | |
| 1099 | - const autoId = generateIdFromLabel(label); | |
| 1100 | - setNewField((prev) => ({ | |
| 1101 | - ...prev, | |
| 1102 | - label, | |
| 1103 | - // Only auto-generate ID if it hasn't been manually edited | |
| 1104 | - id: | |
| 1105 | - prev.id === "" || prev.id === generateIdFromLabel(prev.label || "") | |
| 1106 | - ? autoId | |
| 1107 | - : prev.id, | |
| 1108 | - })); | |
| 1109 | - }; | |
| 1110 | - | |
| 1111 | 1197 | const formTabs = [ |
| 1112 | 1198 | { |
| 1113 | 1199 | id: "contact_form" as const, |
| 1114 | 1200 | label: __("Contact Form", "yatra"), |
| @@ -1126,17 +1212,18 @@ | ||
| 1126 | 1212 | }, |
| 1127 | 1213 | ]; |
| 1128 | 1214 | |
| 1129 | 1215 | const fieldTypes = [ |
| 1130 | - { value: "text", label: "Text" }, | |
| 1131 | - { value: "email", label: "Email" }, | |
| 1132 | - { value: "tel", label: "Phone" }, | |
| 1133 | - { value: "date", label: "Date" }, | |
| 1134 | - { value: "select", label: "Dropdown" }, | |
| 1135 | - { value: "country", label: "Country Selector" }, | |
| 1136 | - { value: "textarea", label: "Text Area" }, | |
| 1137 | - { value: "number", label: "Number" }, | |
| 1138 | - { 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") }, | |
| 1139 | 1226 | ]; |
| 1140 | 1227 | |
| 1141 | 1228 | const widthOptions = [ |
| 1142 | 1229 | { value: "full", label: "Full Width" }, |
| @@ -1154,8 +1241,27 @@ | ||
| 1154 | 1241 | } |
| 1155 | 1242 | ); |
| 1156 | 1243 | }; |
| 1157 | 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 | + | |
| 1158 | 1264 | const updateFormConfig = (updates: Partial<FormSectionConfig>) => { |
| 1159 | 1265 | setFormData((prev) => { |
| 1160 | 1266 | if (!prev) return prev; |
| 1161 | 1267 | return { |
| @@ -1170,165 +1276,9 @@ | ||
| 1170 | 1276 | }; |
| 1171 | 1277 | }); |
| 1172 | 1278 | }; |
| 1173 | 1279 | |
| 1174 | - const updateField = (fieldId: string, updates: Partial<FormFieldConfig>) => { | |
| 1175 | - const currentConfig = getCurrentFormConfig(); | |
| 1176 | - const updatedFields = currentConfig.fields.map((field) => | |
| 1177 | - field.id === fieldId ? { ...field, ...updates } : field, | |
| 1178 | - ); | |
| 1179 | - updateFormConfig({ fields: updatedFields }); | |
| 1180 | - }; | |
| 1181 | 1280 | |
| 1182 | - const toggleFieldEnabled = (fieldId: string) => { | |
| 1183 | - const currentConfig = getCurrentFormConfig(); | |
| 1184 | - const field = currentConfig.fields.find((f) => f.id === fieldId); | |
| 1185 | - // Locked fields cannot be disabled | |
| 1186 | - if (field && !field.locked) { | |
| 1187 | - updateField(fieldId, { enabled: !field.enabled }); | |
| 1188 | - } | |
| 1189 | - }; | |
| 1190 | - | |
| 1191 | - const toggleFieldRequired = (fieldId: string) => { | |
| 1192 | - const currentConfig = getCurrentFormConfig(); | |
| 1193 | - const field = currentConfig.fields.find((f) => f.id === fieldId); | |
| 1194 | - if (field) { | |
| 1195 | - updateField(fieldId, { required: !field.required }); | |
| 1196 | - } | |
| 1197 | - }; | |
| 1198 | - | |
| 1199 | - const moveField = (fieldId: string, direction: "up" | "down") => { | |
| 1200 | - const currentConfig = getCurrentFormConfig(); | |
| 1201 | - const fields = [...currentConfig.fields]; | |
| 1202 | - const index = fields.findIndex((f) => f.id === fieldId); | |
| 1203 | - | |
| 1204 | - if (direction === "up" && index > 0) { | |
| 1205 | - [fields[index - 1], fields[index]] = [fields[index], fields[index - 1]]; | |
| 1206 | - } else if (direction === "down" && index < fields.length - 1) { | |
| 1207 | - [fields[index], fields[index + 1]] = [fields[index + 1], fields[index]]; | |
| 1208 | - } | |
| 1209 | - | |
| 1210 | - // Update order values | |
| 1211 | - fields.forEach((field, i) => { | |
| 1212 | - field.order = i + 1; | |
| 1213 | - }); | |
| 1214 | - | |
| 1215 | - updateFormConfig({ fields }); | |
| 1216 | - }; | |
| 1217 | - | |
| 1218 | - // Drag and Drop handlers | |
| 1219 | - const handleDragStart = (e: React.DragEvent, fieldId: string) => { | |
| 1220 | - setDraggedFieldId(fieldId); | |
| 1221 | - e.dataTransfer.effectAllowed = "move"; | |
| 1222 | - }; | |
| 1223 | - | |
| 1224 | - const handleDragOver = (e: React.DragEvent, fieldId: string) => { | |
| 1225 | - e.preventDefault(); | |
| 1226 | - e.dataTransfer.dropEffect = "move"; | |
| 1227 | - if (fieldId !== draggedFieldId) { | |
| 1228 | - setDragOverFieldId(fieldId); | |
| 1229 | - } | |
| 1230 | - }; | |
| 1231 | - | |
| 1232 | - const handleDragLeave = () => { | |
| 1233 | - setDragOverFieldId(null); | |
| 1234 | - }; | |
| 1235 | - | |
| 1236 | - const handleDrop = (e: React.DragEvent, targetFieldId: string) => { | |
| 1237 | - e.preventDefault(); | |
| 1238 | - if (!draggedFieldId || draggedFieldId === targetFieldId) { | |
| 1239 | - setDraggedFieldId(null); | |
| 1240 | - setDragOverFieldId(null); | |
| 1241 | - return; | |
| 1242 | - } | |
| 1243 | - | |
| 1244 | - const currentConfig = getCurrentFormConfig(); | |
| 1245 | - const fields = [...currentConfig.fields]; | |
| 1246 | - const draggedIndex = fields.findIndex((f) => f.id === draggedFieldId); | |
| 1247 | - const targetIndex = fields.findIndex((f) => f.id === targetFieldId); | |
| 1248 | - | |
| 1249 | - if (draggedIndex !== -1 && targetIndex !== -1) { | |
| 1250 | - const [draggedField] = fields.splice(draggedIndex, 1); | |
| 1251 | - fields.splice(targetIndex, 0, draggedField); | |
| 1252 | - | |
| 1253 | - // Update order values | |
| 1254 | - fields.forEach((field, i) => { | |
| 1255 | - field.order = i + 1; | |
| 1256 | - }); | |
| 1257 | - | |
| 1258 | - updateFormConfig({ fields }); | |
| 1259 | - } | |
| 1260 | - | |
| 1261 | - setDraggedFieldId(null); | |
| 1262 | - setDragOverFieldId(null); | |
| 1263 | - }; | |
| 1264 | - | |
| 1265 | - const handleDragEnd = () => { | |
| 1266 | - setDraggedFieldId(null); | |
| 1267 | - setDragOverFieldId(null); | |
| 1268 | - }; | |
| 1269 | - | |
| 1270 | - const deleteField = (fieldId: string) => { | |
| 1271 | - const currentConfig = getCurrentFormConfig(); | |
| 1272 | - const updatedFields = currentConfig.fields.filter((f) => f.id !== fieldId); | |
| 1273 | - updatedFields.forEach((field, i) => { | |
| 1274 | - field.order = i + 1; | |
| 1275 | - }); | |
| 1276 | - updateFormConfig({ fields: updatedFields }); | |
| 1277 | - setDeleteConfirm({ isOpen: false, fieldId: null, fieldLabel: "" }); | |
| 1278 | - }; | |
| 1279 | - | |
| 1280 | - const addNewField = () => { | |
| 1281 | - if (!newField.label || !newField.id) return; | |
| 1282 | - | |
| 1283 | - const currentConfig = getCurrentFormConfig(); | |
| 1284 | - const fieldId = sanitizeId(newField.id); | |
| 1285 | - | |
| 1286 | - // Check if ID already exists | |
| 1287 | - if (currentConfig.fields.some((f) => f.id === fieldId)) { | |
| 1288 | - // TODO: Replace with toast notification when refactoring this nested component | |
| 1289 | - window.alert( | |
| 1290 | - "A field with this ID already exists. Please use a different ID.", | |
| 1291 | - ); | |
| 1292 | - return; | |
| 1293 | - } | |
| 1294 | - | |
| 1295 | - const newFieldConfig: FormFieldConfig = { | |
| 1296 | - id: fieldId, | |
| 1297 | - type: (newField.type as FormFieldConfig["type"]) || "text", | |
| 1298 | - label: newField.label || "", | |
| 1299 | - placeholder: newField.placeholder || "", | |
| 1300 | - required: newField.required || false, | |
| 1301 | - enabled: true, | |
| 1302 | - order: currentConfig.fields.length + 1, | |
| 1303 | - width: (newField.width as FormFieldConfig["width"]) || "full", | |
| 1304 | - }; | |
| 1305 | - | |
| 1306 | - // Add options if field type is select | |
| 1307 | - if ( | |
| 1308 | - newField.type === "select" && | |
| 1309 | - newField.options && | |
| 1310 | - newField.options.length > 0 | |
| 1311 | - ) { | |
| 1312 | - newFieldConfig.options = newField.options.filter( | |
| 1313 | - (opt) => opt.value && opt.label, | |
| 1314 | - ); | |
| 1315 | - } | |
| 1316 | - | |
| 1317 | - updateFormConfig({ fields: [...currentConfig.fields, newFieldConfig] }); | |
| 1318 | - setNewField({ | |
| 1319 | - id: "", | |
| 1320 | - type: "text", | |
| 1321 | - label: "", | |
| 1322 | - placeholder: "", | |
| 1323 | - required: false, | |
| 1324 | - enabled: true, | |
| 1325 | - width: "full", | |
| 1326 | - options: [], | |
| 1327 | - }); | |
| 1328 | - setShowAddField(false); | |
| 1329 | - }; | |
| 1330 | - | |
| 1331 | 1281 | const currentConfig = getCurrentFormConfig(); |
| 1332 | 1282 | |
| 1333 | 1283 | return ( |
| 1334 | 1284 | <div className="space-y-6"> |
| @@ -1361,14 +1311,51 @@ | ||
| 1361 | 1311 | ))} |
| 1362 | 1312 | </nav> |
| 1363 | 1313 | </div> |
| 1364 | 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 | + | |
| 1365 | 1328 | {/* Form Section Settings */} |
| 1366 | 1329 | <Card> |
| 1367 | 1330 | <CardHeader> |
| 1368 | - <CardTitle className="text-base"> | |
| 1369 | - {__("Form Section Settings", "yatra")} | |
| 1370 | - </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> | |
| 1371 | 1358 | </CardHeader> |
| 1372 | 1359 | <CardContent className="space-y-4"> |
| 1373 | 1360 | <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> |
| 1374 | 1361 | <input |
| @@ -1415,628 +1402,85 @@ | ||
| 1415 | 1402 | className="mt-1" |
| 1416 | 1403 | /> |
| 1417 | 1404 | </div> |
| 1418 | 1405 | </div> |
| 1419 | - </CardContent> | |
| 1420 | - </Card> | |
| 1421 | 1406 | |
| 1422 | - {/* Form Fields */} | |
| 1423 | - <Card> | |
| 1424 | - <CardHeader className="flex flex-row items-center justify-between"> | |
| 1425 | - <CardTitle className="text-base"> | |
| 1426 | - {__("Form Fields", "yatra")} | |
| 1427 | - </CardTitle> | |
| 1428 | - <Button | |
| 1429 | - variant="outline" | |
| 1430 | - size="sm" | |
| 1431 | - onClick={() => setShowAddField(!showAddField)} | |
| 1432 | - className="flex items-center gap-2" | |
| 1433 | - > | |
| 1434 | - <Plus className="w-4 h-4" /> | |
| 1435 | - {__("Add Field", "yatra")} | |
| 1436 | - </Button> | |
| 1437 | - </CardHeader> | |
| 1438 | - <CardContent className="space-y-3"> | |
| 1439 | - {/* Add New Field Form */} | |
| 1440 | - {showAddField && ( | |
| 1441 | - <div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 mb-4"> | |
| 1442 | - <h4 className="font-medium text-sm mb-3"> | |
| 1443 | - {__("Add New Field", "yatra")} | |
| 1444 | - </h4> | |
| 1445 | - <div className="grid grid-cols-1 md:grid-cols-5 gap-3"> | |
| 1446 | - <div> | |
| 1447 | - <Label className="text-xs"> | |
| 1448 | - {__("Field Type", "yatra")} | |
| 1449 | - </Label> | |
| 1450 | - <Select | |
| 1451 | - value={newField.type || "text"} | |
| 1452 | - onChange={(e) => | |
| 1453 | - setNewField((prev) => ({ | |
| 1454 | - ...prev, | |
| 1455 | - type: e.target.value as FormFieldConfig["type"], | |
| 1456 | - })) | |
| 1457 | - } | |
| 1458 | - 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" | |
| 1459 | 1415 | > |
| 1460 | - {fieldTypes.map((type) => ( | |
| 1461 | - <option key={type.value} value={type.value}> | |
| 1462 | - {type.label} | |
| 1463 | - </option> | |
| 1464 | - ))} | |
| 1465 | - </Select> | |
| 1466 | - </div> | |
| 1467 | - <div> | |
| 1468 | - <Label className="text-xs">{__("Label", "yatra")} *</Label> | |
| 1469 | - <Input | |
| 1470 | - value={newField.label || ""} | |
| 1471 | - onChange={(e) => | |
| 1472 | - handleNewFieldLabelChange(e.target.value) | |
| 1473 | - } | |
| 1474 | - placeholder="Field label" | |
| 1475 | - className="mt-1" | |
| 1476 | - /> | |
| 1477 | - </div> | |
| 1478 | - <div> | |
| 1479 | - <Label className="text-xs"> | |
| 1480 | - {__("Field ID", "yatra")} * | |
| 1481 | - </Label> | |
| 1482 | - <Input | |
| 1483 | - value={newField.id || ""} | |
| 1484 | - onChange={(e) => | |
| 1485 | - setNewField((prev) => ({ | |
| 1486 | - ...prev, | |
| 1487 | - id: sanitizeId(e.target.value), | |
| 1488 | - })) | |
| 1489 | - } | |
| 1490 | - placeholder="field_id" | |
| 1491 | - className="mt-1 font-mono text-xs" | |
| 1492 | - /> | |
| 1493 | - <p className="text-[10px] text-gray-400 mt-0.5"> | |
| 1494 | - {__("Lowercase, no spaces", "yatra")} | |
| 1495 | - </p> | |
| 1496 | - </div> | |
| 1497 | - <div> | |
| 1498 | - <Label className="text-xs"> | |
| 1499 | - {__("Placeholder", "yatra")} | |
| 1500 | - </Label> | |
| 1501 | - <Input | |
| 1502 | - value={newField.placeholder || ""} | |
| 1503 | - onChange={(e) => | |
| 1504 | - setNewField((prev) => ({ | |
| 1505 | - ...prev, | |
| 1506 | - placeholder: e.target.value, | |
| 1507 | - })) | |
| 1508 | - } | |
| 1509 | - placeholder="Placeholder text" | |
| 1510 | - className="mt-1" | |
| 1511 | - /> | |
| 1512 | - </div> | |
| 1513 | - <div> | |
| 1514 | - <Label className="text-xs">{__("Width", "yatra")}</Label> | |
| 1515 | - <Select | |
| 1516 | - value={newField.width || "full"} | |
| 1517 | - onChange={(e) => | |
| 1518 | - setNewField((prev) => ({ | |
| 1519 | - ...prev, | |
| 1520 | - width: e.target.value as FormFieldConfig["width"], | |
| 1521 | - })) | |
| 1522 | - } | |
| 1523 | - className="mt-1" | |
| 1524 | - > | |
| 1525 | - {widthOptions.map((w) => ( | |
| 1526 | - <option key={w.value} value={w.value}> | |
| 1527 | - {w.label} | |
| 1528 | - </option> | |
| 1529 | - ))} | |
| 1530 | - </Select> | |
| 1531 | - </div> | |
| 1532 | - </div> | |
| 1533 | - {/* Options editor for select fields */} | |
| 1534 | - {newField.type === "select" && ( | |
| 1535 | - <div className="mt-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-blue-100 dark:border-blue-900"> | |
| 1536 | - <div className="flex items-center justify-between mb-2"> | |
| 1537 | - <Label className="text-xs font-medium"> | |
| 1538 | - {__("Dropdown Options", "yatra")} | |
| 1539 | - </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" /> | |
| 1540 | 1429 | <Button |
| 1541 | 1430 | type="button" |
| 1542 | 1431 | variant="ghost" |
| 1543 | 1432 | size="sm" |
| 1544 | - onClick={() => { | |
| 1545 | - const currentOptions = newField.options || []; | |
| 1546 | - setNewField((prev) => ({ | |
| 1547 | - ...prev, | |
| 1548 | - options: [ | |
| 1549 | - ...currentOptions, | |
| 1550 | - { value: "", label: "" }, | |
| 1551 | - ], | |
| 1552 | - })); | |
| 1553 | - }} | |
| 1554 | - className="h-6 text-xs" | |
| 1433 | + onClick={() => setConditionsOpen(true)} | |
| 1555 | 1434 | > |
| 1556 | - <Plus className="w-3 h-3 mr-1" /> | |
| 1557 | - {__("Add Option", "yatra")} | |
| 1435 | + {__("Edit", "yatra")} | |
| 1558 | 1436 | </Button> |
| 1559 | 1437 | </div> |
| 1560 | - <div className="space-y-2"> | |
| 1561 | - {(newField.options || []).map((option, optIndex) => ( | |
| 1562 | - <div key={optIndex} className="flex items-center gap-2"> | |
| 1563 | - <Input | |
| 1564 | - value={option.value} | |
| 1565 | - onChange={(e) => { | |
| 1566 | - const newOptions = [...(newField.options || [])]; | |
| 1567 | - newOptions[optIndex] = { | |
| 1568 | - ...newOptions[optIndex], | |
| 1569 | - value: e.target.value, | |
| 1570 | - }; | |
| 1571 | - setNewField((prev) => ({ | |
| 1572 | - ...prev, | |
| 1573 | - options: newOptions, | |
| 1574 | - })); | |
| 1575 | - }} | |
| 1576 | - placeholder="Value (e.g., option1)" | |
| 1577 | - className="text-xs flex-1" | |
| 1578 | - /> | |
| 1579 | - <Input | |
| 1580 | - value={option.label} | |
| 1581 | - onChange={(e) => { | |
| 1582 | - const newOptions = [...(newField.options || [])]; | |
| 1583 | - newOptions[optIndex] = { | |
| 1584 | - ...newOptions[optIndex], | |
| 1585 | - label: e.target.value, | |
| 1586 | - }; | |
| 1587 | - setNewField((prev) => ({ | |
| 1588 | - ...prev, | |
| 1589 | - options: newOptions, | |
| 1590 | - })); | |
| 1591 | - }} | |
| 1592 | - placeholder="Label (e.g., Option 1)" | |
| 1593 | - className="text-xs flex-1" | |
| 1594 | - /> | |
| 1595 | - <button | |
| 1596 | - type="button" | |
| 1597 | - onClick={() => { | |
| 1598 | - const newOptions = ( | |
| 1599 | - newField.options || [] | |
| 1600 | - ).filter((_, i) => i !== optIndex); | |
| 1601 | - setNewField((prev) => ({ | |
| 1602 | - ...prev, | |
| 1603 | - options: newOptions, | |
| 1604 | - })); | |
| 1605 | - }} | |
| 1606 | - className="p-1 text-gray-400 hover:text-red-500" | |
| 1607 | - > | |
| 1608 | - <Trash2 className="w-3 h-3" /> | |
| 1609 | - </button> | |
| 1610 | - </div> | |
| 1611 | - ))} | |
| 1612 | - {(!newField.options || newField.options.length === 0) && ( | |
| 1613 | - <p className="text-xs text-gray-400 italic"> | |
| 1614 | - {__( | |
| 1615 | - 'Click "Add Option" to add dropdown choices.', | |
| 1616 | - "yatra", | |
| 1617 | - )} | |
| 1618 | - </p> | |
| 1619 | - )} | |
| 1620 | - </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> | |
| 1621 | 1446 | </div> |
| 1622 | - )} | |
| 1623 | - | |
| 1624 | - <div className="flex items-center gap-4 mt-3"> | |
| 1625 | - <label className="flex items-center gap-2 text-sm"> | |
| 1626 | - <input | |
| 1627 | - type="checkbox" | |
| 1628 | - checked={newField.required || false} | |
| 1629 | - onChange={(e) => | |
| 1630 | - setNewField((prev) => ({ | |
| 1631 | - ...prev, | |
| 1632 | - required: e.target.checked, | |
| 1633 | - })) | |
| 1634 | - } | |
| 1635 | - className="w-4 h-4 rounded border-gray-300 text-blue-600" | |
| 1636 | - /> | |
| 1637 | - {__("Required", "yatra")} | |
| 1638 | - </label> | |
| 1639 | - <div className="flex-1"></div> | |
| 1640 | - <Button | |
| 1641 | - variant="ghost" | |
| 1642 | - size="sm" | |
| 1643 | - onClick={() => setShowAddField(false)} | |
| 1644 | - > | |
| 1645 | - {__("Cancel", "yatra")} | |
| 1646 | - </Button> | |
| 1647 | - <Button | |
| 1648 | - size="sm" | |
| 1649 | - onClick={addNewField} | |
| 1650 | - disabled={!newField.label || !newField.id} | |
| 1651 | - > | |
| 1652 | - {__("Add Field", "yatra")} | |
| 1653 | - </Button> | |
| 1654 | 1447 | </div> |
| 1655 | - </div> | |
| 1656 | - )} | |
| 1657 | - | |
| 1658 | - {/* Field List */} | |
| 1659 | - {currentConfig.fields?.length === 0 ? ( | |
| 1660 | - <div className="text-center py-8 text-gray-500 dark:text-gray-400"> | |
| 1661 | - <p> | |
| 1662 | - {__( | |
| 1663 | - 'No fields configured. Click "Add Field" to get started.', | |
| 1664 | - "yatra", | |
| 1665 | - )} | |
| 1666 | - </p> | |
| 1667 | - </div> | |
| 1668 | - ) : ( | |
| 1669 | - <div className="space-y-2"> | |
| 1670 | - {currentConfig.fields?.map((field, index) => ( | |
| 1671 | - <div | |
| 1672 | - key={field.id} | |
| 1673 | - draggable | |
| 1674 | - onDragStart={(e) => handleDragStart(e, field.id)} | |
| 1675 | - onDragOver={(e) => handleDragOver(e, field.id)} | |
| 1676 | - onDragLeave={handleDragLeave} | |
| 1677 | - onDrop={(e) => handleDrop(e, field.id)} | |
| 1678 | - onDragEnd={handleDragEnd} | |
| 1679 | - className={`flex items-center gap-3 p-3 rounded-lg border transition-all cursor-grab active:cursor-grabbing ${ | |
| 1680 | - draggedFieldId === field.id | |
| 1681 | - ? "opacity-50 border-dashed" | |
| 1682 | - : "" | |
| 1683 | - } ${ | |
| 1684 | - dragOverFieldId === field.id | |
| 1685 | - ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20" | |
| 1686 | - : "" | |
| 1687 | - } ${ | |
| 1688 | - field.enabled | |
| 1689 | - ? "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700" | |
| 1690 | - : "bg-gray-50 dark:bg-gray-900 border-gray-100 dark:border-gray-800 opacity-60" | |
| 1691 | - }`} | |
| 1692 | - > | |
| 1693 | - {/* Drag Handle & Order */} | |
| 1694 | - <div className="flex items-center gap-1"> | |
| 1695 | - <GripVertical className="w-4 h-4 text-gray-400" /> | |
| 1696 | - <div className="flex flex-col gap-0.5"> | |
| 1697 | - <button | |
| 1698 | - type="button" | |
| 1699 | - onClick={() => moveField(field.id, "up")} | |
| 1700 | - disabled={index === 0} | |
| 1701 | - className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed" | |
| 1702 | - > | |
| 1703 | - <ArrowUp className="w-3 h-3" /> | |
| 1704 | - </button> | |
| 1705 | - <button | |
| 1706 | - type="button" | |
| 1707 | - onClick={() => moveField(field.id, "down")} | |
| 1708 | - disabled={index === currentConfig.fields.length - 1} | |
| 1709 | - className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed" | |
| 1710 | - > | |
| 1711 | - <ArrowDown className="w-3 h-3" /> | |
| 1712 | - </button> | |
| 1713 | - </div> | |
| 1714 | - </div> | |
| 1715 | - | |
| 1716 | - {/* Field Info */} | |
| 1717 | - <div className="flex-1 min-w-0"> | |
| 1718 | - {editingField === field.id ? ( | |
| 1719 | - <div className="space-y-3"> | |
| 1720 | - <div className="grid grid-cols-4 gap-2"> | |
| 1721 | - <Input | |
| 1722 | - value={field.label} | |
| 1723 | - onChange={(e) => | |
| 1724 | - updateField(field.id, { label: e.target.value }) | |
| 1725 | - } | |
| 1726 | - placeholder="Label" | |
| 1727 | - className="text-sm" | |
| 1728 | - /> | |
| 1729 | - <Input | |
| 1730 | - value={field.placeholder} | |
| 1731 | - onChange={(e) => | |
| 1732 | - updateField(field.id, { | |
| 1733 | - placeholder: e.target.value, | |
| 1734 | - }) | |
| 1735 | - } | |
| 1736 | - placeholder="Placeholder" | |
| 1737 | - className="text-sm" | |
| 1738 | - /> | |
| 1739 | - <Select | |
| 1740 | - value={field.type} | |
| 1741 | - onChange={(e) => | |
| 1742 | - updateField(field.id, { | |
| 1743 | - type: e.target | |
| 1744 | - .value as FormFieldConfig["type"], | |
| 1745 | - }) | |
| 1746 | - } | |
| 1747 | - className="text-sm" | |
| 1748 | - > | |
| 1749 | - {fieldTypes.map((type) => ( | |
| 1750 | - <option key={type.value} value={type.value}> | |
| 1751 | - {type.label} | |
| 1752 | - </option> | |
| 1753 | - ))} | |
| 1754 | - </Select> | |
| 1755 | - <Select | |
| 1756 | - value={field.width} | |
| 1757 | - onChange={(e) => | |
| 1758 | - updateField(field.id, { | |
| 1759 | - width: e.target | |
| 1760 | - .value as FormFieldConfig["width"], | |
| 1761 | - }) | |
| 1762 | - } | |
| 1763 | - className="text-sm" | |
| 1764 | - > | |
| 1765 | - {widthOptions.map((w) => ( | |
| 1766 | - <option key={w.value} value={w.value}> | |
| 1767 | - {w.label} | |
| 1768 | - </option> | |
| 1769 | - ))} | |
| 1770 | - </Select> | |
| 1771 | - </div> | |
| 1772 | - | |
| 1773 | - {/* Field ID - Below other fields */} | |
| 1774 | - <div className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-900 rounded"> | |
| 1775 | - <Label className="text-xs text-gray-500 whitespace-nowrap"> | |
| 1776 | - {__("Field ID:", "yatra")} | |
| 1777 | - </Label> | |
| 1778 | - <Input | |
| 1779 | - value={field.id} | |
| 1780 | - onChange={(e) => { | |
| 1781 | - if (!field.locked) { | |
| 1782 | - updateField(field.id, { | |
| 1783 | - id: sanitizeId(e.target.value), | |
| 1784 | - }); | |
| 1785 | - } | |
| 1786 | - }} | |
| 1787 | - placeholder="field_id" | |
| 1788 | - className="text-sm font-mono flex-1 max-w-xs" | |
| 1789 | - disabled={field.locked} | |
| 1790 | - title={ | |
| 1791 | - field.locked | |
| 1792 | - ? "Locked fields cannot change ID" | |
| 1793 | - : "Field ID (lowercase, no spaces)" | |
| 1794 | - } | |
| 1795 | - /> | |
| 1796 | - {field.locked && ( | |
| 1797 | - <span className="text-xs text-amber-600"> | |
| 1798 | - {__("(locked)", "yatra")} | |
| 1799 | - </span> | |
| 1800 | - )} | |
| 1801 | - </div> | |
| 1802 | - | |
| 1803 | - {/* Dropdown Options Editor */} | |
| 1804 | - {field.type === "select" && ( | |
| 1805 | - <div className="p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700"> | |
| 1806 | - <div className="flex items-center justify-between mb-2"> | |
| 1807 | - <Label className="text-xs font-medium"> | |
| 1808 | - {__("Dropdown Options", "yatra")} | |
| 1809 | - </Label> | |
| 1810 | - <Button | |
| 1811 | - type="button" | |
| 1812 | - variant="ghost" | |
| 1813 | - size="sm" | |
| 1814 | - onClick={() => { | |
| 1815 | - const newOptions = [ | |
| 1816 | - ...(field.options || []), | |
| 1817 | - { value: "", label: "" }, | |
| 1818 | - ]; | |
| 1819 | - updateField(field.id, { | |
| 1820 | - options: newOptions, | |
| 1821 | - }); | |
| 1822 | - }} | |
| 1823 | - className="h-6 text-xs" | |
| 1824 | - > | |
| 1825 | - <Plus className="w-3 h-3 mr-1" /> | |
| 1826 | - {__("Add Option", "yatra")} | |
| 1827 | - </Button> | |
| 1828 | - </div> | |
| 1829 | - <div className="space-y-2"> | |
| 1830 | - {(field.options || []).map( | |
| 1831 | - (option, optIndex) => ( | |
| 1832 | - <div | |
| 1833 | - key={optIndex} | |
| 1834 | - className="flex items-center gap-2" | |
| 1835 | - > | |
| 1836 | - <Input | |
| 1837 | - value={option.value} | |
| 1838 | - onChange={(e) => { | |
| 1839 | - const newOptions = [ | |
| 1840 | - ...(field.options || []), | |
| 1841 | - ]; | |
| 1842 | - newOptions[optIndex] = { | |
| 1843 | - ...newOptions[optIndex], | |
| 1844 | - value: e.target.value, | |
| 1845 | - }; | |
| 1846 | - updateField(field.id, { | |
| 1847 | - options: newOptions, | |
| 1848 | - }); | |
| 1849 | - }} | |
| 1850 | - placeholder="Value (e.g., spouse)" | |
| 1851 | - className="text-xs flex-1" | |
| 1852 | - /> | |
| 1853 | - <Input | |
| 1854 | - value={option.label} | |
| 1855 | - onChange={(e) => { | |
| 1856 | - const newOptions = [ | |
| 1857 | - ...(field.options || []), | |
| 1858 | - ]; | |
| 1859 | - newOptions[optIndex] = { | |
| 1860 | - ...newOptions[optIndex], | |
| 1861 | - label: e.target.value, | |
| 1862 | - }; | |
| 1863 | - updateField(field.id, { | |
| 1864 | - options: newOptions, | |
| 1865 | - }); | |
| 1866 | - }} | |
| 1867 | - placeholder="Label (e.g., Spouse/Partner)" | |
| 1868 | - className="text-xs flex-1" | |
| 1869 | - /> | |
| 1870 | - <button | |
| 1871 | - type="button" | |
| 1872 | - onClick={() => { | |
| 1873 | - const newOptions = ( | |
| 1874 | - field.options || [] | |
| 1875 | - ).filter((_, i) => i !== optIndex); | |
| 1876 | - updateField(field.id, { | |
| 1877 | - options: newOptions, | |
| 1878 | - }); | |
| 1879 | - }} | |
| 1880 | - className="p-1 text-gray-400 hover:text-red-500" | |
| 1881 | - > | |
| 1882 | - <Trash2 className="w-3 h-3" /> | |
| 1883 | - </button> | |
| 1884 | - </div> | |
| 1885 | - ), | |
| 1886 | - )} | |
| 1887 | - {(!field.options || | |
| 1888 | - field.options.length === 0) && ( | |
| 1889 | - <p className="text-xs text-gray-400 italic"> | |
| 1890 | - {__( | |
| 1891 | - 'No options. Click "Add Option" to add dropdown choices.', | |
| 1892 | - "yatra", | |
| 1893 | - )} | |
| 1894 | - </p> | |
| 1895 | - )} | |
| 1896 | - </div> | |
| 1897 | - </div> | |
| 1898 | - )} | |
| 1899 | - </div> | |
| 1900 | - ) : ( | |
| 1901 | - <div className="flex items-center gap-3 flex-wrap"> | |
| 1902 | - <span className="font-medium text-sm"> | |
| 1903 | - {field.label} | |
| 1904 | - </span> | |
| 1905 | - <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"> | |
| 1906 | - {field.id} | |
| 1907 | - </code> | |
| 1908 | - <span className="text-xs text-gray-400 dark:text-gray-500 px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded"> | |
| 1909 | - {fieldTypes.find((t) => t.value === field.type) | |
| 1910 | - ?.label || field.type} | |
| 1911 | - </span> | |
| 1912 | - <span className="text-xs text-gray-400 dark:text-gray-500"> | |
| 1913 | - {widthOptions.find((w) => w.value === field.width) | |
| 1914 | - ?.label || "Full"} | |
| 1915 | - </span> | |
| 1916 | - {field.type === "select" && | |
| 1917 | - field.options && | |
| 1918 | - field.options.length > 0 && ( | |
| 1919 | - <span className="text-xs text-blue-500 dark:text-blue-400"> | |
| 1920 | - ({field.options.length}{" "} | |
| 1921 | - {field.options.length === 1 | |
| 1922 | - ? "option" | |
| 1923 | - : "options"} | |
| 1924 | - ) | |
| 1925 | - </span> | |
| 1926 | - )} | |
| 1927 | - {field.required && ( | |
| 1928 | - <span className="text-xs text-red-500 font-medium"> | |
| 1929 | - {__("Required", "yatra")} | |
| 1930 | - </span> | |
| 1931 | - )} | |
| 1932 | - {field.locked && ( | |
| 1933 | - <span | |
| 1934 | - 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" | |
| 1935 | - title="This field is protected and cannot be deleted" | |
| 1936 | - > | |
| 1937 | - <Lock className="w-3 h-3" /> | |
| 1938 | - Locked | |
| 1939 | - </span> | |
| 1940 | - )} | |
| 1941 | - </div> | |
| 1942 | - )} | |
| 1943 | - </div> | |
| 1944 | - | |
| 1945 | - {/* Actions */} | |
| 1946 | - <div className="flex items-center gap-1"> | |
| 1947 | - <button | |
| 1948 | - type="button" | |
| 1949 | - onClick={() => | |
| 1950 | - !field.locked && toggleFieldRequired(field.id) | |
| 1951 | - } | |
| 1952 | - disabled={field.locked} | |
| 1953 | - 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"}`} | |
| 1954 | - title={ | |
| 1955 | - field.locked | |
| 1956 | - ? "This field is required and cannot be changed" | |
| 1957 | - : field.required | |
| 1958 | - ? "Make optional" | |
| 1959 | - : "Make required" | |
| 1960 | - } | |
| 1961 | - > | |
| 1962 | - <Star | |
| 1963 | - className="w-4 h-4" | |
| 1964 | - fill={field.required ? "currentColor" : "none"} | |
| 1965 | - /> | |
| 1966 | - </button> | |
| 1967 | - {/* Show enable/disable toggle only for non-locked fields */} | |
| 1968 | - {!field.locked && ( | |
| 1969 | - <button | |
| 1970 | - type="button" | |
| 1971 | - onClick={() => toggleFieldEnabled(field.id)} | |
| 1972 | - className={`p-1.5 rounded ${field.enabled ? "text-green-500" : "text-gray-400"}`} | |
| 1973 | - title={ | |
| 1974 | - field.enabled ? "Disable field" : "Enable field" | |
| 1975 | - } | |
| 1976 | - > | |
| 1977 | - {field.enabled ? ( | |
| 1978 | - <Eye className="w-4 h-4" /> | |
| 1979 | - ) : ( | |
| 1980 | - <EyeOff className="w-4 h-4" /> | |
| 1981 | - )} | |
| 1982 | - </button> | |
| 1983 | - )} | |
| 1984 | - <button | |
| 1985 | - type="button" | |
| 1986 | - onClick={() => | |
| 1987 | - setEditingField( | |
| 1988 | - editingField === field.id ? null : field.id, | |
| 1989 | - ) | |
| 1990 | - } | |
| 1991 | - className="p-1.5 rounded text-gray-400 hover:text-blue-500" | |
| 1992 | - title="Edit field" | |
| 1993 | - > | |
| 1994 | - <Edit2 className="w-4 h-4" /> | |
| 1995 | - </button> | |
| 1996 | - {/* Show delete button only for non-locked fields */} | |
| 1997 | - {!field.locked && ( | |
| 1998 | - <button | |
| 1999 | - type="button" | |
| 2000 | - onClick={() => | |
| 2001 | - setDeleteConfirm({ | |
| 2002 | - isOpen: true, | |
| 2003 | - fieldId: field.id, | |
| 2004 | - fieldLabel: field.label, | |
| 2005 | - }) | |
| 2006 | - } | |
| 2007 | - className="p-1.5 rounded text-gray-400 hover:text-red-500" | |
| 2008 | - title="Delete field" | |
| 2009 | - > | |
| 2010 | - <Trash2 className="w-4 h-4" /> | |
| 2011 | - </button> | |
| 2012 | - )} | |
| 2013 | - </div> | |
| 2014 | - </div> | |
| 2015 | - ))} | |
| 2016 | - </div> | |
| 2017 | - )} | |
| 1448 | + )} | |
| 2018 | 1449 | </CardContent> |
| 2019 | 1450 | </Card> |
| 2020 | 1451 | |
| 2021 | - {/* Delete Confirmation Dialog */} | |
| 2022 | - <ConfirmationDialog | |
| 2023 | - isOpen={deleteConfirm.isOpen} | |
| 2024 | - onClose={() => | |
| 2025 | - setDeleteConfirm({ isOpen: false, fieldId: null, fieldLabel: "" }) | |
| 2026 | - } | |
| 2027 | - onConfirm={() => { | |
| 2028 | - if (deleteConfirm.fieldId) { | |
| 2029 | - 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 || "" | |
| 2030 | 1459 | } |
| 2031 | - }} | |
| 2032 | - title={__("Delete Field", "yatra")} | |
| 2033 | - message={`Are you sure you want to delete the field "${deleteConfirm.fieldLabel}"? This action cannot be undone.`} | |
| 2034 | - confirmText={__("Delete", "yatra")} | |
| 2035 | - cancelText={__("Cancel", "yatra")} | |
| 2036 | - 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} | |
| 2037 | 1480 | /> |
| 2038 | 1481 | |
| 1482 | + | |
| 2039 | 1483 | {/* Preview */} |
| 2040 | 1484 | <Card> |
| 2041 | 1485 | <CardHeader> |
| 2042 | 1486 | <CardTitle className="text-base"> |
| @@ -2064,32 +1508,43 @@ | ||
| 2064 | 1508 | ? "col-span-1" |
| 2065 | 1509 | : "col-span-1" |
| 2066 | 1510 | } |
| 2067 | 1511 | > |
| 2068 | - <Label className="text-sm"> | |
| 2069 | - {field.label} | |
| 2070 | - {field.required && ( | |
| 2071 | - <span className="text-red-500 ml-1">*</span> | |
| 2072 | - )} | |
| 2073 | - </Label> | |
| 2074 | - {field.type === "select" || field.type === "country" ? ( | |
| 2075 | - <Select disabled className="mt-1 w-full"> | |
| 2076 | - <option>{field.placeholder || "Select..."}</option> | |
| 2077 | - </Select> | |
| 2078 | - ) : field.type === "textarea" ? ( | |
| 2079 | - <textarea | |
| 2080 | - disabled | |
| 2081 | - placeholder={field.placeholder} | |
| 2082 | - className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-md bg-white dark:bg-gray-700 text-sm" | |
| 2083 | - rows={2} | |
| 2084 | - /> | |
| 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> | |
| 2085 | 1516 | ) : ( |
| 2086 | - <Input | |
| 2087 | - disabled | |
| 2088 | - type={field.type} | |
| 2089 | - placeholder={field.placeholder} | |
| 2090 | - className="mt-1" | |
| 2091 | - /> | |
| 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 | + </> | |
| 2092 | 1547 | )} |
| 2093 | 1548 | </div> |
| 2094 | 1549 | ))} |
| 2095 | 1550 | </div> |
| @@ -2107,9 +1562,34 @@ | ||
| 2107 | 1562 | const { showToast } = useToast(); |
| 2108 | 1563 | |
| 2109 | 1564 | // Get active section from localStorage or default to 'general' |
| 2110 | 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 | + ]; | |
| 2111 | 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 | + } | |
| 2112 | 1592 | const saved = localStorage.getItem("yatra_settings_active_section"); |
| 2113 | 1593 | if (saved === "email" || saved === "notification") { |
| 2114 | 1594 | return "general"; |
| 2115 | 1595 | } |
| @@ -2129,8 +1609,9 @@ | ||
| 2129 | 1609 | "integration", |
| 2130 | 1610 | "permalink", |
| 2131 | 1611 | "seo", |
| 2132 | 1612 | "advanced", |
| 1613 | + "uninstall", | |
| 2133 | 1614 | ].includes(saved) |
| 2134 | 1615 | ) { |
| 2135 | 1616 | return saved as SettingsSection; |
| 2136 | 1617 | } |
| @@ -2181,9 +1662,9 @@ | ||
| 2181 | 1662 | ); |
| 2182 | 1663 | throw error; |
| 2183 | 1664 | } |
| 2184 | 1665 | }, |
| 2185 | - enabled: can("manage_yatra"), | |
| 1666 | + enabled: can("yatra_manage_settings"), | |
| 2186 | 1667 | // Avoid overwriting in-progress edits due to background refetches |
| 2187 | 1668 | }); |
| 2188 | 1669 | |
| 2189 | 1670 | // Cancellation settings are now saved with main settings |
| @@ -2203,14 +1684,14 @@ | ||
| 2203 | 1684 | console.error("Failed to load gateway definitions:", error); |
| 2204 | 1685 | return {}; |
| 2205 | 1686 | } |
| 2206 | 1687 | }, |
| 2207 | - enabled: can("manage_yatra"), | |
| 1688 | + enabled: can("yatra_manage_settings"), | |
| 2208 | 1689 | staleTime: Infinity, |
| 2209 | 1690 | }); |
| 2210 | 1691 | |
| 2211 | 1692 | useEffect(() => { |
| 2212 | - if (viewingSection !== "advanced" || !can("manage_yatra")) { | |
| 1693 | + if (viewingSection !== "advanced" || !can("yatra_manage_settings")) { | |
| 2213 | 1694 | return; |
| 2214 | 1695 | } |
| 2215 | 1696 | let cancelled = false; |
| 2216 | 1697 | setUsageLoading(true); |
| @@ -2245,9 +1726,9 @@ | ||
| 2245 | 1726 | // Default settings fallback |
| 2246 | 1727 | const defaultSettings: SettingsData = useMemo<SettingsData>( |
| 2247 | 1728 | () => ({ |
| 2248 | 1729 | company_name: "Yatra Travel Agency", |
| 2249 | - company_email: "[email protected]", | |
| 1730 | + company_email: "[email protected]", | |
| 2250 | 1731 | company_phone: "+1-234-567-8900", |
| 2251 | 1732 | company_address: "123 Travel Street", |
| 2252 | 1733 | company_city: "Kathmandu", |
| 2253 | 1734 | company_state: "Bagmati", |
| @@ -2252,9 +1733,9 @@ | ||
| 2252 | 1733 | company_city: "Kathmandu", |
| 2253 | 1734 | company_state: "Bagmati", |
| 2254 | 1735 | company_country: "Nepal", |
| 2255 | 1736 | company_zip: "44600", |
| 2256 | - company_website: "https://yatra.com", | |
| 1737 | + company_website: "https://wpyatra.com", | |
| 2257 | 1738 | company_logo: "", |
| 2258 | 1739 | timezone: "Asia/Kathmandu", |
| 2259 | 1740 | date_format: "Y-m-d", |
| 2260 | 1741 | time_format: "H:i", |
| @@ -2259,19 +1740,29 @@ | ||
| 2259 | 1740 | date_format: "Y-m-d", |
| 2260 | 1741 | time_format: "H:i", |
| 2261 | 1742 | frontend_primary_color: "#3b82f6", |
| 2262 | 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, | |
| 2263 | 1754 | booking_confirmation: true, |
| 2264 | 1755 | auto_confirm_bookings: false, |
| 1756 | + auto_confirm_mode: "online", | |
| 2265 | 1757 | require_login: false, |
| 2266 | 1758 | allow_guest_checkout: true, |
| 2267 | - cancellation_policy: "full_refund", | |
| 2268 | - cancellation_days: 7, | |
| 2269 | - refund_policy: "Full refund if cancelled 7 days before departure", | |
| 1759 | + require_guest_email_verification: false, | |
| 2270 | 1760 | booking_expiry_hours: 24, |
| 2271 | 1761 | booking_reminder_days: 3, |
| 2272 | 1762 | allow_waitlist: true, |
| 2273 | 1763 | waitlist_auto_confirm: false, |
| 1764 | + date_picker_as_dropdown: false, | |
| 2274 | 1765 | |
| 2275 | 1766 | currency: "USD", |
| 2276 | 1767 | payment_test_mode: true, |
| 2277 | 1768 | payment_gateways: ["pay_later"], |
| @@ -2284,10 +1775,13 @@ | ||
| 2284 | 1775 | enable_scheduled_payments: false, |
| 2285 | 1776 | scheduled_payment_type: "single", |
| 2286 | 1777 | scheduled_payment_days: 15, |
| 2287 | 1778 | scheduled_payment_installments: 1, |
| 1779 | + discount_stacking_mode: "both", | |
| 2288 | 1780 | scheduled_payment_interval: 30, |
| 2289 | 1781 | scheduled_payment_reminder_days: 3, |
| 1782 | + balance_anchor: "booking", | |
| 1783 | + balance_due_days: 14, | |
| 2290 | 1784 | gateway_configs: { |
| 2291 | 1785 | stripe: { |
| 2292 | 1786 | enabled: true, |
| 2293 | 1787 | title: "Stripe", |
| @@ -2365,10 +1859,10 @@ | ||
| 2365 | 1859 | "bank_transfer", |
| 2366 | 1860 | "esewa", |
| 2367 | 1861 | "khalti", |
| 2368 | 1862 | ], |
| 2369 | - admin_email: "[email protected]", | |
| 2370 | - from_email: "[email protected]", | |
| 1863 | + admin_email: "[email protected]", | |
| 1864 | + from_email: "[email protected]", | |
| 2371 | 1865 | from_name: "Yatra Travel", |
| 2372 | 1866 | email_template_booking: true, |
| 2373 | 1867 | email_template_confirmation: true, |
| 2374 | 1868 | email_template_cancellation: true, |
| @@ -2377,8 +1871,9 @@ | ||
| 2377 | 1871 | email_template_admin_payment: true, |
| 2378 | 1872 | email_template_admin_cancellation: true, |
| 2379 | 1873 | email_template_trip_consent: true, |
| 2380 | 1874 | email_template_customer_verification: true, |
| 1875 | + email_template_guest_verification: true, | |
| 2381 | 1876 | email_template_booking_completed: true, |
| 2382 | 1877 | email_template_booking_expired_customer: true, |
| 2383 | 1878 | email_template_admin_booking_expired: true, |
| 2384 | 1879 | email_template_scheduled_payment_reminder: true, |
| @@ -2387,10 +1882,12 @@ | ||
| 2387 | 1882 | email_template_admin_scheduled_payment_failed: true, |
| 2388 | 1883 | email_template_enquiry_received: true, |
| 2389 | 1884 | email_template_enquiry_admin: true, |
| 2390 | 1885 | email_template_enquiry_response: true, |
| 2391 | - email_template_review_request: true, | |
| 2392 | - email_template_abandoned_booking_recovery: true, | |
| 1886 | + email_template_review_request: false, | |
| 1887 | + email_template_abandoned_booking_recovery_first: true, | |
| 1888 | + email_template_abandoned_booking_recovery_second: true, | |
| 1889 | + email_template_abandoned_booking_recovery_final: true, | |
| 2393 | 1890 | smtp_enabled: false, |
| 2394 | 1891 | smtp_host: "smtp.gmail.com", |
| 2395 | 1892 | smtp_port: 587, |
| 2396 | 1893 | smtp_username: "", |
| @@ -2413,8 +1910,10 @@ | ||
| 2413 | 1910 | email_tpl_trip_consent_subject: "", |
| 2414 | 1911 | email_tpl_trip_consent_body: "", |
| 2415 | 1912 | email_tpl_customer_verification_subject: "", |
| 2416 | 1913 | email_tpl_customer_verification_body: "", |
| 1914 | + email_tpl_guest_verification_subject: "", | |
| 1915 | + email_tpl_guest_verification_body: "", | |
| 2417 | 1916 | email_tpl_booking_completed_subject: "", |
| 2418 | 1917 | email_tpl_booking_completed_body: "", |
| 2419 | 1918 | email_tpl_booking_expired_customer_subject: "", |
| 2420 | 1919 | email_tpl_booking_expired_customer_body: "", |
| @@ -2435,10 +1934,14 @@ | ||
| 2435 | 1934 | email_tpl_enquiry_response_subject: "", |
| 2436 | 1935 | email_tpl_enquiry_response_body: "", |
| 2437 | 1936 | email_tpl_review_request_subject: "", |
| 2438 | 1937 | email_tpl_review_request_body: "", |
| 2439 | - email_tpl_abandoned_booking_recovery_subject: "", | |
| 2440 | - email_tpl_abandoned_booking_recovery_body: "", | |
| 1938 | + email_tpl_abandoned_booking_recovery_first_subject: "", | |
| 1939 | + email_tpl_abandoned_booking_recovery_first_body: "", | |
| 1940 | + email_tpl_abandoned_booking_recovery_second_subject: "", | |
| 1941 | + email_tpl_abandoned_booking_recovery_second_body: "", | |
| 1942 | + email_tpl_abandoned_booking_recovery_final_subject: "", | |
| 1943 | + email_tpl_abandoned_booking_recovery_final_body: "", | |
| 2441 | 1944 | customer_registration: true, |
| 2442 | 1945 | customer_fields: ["name", "email", "phone", "address"], |
| 2443 | 1946 | require_email_verification: false, |
| 2444 | 1947 | customer_account_page: "/my-account", |
| @@ -2449,9 +1952,10 @@ | ||
| 2449 | 1952 | auto_approve_reviews: false, |
| 2450 | 1953 | review_moderation: true, |
| 2451 | 1954 | min_rating: 1, |
| 2452 | 1955 | allow_anonymous_reviews: false, |
| 2453 | - review_reminder_days: 7, | |
| 1956 | + review_reminder_days: 3, | |
| 1957 | + review_reminder_max_age_days: 14, | |
| 2454 | 1958 | enable_tax: true, |
| 2455 | 1959 | tax_name: __("Tax", "yatra"), |
| 2456 | 1960 | tax_rate: 10, |
| 2457 | 1961 | tax_inclusive: false, |
| @@ -2474,8 +1978,12 @@ | ||
| 2474 | 1978 | facebook_pixel: "", |
| 2475 | 1979 | recaptcha_enabled: false, |
| 2476 | 1980 | recaptcha_site_key: "", |
| 2477 | 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, | |
| 2478 | 1986 | trip_base: "trip", |
| 2479 | 1987 | destination_base: "destination", |
| 2480 | 1988 | activity_base: "activity", |
| 2481 | 1989 | trip_category_base: "trip-category", |
| @@ -2480,8 +1988,10 @@ | ||
| 2480 | 1988 | activity_base: "activity", |
| 2481 | 1989 | trip_category_base: "trip-category", |
| 2482 | 1990 | booking_base: "book", |
| 2483 | 1991 | enable_wishlist: false, |
| 1992 | + show_sold_out: true, | |
| 1993 | + availability_horizon_months: 12, | |
| 2484 | 1994 | use_booking_page: false, |
| 2485 | 1995 | booking_page_id: 0, |
| 2486 | 1996 | terms_page_id: 0, |
| 2487 | 1997 | privacy_policy_page_id: 0, |
| @@ -2486,8 +1996,9 @@ | ||
| 2486 | 1996 | terms_page_id: 0, |
| 2487 | 1997 | privacy_policy_page_id: 0, |
| 2488 | 1998 | debug_mode: false, |
| 2489 | 1999 | enable_logging: false, |
| 2000 | + delete_data_on_uninstall: false, | |
| 2490 | 2001 | cache_enabled: true, |
| 2491 | 2002 | api_key: "", |
| 2492 | 2003 | api_rate_limit: 100, |
| 2493 | 2004 | session_timeout: 3600, |
| @@ -2721,8 +2232,11 @@ | ||
| 2721 | 2232 | seo_trip_meta_title: "", |
| 2722 | 2233 | seo_trip_meta_description: "", |
| 2723 | 2234 | seo_trip_meta_keywords: "", |
| 2724 | 2235 | seo_trip_meta_image: 0, |
| 2236 | + enable_sitemap: true, | |
| 2237 | + sitemap_types: ["archive", "trip", "destination", "activity", "category"], | |
| 2238 | + sitemap_noindex_excluded: false, | |
| 2725 | 2239 | }), |
| 2726 | 2240 | [], |
| 2727 | 2241 | ); |
| 2728 | 2242 | |
| @@ -2750,8 +2264,9 @@ | ||
| 2750 | 2264 | >([]); |
| 2751 | 2265 | const [showApiKey, setShowApiKey] = useState(false); |
| 2752 | 2266 | const [validatingApiKey, setValidatingApiKey] = useState(false); |
| 2753 | 2267 | const [showFacebookToken, setShowFacebookToken] = useState(false); |
| 2268 | + const [showGa4ApiSecret, setShowGa4ApiSecret] = useState(false); | |
| 2754 | 2269 | const [seoImageUrl, setSeoImageUrl] = useState<string>(""); |
| 2755 | 2270 | const [seoImageLoading, setSeoImageLoading] = useState<boolean>(false); |
| 2756 | 2271 | const [seoImageError, setSeoImageError] = useState<string>(""); |
| 2757 | 2272 | |
| @@ -2905,13 +2420,13 @@ | ||
| 2905 | 2420 | if (!formData?.ga4_measurement_id) return; |
| 2906 | 2421 | |
| 2907 | 2422 | setValidatingMeasurementId(true); |
| 2908 | 2423 | try { |
| 2909 | - const response = await apiService.validateGoogleAnalyticsMeasurementId( | |
| 2424 | + const raw = (await apiService.validateGoogleAnalyticsMeasurementId( | |
| 2910 | 2425 | formData.ga4_measurement_id, |
| 2911 | - ); | |
| 2426 | + )) as { success?: boolean; message?: string }; | |
| 2912 | 2427 | |
| 2913 | - if (response.success) { | |
| 2428 | + if (raw?.success) { | |
| 2914 | 2429 | showToast( |
| 2915 | 2430 | __("Measurement ID validated successfully!", "yatra"), |
| 2916 | 2431 | "success", |
| 2917 | 2432 | ); |
| @@ -2916,9 +2431,9 @@ | ||
| 2916 | 2431 | "success", |
| 2917 | 2432 | ); |
| 2918 | 2433 | } else { |
| 2919 | 2434 | showToast( |
| 2920 | - response.message || __("Measurement ID validation failed.", "yatra"), | |
| 2435 | + raw?.message || __("Measurement ID validation failed.", "yatra"), | |
| 2921 | 2436 | "error", |
| 2922 | 2437 | ); |
| 2923 | 2438 | } |
| 2924 | 2439 | } catch (error: any) { |
| @@ -2935,18 +2450,18 @@ | ||
| 2935 | 2450 | if (!formData?.ga4_measurement_id || !formData?.ga4_api_secret) return; |
| 2936 | 2451 | |
| 2937 | 2452 | setValidatingApiSecret(true); |
| 2938 | 2453 | try { |
| 2939 | - const response = await apiService.validateGoogleAnalyticsApiSecret( | |
| 2454 | + const raw = (await apiService.validateGoogleAnalyticsApiSecret( | |
| 2940 | 2455 | formData.ga4_measurement_id, |
| 2941 | 2456 | formData.ga4_api_secret, |
| 2942 | - ); | |
| 2457 | + )) as { success?: boolean; message?: string }; | |
| 2943 | 2458 | |
| 2944 | - if (response.success) { | |
| 2459 | + if (raw?.success) { | |
| 2945 | 2460 | showToast(__("API Secret validated successfully!", "yatra"), "success"); |
| 2946 | 2461 | } else { |
| 2947 | 2462 | showToast( |
| 2948 | - response.message || __("API Secret validation failed.", "yatra"), | |
| 2463 | + raw?.message || __("API Secret validation failed.", "yatra"), | |
| 2949 | 2464 | "error", |
| 2950 | 2465 | ); |
| 2951 | 2466 | } |
| 2952 | 2467 | } catch (error: any) { |
| @@ -3075,8 +2590,9 @@ | ||
| 3075 | 2590 | // Use default settings if API fails or returns empty |
| 3076 | 2591 | setFormData(defaultSettings); |
| 3077 | 2592 | isInitializedRef.current = true; |
| 3078 | 2593 | } |
| 2594 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 3079 | 2595 | }, [settings, isLoading]); |
| 3080 | 2596 | |
| 3081 | 2597 | // Fetch SEO image URL when image ID changes with performance optimization |
| 3082 | 2598 | React.useEffect(() => { |
| @@ -3151,33 +2667,43 @@ | ||
| 3151 | 2667 | controller.abort(); |
| 3152 | 2668 | }; |
| 3153 | 2669 | }, [formData?.seo_trip_meta_image]); |
| 3154 | 2670 | |
| 3155 | - // 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); | |
| 3156 | 2681 | React.useEffect(() => { |
| 3157 | - if ( | |
| 3158 | - gatewayDefinitions && | |
| 3159 | - Object.keys(gatewayDefinitions).length > 0 && | |
| 3160 | - gatewayOrder.length === 0 | |
| 3161 | - ) { | |
| 3162 | - // Use saved order if available, otherwise use default order from definitions | |
| 3163 | - const savedOrder = settings?.gateway_order as string[] | undefined; | |
| 3164 | - if (savedOrder) { | |
| 3165 | - // Merge saved order with any new gateways | |
| 3166 | - const allGateways = Object.keys(gatewayDefinitions); | |
| 3167 | - const validSavedOrder = savedOrder.filter((id) => | |
| 3168 | - allGateways.includes(id), | |
| 3169 | - ); | |
| 3170 | - const newGateways = allGateways.filter( | |
| 3171 | - (id) => !validSavedOrder.includes(id), | |
| 3172 | - ); | |
| 3173 | - setGatewayOrder([...validSavedOrder, ...newGateways]); | |
| 3174 | - } else { | |
| 3175 | - setGatewayOrder(Object.keys(gatewayDefinitions)); | |
| 3176 | - } | |
| 2682 | + if (gatewayOrderInitialized.current) return; | |
| 2683 | + if (!gatewayDefinitions || Object.keys(gatewayDefinitions).length === 0) { | |
| 2684 | + return; | |
| 3177 | 2685 | } |
| 3178 | - }, [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; | |
| 3179 | 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 | + | |
| 3180 | 2706 | // Single stable onChange handler that reads field name from input's name or id attribute |
| 3181 | 2707 | const handleFieldChange = React.useCallback( |
| 3182 | 2708 | ( |
| 3183 | 2709 | e: React.ChangeEvent< |
| @@ -3189,10 +2715,19 @@ | ||
| 3189 | 2715 | |
| 3190 | 2716 | if (e.target.type === "checkbox") { |
| 3191 | 2717 | value = (e.target as HTMLInputElement).checked; |
| 3192 | 2718 | } else if (e.target.type === "number") { |
| 3193 | - const numValue = parseFloat(e.target.value); | |
| 3194 | - 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 | + } | |
| 3195 | 2730 | } else { |
| 3196 | 2731 | value = e.target.value; |
| 3197 | 2732 | } |
| 3198 | 2733 | |
| @@ -3406,9 +2941,9 @@ | ||
| 3406 | 2941 | }>; |
| 3407 | 2942 | }, |
| 3408 | 2943 | // Used by multiple sections (Booking page, Legal pages, etc.), so keep it available |
| 3409 | 2944 | // whenever the user can manage Yatra settings. |
| 3410 | - enabled: can("manage_yatra"), | |
| 2945 | + enabled: can("yatra_manage_settings"), | |
| 3411 | 2946 | }); |
| 3412 | 2947 | |
| 3413 | 2948 | // Check shortcode on-demand when user selects a page |
| 3414 | 2949 | const checkShortcodeMutation = useMutation({ |
| @@ -3555,12 +3090,36 @@ | ||
| 3555 | 3090 | label: __("Booking Form", "yatra"), |
| 3556 | 3091 | icon: ClipboardList, |
| 3557 | 3092 | }, |
| 3558 | 3093 | { |
| 3094 | + id: "search" as SettingsSection, | |
| 3095 | + label: __("Search & Listing", "yatra"), | |
| 3096 | + icon: SlidersHorizontal, | |
| 3097 | + }, | |
| 3098 | + { | |
| 3559 | 3099 | id: "payment" as SettingsSection, |
| 3560 | 3100 | label: __("Payment", "yatra"), |
| 3561 | 3101 | icon: DollarSign, |
| 3562 | 3102 | }, |
| 3103 | + // Pricing tab — visible on every Pro install so the Discount | |
| 3104 | + // Stacking control is *discoverable*. The setting itself only | |
| 3105 | + // takes effect when BOTH Advanced Discount and Dynamic Pricing | |
| 3106 | + // are enabled; the case "pricing" render gives a clear status | |
| 3107 | + // banner + disables the dropdown when prerequisites are missing. | |
| 3108 | + // | |
| 3109 | + // Truthy check (not `=== true`) because some WP installs end up | |
| 3110 | + // with `isPro` serialized as `1` instead of `true` through the | |
| 3111 | + // PHP→JSON→JS pipeline. Free installs serialize false (or omit | |
| 3112 | + // the key), which stays falsy and correctly hides the tab. | |
| 3113 | + ...((window as any)?.yatraAdmin?.isPro | |
| 3114 | + ? [ | |
| 3115 | + { | |
| 3116 | + id: "pricing" as SettingsSection, | |
| 3117 | + label: __("Pricing", "yatra"), | |
| 3118 | + icon: Percent, | |
| 3119 | + }, | |
| 3120 | + ] | |
| 3121 | + : []), | |
| 3563 | 3122 | { |
| 3564 | 3123 | id: "customer" as SettingsSection, |
| 3565 | 3124 | label: __("Customer", "yatra"), |
| 3566 | 3125 | icon: Users, |
| @@ -3595,8 +3154,13 @@ | ||
| 3595 | 3154 | id: "advanced" as SettingsSection, |
| 3596 | 3155 | label: __("Advanced", "yatra"), |
| 3597 | 3156 | icon: Shield, |
| 3598 | 3157 | }, |
| 3158 | + { | |
| 3159 | + id: "uninstall" as SettingsSection, | |
| 3160 | + label: __("Uninstall", "yatra"), | |
| 3161 | + icon: Trash2, | |
| 3162 | + }, | |
| 3599 | 3163 | ]; |
| 3600 | 3164 | |
| 3601 | 3165 | // Helper component for section divider |
| 3602 | 3166 | const SectionDivider = ({ title }: { title: string }) => ( |
| @@ -4011,8 +3575,152 @@ | ||
| 4011 | 3575 | spellCheck={false} |
| 4012 | 3576 | autoComplete="off" |
| 4013 | 3577 | /> |
| 4014 | 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> | |
| 4015 | 3723 | </div> |
| 4016 | 3724 | </div> |
| 4017 | 3725 | ); |
| 4018 | 3726 | |
| @@ -4207,8 +3915,53 @@ | ||
| 4207 | 3915 | |
| 4208 | 3916 | {formData.enable_scheduled_payments && ( |
| 4209 | 3917 | <> |
| 4210 | 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 | |
| 4211 | 3964 | id="scheduled_payment_type" |
| 4212 | 3965 | label={__("Schedule type", "yatra")} |
| 4213 | 3966 | description={__( |
| 4214 | 3967 | "Single final charge on a date, or multiple installments.", |
| @@ -4229,27 +3982,29 @@ | ||
| 4229 | 3982 | </option> |
| 4230 | 3983 | </Select> |
| 4231 | 3984 | </FormField> |
| 4232 | 3985 | |
| 4233 | - <FormField | |
| 4234 | - id="scheduled_payment_days" | |
| 4235 | - label={__("Days until first charge", "yatra")} | |
| 4236 | - description={__( | |
| 4237 | - "How many days after the initial payment to schedule the first balance collection.", | |
| 4238 | - "yatra", | |
| 4239 | - )} | |
| 4240 | - > | |
| 4241 | - <Input | |
| 3986 | + {formData.balance_anchor !== "tour" && ( | |
| 3987 | + <FormField | |
| 4242 | 3988 | id="scheduled_payment_days" |
| 4243 | - type="number" | |
| 4244 | - min={0} | |
| 4245 | - max={365} | |
| 4246 | - value={formData.scheduled_payment_days} | |
| 4247 | - name="scheduled_payment_days" | |
| 4248 | - onChange={handleFieldChange} | |
| 4249 | - className="max-w-xs" | |
| 4250 | - /> | |
| 4251 | - </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 | + )} | |
| 4252 | 4007 | |
| 4253 | 4008 | {formData.scheduled_payment_type === "installments" && ( |
| 4254 | 4009 | <> |
| 4255 | 4010 | <FormField |
| @@ -4682,8 +4437,117 @@ | ||
| 4682 | 4437 | return null; |
| 4683 | 4438 | } |
| 4684 | 4439 | } |
| 4685 | 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 | + | |
| 4686 | 4550 | // Render checkbox fields differently |
| 4687 | 4551 | if (field.type === "checkbox") { |
| 4688 | 4552 | return ( |
| 4689 | 4553 | <div |
| @@ -4964,8 +4828,53 @@ | ||
| 4964 | 4828 | </div> |
| 4965 | 4829 | ); |
| 4966 | 4830 | } |
| 4967 | 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 | + | |
| 4968 | 4877 | // Render text, password, number, email inputs |
| 4969 | 4878 | return ( |
| 4970 | 4879 | <div key={field.id} className="space-y-2"> |
| 4971 | 4880 | <FormField |
| @@ -4987,12 +4896,21 @@ | ||
| 4987 | 4896 | onChange={(e) => |
| 4988 | 4897 | handleGatewayConfigChange( |
| 4989 | 4898 | gatewayId, |
| 4990 | 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. | |
| 4991 | 4907 | field.type === "number" |
| 4992 | - ? parseFloat( | |
| 4993 | - e.target.value, | |
| 4994 | - ) || 0 | |
| 4908 | + ? e.target.value === "" | |
| 4909 | + ? "" | |
| 4910 | + : parseFloat( | |
| 4911 | + e.target.value, | |
| 4912 | + ) || 0 | |
| 4995 | 4913 | : e.target.value, |
| 4996 | 4914 | ) |
| 4997 | 4915 | } |
| 4998 | 4916 | placeholder={field.placeholder} |
| @@ -5055,57 +4973,162 @@ | ||
| 5055 | 4973 | </div> |
| 5056 | 4974 | </> |
| 5057 | 4975 | ); |
| 5058 | 4976 | |
| 5059 | - // Continue with other sections... (I'll add the most important ones) | |
| 5060 | - case "booking": | |
| 4977 | + case "search": | |
| 5061 | 4978 | return ( |
| 5062 | 4979 | <div className="space-y-6"> |
| 5063 | - <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> | |
| 5064 | 5079 | <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> |
| 5065 | 5080 | <input |
| 5066 | 5081 | type="checkbox" |
| 5067 | - id="booking_confirmation" | |
| 5068 | - checked={formData.booking_confirmation} | |
| 5069 | - name="booking_confirmation" | |
| 5082 | + id="collapse_filters_on_mobile" | |
| 5083 | + checked={!!formData.collapse_filters_on_mobile} | |
| 5084 | + name="collapse_filters_on_mobile" | |
| 5070 | 5085 | onChange={handleFieldChange} |
| 5071 | 5086 | className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" |
| 5072 | 5087 | /> |
| 5073 | 5088 | <div className="flex-1"> |
| 5074 | 5089 | <Label |
| 5075 | - htmlFor="booking_confirmation" | |
| 5090 | + htmlFor="collapse_filters_on_mobile" | |
| 5076 | 5091 | className="font-medium cursor-pointer" |
| 5077 | 5092 | > |
| 5078 | - {__("Enable Booking Confirmation", "yatra")} | |
| 5093 | + {__("Collapse filters by default on mobile", "yatra")} | |
| 5079 | 5094 | </Label> |
| 5080 | 5095 | <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> |
| 5081 | 5096 | {__( |
| 5082 | - "Controls post-checkout confirmation (page and customer email when templates/notifications allow).", | |
| 5097 | + "On mobile, show filter sections collapsed so trip listings are visible right away. Visitors tap a section to expand it. Desktop is unaffected.", | |
| 5083 | 5098 | "yatra", |
| 5084 | 5099 | )} |
| 5085 | 5100 | </p> |
| 5086 | 5101 | </div> |
| 5087 | 5102 | </div> |
| 5103 | + </div> | |
| 5104 | + </div> | |
| 5105 | + ); | |
| 5088 | 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"> | |
| 5089 | 5112 | <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> |
| 5090 | 5113 | <input |
| 5091 | 5114 | type="checkbox" |
| 5092 | - id="auto_confirm_bookings" | |
| 5093 | - checked={formData.auto_confirm_bookings} | |
| 5094 | - name="auto_confirm_bookings" | |
| 5115 | + id="booking_confirmation" | |
| 5116 | + checked={formData.booking_confirmation} | |
| 5117 | + name="booking_confirmation" | |
| 5095 | 5118 | onChange={handleFieldChange} |
| 5096 | 5119 | className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" |
| 5097 | 5120 | /> |
| 5098 | 5121 | <div className="flex-1"> |
| 5099 | 5122 | <Label |
| 5100 | - htmlFor="auto_confirm_bookings" | |
| 5123 | + htmlFor="booking_confirmation" | |
| 5101 | 5124 | className="font-medium cursor-pointer" |
| 5102 | 5125 | > |
| 5103 | - {__("Auto-Confirm Bookings", "yatra")} | |
| 5126 | + {__("Send Booking Confirmation Email", "yatra")} | |
| 5104 | 5127 | </Label> |
| 5105 | 5128 | <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> |
| 5106 | 5129 | {__( |
| 5107 | - "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.", | |
| 5108 | 5131 | "yatra", |
| 5109 | 5132 | )} |
| 5110 | 5133 | </p> |
| 5111 | 5134 | </div> |
| @@ -5110,8 +5133,85 @@ | ||
| 5110 | 5133 | </p> |
| 5111 | 5134 | </div> |
| 5112 | 5135 | </div> |
| 5113 | 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 | + | |
| 5114 | 5214 | <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> |
| 5115 | 5215 | <input |
| 5116 | 5216 | type="checkbox" |
| 5117 | 5217 | id="require_login" |
| @@ -5160,11 +5260,94 @@ | ||
| 5160 | 5260 | </p> |
| 5161 | 5261 | </div> |
| 5162 | 5262 | </div> |
| 5163 | 5263 | |
| 5264 | + {/* | |
| 5265 | + Guest email verification — only meaningful when guest | |
| 5266 | + checkout is allowed AND login isn't required, since | |
| 5267 | + logged-in customers' emails are verified by WordPress | |
| 5268 | + on registration. We still render the checkbox at all | |
| 5269 | + times so operators can pre-configure before flipping | |
| 5270 | + the parent toggles; the help text explains the | |
| 5271 | + dependency rather than hiding the control. | |
| 5272 | + */} | |
| 5164 | 5273 | <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> |
| 5165 | 5274 | <input |
| 5166 | 5275 | type="checkbox" |
| 5276 | + id="require_guest_email_verification" | |
| 5277 | + checked={formData.require_guest_email_verification} | |
| 5278 | + name="require_guest_email_verification" | |
| 5279 | + onChange={handleFieldChange} | |
| 5280 | + disabled={ | |
| 5281 | + !formData.allow_guest_checkout || formData.require_login | |
| 5282 | + } | |
| 5283 | + className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:opacity-50" | |
| 5284 | + /> | |
| 5285 | + <div className="flex-1"> | |
| 5286 | + <Label | |
| 5287 | + htmlFor="require_guest_email_verification" | |
| 5288 | + className="font-medium cursor-pointer" | |
| 5289 | + > | |
| 5290 | + {__("Require Guest Email Verification", "yatra")} | |
| 5291 | + </Label> | |
| 5292 | + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> | |
| 5293 | + {__( | |
| 5294 | + "Hold guest bookings until the customer clicks a magic link sent to their email. Stops typo'd addresses and form-spam bots. Only applies when guest checkout is allowed and login isn't required — logged-in customers are already verified.", | |
| 5295 | + "yatra", | |
| 5296 | + )} | |
| 5297 | + </p> | |
| 5298 | + </div> | |
| 5299 | + </div> | |
| 5300 | + | |
| 5301 | + <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> | |
| 5302 | + <input | |
| 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" | |
| 5167 | 5350 | id="allow_waitlist" |
| 5168 | 5351 | checked={formData.allow_waitlist} |
| 5169 | 5352 | name="allow_waitlist" |
| 5170 | 5353 | onChange={handleFieldChange} |
| @@ -5209,80 +5392,65 @@ | ||
| 5209 | 5392 | )} |
| 5210 | 5393 | </p> |
| 5211 | 5394 | </div> |
| 5212 | 5395 | </div> |
| 5396 | + | |
| 5397 | + {/* Pro feature: replace flatpickr calendar with a dropdown of | |
| 5398 | + available departure dates on the single-trip sidebar + the | |
| 5399 | + mobile sticky bar. The checkbox stays interactive for non-Pro | |
| 5400 | + installs so the value can be staged, but the frontend gate | |
| 5401 | + ignores it unless Pro is active. */} | |
| 5402 | + <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> | |
| 5403 | + <input | |
| 5404 | + type="checkbox" | |
| 5405 | + id="date_picker_as_dropdown" | |
| 5406 | + checked={!!formData.date_picker_as_dropdown} | |
| 5407 | + name="date_picker_as_dropdown" | |
| 5408 | + onChange={handleFieldChange} | |
| 5409 | + disabled={!(window as any).yatraAdmin?.isPro} | |
| 5410 | + className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:opacity-50" | |
| 5411 | + /> | |
| 5412 | + <div className="flex-1"> | |
| 5413 | + <Label | |
| 5414 | + htmlFor="date_picker_as_dropdown" | |
| 5415 | + className="font-medium cursor-pointer inline-flex items-center" | |
| 5416 | + > | |
| 5417 | + {__("Show available dates as a dropdown", "yatra")} | |
| 5418 | + <ProBadge | |
| 5419 | + isProActive={!!(window as any).yatraAdmin?.isPro} | |
| 5420 | + /> | |
| 5421 | + </Label> | |
| 5422 | + <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> | |
| 5423 | + {__( | |
| 5424 | + "Replace the calendar date picker with a dropdown listing every available departure date on the single-trip sidebar and the mobile sticky bar. Best for trips with a small set of fixed departures.", | |
| 5425 | + "yatra", | |
| 5426 | + )} | |
| 5427 | + </p> | |
| 5428 | + </div> | |
| 5429 | + </div> | |
| 5213 | 5430 | </div> |
| 5214 | 5431 | |
| 5215 | - <SectionDivider | |
| 5216 | - title={__("Cancellation & Refund Policy", "yatra")} | |
| 5217 | - /> | |
| 5432 | + {/* | |
| 5433 | + Cancellation & Refund Policy section removed. | |
| 5218 | 5434 | |
| 5219 | - <div className="space-y-4"> | |
| 5220 | - <FormField | |
| 5221 | - id="cancellation_policy" | |
| 5222 | - label={__("Cancellation Policy", "yatra")} | |
| 5223 | - description={__( | |
| 5224 | - "What happens when a booking is cancelled", | |
| 5225 | - "yatra", | |
| 5226 | - )} | |
| 5227 | - > | |
| 5228 | - <Select | |
| 5229 | - id="cancellation_policy" | |
| 5230 | - value={formData.cancellation_policy} | |
| 5231 | - name="cancellation_policy" | |
| 5232 | - onChange={handleFieldChange} | |
| 5233 | - > | |
| 5234 | - <option value="no_refund">{__("No Refund", "yatra")}</option> | |
| 5235 | - <option value="full_refund"> | |
| 5236 | - {__("Full Refund", "yatra")} | |
| 5237 | - </option> | |
| 5238 | - <option value="partial_refund"> | |
| 5239 | - {__("Partial Refund", "yatra")} | |
| 5240 | - </option> | |
| 5241 | - </Select> | |
| 5242 | - </FormField> | |
| 5435 | + Earlier versions exposed three settings here | |
| 5436 | + (cancellation_policy dropdown, cancellation_days number, | |
| 5437 | + refund_policy textarea). All three were display-only — | |
| 5438 | + they appeared in the booking confirmation email but | |
| 5439 | + did NOT enforce any cancellation cutoff or compute any | |
| 5440 | + refund amount, because Yatra has no customer-facing | |
| 5441 | + self-service cancellation flow. The names implied | |
| 5442 | + enforcement that did not exist, which is worse than | |
| 5443 | + having no setting at all. | |
| 5243 | 5444 | |
| 5244 | - <FormField | |
| 5245 | - id="cancellation_days" | |
| 5246 | - label={__("Cancellation Days Before Departure", "yatra")} | |
| 5247 | - description={__( | |
| 5248 | - "Number of days before departure when cancellation is allowed", | |
| 5249 | - "yatra", | |
| 5250 | - )} | |
| 5251 | - > | |
| 5252 | - <Input | |
| 5253 | - id="cancellation_days" | |
| 5254 | - type="number" | |
| 5255 | - value={formData.cancellation_days} | |
| 5256 | - name="cancellation_days" | |
| 5257 | - onChange={handleFieldChange} | |
| 5258 | - min="0" | |
| 5259 | - /> | |
| 5260 | - </FormField> | |
| 5445 | + Per-trip cancellation copy still lives on the Trip | |
| 5446 | + editor (Trip Builder → cancellation_policy field) and | |
| 5447 | + is what the booking confirmation page + email now | |
| 5448 | + show. To restore site-wide cancellation enforcement, | |
| 5449 | + build the cancellation REST endpoint and re-add gated | |
| 5450 | + settings here. | |
| 5451 | + */} | |
| 5261 | 5452 | |
| 5262 | - <FormField | |
| 5263 | - id="refund_policy" | |
| 5264 | - label={__("Refund Policy", "yatra")} | |
| 5265 | - description={__( | |
| 5266 | - "Detailed refund policy description shown to customers", | |
| 5267 | - "yatra", | |
| 5268 | - )} | |
| 5269 | - > | |
| 5270 | - <textarea | |
| 5271 | - id="refund_policy" | |
| 5272 | - value={formData.refund_policy} | |
| 5273 | - name="refund_policy" | |
| 5274 | - onChange={handleFieldChange} | |
| 5275 | - rows={4} | |
| 5276 | - className="flex w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:ring-offset-gray-900 dark:placeholder:text-gray-400 dark:focus-visible:ring-blue-400 resize-none" | |
| 5277 | - placeholder={__( | |
| 5278 | - "Enter your refund policy details...", | |
| 5279 | - "yatra", | |
| 5280 | - )} | |
| 5281 | - /> | |
| 5282 | - </FormField> | |
| 5283 | - </div> | |
| 5284 | - | |
| 5285 | 5453 | <SectionDivider title={__("Booking Expiry & Reminders", "yatra")} /> |
| 5286 | 5454 | |
| 5287 | 5455 | <div className="space-y-4"> |
| 5288 | 5456 | <FormField |
| @@ -5323,8 +5491,188 @@ | ||
| 5323 | 5491 | </div> |
| 5324 | 5492 | </div> |
| 5325 | 5493 | ); |
| 5326 | 5494 | |
| 5495 | + case "pricing": { | |
| 5496 | + // Snapshot the two Pro-module flags. The free plugin's | |
| 5497 | + // AdminAssetsProvider sets these directly from the canonical | |
| 5498 | + // ModuleManager::isModuleEnabled(). Truthy check (not | |
| 5499 | + // `=== true`) is mandatory because wp_localize_script | |
| 5500 | + // serializes every scalar to a string before handing it to | |
| 5501 | + // JS — true becomes "1", false becomes "". Comparing | |
| 5502 | + // === true would always be false for an enabled module, | |
| 5503 | + // which was why every operator saw the "not active" banner | |
| 5504 | + // even on a correctly-configured site. | |
| 5505 | + const adEnabled = !!(window as any)?.yatraAdmin | |
| 5506 | + ?.advancedDiscountEnabled; | |
| 5507 | + const dpEnabled = !!(window as any)?.yatraAdmin?.dynamicPricingEnabled; | |
| 5508 | + const bothActive = adEnabled && dpEnabled; | |
| 5509 | + return ( | |
| 5510 | + <div className="space-y-6"> | |
| 5511 | + <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"> | |
| 5512 | + {__( | |
| 5513 | + "Controls what happens when both a Discount (coupon or group discount) and a Dynamic Pricing rule could apply to the same booking. The control only takes effect when both the Advanced Discount and Dynamic Pricing modules are turned on.", | |
| 5514 | + "yatra", | |
| 5515 | + )} | |
| 5516 | + </div> | |
| 5517 | + | |
| 5518 | + {/* Module-status banner. Green when both modules are on, | |
| 5519 | + amber when the setting is inert because one (or both) | |
| 5520 | + is off. Either way the dropdown stays rendered so the | |
| 5521 | + operator knows the feature exists and what it does. */} | |
| 5522 | + {bothActive ? ( | |
| 5523 | + <div className="rounded-md border border-green-200 bg-green-50 p-3 text-xs text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-200"> | |
| 5524 | + <div className="flex items-start gap-2"> | |
| 5525 | + <Check className="mt-0.5 h-4 w-4 flex-shrink-0" /> | |
| 5526 | + <div> | |
| 5527 | + <div className="font-medium">{__("Active", "yatra")}</div> | |
| 5528 | + <div className="mt-0.5"> | |
| 5529 | + {__( | |
| 5530 | + "Advanced Discount and Dynamic Pricing are both enabled. Your stacking choice below is being applied to every new booking calculation.", | |
| 5531 | + "yatra", | |
| 5532 | + )} | |
| 5533 | + </div> | |
| 5534 | + </div> | |
| 5535 | + </div> | |
| 5536 | + </div> | |
| 5537 | + ) : ( | |
| 5538 | + <div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-200"> | |
| 5539 | + <div className="flex items-start gap-2"> | |
| 5540 | + <AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" /> | |
| 5541 | + <div> | |
| 5542 | + <div className="font-medium"> | |
| 5543 | + {__("Not active yet", "yatra")} | |
| 5544 | + </div> | |
| 5545 | + <div className="mt-0.5"> | |
| 5546 | + {__( | |
| 5547 | + "This setting only applies when BOTH modules below are enabled. Without both, bookings calculate exactly as they do today (no stacking control).", | |
| 5548 | + "yatra", | |
| 5549 | + )} | |
| 5550 | + </div> | |
| 5551 | + <ul className="mt-2 space-y-1"> | |
| 5552 | + <li className="flex items-center gap-1.5"> | |
| 5553 | + {adEnabled ? ( | |
| 5554 | + <Check className="h-3.5 w-3.5 text-green-600 dark:text-green-400" /> | |
| 5555 | + ) : ( | |
| 5556 | + <XCircle className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400" /> | |
| 5557 | + )} | |
| 5558 | + <span> | |
| 5559 | + {__("Advanced Discount module", "yatra")}{" "} | |
| 5560 | + {adEnabled | |
| 5561 | + ? __("(enabled)", "yatra") | |
| 5562 | + : __("(not enabled)", "yatra")} | |
| 5563 | + </span> | |
| 5564 | + </li> | |
| 5565 | + <li className="flex items-center gap-1.5"> | |
| 5566 | + {dpEnabled ? ( | |
| 5567 | + <Check className="h-3.5 w-3.5 text-green-600 dark:text-green-400" /> | |
| 5568 | + ) : ( | |
| 5569 | + <XCircle className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400" /> | |
| 5570 | + )} | |
| 5571 | + <span> | |
| 5572 | + {__("Dynamic Pricing module", "yatra")}{" "} | |
| 5573 | + {dpEnabled | |
| 5574 | + ? __("(enabled)", "yatra") | |
| 5575 | + : __("(not enabled)", "yatra")} | |
| 5576 | + </span> | |
| 5577 | + </li> | |
| 5578 | + </ul> | |
| 5579 | + <button | |
| 5580 | + type="button" | |
| 5581 | + onClick={() => navigateMenu("modules")} | |
| 5582 | + className="mt-2 inline-flex items-center gap-1 text-amber-900 underline hover:text-amber-700 dark:text-amber-100 dark:hover:text-amber-300" | |
| 5583 | + > | |
| 5584 | + {__("Open Modules", "yatra")} | |
| 5585 | + <ExternalLink className="h-3 w-3" /> | |
| 5586 | + </button> | |
| 5587 | + </div> | |
| 5588 | + </div> | |
| 5589 | + </div> | |
| 5590 | + )} | |
| 5591 | + | |
| 5592 | + <FormField | |
| 5593 | + id="discount_stacking_mode" | |
| 5594 | + label={__("Discount Stacking", "yatra")} | |
| 5595 | + description={__( | |
| 5596 | + "Pick how Discounts and Dynamic Pricing combine on the same booking.", | |
| 5597 | + "yatra", | |
| 5598 | + )} | |
| 5599 | + > | |
| 5600 | + <Select | |
| 5601 | + id="discount_stacking_mode" | |
| 5602 | + name="discount_stacking_mode" | |
| 5603 | + value={formData.discount_stacking_mode ?? "both"} | |
| 5604 | + onChange={handleFieldChange} | |
| 5605 | + disabled={!bothActive} | |
| 5606 | + > | |
| 5607 | + <option value="both"> | |
| 5608 | + {__( | |
| 5609 | + "Both apply (legacy) — Dynamic Pricing and Discount stack", | |
| 5610 | + "yatra", | |
| 5611 | + )} | |
| 5612 | + </option> | |
| 5613 | + <option value="best_for_customer"> | |
| 5614 | + {__( | |
| 5615 | + "Best for the customer — pick whichever single mechanism gives the larger reduction", | |
| 5616 | + "yatra", | |
| 5617 | + )} | |
| 5618 | + </option> | |
| 5619 | + <option value="discount_only"> | |
| 5620 | + {__( | |
| 5621 | + "Discount only — if a coupon or group discount applies, ignore Dynamic Pricing", | |
| 5622 | + "yatra", | |
| 5623 | + )} | |
| 5624 | + </option> | |
| 5625 | + <option value="dynamic_pricing_only"> | |
| 5626 | + {__( | |
| 5627 | + "Dynamic Pricing only — if a Dynamic Pricing rule fires, ignore the discount", | |
| 5628 | + "yatra", | |
| 5629 | + )} | |
| 5630 | + </option> | |
| 5631 | + </Select> | |
| 5632 | + </FormField> | |
| 5633 | + | |
| 5634 | + <div className="rounded-md border border-blue-200 bg-blue-50 p-3 text-xs text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200"> | |
| 5635 | + {__("What each mode does:", "yatra")} | |
| 5636 | + <ul className="mt-2 ml-4 list-disc space-y-1"> | |
| 5637 | + <li> | |
| 5638 | + <strong>{__("Both apply", "yatra")}</strong> | |
| 5639 | + {" — "} | |
| 5640 | + {__( | |
| 5641 | + "default and matches the original behavior. A coupon stacks on top of the Dynamic Pricing-adjusted price.", | |
| 5642 | + "yatra", | |
| 5643 | + )} | |
| 5644 | + </li> | |
| 5645 | + <li> | |
| 5646 | + <strong>{__("Best for the customer", "yatra")}</strong> | |
| 5647 | + {" — "} | |
| 5648 | + {__( | |
| 5649 | + "calculates both scenarios and picks the cheaper one for the customer. Never combines.", | |
| 5650 | + "yatra", | |
| 5651 | + )} | |
| 5652 | + </li> | |
| 5653 | + <li> | |
| 5654 | + <strong>{__("Discount only", "yatra")}</strong> | |
| 5655 | + {" — "} | |
| 5656 | + {__( | |
| 5657 | + "when a discount is valid, Dynamic Pricing is skipped for that booking.", | |
| 5658 | + "yatra", | |
| 5659 | + )} | |
| 5660 | + </li> | |
| 5661 | + <li> | |
| 5662 | + <strong>{__("Dynamic Pricing only", "yatra")}</strong> | |
| 5663 | + {" — "} | |
| 5664 | + {__( | |
| 5665 | + "when a Dynamic Pricing rule reduced the price, discount codes and group discounts are ignored for that booking.", | |
| 5666 | + "yatra", | |
| 5667 | + )} | |
| 5668 | + </li> | |
| 5669 | + </ul> | |
| 5670 | + </div> | |
| 5671 | + </div> | |
| 5672 | + ); | |
| 5673 | + } | |
| 5674 | + | |
| 5327 | 5675 | case "customer": |
| 5328 | 5676 | return ( |
| 5329 | 5677 | <div className="space-y-6"> |
| 5330 | 5678 | <div className="space-y-4"> |
| @@ -5591,9 +5939,9 @@ | ||
| 5591 | 5939 | <FormField |
| 5592 | 5940 | id="review_reminder_days" |
| 5593 | 5941 | label={__("Review Reminder Days", "yatra")} |
| 5594 | 5942 | description={__( |
| 5595 | - "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.", | |
| 5596 | 5944 | "yatra", |
| 5597 | 5945 | )} |
| 5598 | 5946 | > |
| 5599 | 5947 | <Input |
| @@ -5604,8 +5952,26 @@ | ||
| 5604 | 5952 | onChange={handleFieldChange} |
| 5605 | 5953 | min="0" |
| 5606 | 5954 | /> |
| 5607 | 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> | |
| 5608 | 5974 | </> |
| 5609 | 5975 | )} |
| 5610 | 5976 | </div> |
| 5611 | 5977 | </div> |
| @@ -6793,37 +7159,46 @@ | ||
| 6793 | 7159 | {/* Google Analytics 4 Enhanced - Pro Feature */} |
| 6794 | 7160 | <SectionDivider |
| 6795 | 7161 | title={__("Google Analytics 4 Enhanced", "yatra")} |
| 6796 | 7162 | /> |
| 6797 | - <Card> | |
| 6798 | - <CardHeader className="flex flex-row items-center justify-between pb-2"> | |
| 6799 | - <CardTitle className="text-lg flex items-center gap-2"> | |
| 6800 | - <svg | |
| 6801 | - className="w-5 h-5 text-orange-500" | |
| 6802 | - viewBox="0 0 24 24" | |
| 6803 | - fill="currentColor" | |
| 6804 | - > | |
| 6805 | - <path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z" /> | |
| 6806 | - </svg> | |
| 6807 | - {__("Google Analytics 4 Enhanced", "yatra")} | |
| 6808 | - <ProBadge isProActive={(window as any).yatraAdmin?.isPro} /> | |
| 7163 | + <Card className="overflow-hidden border-gray-200 dark:border-gray-700 shadow-sm"> | |
| 7164 | + <CardHeader className="flex flex-row flex-wrap items-start gap-3 justify-between border-b border-gray-100 dark:border-gray-800 bg-gray-50/80 dark:bg-gray-900/40 pb-4"> | |
| 7165 | + <CardTitle className="text-lg flex items-center gap-2.5 text-gray-900 dark:text-white"> | |
| 7166 | + <span className="flex h-9 w-9 items-center justify-center rounded-lg bg-orange-500/10 dark:bg-orange-500/15"> | |
| 7167 | + <BarChart3 | |
| 7168 | + className="w-5 h-5 text-orange-600 dark:text-orange-400" | |
| 7169 | + aria-hidden | |
| 7170 | + /> | |
| 7171 | + </span> | |
| 7172 | + <span className="flex flex-wrap items-center gap-2 leading-tight"> | |
| 7173 | + {__("Google Analytics 4 Enhanced", "yatra")} | |
| 7174 | + <ProBadge isProActive={(window as any).yatraAdmin?.isPro} /> | |
| 7175 | + </span> | |
| 6809 | 7176 | </CardTitle> |
| 6810 | - {(window as any).yatraAdmin?.googleAnalytics?.connected ? ( | |
| 6811 | - <div className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400"> | |
| 6812 | - <CheckCircle className="w-4 h-4" /> | |
| 6813 | - {__("Connected", "yatra")} | |
| 6814 | - </div> | |
| 6815 | - ) : ( | |
| 6816 | - <div className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400"> | |
| 6817 | - <XCircle className="w-4 h-4" /> | |
| 6818 | - {__("Not Connected", "yatra")} | |
| 6819 | - </div> | |
| 6820 | - )} | |
| 7177 | + {(() => { | |
| 7178 | + const ga4HasId = Boolean(formData.ga4_measurement_id?.trim()); | |
| 7179 | + const ga4Connected = | |
| 7180 | + ga4HasId || | |
| 7181 | + Boolean( | |
| 7182 | + (window as any).yatraAdmin?.googleAnalytics?.connected, | |
| 7183 | + ); | |
| 7184 | + return ga4Connected ? ( | |
| 7185 | + <div className="inline-flex items-center gap-1.5 rounded-full border border-green-200 bg-green-50 px-2.5 py-1 text-xs font-medium text-green-800 dark:border-green-800 dark:bg-green-950/40 dark:text-green-300"> | |
| 7186 | + <CheckCircle className="w-3.5 h-3.5 shrink-0" /> | |
| 7187 | + {__("Measurement ID set", "yatra")} | |
| 7188 | + </div> | |
| 7189 | + ) : ( | |
| 7190 | + <div className="inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400"> | |
| 7191 | + <XCircle className="w-3.5 h-3.5 shrink-0" /> | |
| 7192 | + {__("Not configured", "yatra")} | |
| 7193 | + </div> | |
| 7194 | + ); | |
| 7195 | + })()} | |
| 6821 | 7196 | </CardHeader> |
| 6822 | - <CardContent className="space-y-4"> | |
| 6823 | - <p className="text-sm text-gray-600 dark:text-gray-300"> | |
| 7197 | + <CardContent className="space-y-6 pt-6"> | |
| 7198 | + <p className="text-sm leading-relaxed text-gray-600 dark:text-gray-300"> | |
| 6824 | 7199 | {__( |
| 6825 | - "Enhanced e-commerce tracking for GA4. Track view_item, begin_checkout, and purchase events with server-side Measurement Protocol support.", | |
| 7200 | + "Connect GA4 for ecommerce events (view item, add to cart, checkout, purchase). Optional Measurement Protocol sends verified server-side events for more reliable attribution.", | |
| 6826 | 7201 | "yatra", |
| 6827 | 7202 | )} |
| 6828 | 7203 | </p> |
| 6829 | 7204 | |
| @@ -6839,206 +7214,114 @@ | ||
| 6839 | 7214 | isModuleEnabled={ |
| 6840 | 7215 | (window as any).yatraAdmin?.showGoogleAnalyticsSettingsUI |
| 6841 | 7216 | } |
| 6842 | 7217 | > |
| 6843 | - <div className="space-y-4"> | |
| 6844 | - <FormField | |
| 6845 | - id="ga4_measurement_id" | |
| 6846 | - label={__("Measurement ID", "yatra")} | |
| 6847 | - description={ | |
| 6848 | - <> | |
| 6849 | - {__("Get your Measurement ID from", "yatra")}{" "} | |
| 6850 | - <a | |
| 6851 | - href="https://analytics.google.com/analytics/web/" | |
| 6852 | - target="_blank" | |
| 6853 | - rel="noopener noreferrer" | |
| 6854 | - className="text-blue-600 hover:text-blue-500 dark:text-blue-400 underline" | |
| 6855 | - > | |
| 6856 | - {__("Google Analytics", "yatra")} | |
| 6857 | - </a>{" "} | |
| 6858 | - {__("(Admin > Data Streams)", "yatra")} | |
| 6859 | - </> | |
| 6860 | - } | |
| 6861 | - actionButton={ | |
| 6862 | - <Button | |
| 6863 | - type="button" | |
| 6864 | - variant="outline" | |
| 6865 | - size="sm" | |
| 6866 | - onClick={() => validateMeasurementId()} | |
| 6867 | - disabled={ | |
| 6868 | - !formData.ga4_measurement_id || | |
| 6869 | - validatingMeasurementId | |
| 6870 | - } | |
| 6871 | - className="shrink-0" | |
| 6872 | - > | |
| 6873 | - {validatingMeasurementId ? ( | |
| 6874 | - <> | |
| 6875 | - <svg | |
| 6876 | - className="animate-spin -ml-1 mr-2 h-4 w-4" | |
| 6877 | - fill="none" | |
| 6878 | - viewBox="0 0 24 24" | |
| 6879 | - > | |
| 6880 | - <circle | |
| 6881 | - className="opacity-25" | |
| 6882 | - cx="12" | |
| 6883 | - cy="12" | |
| 6884 | - r="10" | |
| 6885 | - stroke="currentColor" | |
| 6886 | - strokeWidth="4" | |
| 6887 | - ></circle> | |
| 6888 | - <path | |
| 6889 | - className="opacity-75" | |
| 6890 | - fill="currentColor" | |
| 6891 | - d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" | |
| 6892 | - ></path> | |
| 6893 | - </svg> | |
| 6894 | - {__("Validating...", "yatra")} | |
| 6895 | - </> | |
| 6896 | - ) : ( | |
| 6897 | - <> | |
| 6898 | - <svg | |
| 6899 | - className="-ml-1 mr-2 h-4 w-4" | |
| 6900 | - fill="none" | |
| 6901 | - viewBox="0 0 24 24" | |
| 6902 | - stroke="currentColor" | |
| 6903 | - > | |
| 6904 | - <path | |
| 6905 | - strokeLinecap="round" | |
| 6906 | - strokeLinejoin="round" | |
| 6907 | - strokeWidth={2} | |
| 6908 | - d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" | |
| 6909 | - /> | |
| 6910 | - </svg> | |
| 6911 | - {__("Validate", "yatra")} | |
| 6912 | - </> | |
| 7218 | + <div className="space-y-8"> | |
| 7219 | + {/* Connection */} | |
| 7220 | + <div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-950/30 sm:p-5"> | |
| 7221 | + <div className="mb-4"> | |
| 7222 | + <h4 className="text-sm font-semibold text-gray-900 dark:text-white"> | |
| 7223 | + {__("Connection", "yatra")} | |
| 7224 | + </h4> | |
| 7225 | + <p className="mt-1 text-xs leading-relaxed text-gray-500 dark:text-gray-400"> | |
| 7226 | + {__( | |
| 7227 | + "Use your GA4 web data stream Measurement ID (format G-XXXXXXXXXX). Find it under Admin → Data streams → your stream.", | |
| 7228 | + "yatra", | |
| 6913 | 7229 | )} |
| 6914 | - </Button> | |
| 6915 | - } | |
| 6916 | - > | |
| 6917 | - <Input | |
| 6918 | - id="ga4_measurement_id" | |
| 6919 | - value={formData.ga4_measurement_id || ""} | |
| 6920 | - name="ga4_measurement_id" | |
| 6921 | - onChange={handleFieldChange} | |
| 6922 | - placeholder="G-XXXXXXXXXX" | |
| 6923 | - /> | |
| 6924 | - </FormField> | |
| 6925 | - | |
| 6926 | - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| 6927 | - <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> | |
| 6928 | - <input | |
| 6929 | - type="checkbox" | |
| 6930 | - id="ga4_track_view_item" | |
| 6931 | - checked={formData.ga4_track_view_item ?? false} | |
| 6932 | - name="ga4_track_view_item" | |
| 6933 | - onChange={handleFieldChange} | |
| 6934 | - className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" | |
| 6935 | - /> | |
| 6936 | - <Label | |
| 6937 | - htmlFor="ga4_track_view_item" | |
| 6938 | - className="text-sm cursor-pointer" | |
| 6939 | - > | |
| 6940 | - {__("Track View Item", "yatra")} | |
| 6941 | - </Label> | |
| 7230 | + </p> | |
| 6942 | 7231 | </div> |
| 6943 | - | |
| 6944 | - <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> | |
| 6945 | - <input | |
| 6946 | - type="checkbox" | |
| 6947 | - id="ga4_track_add_to_cart" | |
| 6948 | - checked={formData.ga4_track_add_to_cart ?? false} | |
| 6949 | - name="ga4_track_add_to_cart" | |
| 6950 | - onChange={handleFieldChange} | |
| 6951 | - className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" | |
| 6952 | - /> | |
| 6953 | - <Label | |
| 6954 | - htmlFor="ga4_track_add_to_cart" | |
| 6955 | - className="text-sm cursor-pointer" | |
| 6956 | - > | |
| 6957 | - {__("Track Add to Cart", "yatra")} | |
| 6958 | - </Label> | |
| 6959 | - </div> | |
| 6960 | - | |
| 6961 | - <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> | |
| 6962 | - <input | |
| 6963 | - type="checkbox" | |
| 6964 | - id="ga4_track_begin_checkout" | |
| 6965 | - checked={formData.ga4_track_begin_checkout ?? false} | |
| 6966 | - name="ga4_track_begin_checkout" | |
| 6967 | - onChange={handleFieldChange} | |
| 6968 | - className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" | |
| 6969 | - /> | |
| 6970 | - <Label | |
| 6971 | - htmlFor="ga4_track_begin_checkout" | |
| 6972 | - className="text-sm cursor-pointer" | |
| 6973 | - > | |
| 6974 | - {__("Track Begin Checkout", "yatra")} | |
| 6975 | - </Label> | |
| 6976 | - </div> | |
| 6977 | - | |
| 6978 | - <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> | |
| 6979 | - <input | |
| 6980 | - type="checkbox" | |
| 6981 | - id="ga4_track_purchase" | |
| 6982 | - checked={formData.ga4_track_purchase ?? false} | |
| 6983 | - name="ga4_track_purchase" | |
| 6984 | - onChange={handleFieldChange} | |
| 6985 | - className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" | |
| 6986 | - /> | |
| 6987 | - <Label | |
| 6988 | - htmlFor="ga4_track_purchase" | |
| 6989 | - className="text-sm cursor-pointer" | |
| 6990 | - > | |
| 6991 | - {__("Track Purchase", "yatra")} | |
| 6992 | - </Label> | |
| 6993 | - </div> | |
| 6994 | - </div> | |
| 6995 | - <div className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-md"> | |
| 6996 | - <input | |
| 6997 | - type="checkbox" | |
| 6998 | - id="ga4_use_measurement_protocol" | |
| 6999 | - checked={formData.ga4_use_measurement_protocol ?? false} | |
| 7000 | - name="ga4_use_measurement_protocol" | |
| 7001 | - onChange={handleFieldChange} | |
| 7002 | - className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" | |
| 7003 | - /> | |
| 7004 | - <Label | |
| 7005 | - htmlFor="ga4_use_measurement_protocol" | |
| 7006 | - className="text-sm cursor-pointer" | |
| 7007 | - > | |
| 7008 | - {__("Use Measurement Protocol", "yatra")} | |
| 7009 | - </Label> | |
| 7010 | - </div> | |
| 7011 | - | |
| 7012 | - {formData.ga4_use_measurement_protocol && ( | |
| 7013 | 7232 | <FormField |
| 7014 | - id="ga4_api_secret" | |
| 7015 | - label={__("API Secret", "yatra")} | |
| 7016 | - description={__( | |
| 7017 | - "Generate API secret from Google Analytics Admin > Data Streams > Measurement Protocol", | |
| 7018 | - "yatra", | |
| 7019 | - )} | |
| 7233 | + id="ga4_measurement_id" | |
| 7234 | + label={__("Measurement ID", "yatra")} | |
| 7235 | + description={ | |
| 7236 | + <div className="space-y-2"> | |
| 7237 | + <p> | |
| 7238 | + {__( | |
| 7239 | + "Your GA4 web data stream Measurement ID (format G-XXXXXXXXXX). The Validate button checks the format only — confirm the stream is live in your GA4 Admin.", | |
| 7240 | + "yatra", | |
| 7241 | + )} | |
| 7242 | + </p> | |
| 7243 | + <details className="text-sm"> | |
| 7244 | + <summary className="cursor-pointer font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"> | |
| 7245 | + {__("How to get your Measurement ID:", "yatra")} | |
| 7246 | + </summary> | |
| 7247 | + <ol className="mt-2 ml-4 list-decimal space-y-1 text-gray-600 dark:text-gray-400"> | |
| 7248 | + <li> | |
| 7249 | + {__( | |
| 7250 | + "Go to Google Analytics: analytics.google.com", | |
| 7251 | + "yatra", | |
| 7252 | + )} | |
| 7253 | + </li> | |
| 7254 | + <li> | |
| 7255 | + {__( | |
| 7256 | + "Sign in and select the property you want to track this site with (or create one: Admin → Create → Property → choose your business details).", | |
| 7257 | + "yatra", | |
| 7258 | + )} | |
| 7259 | + </li> | |
| 7260 | + <li> | |
| 7261 | + {__( | |
| 7262 | + "In the bottom-left corner click the gear icon (Admin).", | |
| 7263 | + "yatra", | |
| 7264 | + )} | |
| 7265 | + </li> | |
| 7266 | + <li> | |
| 7267 | + {__( | |
| 7268 | + "In the Property column click Data Streams.", | |
| 7269 | + "yatra", | |
| 7270 | + )} | |
| 7271 | + </li> | |
| 7272 | + <li> | |
| 7273 | + {__( | |
| 7274 | + "Click your existing Web stream — or click Add stream → Web, enter your site URL and a stream name, then Create stream.", | |
| 7275 | + "yatra", | |
| 7276 | + )} | |
| 7277 | + </li> | |
| 7278 | + <li> | |
| 7279 | + {__( | |
| 7280 | + "On the stream details panel, copy the Measurement ID at the top right (format G-XXXXXXXXXX).", | |
| 7281 | + "yatra", | |
| 7282 | + )} | |
| 7283 | + </li> | |
| 7284 | + <li> | |
| 7285 | + {__( | |
| 7286 | + "Paste it in the field above and click Validate.", | |
| 7287 | + "yatra", | |
| 7288 | + )} | |
| 7289 | + </li> | |
| 7290 | + </ol> | |
| 7291 | + </details> | |
| 7292 | + <div className="mt-2"> | |
| 7293 | + <a | |
| 7294 | + href="https://analytics.google.com/analytics/web/" | |
| 7295 | + target="_blank" | |
| 7296 | + rel="noopener noreferrer" | |
| 7297 | + className="text-blue-600 hover:text-blue-500 dark:text-blue-400 underline text-sm" | |
| 7298 | + > | |
| 7299 | + {__("Open Google Analytics →", "yatra")} | |
| 7300 | + </a> | |
| 7301 | + </div> | |
| 7302 | + </div> | |
| 7303 | + } | |
| 7020 | 7304 | actionButton={ |
| 7021 | 7305 | <Button |
| 7022 | 7306 | type="button" |
| 7023 | 7307 | variant="outline" |
| 7024 | 7308 | size="sm" |
| 7025 | - onClick={() => validateApiSecret()} | |
| 7309 | + onClick={() => validateMeasurementId()} | |
| 7026 | 7310 | disabled={ |
| 7027 | - !formData.ga4_api_secret || | |
| 7028 | - !formData.ga4_measurement_id || | |
| 7029 | - validatingApiSecret | |
| 7311 | + !formData.ga4_measurement_id?.trim() || | |
| 7312 | + validatingMeasurementId | |
| 7030 | 7313 | } |
| 7031 | 7314 | className="shrink-0" |
| 7032 | 7315 | > |
| 7033 | - {validatingApiSecret ? ( | |
| 7316 | + {validatingMeasurementId ? ( | |
| 7034 | 7317 | <> |
| 7035 | - <Loader2 className="w-4 h-4 animate-spin" /> | |
| 7318 | + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> | |
| 7036 | 7319 | {__("Validating...", "yatra")} |
| 7037 | 7320 | </> |
| 7038 | 7321 | ) : ( |
| 7039 | 7322 | <> |
| 7040 | - <CheckCircle className="w-4 h-4" /> | |
| 7323 | + <Check className="mr-2 h-4 w-4" /> | |
| 7041 | 7324 | {__("Validate", "yatra")} |
| 7042 | 7325 | </> |
| 7043 | 7326 | )} |
| 7044 | 7327 | </Button> |
| @@ -7044,56 +7327,375 @@ | ||
| 7044 | 7327 | </Button> |
| 7045 | 7328 | } |
| 7046 | 7329 | > |
| 7047 | 7330 | <Input |
| 7048 | - id="ga4_api_secret" | |
| 7049 | - value={formData.ga4_api_secret || ""} | |
| 7050 | - name="ga4_api_secret" | |
| 7331 | + id="ga4_measurement_id" | |
| 7332 | + value={formData.ga4_measurement_id || ""} | |
| 7333 | + name="ga4_measurement_id" | |
| 7051 | 7334 | onChange={handleFieldChange} |
| 7052 | - placeholder="Your API secret" | |
| 7335 | + placeholder="G-XXXXXXXXXX" | |
| 7336 | + className="font-mono text-sm" | |
| 7337 | + spellCheck={false} | |
| 7338 | + autoComplete="off" | |
| 7053 | 7339 | /> |
| 7054 | 7340 | </FormField> |
| 7055 | - )} | |
| 7056 | - </div> | |
| 7057 | - </ProFeature> | |
| 7341 | + </div> | |
| 7058 | 7342 | |
| 7059 | - {/* Google Analytics Monitoring Notice */} | |
| 7060 | - {(window as any).yatraAdmin?.googleAnalytics?.measurementId && ( | |
| 7061 | - <div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg"> | |
| 7062 | - <div className="flex items-center gap-2"> | |
| 7063 | - <svg | |
| 7064 | - className="w-4 h-4 text-blue-600 dark:text-blue-400" | |
| 7065 | - fill="none" | |
| 7066 | - stroke="currentColor" | |
| 7067 | - viewBox="0 0 24 24" | |
| 7068 | - > | |
| 7069 | - <path | |
| 7070 | - strokeLinecap="round" | |
| 7071 | - strokeLinejoin="round" | |
| 7072 | - strokeWidth={2} | |
| 7073 | - d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" | |
| 7074 | - /> | |
| 7075 | - </svg> | |
| 7076 | - <div className="text-sm"> | |
| 7077 | - <span className="text-blue-800 dark:text-blue-300 font-medium"> | |
| 7078 | - {__("Google Analytics Monitoring:", "yatra")} | |
| 7079 | - </span> | |
| 7080 | - <span className="text-blue-700 dark:text-blue-400 ml-1"> | |
| 7343 | + {/* Events */} | |
| 7344 | + <div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-950/30 sm:p-5"> | |
| 7345 | + <div className="mb-4"> | |
| 7346 | + <h4 className="text-sm font-semibold text-gray-900 dark:text-white"> | |
| 7347 | + {__("Events", "yatra")} | |
| 7348 | + </h4> | |
| 7349 | + <p className="mt-1 text-xs leading-relaxed text-gray-500 dark:text-gray-400"> | |
| 7081 | 7350 | {__( |
| 7082 | - "View event statistics and activity in the ", | |
| 7351 | + "Choose which standard ecommerce events Yatra should emit to GA4 on the storefront and booking flow.", | |
| 7083 | 7352 | "yatra", |
| 7084 | 7353 | )} |
| 7085 | - <a | |
| 7086 | - href={`${(window as any).yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=reports`} | |
| 7087 | - className="underline hover:text-blue-600 dark:hover:text-blue-300 font-medium" | |
| 7354 | + </p> | |
| 7355 | + </div> | |
| 7356 | + <div className="grid grid-cols-1 gap-3 md:grid-cols-2"> | |
| 7357 | + <div className="flex gap-3 rounded-lg border border-gray-100 bg-gray-50/80 p-3 dark:border-gray-700 dark:bg-gray-900/50"> | |
| 7358 | + <input | |
| 7359 | + type="checkbox" | |
| 7360 | + id="ga4_track_view_item" | |
| 7361 | + checked={formData.ga4_track_view_item ?? true} | |
| 7362 | + name="ga4_track_view_item" | |
| 7363 | + onChange={handleFieldChange} | |
| 7364 | + className="mt-0.5 h-4 w-4 shrink-0 rounded border-gray-300 text-blue-600 focus:ring-blue-500" | |
| 7365 | + /> | |
| 7366 | + <div className="min-w-0 flex-1"> | |
| 7367 | + <Label | |
| 7368 | + htmlFor="ga4_track_view_item" | |
| 7369 | + className="cursor-pointer text-sm font-medium text-gray-900 dark:text-white" | |
| 7370 | + > | |
| 7371 | + {__("View item", "yatra")} | |
| 7372 | + </Label> | |
| 7373 | + <p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400"> | |
| 7374 | + {__( | |
| 7375 | + "Trip detail pages (recommended for remarketing).", | |
| 7376 | + "yatra", | |
| 7377 | + )} | |
| 7378 | + </p> | |
| 7379 | + </div> | |
| 7380 | + </div> | |
| 7381 | + <div className="flex gap-3 rounded-lg border border-gray-100 bg-gray-50/80 p-3 dark:border-gray-700 dark:bg-gray-900/50"> | |
| 7382 | + <input | |
| 7383 | + type="checkbox" | |
| 7384 | + id="ga4_track_add_to_cart" | |
| 7385 | + checked={formData.ga4_track_add_to_cart ?? true} | |
| 7386 | + name="ga4_track_add_to_cart" | |
| 7387 | + onChange={handleFieldChange} | |
| 7388 | + className="mt-0.5 h-4 w-4 shrink-0 rounded border-gray-300 text-blue-600 focus:ring-blue-500" | |
| 7389 | + /> | |
| 7390 | + <div className="min-w-0 flex-1"> | |
| 7391 | + <Label | |
| 7392 | + htmlFor="ga4_track_add_to_cart" | |
| 7393 | + className="cursor-pointer text-sm font-medium text-gray-900 dark:text-white" | |
| 7394 | + > | |
| 7395 | + {__("Add to cart", "yatra")} | |
| 7396 | + </Label> | |
| 7397 | + <p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400"> | |
| 7398 | + {__( | |
| 7399 | + "When a trip is added to the booking session / checkout.", | |
| 7400 | + "yatra", | |
| 7401 | + )} | |
| 7402 | + </p> | |
| 7403 | + </div> | |
| 7404 | + </div> | |
| 7405 | + <div className="flex gap-3 rounded-lg border border-gray-100 bg-gray-50/80 p-3 dark:border-gray-700 dark:bg-gray-900/50"> | |
| 7406 | + <input | |
| 7407 | + type="checkbox" | |
| 7408 | + id="ga4_track_begin_checkout" | |
| 7409 | + checked={formData.ga4_track_begin_checkout ?? true} | |
| 7410 | + name="ga4_track_begin_checkout" | |
| 7411 | + onChange={handleFieldChange} | |
| 7412 | + className="mt-0.5 h-4 w-4 shrink-0 rounded border-gray-300 text-blue-600 focus:ring-blue-500" | |
| 7413 | + /> | |
| 7414 | + <div className="min-w-0 flex-1"> | |
| 7415 | + <Label | |
| 7416 | + htmlFor="ga4_track_begin_checkout" | |
| 7417 | + className="cursor-pointer text-sm font-medium text-gray-900 dark:text-white" | |
| 7418 | + > | |
| 7419 | + {__("Begin checkout", "yatra")} | |
| 7420 | + </Label> | |
| 7421 | + <p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400"> | |
| 7422 | + {__( | |
| 7423 | + "When a booking is created (checkout started).", | |
| 7424 | + "yatra", | |
| 7425 | + )} | |
| 7426 | + </p> | |
| 7427 | + </div> | |
| 7428 | + </div> | |
| 7429 | + <div className="flex gap-3 rounded-lg border border-gray-100 bg-gray-50/80 p-3 dark:border-gray-700 dark:bg-gray-900/50"> | |
| 7430 | + <input | |
| 7431 | + type="checkbox" | |
| 7432 | + id="ga4_track_purchase" | |
| 7433 | + checked={formData.ga4_track_purchase ?? true} | |
| 7434 | + name="ga4_track_purchase" | |
| 7435 | + onChange={handleFieldChange} | |
| 7436 | + className="mt-0.5 h-4 w-4 shrink-0 rounded border-gray-300 text-blue-600 focus:ring-blue-500" | |
| 7437 | + /> | |
| 7438 | + <div className="min-w-0 flex-1"> | |
| 7439 | + <Label | |
| 7440 | + htmlFor="ga4_track_purchase" | |
| 7441 | + className="cursor-pointer text-sm font-medium text-gray-900 dark:text-white" | |
| 7442 | + > | |
| 7443 | + {__("Purchase", "yatra")} | |
| 7444 | + </Label> | |
| 7445 | + <p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400"> | |
| 7446 | + {__( | |
| 7447 | + "When payment completes (and on confirmation page).", | |
| 7448 | + "yatra", | |
| 7449 | + )} | |
| 7450 | + </p> | |
| 7451 | + </div> | |
| 7452 | + </div> | |
| 7453 | + </div> | |
| 7454 | + </div> | |
| 7455 | + | |
| 7456 | + {/* Measurement Protocol */} | |
| 7457 | + <div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-950/30 sm:p-5"> | |
| 7458 | + <div className="mb-4"> | |
| 7459 | + <h4 className="text-sm font-semibold text-gray-900 dark:text-white"> | |
| 7460 | + {__("Server-side (Measurement Protocol)", "yatra")} | |
| 7461 | + </h4> | |
| 7462 | + <p className="mt-1 text-xs leading-relaxed text-gray-500 dark:text-gray-400"> | |
| 7463 | + {__( | |
| 7464 | + "Send duplicate events from your server with an API secret—useful when ad blockers affect the browser tag or for reconciliation.", | |
| 7465 | + "yatra", | |
| 7466 | + )} | |
| 7467 | + </p> | |
| 7468 | + </div> | |
| 7469 | + | |
| 7470 | + <div className="flex gap-3 rounded-lg border border-gray-100 bg-gray-50/80 p-3 dark:border-gray-700 dark:bg-gray-900/50"> | |
| 7471 | + <input | |
| 7472 | + type="checkbox" | |
| 7473 | + id="ga4_use_measurement_protocol" | |
| 7474 | + checked={ | |
| 7475 | + formData.ga4_use_measurement_protocol ?? false | |
| 7476 | + } | |
| 7477 | + name="ga4_use_measurement_protocol" | |
| 7478 | + onChange={handleFieldChange} | |
| 7479 | + className="mt-0.5 h-4 w-4 shrink-0 rounded border-gray-300 text-blue-600 focus:ring-blue-500" | |
| 7480 | + /> | |
| 7481 | + <div className="min-w-0 flex-1"> | |
| 7482 | + <Label | |
| 7483 | + htmlFor="ga4_use_measurement_protocol" | |
| 7484 | + className="cursor-pointer text-sm font-medium text-gray-900 dark:text-white" | |
| 7088 | 7485 | > |
| 7089 | - {__("Reports page", "yatra")} | |
| 7090 | - </a> | |
| 7091 | - {__(" → Google Analytics 4 tab.", "yatra")} | |
| 7092 | - </span> | |
| 7486 | + {__("Enable Measurement Protocol", "yatra")} | |
| 7487 | + </Label> | |
| 7488 | + <p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400"> | |
| 7489 | + {__( | |
| 7490 | + "Requires an API secret from the same GA4 data stream.", | |
| 7491 | + "yatra", | |
| 7492 | + )} | |
| 7493 | + </p> | |
| 7494 | + </div> | |
| 7093 | 7495 | </div> |
| 7496 | + | |
| 7497 | + {formData.ga4_use_measurement_protocol && ( | |
| 7498 | + <div className="mt-4 space-y-4 border-t border-gray-100 pt-4 dark:border-gray-800"> | |
| 7499 | + <div className="flex gap-3 rounded-lg border border-amber-100 bg-amber-50/60 p-3 dark:border-amber-900/40 dark:bg-amber-950/20"> | |
| 7500 | + <input | |
| 7501 | + type="checkbox" | |
| 7502 | + id="ga4_debug_mode" | |
| 7503 | + checked={formData.ga4_debug_mode ?? false} | |
| 7504 | + name="ga4_debug_mode" | |
| 7505 | + onChange={handleFieldChange} | |
| 7506 | + className="mt-0.5 h-4 w-4 shrink-0 rounded border-amber-300 text-amber-600 focus:ring-amber-500" | |
| 7507 | + /> | |
| 7508 | + <div className="min-w-0 flex-1"> | |
| 7509 | + <Label | |
| 7510 | + htmlFor="ga4_debug_mode" | |
| 7511 | + className="cursor-pointer text-sm font-medium text-amber-950 dark:text-amber-100" | |
| 7512 | + > | |
| 7513 | + {__( | |
| 7514 | + "Validation mode (debug endpoint)", | |
| 7515 | + "yatra", | |
| 7516 | + )} | |
| 7517 | + </Label> | |
| 7518 | + <p className="mt-0.5 text-xs text-amber-900/90 dark:text-amber-200/90"> | |
| 7519 | + {__( | |
| 7520 | + "Send Measurement Protocol hits to Google’s validation URL for troubleshooting. Events won’t appear in standard reports.", | |
| 7521 | + "yatra", | |
| 7522 | + )} | |
| 7523 | + </p> | |
| 7524 | + </div> | |
| 7525 | + </div> | |
| 7526 | + | |
| 7527 | + <FormField | |
| 7528 | + id="ga4_api_secret" | |
| 7529 | + label={__( | |
| 7530 | + "Measurement Protocol API secret", | |
| 7531 | + "yatra", | |
| 7532 | + )} | |
| 7533 | + description={ | |
| 7534 | + <div className="space-y-2"> | |
| 7535 | + <p> | |
| 7536 | + {__( | |
| 7537 | + "Treat this like a password — anyone with it can send fake events to your GA4 property. Restrict who can view Yatra settings.", | |
| 7538 | + "yatra", | |
| 7539 | + )} | |
| 7540 | + </p> | |
| 7541 | + <details className="text-sm"> | |
| 7542 | + <summary className="cursor-pointer font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"> | |
| 7543 | + {__("How to get your API secret:", "yatra")} | |
| 7544 | + </summary> | |
| 7545 | + <ol className="mt-2 ml-4 list-decimal space-y-1 text-gray-600 dark:text-gray-400"> | |
| 7546 | + <li> | |
| 7547 | + {__( | |
| 7548 | + "Go to Google Analytics: analytics.google.com", | |
| 7549 | + "yatra", | |
| 7550 | + )} | |
| 7551 | + </li> | |
| 7552 | + <li> | |
| 7553 | + {__( | |
| 7554 | + "Sign in and select the same property you used for the Measurement ID above.", | |
| 7555 | + "yatra", | |
| 7556 | + )} | |
| 7557 | + </li> | |
| 7558 | + <li> | |
| 7559 | + {__( | |
| 7560 | + "Click the gear icon (Admin) in the bottom-left corner.", | |
| 7561 | + "yatra", | |
| 7562 | + )} | |
| 7563 | + </li> | |
| 7564 | + <li> | |
| 7565 | + {__( | |
| 7566 | + "In the Property column click Data Streams, then click your Web stream.", | |
| 7567 | + "yatra", | |
| 7568 | + )} | |
| 7569 | + </li> | |
| 7570 | + <li> | |
| 7571 | + {__( | |
| 7572 | + "Scroll to Events → Measurement Protocol API secrets → Create.", | |
| 7573 | + "yatra", | |
| 7574 | + )} | |
| 7575 | + </li> | |
| 7576 | + <li> | |
| 7577 | + {__( | |
| 7578 | + "Give the secret a nickname (e.g. “Yatra server”) and click Create.", | |
| 7579 | + "yatra", | |
| 7580 | + )} | |
| 7581 | + </li> | |
| 7582 | + <li> | |
| 7583 | + {__( | |
| 7584 | + "Copy the Secret value (not the nickname) and paste it in the field above.", | |
| 7585 | + "yatra", | |
| 7586 | + )} | |
| 7587 | + </li> | |
| 7588 | + </ol> | |
| 7589 | + </details> | |
| 7590 | + <div className="mt-2"> | |
| 7591 | + <a | |
| 7592 | + href="https://analytics.google.com/analytics/web/" | |
| 7593 | + target="_blank" | |
| 7594 | + rel="noopener noreferrer" | |
| 7595 | + className="text-blue-600 hover:text-blue-500 dark:text-blue-400 underline text-sm" | |
| 7596 | + > | |
| 7597 | + {__("Open Google Analytics →", "yatra")} | |
| 7598 | + </a> | |
| 7599 | + </div> | |
| 7600 | + </div> | |
| 7601 | + } | |
| 7602 | + actionButton={ | |
| 7603 | + <Button | |
| 7604 | + type="button" | |
| 7605 | + variant="outline" | |
| 7606 | + size="sm" | |
| 7607 | + onClick={() => validateApiSecret()} | |
| 7608 | + disabled={ | |
| 7609 | + !formData.ga4_api_secret || | |
| 7610 | + !formData.ga4_measurement_id || | |
| 7611 | + validatingApiSecret | |
| 7612 | + } | |
| 7613 | + className="shrink-0" | |
| 7614 | + > | |
| 7615 | + {validatingApiSecret ? ( | |
| 7616 | + <> | |
| 7617 | + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> | |
| 7618 | + {__("Validating...", "yatra")} | |
| 7619 | + </> | |
| 7620 | + ) : ( | |
| 7621 | + <> | |
| 7622 | + <Check className="mr-2 h-4 w-4" /> | |
| 7623 | + {__("Validate", "yatra")} | |
| 7624 | + </> | |
| 7625 | + )} | |
| 7626 | + </Button> | |
| 7627 | + } | |
| 7628 | + > | |
| 7629 | + <div className="relative"> | |
| 7630 | + <Input | |
| 7631 | + id="ga4_api_secret" | |
| 7632 | + type={showGa4ApiSecret ? "text" : "password"} | |
| 7633 | + value={formData.ga4_api_secret || ""} | |
| 7634 | + name="ga4_api_secret" | |
| 7635 | + onChange={handleFieldChange} | |
| 7636 | + placeholder={__( | |
| 7637 | + "Paste secret from GA4", | |
| 7638 | + "yatra", | |
| 7639 | + )} | |
| 7640 | + className="pr-10 font-mono text-sm" | |
| 7641 | + spellCheck={false} | |
| 7642 | + autoComplete="new-password" | |
| 7643 | + /> | |
| 7644 | + <button | |
| 7645 | + type="button" | |
| 7646 | + onClick={() => | |
| 7647 | + setShowGa4ApiSecret(!showGa4ApiSecret) | |
| 7648 | + } | |
| 7649 | + className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" | |
| 7650 | + tabIndex={-1} | |
| 7651 | + aria-label={ | |
| 7652 | + showGa4ApiSecret | |
| 7653 | + ? __("Hide secret", "yatra") | |
| 7654 | + : __("Show secret", "yatra") | |
| 7655 | + } | |
| 7656 | + > | |
| 7657 | + {showGa4ApiSecret ? ( | |
| 7658 | + <EyeOff className="h-4 w-4" /> | |
| 7659 | + ) : ( | |
| 7660 | + <Eye className="h-4 w-4" /> | |
| 7661 | + )} | |
| 7662 | + </button> | |
| 7663 | + </div> | |
| 7664 | + </FormField> | |
| 7665 | + </div> | |
| 7666 | + )} | |
| 7094 | 7667 | </div> |
| 7095 | 7668 | </div> |
| 7669 | + </ProFeature> | |
| 7670 | + | |
| 7671 | + {/* Reports shortcut */} | |
| 7672 | + {(Boolean(formData.ga4_measurement_id?.trim()) || | |
| 7673 | + Boolean( | |
| 7674 | + (window as any).yatraAdmin?.googleAnalytics?.measurementId, | |
| 7675 | + )) && ( | |
| 7676 | + <div className="flex gap-3 rounded-lg border border-blue-200 bg-blue-50/90 p-4 dark:border-blue-800 dark:bg-blue-950/30"> | |
| 7677 | + <BarChart3 className="mt-0.5 h-5 w-5 shrink-0 text-blue-600 dark:text-blue-400" /> | |
| 7678 | + <div className="min-w-0 text-sm leading-relaxed"> | |
| 7679 | + <p className="font-medium text-blue-900 dark:text-blue-100"> | |
| 7680 | + {__("Reports & event log", "yatra")} | |
| 7681 | + </p> | |
| 7682 | + <p className="mt-1 text-blue-800/90 dark:text-blue-200/90"> | |
| 7683 | + {__( | |
| 7684 | + "Review connection status and recent Measurement Protocol activity under", | |
| 7685 | + "yatra", | |
| 7686 | + )}{" "} | |
| 7687 | + <a | |
| 7688 | + href={`${(window as any).yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=reports`} | |
| 7689 | + className="font-medium underline decoration-blue-600/40 underline-offset-2 hover:text-blue-950 dark:hover:text-white" | |
| 7690 | + > | |
| 7691 | + {__("Reports", "yatra")} | |
| 7692 | + </a> | |
| 7693 | + {" → "} | |
| 7694 | + {__("Google Analytics 4.", "yatra")} | |
| 7695 | + </p> | |
| 7696 | + </div> | |
| 7697 | + </div> | |
| 7096 | 7698 | )} |
| 7097 | 7699 | </CardContent> |
| 7098 | 7700 | </Card> |
| 7099 | 7701 | |
| @@ -7184,8 +7786,93 @@ | ||
| 7184 | 7786 | onChange={handleFieldChange} |
| 7185 | 7787 | placeholder={__("Enter secret key", "yatra")} |
| 7186 | 7788 | /> |
| 7187 | 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> | |
| 7188 | 7875 | </> |
| 7189 | 7876 | )} |
| 7190 | 7877 | </div> |
| 7191 | 7878 | </div> |
| @@ -7879,11 +8566,235 @@ | ||
| 7879 | 8566 | </div> |
| 7880 | 8567 | </FormField> |
| 7881 | 8568 | </div> |
| 7882 | 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> | |
| 7883 | 8734 | </div> |
| 7884 | 8735 | ); |
| 7885 | 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 | + )} | |
| 8793 | + </div> | |
| 8794 | + </div> | |
| 8795 | + ); | |
| 8796 | + | |
| 7886 | 8797 | case "advanced": |
| 7887 | 8798 | return ( |
| 7888 | 8799 | <div className="space-y-6"> |
| 7889 | 8800 | <div className="space-y-4"> |
| @@ -8282,9 +9193,9 @@ | ||
| 8282 | 9193 | </Button> |
| 8283 | 9194 | } |
| 8284 | 9195 | /> |
| 8285 | 9196 | |
| 8286 | - <ConditionalRender capability="manage_yatra"> | |
| 9197 | + <ConditionalRender capability="yatra_manage_settings"> | |
| 8287 | 9198 | <div className="grid grid-cols-1 lg:grid-cols-4 gap-3"> |
| 8288 | 9199 | {/* Left Sidebar */} |
| 8289 | 9200 | <div className="lg:col-span-1"> |
| 8290 | 9201 | <Card> |