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

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