/** * Booking Form Page * Add/Edit Booking form with clean, minimal SaaS-style design */ import React, { useState, useEffect, useMemo, useRef } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Save, Loader2, Plus, Trash2, Users, ChevronDown, ChevronUp, AlertCircle, Search, X, } from "lucide-react"; import { __ } from "../lib/i18n"; import { formatDate as formatDateUtil, formatDateForInput, } from "../lib/dateFormat"; import { apiService } from "../lib/api-client"; import { usePermissions } from "../hooks/usePermissions"; import { getCurrencySymbol } from "../data/currencies"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Select } from "../components/ui/select"; import { PageHeader } from "../components/common/PageHeader"; import { Card, CardContent, CardHeader, CardTitle, } from "../components/ui/card"; import { ConditionalRender } from "../components/ui/conditional-render"; import { Skeleton } from "../components/ui/skeleton"; import { DatePicker } from "../components/ui/date-picker"; import { taxService } from "../services/TaxService"; // Dynamic traveler data - keys are field IDs from form config interface TravelerData { [key: string]: string; } interface FormFieldConfig { id: string; type: string; label: string; placeholder?: string; required: boolean; enabled: boolean; order: number; width?: string; options?: { value: string; label: string }[]; section?: string; } interface FormSectionConfig { title: string; description?: string; enabled: boolean; fields: FormFieldConfig[]; } interface BookingFormConfig { contact_form: FormSectionConfig; emergency_contact_form: FormSectionConfig; traveler_form: FormSectionConfig; } interface BookingFormData { customer_name: string; customer_email: string; customer_phone: string; customer_country: string; trip_id: string; booking_date: string; travel_date: string; travelers: string; subtotal: string; tax_amount: string; total_amount: string; currency: string; payment_status: string; booking_status: string; payment_method: string; notes: string; } // Country list for country fields const countryList = [ { code: "US", name: "United States" }, { code: "GB", name: "United Kingdom" }, { code: "CA", name: "Canada" }, { code: "AU", name: "Australia" }, { code: "DE", name: "Germany" }, { code: "FR", name: "France" }, { code: "IN", name: "India" }, { code: "NP", name: "Nepal" }, { code: "CN", name: "China" }, { code: "JP", name: "Japan" }, { code: "BR", name: "Brazil" }, { code: "MX", name: "Mexico" }, { code: "ES", name: "Spain" }, { code: "IT", name: "Italy" }, { code: "NL", name: "Netherlands" }, { code: "CH", name: "Switzerland" }, { code: "SE", name: "Sweden" }, { code: "NO", name: "Norway" }, { code: "DK", name: "Denmark" }, { code: "FI", name: "Finland" }, { code: "NZ", name: "New Zealand" }, { code: "SG", name: "Singapore" }, { code: "AE", name: "United Arab Emirates" }, { code: "TH", name: "Thailand" }, { code: "MY", name: "Malaysia" }, { code: "ID", name: "Indonesia" }, { code: "PH", name: "Philippines" }, { code: "VN", name: "Vietnam" }, { code: "KR", name: "South Korea" }, { code: "ZA", name: "South Africa" }, ]; // Normalize ISO/date-like string to formatted date using shared date library const normalizeDateInput = (value?: string | null) => { if (!value) return ""; // DatePicker strictly requires YYYY-MM-DD; use formatDateForInput to ensure correct format return formatDateForInput(value) || value; }; const BookingForm: React.FC = () => { const queryClient = useQueryClient(); const { can } = usePermissions(); // Get global currency settings const globalCurrency = (window as any)?.yatraAdmin?.currency || "USD"; const currencySymbol = getCurrencySymbol(globalCurrency); const [formData, setFormData] = useState({ customer_name: "", customer_email: "", customer_phone: "", customer_country: "", trip_id: "", booking_date: normalizeDateInput(new Date().toISOString()), travel_date: "", travelers: "1", subtotal: "", tax_amount: "", total_amount: "", currency: globalCurrency, payment_status: "pending", booking_status: "pending", payment_method: "", notes: "", }); const [travelersData, setTravelersData] = useState([{}]); const [expandedTravelers, setExpandedTravelers] = useState([0]); const [emergencyContactData, setEmergencyContactData] = useState< Record >({}); const [errors, setErrors] = useState>({}); const [isSubmitting, setIsSubmitting] = useState(false); // Trip search state const [tripSearchQuery, setTripSearchQuery] = useState(""); const [isTripDropdownOpen, setIsTripDropdownOpen] = useState(false); const tripDropdownRef = useRef(null); // Close dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( tripDropdownRef.current && !tripDropdownRef.current.contains(event.target as Node) ) { setIsTripDropdownOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); // Fetch booking form configuration const { data: formConfig } = useQuery({ queryKey: ["booking-form-config"], queryFn: async () => { const response = await apiService.getSettings(); return ( response?.data?.booking_form_config || response?.booking_form_config || null ); }, }); // Get enabled traveler fields from config const travelerFields = useMemo(() => { if (!formConfig?.traveler_form?.fields) return []; return formConfig.traveler_form.fields .filter((field) => field.enabled) .sort((a, b) => a.order - b.order); }, [formConfig]); // Get enabled emergency contact fields from config const emergencyContactFields = useMemo(() => { if (!formConfig?.emergency_contact_form?.fields) return []; return formConfig.emergency_contact_form.fields .filter((field) => field.enabled) .sort((a, b) => a.order - b.order); }, [formConfig]); // Create empty traveler based on enabled fields const createEmptyTraveler = (): TravelerData => { const empty: TravelerData = {}; travelerFields.forEach((field) => { empty[field.id] = ""; }); return empty; }; // Get action and id from URL const action = useMemo(() => { const params = new URLSearchParams(window.location.search); return params.get("action") || "create"; }, []); const bookingId = useMemo(() => { const params = new URLSearchParams(window.location.search); return params.get("id") ? parseInt(params.get("id") || "0") : null; }, []); const isEditMode = action === "edit" && bookingId !== null; // Fetch payment gateways for dropdown const { data: gatewaysData } = useQuery({ queryKey: ["payment-gateways"], queryFn: async () => { const result = await apiService.getPaymentGateways(); if (result?.success) { return { data: result.data .filter((gw: any) => gw.enabled) .map((gw: any) => ({ id: gw.id, title: gw.title || gw.name, })), }; } return { data: [] }; }, }); // Fetch trips for dropdown - get all trips regardless of status const { data: tripsData, isLoading: isLoadingTrips } = useQuery({ queryKey: ["trips-list-all"], queryFn: async () => { // Fetch all trips - try without status filter first, then with status=all return await apiService.getTrips({ per_page: 500 }); }, // Always fetch trips when user can manage bookings enabled: can("yatra_view_bookings") || can("yatra_view_trips"), retry: 1, }); // Fetch booking data if editing const { data: bookingData, isLoading: isLoadingBooking } = useQuery({ queryKey: ["booking", bookingId], queryFn: async () => { if (!bookingId) return null; const result = await apiService.getBooking(bookingId); if (!result) { throw new Error("Failed to fetch booking"); } // Handle both wrapped { success, data } and direct data response formats const booking = (result as any)?.data ?? result; if (booking && booking.id) { // Use customer_name if available, otherwise construct from first/last name const contact = booking.contact || {}; const firstName = booking.contact_first_name || contact.first_name || ""; const lastName = booking.contact_last_name || contact.last_name || ""; const customerName = booking.customer_name || `${firstName} ${lastName}`.trim(); // Parse travelers data - dynamic fields based on stored data let travelers: TravelerData[] = []; if (booking.travelers && Array.isArray(booking.travelers)) { // Map all stored fields dynamically (keys are field IDs from form builder) // The API returns travelers with a nested 'fields' object containing the form data travelers = booking.travelers.map((t: any) => { const traveler: TravelerData = {}; // Check if fields are nested in a 'fields' property (from TravellerRepository) const fieldsData = t.fields || t; if (fieldsData && typeof fieldsData === "object") { Object.entries(fieldsData).forEach(([key, value]) => { // Skip internal fields like id, booking_id, traveller_index, etc. if ( ![ "id", "booking_id", "traveller_index", "is_lead", "created_at", "updated_at", ].includes(key) ) { traveler[key] = String(value || ""); } }); } // Also copy is_lead if present if (t.is_lead !== undefined) { traveler["is_lead"] = String(t.is_lead); } return traveler; }); } // Ensure at least one traveler exists if (travelers.length === 0) { travelers = [{}]; // Empty object - fields will be populated from form config } // Parse emergency contact data let emergencyContact: Record = {}; if ( booking.emergency_contact && typeof booking.emergency_contact === "object" ) { Object.entries(booking.emergency_contact).forEach(([key, value]) => { emergencyContact[key] = String(value || ""); }); } else if ( contact.emergency_name || contact.emergency_phone || contact.emergency_relationship ) { emergencyContact = { name: String(contact.emergency_name || ""), phone: String(contact.emergency_phone || ""), relationship: String(contact.emergency_relationship || ""), }; } const mappedData = { id: booking.id, customer_name: customerName, customer_email: booking.customer_email || contact.email || booking.contact_email || "", customer_phone: booking.customer_phone || contact.phone || booking.contact_phone || "", customer_country: (booking as any).contact_country || "", trip_id: String(booking.trip_id || ""), trip_title: booking.trip_title || "", booking_date: normalizeDateInput( booking.booking_date ?? booking.created_at ?? new Date().toISOString(), ), travel_date: normalizeDateInput(booking.travel_date ?? ""), travelers: String(booking.travelers || "1"), travelers_data: travelers, // Add the correctly parsed travelers data subtotal: String( (booking as any).subtotal || booking.total_amount || "", ), tax_amount: String((booking as any).tax_amount || "0"), total_amount: String(booking.total_amount || ""), currency: booking.currency || "USD", payment_status: booking.payment_status || "pending", booking_status: booking.booking_status || "pending", payment_method: booking.payment_method || "", notes: booking.notes || "", emergency_contact: emergencyContact, }; return mappedData; } return null; }, enabled: isEditMode && can("yatra_view_bookings"), }); // Track if we've loaded booking data to prevent auto-calculation from overwriting const [isDataLoaded, setIsDataLoaded] = useState(false); // Load booking data into form when editing useEffect(() => { if (bookingData && isEditMode) { setFormData({ customer_name: bookingData.customer_name || "", customer_email: bookingData.customer_email || "", customer_phone: bookingData.customer_phone || "", customer_country: (bookingData as any).contact_country || (bookingData as any).customer_country || "", trip_id: String(bookingData.trip_id || ""), booking_date: bookingData.booking_date || new Date().toISOString().split("T")[0], travel_date: bookingData.travel_date || "", travelers: String( (bookingData as any).travelers_count || bookingData.travelers_data?.length || "1", ), subtotal: String( (bookingData as any).subtotal || bookingData.total_amount || "", ), tax_amount: String((bookingData as any).tax_amount || "0"), total_amount: String(bookingData.total_amount || ""), currency: bookingData.currency || "USD", payment_status: bookingData.payment_status || "pending", booking_status: bookingData.booking_status || "pending", payment_method: bookingData.payment_method || "", notes: (bookingData as any).special_requests || bookingData.notes || "", }); // Set travelers data - use the correctly parsed travelers_data if ( bookingData.travelers_data && Array.isArray(bookingData.travelers_data) && bookingData.travelers_data.length > 0 ) { setTravelersData(bookingData.travelers_data); // Expand first traveler by default setExpandedTravelers([0]); } else { // Ensure at least one empty traveler setTravelersData([{}]); setExpandedTravelers([0]); } // Set emergency contact data if (bookingData.emergency_contact) { setEmergencyContactData(bookingData.emergency_contact); } setIsDataLoaded(true); } }, [bookingData, isEditMode]); // Auto-calculate pricing when trip or travelers change useEffect(() => { // Don't auto-calculate if we're in edit mode - user can manually update if needed if (isEditMode) return; if (formData.trip_id && formData.travelers) { const selectedTrip = tripsData?.data?.find( (trip: any) => String(trip.id) === formData.trip_id, ); if (selectedTrip) { const subtotal = selectedTrip.price * parseInt(formData.travelers); // Calculate tax const taxCalculation = taxService.calculateBookingTax( subtotal, formData.customer_country, ); setFormData((prev) => ({ ...prev, subtotal: taxCalculation.subtotal.toString(), tax_amount: taxCalculation.tax_amount.toString(), total_amount: taxCalculation.total_amount.toString(), })); } } }, [ formData.trip_id, formData.travelers, formData.customer_country, tripsData?.data, isEditMode, ]); // Load tax settings on mount useEffect(() => { taxService.loadTaxSettings(); }, []); const handleFieldChange = (field: keyof BookingFormData, value: string) => { setFormData((prev) => ({ ...prev, [field]: value })); if (errors[field]) { setErrors((prev) => ({ ...prev, [field]: "" })); } }; // Emergency contact change handler const handleEmergencyContactChange = (field: string, value: string) => { setEmergencyContactData((prev) => ({ ...prev, [field]: value })); }; // Traveler management functions const handleTravelerChange = ( index: number, field: keyof TravelerData, value: string, ) => { setTravelersData((prev) => { const updated = [...prev]; updated[index] = { ...updated[index], [field]: value }; return updated; }); }; const addTraveler = () => { const newTraveler = createEmptyTraveler(); setTravelersData((prev) => [...prev, newTraveler]); setFormData((prev) => ({ ...prev, travelers: String(travelersData.length + 1), })); setExpandedTravelers((prev) => [...prev, travelersData.length]); }; const removeTraveler = (index: number) => { if (travelersData.length <= 1) return; setTravelersData((prev) => prev.filter((_, i) => i !== index)); setFormData((prev) => ({ ...prev, travelers: String(travelersData.length - 1), })); setExpandedTravelers((prev) => prev.filter((i) => i !== index).map((i) => (i > index ? i - 1 : i)), ); }; const toggleTravelerExpanded = (index: number) => { setExpandedTravelers((prev) => prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index], ); }; const validateForm = (): boolean => { const newErrors: Record = {}; if (!formData.customer_name.trim()) { newErrors.customer_name = __("Customer name is required", "yatra"); } if (!formData.customer_email.trim()) { newErrors.customer_email = __("Customer email is required", "yatra"); } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) { newErrors.customer_email = __("Invalid email address", "yatra"); } if (!formData.trip_id) { newErrors.trip_id = __("Trip is required", "yatra"); } if (!formData.booking_date) { newErrors.booking_date = __("Booking date is required", "yatra"); } if (!formData.travel_date) { newErrors.travel_date = __("Travel date is required", "yatra"); } if (!formData.travelers || parseInt(formData.travelers) < 1) { newErrors.travelers = __( "Number of travelers must be at least 1", "yatra", ); } if (!formData.total_amount || parseFloat(formData.total_amount) <= 0) { newErrors.total_amount = __( "Total amount must be greater than 0", "yatra", ); } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; // Create/Update mutation const saveMutation = useMutation({ mutationFn: async (data: BookingFormData) => { // Split customer name into first and last const nameParts = data.customer_name.trim().split(" "); const firstName = nameParts[0] || ""; const lastName = nameParts.slice(1).join(" ") || ""; const payload: any = { contact_first_name: firstName, contact_last_name: lastName, contact_email: data.customer_email.trim(), contact_phone: data.customer_phone.trim(), trip_id: parseInt(data.trip_id), travel_date: data.travel_date, travelers_count: travelersData.length, total_amount: parseFloat(data.total_amount), payment_status: data.payment_status, status: data.booking_status, payment_gateway: data.payment_method.trim(), special_requests: data.notes.trim(), // Include travelers data - use 'travelers' key as expected by BookingService.updateBooking travelers: travelersData, // Include emergency contact data emergency_contact: emergencyContactData, }; return isEditMode ? await apiService.updateBooking(bookingId!, payload) : await apiService.createBooking(payload); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["bookings"] }); // Redirect to bookings list window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings`; }, onError: (error: any) => { const errorMessage = error?.message || __("An error occurred while saving the booking", "yatra"); setErrors({ submit: errorMessage }); setIsSubmitting(false); }, }); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!validateForm()) { return; } setIsSubmitting(true); setErrors({}); saveMutation.mutate(formData); }; const handleCancel = () => { window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings`; }; if (isEditMode && isLoadingBooking) { return (
{/* Header Skeleton */}
{/* Main Form Skeleton */}
{/* Customer Information Card */}
{/* Booking Details Card */}
{/* Sidebar Skeleton */}
); } return (
{__("Back", "yatra")} } />
{/* Main Form Fields */}
{/* Customer Information */} {__("Customer Information", "yatra")}
{/* Customer Name */}
handleFieldChange("customer_name", e.target.value) } placeholder={__("Enter customer name", "yatra")} className={errors.customer_name ? "border-red-500" : ""} required /> {errors.customer_name && (

{errors.customer_name}

)}
{/* Customer Email */}
handleFieldChange("customer_email", e.target.value) } placeholder={__("customer@example.com", "yatra")} className={ errors.customer_email ? "border-red-500" : "" } required /> {errors.customer_email && (

{errors.customer_email}

)}
{/* Customer Phone */}
handleFieldChange("customer_phone", e.target.value) } placeholder={__("+1234567890", "yatra")} />
{/* Customer Country */}
{/* Booking Details */} {__("Booking Details", "yatra")} {/* Trip Selection - Searchable Dropdown */}
setIsTripDropdownOpen(!isTripDropdownOpen) } >
{formData.trip_id ? ( {tripsData?.data?.find( (t: any) => String(t.id) === formData.trip_id, )?.title || bookingData?.trip_title || `Trip ID: ${formData.trip_id}`} ) : ( {isLoadingTrips ? __("Loading trips...", "yatra") : __("Select a trip", "yatra")} )}
{formData.trip_id && ( )}
{/* Dropdown */} {isTripDropdownOpen && (
{/* Search Input */}
setTripSearchQuery(e.target.value) } onClick={(e) => e.stopPropagation()} autoFocus />
{/* Trip List */}
{(() => { // Combine booked trip with trips list let allTrips = [...(tripsData?.data || [])]; // Add booked trip if not in list if ( isEditMode && bookingData?.trip_id && bookingData?.trip_title ) { const bookedTripExists = allTrips.some( (t: any) => String(t.id) === String(bookingData.trip_id), ); if (!bookedTripExists) { allTrips.unshift({ id: bookingData.trip_id, title: bookingData.trip_title, price: bookingData.total_amount || 0, currency: globalCurrency, status: "publish", }); } } // Filter by search query const filteredTrips = allTrips.filter( (trip: any) => trip.title .toLowerCase() .includes(tripSearchQuery.toLowerCase()) || String(trip.id).includes(tripSearchQuery), ); if (filteredTrips.length === 0) { return (
{__("No trips found", "yatra")}
); } return filteredTrips.map((trip: any) => (
{ handleFieldChange( "trip_id", String(trip.id), ); setIsTripDropdownOpen(false); setTripSearchQuery(""); }} >
{trip.title}
ID: {trip.id}
)); })()}
)}
{errors.trip_id && (

{errors.trip_id}

)}
{/* Booking Date */}
handleFieldChange("booking_date", value) } placeholder={__("Select booking date", "yatra")} error={!!errors.booking_date} /> {errors.booking_date && (

{errors.booking_date}

)}
{/* Travel Date */}
handleFieldChange("travel_date", value) } placeholder={__("Select travel date", "yatra")} error={!!errors.travel_date} /> {errors.travel_date && (

{errors.travel_date}

)}
{/* Number of Travelers */}
handleFieldChange("travelers", e.target.value) } className={errors.travelers ? "border-red-500" : ""} required /> {errors.travelers && (

{errors.travelers}

)}
{/* Tax Breakdown */}

{__("Pricing Breakdown", "yatra")}

{formData.subtotal ? ( <>
{__("Subtotal", "yatra")} {currencySymbol} {parseFloat(formData.subtotal).toFixed(2)}
{/* Itinerary Costs */} {(bookingData as any)?.itinerary_costs_total > 0 && (
{__("Itinerary Costs", "yatra")} {getCurrencySymbol( formData.currency || globalCurrency, )} {( bookingData as any ).itinerary_costs_total.toFixed(2)}
)} {parseFloat(formData.tax_amount) > 0 && (
{__("Tax", "yatra")} {currencySymbol} {parseFloat(formData.tax_amount).toFixed(2)}
)}
{__("Total", "yatra")} {getCurrencySymbol( formData.currency || globalCurrency, )} {formData.total_amount}
) : (
{__( "Select a trip and number of travelers to see pricing", "yatra", )}
)}
{/* Total Amount */}
{getCurrencySymbol( formData.currency || globalCurrency, )} handleFieldChange("total_amount", e.target.value) } placeholder="0.00" className={`pl-7 ${errors.total_amount ? "border-red-500" : ""}`} required />
{errors.total_amount && (

{errors.total_amount}

)}
{/* Notes */}