PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.7
Yatra – Travel Booking & Tour Operator Software v3.0.7
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.7, at resources/js/pages/BookingForm.tsx

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