PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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 2.0.10 All 83 releases
yatra / resources / js / pages / BookingForm.tsx

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

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