PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.11
Yatra – Travel Booking & Tour Operator Software v3.0.11
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / resources / js / pages / BookingForm.tsx

BookingForm.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.11, at resources/js/pages/BookingForm.tsx

2,024 lines 84.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Booking Form Page
3 * Add/Edit Booking form with clean, minimal SaaS-style design
4 */
5
6 import React, { useState, useEffect, useMemo, useRef } from "react";
7 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8 import {
9 ArrowLeft,
10 Save,
11 Loader2,
12 Plus,
13 Trash2,
14 Users,
15 ChevronDown,
16 ChevronUp,
17 AlertCircle,
18 Search,
19 X,
20 } from "lucide-react";
21 import { __ } from "../lib/i18n";
22 import {
23 formatDate as formatDateUtil,
24 formatDateForInput,
25 todayYmd,
26 } from "../lib/dateFormat";
27 import { getCountrySelectOptions } from "../lib/countries";
28 import { SearchableSelect } from "../components/ui/searchable-select";
29 import { apiService } from "../lib/api-client";
30 import { usePermissions } from "../hooks/usePermissions";
31 import { getCurrencySymbol } from "../data/currencies";
32 import { Button } from "../components/ui/button";
33 import { Input } from "../components/ui/input";
34 import { Select } from "../components/ui/select";
35 import { PageHeader } from "../components/common/PageHeader";
36 import {
37 Card,
38 CardContent,
39 CardHeader,
40 CardTitle,
41 } from "../components/ui/card";
42 import { ConditionalRender } from "../components/ui/conditional-render";
43 import { Skeleton } from "../components/ui/skeleton";
44 import { DatePicker } from "../components/ui/date-picker";
45 import { taxService } from "../services/TaxService";
46
47 // Dynamic traveler data - keys are field IDs from form config
48 interface TravelerData {
49 [key: string]: string;
50 }
51
52 interface FormFieldConfig {
53 id: string;
54 type: string;
55 label: string;
56 placeholder?: string;
57 required: boolean;
58 enabled: boolean;
59 order: number;
60 width?: string;
61 options?: { value: string; label: string }[];
62 section?: string;
63 /** Traveler section: "lead" shows only for the lead traveler; "all"/absent = every traveler. */
64 applies_to?: "all" | "lead";
65 }
66
67 interface FormSectionConfig {
68 title: string;
69 description?: string;
70 enabled: boolean;
71 fields: FormFieldConfig[];
72 }
73
74 interface BookingFormConfig {
75 contact_form: FormSectionConfig;
76 emergency_contact_form: FormSectionConfig;
77 traveler_form: FormSectionConfig;
78 }
79
80 interface BookingFormData {
81 customer_name: string;
82 customer_email: string;
83 customer_phone: string;
84 customer_country: string;
85 trip_id: string;
86 booking_date: string;
87 travel_date: string;
88 travelers: string;
89 subtotal: string;
90 tax_amount: string;
91 total_amount: string;
92 currency: string;
93 payment_status: string;
94 booking_status: string;
95 payment_method: string;
96 notes: string;
97 }
98
99 // Country list — pulled from the canonical source (PHP
100 // FormatHelper::getCountries() → localized to window.yatraAdmin.countries).
101 // One source of truth; operators that want a curated subset apply
102 // the `yatra_countries_list` PHP filter once and every dropdown
103 // (admin + public booking + Pro modules) picks it up.
104 const countrySelectOptions = getCountrySelectOptions();
105
106 // Core contact fields rendered explicitly (name/email/phone/country); everything
107 // else in the contact form is treated as an "extra/custom" field.
108 const CORE_CONTACT_IDS = [
109 "first_name",
110 "last_name",
111 "email",
112 "phone",
113 "country",
114 ];
115
116 // Normalize ISO/date-like string to formatted date using shared date library
117 const normalizeDateInput = (value?: string | null) => {
118 if (!value) return "";
119 // DatePicker strictly requires YYYY-MM-DD; use formatDateForInput to ensure correct format
120 return formatDateForInput(value) || value;
121 };
122
123 const BookingForm: React.FC = () => {
124 const queryClient = useQueryClient();
125 const { can } = usePermissions();
126
127 // Get global currency settings
128 const globalCurrency = (window as any)?.yatraAdmin?.currency || "USD";
129 const currencySymbol = getCurrencySymbol(globalCurrency);
130
131 const [formData, setFormData] = useState<BookingFormData>({
132 customer_name: "",
133 customer_email: "",
134 customer_phone: "",
135 customer_country: "",
136 trip_id: "",
137 booking_date: todayYmd(),
138 travel_date: "",
139 travelers: "1",
140 subtotal: "",
141 tax_amount: "",
142 total_amount: "",
143 currency: globalCurrency,
144 payment_status: "pending",
145 booking_status: "pending",
146 payment_method: "",
147 notes: "",
148 });
149 const [travelersData, setTravelersData] = useState<TravelerData[]>([{}]);
150 const [expandedTravelers, setExpandedTravelers] = useState<number[]>([0]);
151 const [emergencyContactData, setEmergencyContactData] = useState<
152 Record<string, string>
153 >({});
154 // Extra/custom contact fields (everything beyond the core name/email/phone/
155 // country handled by formData), keyed by field id — kept in sync with the
156 // booking's contact_data JSON so admins can edit custom contact fields.
157 const [contactExtraData, setContactExtraData] = useState<
158 Record<string, string>
159 >({});
160 const [errors, setErrors] = useState<Record<string, string>>({});
161 const [isSubmitting, setIsSubmitting] = useState(false);
162
163 // Trip search state
164 const [tripSearchQuery, setTripSearchQuery] = useState("");
165 const [isTripDropdownOpen, setIsTripDropdownOpen] = useState(false);
166 const tripDropdownRef = useRef<HTMLDivElement>(null);
167
168 // Close dropdown when clicking outside
169 useEffect(() => {
170 const handleClickOutside = (event: MouseEvent) => {
171 if (
172 tripDropdownRef.current &&
173 !tripDropdownRef.current.contains(event.target as Node)
174 ) {
175 setIsTripDropdownOpen(false);
176 }
177 };
178 document.addEventListener("mousedown", handleClickOutside);
179 return () => document.removeEventListener("mousedown", handleClickOutside);
180 }, []);
181
182 // Fetch booking form configuration
183 const { data: formConfig } = useQuery<BookingFormConfig>({
184 queryKey: ["booking-form-config"],
185 queryFn: async () => {
186 const response = await apiService.getSettings();
187 return (
188 response?.data?.booking_form_config ||
189 response?.booking_form_config ||
190 null
191 );
192 },
193 });
194
195 // Get enabled traveler fields from config
196 const travelerFields = useMemo(() => {
197 if (!formConfig?.traveler_form?.fields) return [];
198 return formConfig.traveler_form.fields
199 .filter((field) => field.enabled)
200 .sort((a, b) => a.order - b.order);
201 }, [formConfig]);
202
203 // Get enabled emergency contact fields from config
204 const emergencyContactFields = useMemo(() => {
205 if (!formConfig?.emergency_contact_form?.fields) return [];
206 return formConfig.emergency_contact_form.fields
207 .filter((field) => field.enabled)
208 .sort((a, b) => a.order - b.order);
209 }, [formConfig]);
210
211 // Contact fields beyond the core ones already rendered above (name/email/
212 // phone/country) — i.e. nationality, address, and any CUSTOM contact fields.
213 const contactExtraFields = useMemo(() => {
214 if (!formConfig?.contact_form?.fields) return [];
215 if (formConfig.contact_form.enabled === false) return [];
216 return formConfig.contact_form.fields
217 .filter((field) => field.enabled && !CORE_CONTACT_IDS.includes(field.id))
218 .sort((a, b) => a.order - b.order);
219 }, [formConfig]);
220
221 // Create empty traveler based on enabled fields
222 const createEmptyTraveler = (): TravelerData => {
223 const empty: TravelerData = {};
224 travelerFields.forEach((field) => {
225 empty[field.id] = "";
226 });
227 return empty;
228 };
229
230 // Get action and id from URL
231 const action = useMemo(() => {
232 const params = new URLSearchParams(window.location.search);
233 return params.get("action") || "create";
234 }, []);
235
236 const bookingId = useMemo(() => {
237 const params = new URLSearchParams(window.location.search);
238 return params.get("id") ? parseInt(params.get("id") || "0") : null;
239 }, []);
240
241 const isEditMode = action === "edit" && bookingId !== null;
242
243 // Fetch payment gateways for dropdown
244 const { data: gatewaysData } = useQuery({
245 queryKey: ["payment-gateways"],
246 queryFn: async () => {
247 const result = await apiService.getPaymentGateways();
248 if (result?.success) {
249 return {
250 data: result.data
251 .filter((gw: any) => gw.enabled)
252 .map((gw: any) => ({
253 id: gw.id,
254 title: gw.title || gw.name,
255 })),
256 };
257 }
258 return { data: [] };
259 },
260 });
261
262 // Fetch trips for dropdown - get all trips regardless of status
263 const { data: tripsData, isLoading: isLoadingTrips } = useQuery({
264 queryKey: ["trips-list-all"],
265 queryFn: async () => {
266 // Fetch all trips - try without status filter first, then with status=all
267 return await apiService.getTrips({ per_page: 500 });
268 },
269 // Always fetch trips when user can manage bookings
270 enabled: can("yatra_view_bookings") || can("yatra_view_trips"),
271 retry: 1,
272 });
273
274 // Fetch booking data if editing
275 const { data: bookingData, isLoading: isLoadingBooking } = useQuery({
276 queryKey: ["booking", bookingId],
277 queryFn: async () => {
278 if (!bookingId) return null;
279 const result = await apiService.getBooking(bookingId);
280
281 if (!result) {
282 throw new Error("Failed to fetch booking");
283 }
284
285 // Handle both wrapped { success, data } and direct data response formats
286 const booking = (result as any)?.data ?? result;
287
288 if (booking && booking.id) {
289 // Use customer_name if available, otherwise construct from first/last name
290 const contact = booking.contact || {};
291 const firstName =
292 booking.contact_first_name || contact.first_name || "";
293 const lastName = booking.contact_last_name || contact.last_name || "";
294 const customerName =
295 booking.customer_name || `${firstName} ${lastName}`.trim();
296
297 // Parse travelers data - dynamic fields based on stored data
298 let travelers: TravelerData[] = [];
299 if (booking.travelers && Array.isArray(booking.travelers)) {
300 // Map all stored fields dynamically (keys are field IDs from form builder)
301 // The API returns travelers with a nested 'fields' object containing the form data
302 travelers = booking.travelers.map((t: any) => {
303 const traveler: TravelerData = {};
304
305 // Check if fields are nested in a 'fields' property (from TravellerRepository)
306 const fieldsData = t.fields || t;
307
308 if (fieldsData && typeof fieldsData === "object") {
309 Object.entries(fieldsData).forEach(([key, value]) => {
310 // Skip internal fields like id, booking_id, traveller_index, etc.
311 if (
312 ![
313 "id",
314 "booking_id",
315 "traveller_index",
316 "is_lead",
317 "created_at",
318 "updated_at",
319 ].includes(key)
320 ) {
321 traveler[key] = String(value || "");
322 }
323 });
324 }
325
326 // Also copy is_lead if present
327 if (t.is_lead !== undefined) {
328 traveler["is_lead"] = String(t.is_lead);
329 }
330
331 return traveler;
332 });
333 }
334
335 // Ensure at least one traveler exists
336 if (travelers.length === 0) {
337 travelers = [{}]; // Empty object - fields will be populated from form config
338 }
339
340 // Parse emergency contact data
341 let emergencyContact: Record<string, string> = {};
342 if (
343 booking.emergency_contact &&
344 typeof booking.emergency_contact === "object"
345 ) {
346 Object.entries(booking.emergency_contact).forEach(([key, value]) => {
347 emergencyContact[key] = String(value || "");
348 });
349 } else if (
350 contact.emergency_name ||
351 contact.emergency_phone ||
352 contact.emergency_relationship
353 ) {
354 emergencyContact = {
355 name: String(contact.emergency_name || ""),
356 phone: String(contact.emergency_phone || ""),
357 relationship: String(contact.emergency_relationship || ""),
358 };
359 }
360
361 const mappedData = {
362 id: booking.id,
363 customer_name: customerName,
364 customer_email:
365 booking.customer_email ||
366 contact.email ||
367 booking.contact_email ||
368 "",
369 customer_phone:
370 booking.customer_phone ||
371 contact.phone ||
372 booking.contact_phone ||
373 "",
374 customer_country: (booking as any).contact_country || "",
375 trip_id: String(booking.trip_id || ""),
376 trip_title: booking.trip_title || "",
377 booking_date: normalizeDateInput(
378 booking.booking_date ??
379 booking.created_at ??
380 new Date().toISOString(),
381 ),
382 travel_date: normalizeDateInput(booking.travel_date ?? ""),
383 travelers: String(booking.travelers || "1"),
384 travelers_data: travelers, // Add the correctly parsed travelers data
385 subtotal: String(
386 (booking as any).subtotal || booking.total_amount || "",
387 ),
388 tax_amount: String((booking as any).tax_amount || "0"),
389 total_amount: String(booking.total_amount || ""),
390 currency: booking.currency || "USD",
391 payment_status: booking.payment_status || "pending",
392 booking_status: booking.booking_status || "pending",
393 payment_method: booking.payment_method || "",
394 notes: booking.notes || "",
395 emergency_contact: emergencyContact,
396 contact_data: (booking as any).contact_data || null,
397 };
398
399 return mappedData;
400 }
401 return null;
402 },
403 enabled: isEditMode && can("yatra_view_bookings"),
404 });
405
406 // Track if we've loaded booking data to prevent auto-calculation from overwriting
407 const [isDataLoaded, setIsDataLoaded] = useState(false);
408
409 // Load booking data into form when editing
410 useEffect(() => {
411 if (bookingData && isEditMode) {
412 setFormData({
413 customer_name: bookingData.customer_name || "",
414 customer_email: bookingData.customer_email || "",
415 customer_phone: bookingData.customer_phone || "",
416 customer_country:
417 (bookingData as any).contact_country ||
418 (bookingData as any).customer_country ||
419 "",
420 trip_id: String(bookingData.trip_id || ""),
421 booking_date: bookingData.booking_date || todayYmd(),
422 travel_date: bookingData.travel_date || "",
423 travelers: String(
424 (bookingData as any).travelers_count ||
425 bookingData.travelers_data?.length ||
426 "1",
427 ),
428 subtotal: String(
429 (bookingData as any).subtotal || bookingData.total_amount || "",
430 ),
431 tax_amount: String((bookingData as any).tax_amount || "0"),
432 total_amount: String(bookingData.total_amount || ""),
433 currency: bookingData.currency || "USD",
434 payment_status: bookingData.payment_status || "pending",
435 booking_status: bookingData.booking_status || "pending",
436 payment_method: bookingData.payment_method || "",
437 notes: (bookingData as any).special_requests || bookingData.notes || "",
438 });
439
440 // Set travelers data - use the correctly parsed travelers_data
441 if (
442 bookingData.travelers_data &&
443 Array.isArray(bookingData.travelers_data) &&
444 bookingData.travelers_data.length > 0
445 ) {
446 setTravelersData(bookingData.travelers_data);
447 // Expand first traveler by default
448 setExpandedTravelers([0]);
449 } else {
450 // Ensure at least one empty traveler
451 setTravelersData([{}]);
452 setExpandedTravelers([0]);
453 }
454
455 // Set emergency contact data
456 if (bookingData.emergency_contact) {
457 setEmergencyContactData(bookingData.emergency_contact);
458 }
459
460 // Set extra/custom contact fields from the booking's contact_data JSON
461 // (everything beyond the core name/email/phone/country handled above).
462 const cd = (bookingData as any).contact_data;
463 if (cd && typeof cd === "object") {
464 const extras: Record<string, string> = {};
465 Object.entries(cd).forEach(([k, v]) => {
466 if (!CORE_CONTACT_IDS.includes(k)) {
467 extras[k] = v == null ? "" : String(v);
468 }
469 });
470 setContactExtraData(extras);
471 }
472
473 setIsDataLoaded(true);
474 }
475 }, [bookingData, isEditMode]);
476
477 // Auto-calculate pricing when trip or travelers change
478 useEffect(() => {
479 // Don't auto-calculate if we're in edit mode - user can manually update if needed
480 if (isEditMode) return;
481
482 if (formData.trip_id && formData.travelers) {
483 const selectedTrip = tripsData?.data?.find(
484 (trip: any) => String(trip.id) === formData.trip_id,
485 );
486 if (selectedTrip) {
487 const subtotal = selectedTrip.price * parseInt(formData.travelers);
488
489 // Calculate tax
490 const taxCalculation = taxService.calculateBookingTax(
491 subtotal,
492 formData.customer_country,
493 );
494
495 setFormData((prev) => ({
496 ...prev,
497 subtotal: taxCalculation.subtotal.toString(),
498 tax_amount: taxCalculation.tax_amount.toString(),
499 total_amount: taxCalculation.total_amount.toString(),
500 }));
501 }
502 }
503 }, [
504 formData.trip_id,
505 formData.travelers,
506 formData.customer_country,
507 tripsData?.data,
508 isEditMode,
509 ]);
510
511 // Load tax settings on mount
512 useEffect(() => {
513 taxService.loadTaxSettings();
514 }, []);
515
516 const handleFieldChange = (field: keyof BookingFormData, value: string) => {
517 setFormData((prev) => ({ ...prev, [field]: value }));
518 if (errors[field]) {
519 setErrors((prev) => ({ ...prev, [field]: "" }));
520 }
521 };
522
523 // Emergency contact change handler
524 const handleEmergencyContactChange = (field: string, value: string) => {
525 setEmergencyContactData((prev) => ({ ...prev, [field]: value }));
526 };
527
528 const handleContactExtraChange = (field: string, value: string) => {
529 setContactExtraData((prev) => ({ ...prev, [field]: value }));
530 };
531
532 // Shared renderer for a dynamic form field's input (matches the checkout
533 // field types). Used for the extra/custom contact fields.
534 const renderDynamicInput = (
535 field: FormFieldConfig,
536 value: string,
537 onChange: (v: string) => void,
538 idPrefix: string,
539 ) => {
540 const id = `${idPrefix}-${field.id}`;
541 if (field.type === "select") {
542 return (
543 <Select
544 id={id}
545 value={value}
546 onChange={(e) => onChange(e.target.value)}
547 >
548 <option value="">
549 {field.placeholder || `Select ${field.label}`}
550 </option>
551 {field.options?.map((opt) => (
552 <option key={opt.value} value={opt.value}>
553 {opt.label}
554 </option>
555 ))}
556 </Select>
557 );
558 }
559 if (field.type === "country") {
560 // Searchable list with national flags, matching the public booking form
561 // and the phone country-code control.
562 return (
563 <SearchableSelect
564 value={value}
565 onChange={onChange}
566 options={countrySelectOptions}
567 placeholder={field.placeholder || __("Select Country", "yatra")}
568 searchPlaceholder={__("Search country", "yatra")}
569 />
570 );
571 }
572 if (field.type === "textarea") {
573 return (
574 <textarea
575 id={id}
576 value={value}
577 onChange={(e) => onChange(e.target.value)}
578 placeholder={field.placeholder}
579 rows={2}
580 className="flex w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:placeholder:text-gray-400 resize-none"
581 />
582 );
583 }
584 if (field.type === "checkbox") {
585 return (
586 <label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
587 <input
588 type="checkbox"
589 className="rounded border-gray-300"
590 checked={value === "1" || value === "true"}
591 onChange={(e) => onChange(e.target.checked ? "1" : "")}
592 />
593 <span>{field.placeholder || field.label}</span>
594 </label>
595 );
596 }
597 return (
598 <Input
599 id={id}
600 type={
601 field.type === "email"
602 ? "email"
603 : field.type === "tel"
604 ? "tel"
605 : field.type === "date"
606 ? "date"
607 : field.type === "number"
608 ? "number"
609 : "text"
610 }
611 value={value}
612 onChange={(e) => onChange(e.target.value)}
613 placeholder={field.placeholder}
614 />
615 );
616 };
617
618 // Traveler management functions
619 const handleTravelerChange = (
620 index: number,
621 field: keyof TravelerData,
622 value: string,
623 ) => {
624 setTravelersData((prev) => {
625 const updated = [...prev];
626 updated[index] = { ...updated[index], [field]: value };
627 return updated;
628 });
629 };
630
631 const addTraveler = () => {
632 const newTraveler = createEmptyTraveler();
633 setTravelersData((prev) => [...prev, newTraveler]);
634 setFormData((prev) => ({
635 ...prev,
636 travelers: String(travelersData.length + 1),
637 }));
638 setExpandedTravelers((prev) => [...prev, travelersData.length]);
639 };
640
641 const removeTraveler = (index: number) => {
642 if (travelersData.length <= 1) return;
643 setTravelersData((prev) => prev.filter((_, i) => i !== index));
644 setFormData((prev) => ({
645 ...prev,
646 travelers: String(travelersData.length - 1),
647 }));
648 setExpandedTravelers((prev) =>
649 prev.filter((i) => i !== index).map((i) => (i > index ? i - 1 : i)),
650 );
651 };
652
653 const toggleTravelerExpanded = (index: number) => {
654 setExpandedTravelers((prev) =>
655 prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index],
656 );
657 };
658
659 const validateForm = (): boolean => {
660 const newErrors: Record<string, string> = {};
661
662 if (!formData.customer_name.trim()) {
663 newErrors.customer_name = __("Customer name is required", "yatra");
664 }
665
666 if (!formData.customer_email.trim()) {
667 newErrors.customer_email = __("Customer email is required", "yatra");
668 } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
669 newErrors.customer_email = __("Invalid email address", "yatra");
670 }
671
672 if (!formData.trip_id) {
673 newErrors.trip_id = __("Trip is required", "yatra");
674 }
675
676 if (!formData.booking_date) {
677 newErrors.booking_date = __("Booking date is required", "yatra");
678 }
679
680 if (!formData.travel_date) {
681 newErrors.travel_date = __("Travel date is required", "yatra");
682 }
683
684 if (!formData.travelers || parseInt(formData.travelers) < 1) {
685 newErrors.travelers = __(
686 "Number of travelers must be at least 1",
687 "yatra",
688 );
689 }
690
691 if (!formData.total_amount || parseFloat(formData.total_amount) <= 0) {
692 newErrors.total_amount = __(
693 "Total amount must be greater than 0",
694 "yatra",
695 );
696 }
697
698 setErrors(newErrors);
699 return Object.keys(newErrors).length === 0;
700 };
701
702 // Create/Update mutation
703 const saveMutation = useMutation({
704 mutationFn: async (data: BookingFormData) => {
705 // Split customer name into first and last
706 const nameParts = data.customer_name.trim().split(" ");
707 const firstName = nameParts[0] || "";
708 const lastName = nameParts.slice(1).join(" ") || "";
709
710 const payload: any = {
711 contact_first_name: firstName,
712 contact_last_name: lastName,
713 contact_email: data.customer_email.trim(),
714 contact_phone: data.customer_phone.trim(),
715 // contact_country column (previously not submitted, so edits were lost).
716 contact_country: data.customer_country || "",
717 trip_id: parseInt(data.trip_id),
718 travel_date: data.travel_date,
719 travelers_count: travelersData.length,
720 total_amount: parseFloat(data.total_amount),
721 payment_status: data.payment_status,
722 status: data.booking_status,
723 payment_gateway: data.payment_method.trim(),
724 special_requests: data.notes.trim(),
725 // Include travelers data - use 'travelers' key as expected by BookingService.updateBooking
726 travelers: travelersData,
727 // Include emergency contact data
728 emergency_contact: emergencyContactData,
729 // Full contact_data JSON: core fields + every extra/custom contact field,
730 // so admin edits round-trip and feed the {{contact_*}} email variables.
731 contact_data: {
732 first_name: firstName,
733 last_name: lastName,
734 email: data.customer_email.trim(),
735 phone: data.customer_phone.trim(),
736 country: data.customer_country || "",
737 ...contactExtraData,
738 },
739 };
740
741 return isEditMode
742 ? await apiService.updateBooking(bookingId!, payload)
743 : await apiService.createBooking(payload);
744 },
745 onSuccess: () => {
746 queryClient.invalidateQueries({ queryKey: ["bookings"] });
747 // Redirect to bookings list
748 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings`;
749 },
750 onError: (error: any) => {
751 // The REST API returns field-level reasons under
752 // response.data.data.validation_errors (WP_Error shape). The generic
753 // "Booking validation failed" message alone hid which field was at fault
754 // (e.g. a locale-formatted amount), so surface the specific reasons.
755 const validationErrors =
756 error?.response?.data?.data?.validation_errors ||
757 error?.response?.data?.validation_errors;
758
759 const fieldErrors: Record<string, string> = {};
760 let detail = "";
761 if (validationErrors && typeof validationErrors === "object") {
762 for (const [field, messages] of Object.entries(validationErrors)) {
763 const msg = Array.isArray(messages)
764 ? String(messages[0])
765 : String(messages);
766 fieldErrors[field] = msg;
767 detail = detail ? `${detail} ${msg}` : msg;
768 }
769 }
770
771 const errorMessage =
772 detail ||
773 error?.message ||
774 __("An error occurred while saving the booking", "yatra");
775 setErrors({ ...fieldErrors, submit: errorMessage });
776 setIsSubmitting(false);
777 },
778 });
779
780 const handleSubmit = async (e: React.FormEvent) => {
781 e.preventDefault();
782
783 if (!validateForm()) {
784 return;
785 }
786
787 setIsSubmitting(true);
788 setErrors({});
789 saveMutation.mutate(formData);
790 };
791
792 const handleCancel = () => {
793 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings`;
794 };
795
796 if (isEditMode && isLoadingBooking) {
797 return (
798 <div className="space-y-3">
799 {/* Header Skeleton */}
800 <div className="flex items-center justify-between">
801 <div className="space-y-2">
802 <Skeleton className="h-8 w-48" />
803 <Skeleton className="h-4 w-64" />
804 </div>
805 <Skeleton className="h-10 w-24" />
806 </div>
807
808 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
809 {/* Main Form Skeleton */}
810 <div className="lg:col-span-2 space-y-3">
811 {/* Customer Information Card */}
812 <Card>
813 <CardHeader className="pb-2">
814 <Skeleton className="h-5 w-40" />
815 </CardHeader>
816 <CardContent className="space-y-3">
817 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
818 <div className="space-y-1.5">
819 <Skeleton className="h-4 w-24" />
820 <Skeleton className="h-10 w-full" />
821 </div>
822 <div className="space-y-1.5">
823 <Skeleton className="h-4 w-24" />
824 <Skeleton className="h-10 w-full" />
825 </div>
826 </div>
827 <div className="space-y-1.5">
828 <Skeleton className="h-4 w-28" />
829 <Skeleton className="h-10 w-full" />
830 </div>
831 </CardContent>
832 </Card>
833
834 {/* Booking Details Card */}
835 <Card>
836 <CardHeader className="pb-2">
837 <Skeleton className="h-5 w-32" />
838 </CardHeader>
839 <CardContent className="space-y-3">
840 <div className="space-y-1.5">
841 <Skeleton className="h-4 w-16" />
842 <Skeleton className="h-10 w-full" />
843 </div>
844 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
845 <div className="space-y-1.5">
846 <Skeleton className="h-4 w-24" />
847 <Skeleton className="h-10 w-full" />
848 </div>
849 <div className="space-y-1.5">
850 <Skeleton className="h-4 w-20" />
851 <Skeleton className="h-10 w-full" />
852 </div>
853 </div>
854 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
855 <div className="space-y-1.5">
856 <Skeleton className="h-4 w-32" />
857 <Skeleton className="h-10 w-full" />
858 </div>
859 <div className="space-y-1.5">
860 <Skeleton className="h-4 w-24" />
861 <Skeleton className="h-10 w-full" />
862 </div>
863 </div>
864 <div className="space-y-1.5">
865 <Skeleton className="h-4 w-16" />
866 <Skeleton className="h-20 w-full" />
867 </div>
868 </CardContent>
869 </Card>
870 </div>
871
872 {/* Sidebar Skeleton */}
873 <div className="space-y-3">
874 <Card>
875 <CardHeader className="pb-2">
876 <Skeleton className="h-5 w-32" />
877 </CardHeader>
878 <CardContent className="space-y-3">
879 <div className="space-y-1.5">
880 <Skeleton className="h-4 w-24" />
881 <Skeleton className="h-10 w-full" />
882 </div>
883 <div className="space-y-1.5">
884 <Skeleton className="h-4 w-28" />
885 <Skeleton className="h-10 w-full" />
886 </div>
887 <div className="space-y-1.5">
888 <Skeleton className="h-4 w-28" />
889 <Skeleton className="h-10 w-full" />
890 </div>
891 </CardContent>
892 </Card>
893
894 <Card>
895 <CardContent className="p-3">
896 <div className="flex gap-2">
897 <Skeleton className="h-10 flex-1" />
898 <Skeleton className="h-10 w-20" />
899 </div>
900 </CardContent>
901 </Card>
902 </div>
903 </div>
904 </div>
905 );
906 }
907
908 return (
909 <div className="space-y-3">
910 <PageHeader
911 title={
912 isEditMode
913 ? __("Edit Booking", "yatra")
914 : __("Create Booking", "yatra")
915 }
916 description={
917 isEditMode
918 ? __("Update booking information", "yatra")
919 : __("Create a new customer booking", "yatra")
920 }
921 actions={
922 <Button
923 variant="outline"
924 onClick={handleCancel}
925 className="flex items-center gap-2"
926 >
927 <ArrowLeft className="w-4 h-4" />
928 {__("Back", "yatra")}
929 </Button>
930 }
931 />
932
933 <ConditionalRender capability="yatra_edit_bookings">
934 <form onSubmit={handleSubmit}>
935 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
936 {/* Main Form Fields */}
937 <div className="lg:col-span-2 space-y-3">
938 {/* Customer Information */}
939 <Card>
940 <CardHeader className="pb-2">
941 <CardTitle className="text-base">
942 {__("Customer Information", "yatra")}
943 </CardTitle>
944 </CardHeader>
945 <CardContent className="space-y-3">
946 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
947 {/* Customer Name */}
948 <div>
949 <label
950 htmlFor="customer_name"
951 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
952 >
953 {__("Customer Name", "yatra")}{" "}
954 <span className="text-red-500">*</span>
955 </label>
956 <Input
957 id="customer_name"
958 type="text"
959 value={formData.customer_name}
960 onChange={(e) =>
961 handleFieldChange("customer_name", e.target.value)
962 }
963 placeholder={__("Enter customer name", "yatra")}
964 className={errors.customer_name ? "border-red-500" : ""}
965 required
966 />
967 {errors.customer_name && (
968 <p className="mt-1 text-sm text-red-500">
969 {errors.customer_name}
970 </p>
971 )}
972 </div>
973
974 {/* Customer Email */}
975 <div>
976 <label
977 htmlFor="customer_email"
978 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
979 >
980 {__("Customer Email", "yatra")}{" "}
981 <span className="text-red-500">*</span>
982 </label>
983 <Input
984 id="customer_email"
985 type="email"
986 value={formData.customer_email}
987 onChange={(e) =>
988 handleFieldChange("customer_email", e.target.value)
989 }
990 placeholder={__("[email protected]", "yatra")}
991 className={
992 errors.customer_email ? "border-red-500" : ""
993 }
994 required
995 />
996 {errors.customer_email && (
997 <p className="mt-1 text-sm text-red-500">
998 {errors.customer_email}
999 </p>
1000 )}
1001 </div>
1002 </div>
1003
1004 {/* Customer Phone */}
1005 <div>
1006 <label
1007 htmlFor="customer_phone"
1008 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1009 >
1010 {__("Customer Phone", "yatra")}
1011 </label>
1012 <Input
1013 id="customer_phone"
1014 type="tel"
1015 value={formData.customer_phone}
1016 onChange={(e) =>
1017 handleFieldChange("customer_phone", e.target.value)
1018 }
1019 placeholder={__("+1234567890", "yatra")}
1020 />
1021 </div>
1022
1023 {/* Customer Country */}
1024 <div>
1025 <label
1026 htmlFor="customer_country"
1027 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1028 >
1029 {__("Country", "yatra")}
1030 </label>
1031 <SearchableSelect
1032 value={formData.customer_country}
1033 onChange={(v) => handleFieldChange("customer_country", v)}
1034 options={countrySelectOptions}
1035 placeholder={__("Select country", "yatra")}
1036 searchPlaceholder={__("Search country", "yatra")}
1037 />
1038 </div>
1039
1040 {/* Extra / custom contact fields (nationality, address, and
1041 any fields the operator added via the Dynamic Form module). */}
1042 {contactExtraFields.map((field) => (
1043 <div
1044 key={field.id}
1045 className={field.width === "full" ? "md:col-span-2" : ""}
1046 >
1047 <label
1048 htmlFor={`contact-${field.id}`}
1049 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1050 >
1051 {field.label}
1052 {field.required && (
1053 <span className="text-red-500 ml-1">*</span>
1054 )}
1055 </label>
1056 {renderDynamicInput(
1057 field,
1058 contactExtraData[field.id] || "",
1059 (v) => handleContactExtraChange(field.id, v),
1060 "contact",
1061 )}
1062 </div>
1063 ))}
1064 </CardContent>
1065 </Card>
1066
1067 {/* Booking Details */}
1068 <Card>
1069 <CardHeader className="pb-2">
1070 <CardTitle className="text-base">
1071 {__("Booking Details", "yatra")}
1072 </CardTitle>
1073 </CardHeader>
1074 <CardContent className="space-y-3">
1075 {/* Trip Selection - Searchable Dropdown */}
1076 <div ref={tripDropdownRef} className="relative">
1077 <label
1078 htmlFor="trip_search"
1079 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1080 >
1081 {__("Trip", "yatra")}{" "}
1082 <span className="text-red-500">*</span>
1083 </label>
1084 <div className="relative">
1085 <div
1086 className={`flex items-center border rounded-md bg-white dark:bg-gray-800 cursor-pointer ${errors.trip_id ? "border-red-500" : "border-gray-300 dark:border-gray-600"} ${isTripDropdownOpen ? "ring-2 ring-blue-500" : ""}`}
1087 onClick={() =>
1088 setIsTripDropdownOpen(!isTripDropdownOpen)
1089 }
1090 >
1091 <div className="flex-1 px-3 py-2 text-sm">
1092 {formData.trip_id ? (
1093 <span className="text-gray-900 dark:text-white">
1094 {tripsData?.data?.find(
1095 (t: any) => String(t.id) === formData.trip_id,
1096 )?.title ||
1097 bookingData?.trip_title ||
1098 `Trip ID: ${formData.trip_id}`}
1099 </span>
1100 ) : (
1101 <span className="text-gray-500">
1102 {isLoadingTrips
1103 ? __("Loading trips...", "yatra")
1104 : __("Select a trip", "yatra")}
1105 </span>
1106 )}
1107 </div>
1108 {formData.trip_id && (
1109 <button
1110 type="button"
1111 className="p-2 text-gray-400 hover:text-gray-600"
1112 onClick={(e) => {
1113 e.stopPropagation();
1114 handleFieldChange("trip_id", "");
1115 setTripSearchQuery("");
1116 }}
1117 >
1118 <X className="w-4 h-4" />
1119 </button>
1120 )}
1121 <div className="p-2 text-gray-400">
1122 <ChevronDown
1123 className={`w-4 h-4 transition-transform ${isTripDropdownOpen ? "rotate-180" : ""}`}
1124 />
1125 </div>
1126 </div>
1127
1128 {/* Dropdown */}
1129 {isTripDropdownOpen && (
1130 <div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-60 overflow-hidden">
1131 {/* Search Input */}
1132 <div className="p-2 border-b border-gray-200 dark:border-gray-700">
1133 <div className="relative">
1134 <Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
1135 <input
1136 type="text"
1137 className="w-full pl-8 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
1138 placeholder={__("Search trips...", "yatra")}
1139 value={tripSearchQuery}
1140 onChange={(e) =>
1141 setTripSearchQuery(e.target.value)
1142 }
1143 onClick={(e) => e.stopPropagation()}
1144 autoFocus
1145 />
1146 </div>
1147 </div>
1148
1149 {/* Trip List */}
1150 <div className="max-h-48 overflow-y-auto">
1151 {(() => {
1152 // Combine booked trip with trips list
1153 let allTrips = [...(tripsData?.data || [])];
1154
1155 // Add booked trip if not in list
1156 if (
1157 isEditMode &&
1158 bookingData?.trip_id &&
1159 bookingData?.trip_title
1160 ) {
1161 const bookedTripExists = allTrips.some(
1162 (t: any) =>
1163 String(t.id) ===
1164 String(bookingData.trip_id),
1165 );
1166 if (!bookedTripExists) {
1167 allTrips.unshift({
1168 id: bookingData.trip_id,
1169 title: bookingData.trip_title,
1170 price: bookingData.total_amount || 0,
1171 currency: globalCurrency,
1172 status: "publish",
1173 });
1174 }
1175 }
1176
1177 // Filter by search query
1178 const filteredTrips = allTrips.filter(
1179 (trip: any) =>
1180 trip.title
1181 .toLowerCase()
1182 .includes(tripSearchQuery.toLowerCase()) ||
1183 String(trip.id).includes(tripSearchQuery),
1184 );
1185
1186 if (filteredTrips.length === 0) {
1187 return (
1188 <div className="px-3 py-4 text-sm text-gray-500 text-center">
1189 {__("No trips found", "yatra")}
1190 </div>
1191 );
1192 }
1193
1194 return filteredTrips.map((trip: any) => (
1195 <div
1196 key={trip.id}
1197 className={`px-3 py-2 text-sm cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 ${
1198 formData.trip_id === String(trip.id)
1199 ? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400"
1200 : "text-gray-900 dark:text-white"
1201 }`}
1202 onClick={() => {
1203 handleFieldChange(
1204 "trip_id",
1205 String(trip.id),
1206 );
1207 setIsTripDropdownOpen(false);
1208 setTripSearchQuery("");
1209 }}
1210 >
1211 <div className="font-medium">
1212 {trip.title}
1213 </div>
1214 <div className="text-xs text-gray-500 dark:text-gray-400">
1215 ID: {trip.id}
1216 </div>
1217 </div>
1218 ));
1219 })()}
1220 </div>
1221 </div>
1222 )}
1223 </div>
1224 {errors.trip_id && (
1225 <p className="mt-1 text-sm text-red-500">
1226 {errors.trip_id}
1227 </p>
1228 )}
1229 </div>
1230
1231 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
1232 {/* Booking Date */}
1233 <div>
1234 <label
1235 htmlFor="booking_date"
1236 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1237 >
1238 {__("Booking Date", "yatra")}{" "}
1239 <span className="text-red-500">*</span>
1240 </label>
1241 <DatePicker
1242 value={formData.booking_date}
1243 onChange={(value: string) =>
1244 handleFieldChange("booking_date", value)
1245 }
1246 placeholder={__("Select booking date", "yatra")}
1247 error={!!errors.booking_date}
1248 />
1249 {errors.booking_date && (
1250 <p className="mt-1 text-sm text-red-500">
1251 {errors.booking_date}
1252 </p>
1253 )}
1254 </div>
1255
1256 {/* Travel Date */}
1257 <div>
1258 <label
1259 htmlFor="travel_date"
1260 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1261 >
1262 {__("Travel Date", "yatra")}{" "}
1263 <span className="text-red-500">*</span>
1264 </label>
1265 <DatePicker
1266 value={formData.travel_date}
1267 onChange={(value: string) =>
1268 handleFieldChange("travel_date", value)
1269 }
1270 placeholder={__("Select travel date", "yatra")}
1271 error={!!errors.travel_date}
1272 />
1273 {errors.travel_date && (
1274 <p className="mt-1 text-sm text-red-500">
1275 {errors.travel_date}
1276 </p>
1277 )}
1278 </div>
1279 </div>
1280
1281 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
1282 {/* Number of Travelers */}
1283 <div>
1284 <label
1285 htmlFor="travelers"
1286 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1287 >
1288 {__("Number of Travelers", "yatra")}{" "}
1289 <span className="text-red-500">*</span>
1290 </label>
1291 <Input
1292 id="travelers"
1293 type="number"
1294 min="1"
1295 value={formData.travelers}
1296 onChange={(e) =>
1297 handleFieldChange("travelers", e.target.value)
1298 }
1299 className={errors.travelers ? "border-red-500" : ""}
1300 required
1301 />
1302 {errors.travelers && (
1303 <p className="mt-1 text-sm text-red-500">
1304 {errors.travelers}
1305 </p>
1306 )}
1307 </div>
1308
1309 {/* Tax Breakdown */}
1310 <div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-3 border border-gray-200 dark:border-gray-600">
1311 <h4 className="font-medium text-gray-900 dark:text-white mb-2 text-sm">
1312 {__("Pricing Breakdown", "yatra")}
1313 </h4>
1314 <div className="space-y-2 text-sm">
1315 {formData.subtotal ? (
1316 <>
1317 <div className="flex justify-between">
1318 <span className="text-gray-600 dark:text-gray-400">
1319 {__("Subtotal", "yatra")}
1320 </span>
1321 <span className="font-medium text-gray-900 dark:text-white">
1322 {currencySymbol}
1323 {parseFloat(formData.subtotal).toFixed(2)}
1324 </span>
1325 </div>
1326 {/* Itinerary Costs */}
1327 {(bookingData as any)?.itinerary_costs_total >
1328 0 && (
1329 <div className="flex justify-between">
1330 <span className="text-gray-600 dark:text-gray-400">
1331 {__("Itinerary Costs", "yatra")}
1332 </span>
1333 <span className="font-medium text-gray-900 dark:text-white">
1334 {getCurrencySymbol(
1335 formData.currency || globalCurrency,
1336 )}
1337 {(
1338 bookingData as any
1339 ).itinerary_costs_total.toFixed(2)}
1340 </span>
1341 </div>
1342 )}
1343 {parseFloat(formData.tax_amount) > 0 && (
1344 <div className="flex justify-between">
1345 <span className="text-gray-600 dark:text-gray-400">
1346 {__("Tax", "yatra")}
1347 </span>
1348 <span className="font-medium text-gray-900 dark:text-white">
1349 {currencySymbol}
1350 {parseFloat(formData.tax_amount).toFixed(2)}
1351 </span>
1352 </div>
1353 )}
1354 <div className="border-t pt-2 flex justify-between font-semibold">
1355 <span className="text-gray-900 dark:text-white">
1356 {__("Total", "yatra")}
1357 </span>
1358 <span className="text-gray-900 dark:text-white">
1359 {getCurrencySymbol(
1360 formData.currency || globalCurrency,
1361 )}
1362 {formData.total_amount}
1363 </span>
1364 </div>
1365 </>
1366 ) : (
1367 <div className="text-center text-gray-500 dark:text-gray-400 py-2">
1368 {__(
1369 "Select a trip and number of travelers to see pricing",
1370 "yatra",
1371 )}
1372 </div>
1373 )}
1374 </div>
1375 </div>
1376
1377 {/* Total Amount */}
1378 <div>
1379 <label
1380 htmlFor="total_amount"
1381 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1382 >
1383 {__("Total Amount", "yatra")} (
1384 {formData.currency || globalCurrency}){" "}
1385 <span className="text-red-500">*</span>
1386 </label>
1387 <div className="relative">
1388 <span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500">
1389 {getCurrencySymbol(
1390 formData.currency || globalCurrency,
1391 )}
1392 </span>
1393 <Input
1394 id="total_amount"
1395 type="number"
1396 step="0.01"
1397 min="0"
1398 value={formData.total_amount}
1399 onChange={(e) =>
1400 handleFieldChange("total_amount", e.target.value)
1401 }
1402 placeholder="0.00"
1403 className={`pl-7 ${errors.total_amount ? "border-red-500" : ""}`}
1404 required
1405 />
1406 </div>
1407 {errors.total_amount && (
1408 <p className="mt-1 text-sm text-red-500">
1409 {errors.total_amount}
1410 </p>
1411 )}
1412 </div>
1413 </div>
1414
1415 {/* Notes */}
1416 <div>
1417 <label
1418 htmlFor="notes"
1419 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1420 >
1421 {__("Notes", "yatra")}
1422 </label>
1423 <textarea
1424 id="notes"
1425 value={formData.notes}
1426 onChange={(e) =>
1427 handleFieldChange("notes", e.target.value)
1428 }
1429 placeholder={__(
1430 "Additional notes or special requests",
1431 "yatra",
1432 )}
1433 rows={3}
1434 className="flex w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium 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"
1435 />
1436 </div>
1437 </CardContent>
1438 </Card>
1439
1440 {/* Emergency Contact - Dynamic Fields */}
1441 {formConfig?.emergency_contact_form?.enabled !== false &&
1442 emergencyContactFields.length > 0 && (
1443 <Card>
1444 <CardHeader className="pb-2">
1445 <CardTitle className="text-base flex items-center gap-2">
1446 <AlertCircle className="w-4 h-4" />
1447 {formConfig?.emergency_contact_form?.title ||
1448 __("Emergency Contact", "yatra")}
1449 </CardTitle>
1450 {formConfig?.emergency_contact_form?.description && (
1451 <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
1452 {formConfig.emergency_contact_form.description}
1453 </p>
1454 )}
1455 </CardHeader>
1456 <CardContent>
1457 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
1458 {emergencyContactFields.map((field) => (
1459 <div
1460 key={field.id}
1461 className={
1462 field.width === "full" ? "md:col-span-2" : ""
1463 }
1464 >
1465 <label
1466 htmlFor={`emergency-${field.id}`}
1467 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
1468 >
1469 {field.label}
1470 {field.required && (
1471 <span className="text-red-500 ml-1">*</span>
1472 )}
1473 </label>
1474
1475 {/* Render field based on type */}
1476 {field.type === "select" ? (
1477 <Select
1478 id={`emergency-${field.id}`}
1479 value={emergencyContactData[field.id] || ""}
1480 onChange={(e) =>
1481 handleEmergencyContactChange(
1482 field.id,
1483 e.target.value,
1484 )
1485 }
1486 >
1487 <option value="">
1488 {field.placeholder || `Select ${field.label}`}
1489 </option>
1490 {field.options?.map((opt) => (
1491 <option key={opt.value} value={opt.value}>
1492 {opt.label}
1493 </option>
1494 ))}
1495 </Select>
1496 ) : field.type === "country" ? (
1497 <SearchableSelect
1498 value={emergencyContactData[field.id] || ""}
1499 onChange={(v) =>
1500 handleEmergencyContactChange(field.id, v)
1501 }
1502 options={countrySelectOptions}
1503 placeholder={
1504 field.placeholder ||
1505 __("Select Country", "yatra")
1506 }
1507 searchPlaceholder={__("Search country", "yatra")}
1508 />
1509 ) : field.type === "textarea" ? (
1510 <textarea
1511 id={`emergency-${field.id}`}
1512 value={emergencyContactData[field.id] || ""}
1513 onChange={(e) =>
1514 handleEmergencyContactChange(
1515 field.id,
1516 e.target.value,
1517 )
1518 }
1519 placeholder={field.placeholder}
1520 rows={2}
1521 className="flex w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:placeholder:text-gray-400 resize-none"
1522 />
1523 ) : field.type === "checkbox" ? (
1524 <label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
1525 <input
1526 type="checkbox"
1527 className="rounded border-gray-300"
1528 checked={
1529 emergencyContactData[field.id] === "1" ||
1530 emergencyContactData[field.id] === "true"
1531 }
1532 onChange={(e) =>
1533 handleEmergencyContactChange(
1534 field.id,
1535 e.target.checked ? "1" : "",
1536 )
1537 }
1538 />
1539 <span>{field.placeholder || field.label}</span>
1540 </label>
1541 ) : (
1542 <Input
1543 id={`emergency-${field.id}`}
1544 type={
1545 field.type === "email"
1546 ? "email"
1547 : field.type === "tel"
1548 ? "tel"
1549 : field.type === "date"
1550 ? "date"
1551 : field.type === "number"
1552 ? "number"
1553 : "text"
1554 }
1555 value={emergencyContactData[field.id] || ""}
1556 onChange={(e) =>
1557 handleEmergencyContactChange(
1558 field.id,
1559 e.target.value,
1560 )
1561 }
1562 placeholder={field.placeholder}
1563 />
1564 )}
1565 </div>
1566 ))}
1567 </div>
1568 </CardContent>
1569 </Card>
1570 )}
1571
1572 {/* Travelers Information - Dynamic Fields */}
1573 {formConfig?.traveler_form?.enabled !== false &&
1574 travelerFields.length > 0 && (
1575 <Card>
1576 <CardHeader className="pb-2">
1577 <div className="flex items-center justify-between">
1578 <CardTitle className="text-base flex items-center gap-2">
1579 <Users className="w-4 h-4" />
1580 {formConfig?.traveler_form?.title ||
1581 __("Travelers Information", "yatra")}
1582 <span className="text-xs font-normal text-gray-500 dark:text-gray-400">
1583 ({travelersData.length}{" "}
1584 {travelersData.length === 1
1585 ? __("traveler", "yatra")
1586 : __("travelers", "yatra")}
1587 )
1588 </span>
1589 </CardTitle>
1590 <div className="flex items-center gap-2">
1591 {travelersData.length > 1 && (
1592 <Button
1593 type="button"
1594 variant="ghost"
1595 size="sm"
1596 onClick={() =>
1597 setExpandedTravelers(
1598 expandedTravelers.length ===
1599 travelersData.length
1600 ? []
1601 : travelersData.map((_, i) => i),
1602 )
1603 }
1604 className="text-gray-500 text-xs"
1605 >
1606 {expandedTravelers.length === travelersData.length
1607 ? __("Collapse All", "yatra")
1608 : __("Expand All", "yatra")}
1609 </Button>
1610 )}
1611 <Button
1612 type="button"
1613 variant="outline"
1614 size="sm"
1615 onClick={addTraveler}
1616 className="flex items-center gap-1"
1617 >
1618 <Plus className="w-3 h-3" />
1619 {__("Add Traveler", "yatra")}
1620 </Button>
1621 </div>
1622 </div>
1623 {formConfig?.traveler_form?.description && (
1624 <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
1625 {formConfig.traveler_form.description}
1626 </p>
1627 )}
1628 </CardHeader>
1629 <CardContent className="space-y-3">
1630 {travelersData.map((traveler, travelerIndex) => (
1631 <div
1632 key={travelerIndex}
1633 className={`border rounded-lg ${travelerIndex === 0 ? "border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-900/10" : "border-gray-200 dark:border-gray-700"}`}
1634 >
1635 {/* Traveler Header - Collapsible */}
1636 <div
1637 className="flex items-center justify-between p-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-t-lg"
1638 onClick={() =>
1639 toggleTravelerExpanded(travelerIndex)
1640 }
1641 >
1642 <div className="flex items-center gap-2 flex-1 min-w-0">
1643 <span className="font-medium text-sm text-gray-900 dark:text-white whitespace-nowrap">
1644 {travelerIndex === 0
1645 ? __("Lead Traveler", "yatra")
1646 : `${__("Traveler", "yatra")} ${travelerIndex + 1}`}
1647 </span>
1648 {traveler.first_name || traveler.last_name ? (
1649 <span className="text-sm text-gray-500 dark:text-gray-400 truncate">
1650 -{" "}
1651 {[traveler.first_name, traveler.last_name]
1652 .filter(Boolean)
1653 .join(" ")}
1654 </span>
1655 ) : null}
1656 {travelerIndex === 0 && (
1657 <span className="text-xs bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 px-2 py-0.5 rounded whitespace-nowrap">
1658 {__("Primary", "yatra")}
1659 </span>
1660 )}
1661 </div>
1662 <div className="flex items-center gap-1 ml-2">
1663 {/* Remove button - show for all travelers if more than 1 exists */}
1664 {travelersData.length > 1 && (
1665 <Button
1666 type="button"
1667 variant="ghost"
1668 size="sm"
1669 onClick={(e) => {
1670 e.stopPropagation();
1671 removeTraveler(travelerIndex);
1672 }}
1673 className="text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 h-8 px-2 flex items-center gap-1"
1674 title={__("Remove Traveler", "yatra")}
1675 >
1676 <Trash2 className="w-4 h-4" />
1677 <span className="text-xs hidden sm:inline">
1678 {__("Remove", "yatra")}
1679 </span>
1680 </Button>
1681 )}
1682 {/* Expand/Collapse toggle */}
1683 <div className="w-6 h-6 flex items-center justify-center">
1684 {expandedTravelers.includes(travelerIndex) ? (
1685 <ChevronUp className="w-4 h-4 text-gray-500" />
1686 ) : (
1687 <ChevronDown className="w-4 h-4 text-gray-500" />
1688 )}
1689 </div>
1690 </div>
1691 </div>
1692
1693 {/* Traveler Fields - Collapsible Content */}
1694 {expandedTravelers.includes(travelerIndex) && (
1695 <div className="p-3 pt-0 border-t border-gray-100 dark:border-gray-700/50">
1696 <div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
1697 {travelerFields
1698 .filter(
1699 (field) =>
1700 (field.applies_to ?? "all") !== "lead" ||
1701 travelerIndex === 0,
1702 )
1703 .map((field) => (
1704 <div
1705 key={field.id}
1706 className={
1707 field.width === "full"
1708 ? "md:col-span-2"
1709 : ""
1710 }
1711 >
1712 <label
1713 htmlFor={`traveler-${travelerIndex}-${field.id}`}
1714 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
1715 >
1716 {field.label}
1717 {field.required && (
1718 <span className="text-red-500 ml-1">
1719 *
1720 </span>
1721 )}
1722 </label>
1723
1724 {/* Render field based on type */}
1725 {field.type === "select" ? (
1726 <Select
1727 id={`traveler-${travelerIndex}-${field.id}`}
1728 value={traveler[field.id] || ""}
1729 onChange={(e) =>
1730 handleTravelerChange(
1731 travelerIndex,
1732 field.id,
1733 e.target.value,
1734 )
1735 }
1736 >
1737 <option value="">
1738 {field.placeholder ||
1739 `Select ${field.label}`}
1740 </option>
1741 {field.options?.map((opt) => (
1742 <option
1743 key={opt.value}
1744 value={opt.value}
1745 >
1746 {opt.label}
1747 </option>
1748 ))}
1749 </Select>
1750 ) : field.type === "country" ? (
1751 <SearchableSelect
1752 value={traveler[field.id] || ""}
1753 onChange={(v) =>
1754 handleTravelerChange(
1755 travelerIndex,
1756 field.id,
1757 v,
1758 )
1759 }
1760 options={countrySelectOptions}
1761 placeholder={
1762 field.placeholder ||
1763 __("Select Country", "yatra")
1764 }
1765 searchPlaceholder={__(
1766 "Search country",
1767 "yatra",
1768 )}
1769 />
1770 ) : field.type === "textarea" ? (
1771 <textarea
1772 id={`traveler-${travelerIndex}-${field.id}`}
1773 value={traveler[field.id] || ""}
1774 onChange={(e) =>
1775 handleTravelerChange(
1776 travelerIndex,
1777 field.id,
1778 e.target.value,
1779 )
1780 }
1781 placeholder={field.placeholder}
1782 rows={2}
1783 className="flex w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:placeholder:text-gray-400 resize-none"
1784 />
1785 ) : field.type === "checkbox" ? (
1786 <label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
1787 <input
1788 type="checkbox"
1789 className="rounded border-gray-300"
1790 checked={
1791 traveler[field.id] === "1" ||
1792 traveler[field.id] === "true"
1793 }
1794 onChange={(e) =>
1795 handleTravelerChange(
1796 travelerIndex,
1797 field.id,
1798 e.target.checked ? "1" : "",
1799 )
1800 }
1801 />
1802 <span>
1803 {field.placeholder || field.label}
1804 </span>
1805 </label>
1806 ) : field.type === "date" ||
1807 field.id
1808 .toLowerCase()
1809 .includes("date") ||
1810 field.id
1811 .toLowerCase()
1812 .includes("expiry") ? (
1813 <DatePicker
1814 value={traveler[field.id] || ""}
1815 onChange={(value: string) =>
1816 handleTravelerChange(
1817 travelerIndex,
1818 field.id,
1819 value,
1820 )
1821 }
1822 placeholder={
1823 field.placeholder ||
1824 __("Select date", "yatra")
1825 }
1826 />
1827 ) : (
1828 <Input
1829 id={`traveler-${travelerIndex}-${field.id}`}
1830 type={
1831 field.type === "email"
1832 ? "email"
1833 : field.type === "tel"
1834 ? "tel"
1835 : field.type === "number"
1836 ? "number"
1837 : "text"
1838 }
1839 value={traveler[field.id] || ""}
1840 onChange={(e) =>
1841 handleTravelerChange(
1842 travelerIndex,
1843 field.id,
1844 e.target.value,
1845 )
1846 }
1847 placeholder={field.placeholder}
1848 />
1849 )}
1850 </div>
1851 ))}
1852 </div>
1853 </div>
1854 )}
1855 </div>
1856 ))}
1857 </CardContent>
1858 </Card>
1859 )}
1860 </div>
1861
1862 {/* Sidebar */}
1863 <div className="space-y-3">
1864 {/* Status & Payment */}
1865 <Card>
1866 <CardHeader className="pb-2">
1867 <CardTitle className="text-base">
1868 {__("Status & Payment", "yatra")}
1869 </CardTitle>
1870 </CardHeader>
1871 <CardContent className="space-y-3">
1872 {/* Booking Status */}
1873 <div>
1874 <label
1875 htmlFor="booking_status"
1876 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1877 >
1878 {__("Booking Status", "yatra")}
1879 </label>
1880 <Select
1881 id="booking_status"
1882 value={formData.booking_status}
1883 onChange={(e) =>
1884 handleFieldChange("booking_status", e.target.value)
1885 }
1886 >
1887 <option value="pending">{__("Pending", "yatra")}</option>
1888 <option value="confirmed">
1889 {__("Confirmed", "yatra")}
1890 </option>
1891 <option value="cancelled">
1892 {__("Cancelled", "yatra")}
1893 </option>
1894 <option value="completed">
1895 {__("Completed", "yatra")}
1896 </option>
1897 <option value="refunded">
1898 {__("Refunded", "yatra")}
1899 </option>
1900 <option value="failed">{__("Failed", "yatra")}</option>
1901 </Select>
1902 </div>
1903
1904 {/* Payment Status */}
1905 <div>
1906 <label
1907 htmlFor="payment_status"
1908 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1909 >
1910 {__("Payment Status", "yatra")}
1911 </label>
1912 <Select
1913 id="payment_status"
1914 value={formData.payment_status}
1915 onChange={(e) =>
1916 handleFieldChange("payment_status", e.target.value)
1917 }
1918 >
1919 <option value="pending">{__("Pending", "yatra")}</option>
1920 <option value="partial">{__("Partial", "yatra")}</option>
1921 <option value="paid">{__("Paid", "yatra")}</option>
1922 <option value="refunded">
1923 {__("Refunded", "yatra")}
1924 </option>
1925 </Select>
1926 </div>
1927
1928 {/* Payment Method */}
1929 <div>
1930 <label
1931 htmlFor="payment_method"
1932 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1933 >
1934 {__("Payment Method", "yatra")}
1935 </label>
1936 <Select
1937 id="payment_method"
1938 value={formData.payment_method}
1939 onChange={(e) =>
1940 handleFieldChange("payment_method", e.target.value)
1941 }
1942 >
1943 <option value="">
1944 {__("Select payment method", "yatra")}
1945 </option>
1946 {gatewaysData?.data?.map((gw: any) => (
1947 <option key={gw.id} value={gw.id}>
1948 {gw.title}
1949 </option>
1950 ))}
1951 {/* Fallback options if no gateways loaded */}
1952 {(!gatewaysData?.data ||
1953 gatewaysData.data.length === 0) && (
1954 <>
1955 <option value="pay_later">
1956 {__("Pay Later", "yatra")}
1957 </option>
1958 <option value="bank_transfer">
1959 {__("Bank Transfer", "yatra")}
1960 </option>
1961 <option value="stripe">
1962 {__("Stripe", "yatra")}
1963 </option>
1964 <option value="paypal">
1965 {__("PayPal", "yatra")}
1966 </option>
1967 </>
1968 )}
1969 </Select>
1970 </div>
1971 </CardContent>
1972 </Card>
1973
1974 {/* Submit Actions */}
1975 <Card>
1976 <CardContent className="p-3">
1977 <div className="space-y-2">
1978 {errors.submit && (
1979 <div className="p-2 text-sm text-red-500 bg-red-50 dark:bg-red-900/20 rounded-md">
1980 {errors.submit}
1981 </div>
1982 )}
1983 <div className="flex gap-2">
1984 <Button
1985 type="submit"
1986 disabled={isSubmitting}
1987 className="flex-1 flex items-center justify-center gap-2"
1988 >
1989 {isSubmitting ? (
1990 <>
1991 <Loader2 className="w-4 h-4 animate-spin" />
1992 {__("Saving...", "yatra")}
1993 </>
1994 ) : (
1995 <>
1996 <Save className="w-4 h-4" />
1997 {isEditMode
1998 ? __("Update Booking", "yatra")
1999 : __("Create Booking", "yatra")}
2000 </>
2001 )}
2002 </Button>
2003 <Button
2004 type="button"
2005 variant="outline"
2006 onClick={handleCancel}
2007 disabled={isSubmitting}
2008 >
2009 {__("Cancel", "yatra")}
2010 </Button>
2011 </div>
2012 </div>
2013 </CardContent>
2014 </Card>
2015 </div>
2016 </div>
2017 </form>
2018 </ConditionalRender>
2019 </div>
2020 );
2021 };
2022
2023 export default BookingForm;
2024