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

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