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

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