PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / resources / js / pages / account / BookingDetails.tsx

BookingDetails.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.13, at resources/js/pages/account/BookingDetails.tsx

1,046 lines 43.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React from "react";
2 import {
3 Calendar as CalendarIcon,
4 Users,
5 DollarSign as DollarSignIcon,
6 CreditCard,
7 Mail,
8 Phone as PhoneIcon,
9 FileText as FileTextIcon,
10 AlertCircle,
11 ArrowRight,
12 ExternalLink,
13 Download,
14 } from "lucide-react";
15 import { __, sprintf } from "../../lib/i18n";
16 import {
17 formatDate,
18 formatTravelDateRange,
19 getBadge,
20 getStatusLabel,
21 formatPriceForBooking,
22 getYatraAccountPageGlobals,
23 } from "./utils";
24 import TaxDisplay from "../../components/booking/TaxDisplay";
25
26 const getCountryName = (code: string): string => {
27 if (!code || typeof code !== "string") {
28 return "";
29 }
30 const upperCode = code.trim().toUpperCase();
31 if (upperCode.length !== 2) {
32 return code;
33 }
34 try {
35 const locale = getYatraAccountPageGlobals().locale;
36 const tag = locale ? locale.replace(/_/g, "-") : undefined;
37 const display = new Intl.DisplayNames(tag ? [tag] : undefined, {
38 type: "region",
39 });
40 const name = display.of(upperCode);
41 return name && name !== upperCode ? name : code;
42 } catch {
43 return code;
44 }
45 };
46
47 /**
48 * Human, translatable label for a traveler / contact / emergency field.
49 *
50 * Traveler data is stored keyed by field id (e.g. "date_of_birth"), so the
51 * account page previously derived labels by de-snake-casing the id. That left
52 * them permanently English. Map the known standard fields to translatable
53 * strings (resolved via the account-page translations catalog) and fall back
54 * to the prettified id for any custom field an operator may have added.
55 *
56 * Keep the English source strings here in sync with the `getFrontendTranslations()`
57 * map in FrontendAssetsProvider so they resolve on the account page.
58 */
59 // Built once on first use (lazily, so __() runs after the account-page
60 // translations catalog is in place), then cached for every subsequent field.
61 let knownFieldLabels: Record<string, string> | null = null;
62 const getKnownFieldLabels = (): Record<string, string> => {
63 if (knownFieldLabels === null) {
64 knownFieldLabels = {
65 first_name: __("First Name", "yatra"),
66 last_name: __("Last Name", "yatra"),
67 full_name: __("Full Name", "yatra"),
68 name: __("Name", "yatra"),
69 email: __("Email", "yatra"),
70 phone: __("Phone", "yatra"),
71 mobile: __("Mobile", "yatra"),
72 date_of_birth: __("Date of Birth", "yatra"),
73 gender: __("Gender", "yatra"),
74 nationality: __("Nationality", "yatra"),
75 country: __("Country", "yatra"),
76 address: __("Address", "yatra"),
77 city: __("City", "yatra"),
78 state: __("State", "yatra"),
79 postal_code: __("Postal Code", "yatra"),
80 zip_code: __("Zip Code", "yatra"),
81 passport: __("Passport", "yatra"),
82 passport_number: __("Passport Number", "yatra"),
83 passport_expiry: __("Passport Expiry", "yatra"),
84 dietary_requirements: __("Dietary Requirements", "yatra"),
85 special_requirements: __("Special Requirements", "yatra"),
86 relationship: __("Relationship", "yatra"),
87 company: __("Company", "yatra"),
88 };
89 }
90 return knownFieldLabels;
91 };
92
93 const fieldLabel = (fieldId: string): string => {
94 const known = getKnownFieldLabels();
95 if (Object.prototype.hasOwnProperty.call(known, fieldId)) {
96 return known[fieldId];
97 }
98 return fieldId.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
99 };
100
101 interface BookingDetailsData {
102 id: number;
103 booking_number: string;
104 customer_name: string;
105 customer_email?: string;
106 customer_phone?: string;
107 customer_country?: string;
108 trip_id: number;
109 trip_title: string;
110 trip_url?: string;
111 trip_image?: string;
112 trip_price: number;
113 booking_date: string;
114 travel_date: string;
115 end_date?: string | null;
116 start_date?: string | null;
117 travelers: number;
118 travelers_data?: any[];
119 total_amount: number;
120 amount_paid: number;
121 amount_due: number;
122 currency: string;
123 discount_amount?: number;
124 payment_status: string;
125 booking_status: string;
126 payment_method?: string;
127 payment_date?: string;
128 notes?: string;
129 emergency_contact?: any;
130 contact_data?: any;
131 payments?: any[];
132 itinerary_costs?: Array<{
133 id: number;
134 name: string;
135 description?: string;
136 price: number;
137 price_per: string;
138 total_cost: number;
139 cost_per_person: boolean;
140 item_type?: string;
141 day_number?: number;
142 }>;
143 itinerary_costs_total?: number;
144 downloads?: Array<{
145 id: string;
146 title: string;
147 url: string;
148 visibility?: string;
149 access_label?: string;
150 locked?: boolean;
151 locked_reason?: string;
152 }>;
153 created_at: string;
154 updated_at?: string;
155 }
156
157 interface BookingDetailsProps {
158 booking: BookingDetailsData | null;
159 isLoading: boolean;
160 onBack: () => void;
161 }
162
163 const BookingDetails: React.FC<BookingDetailsProps> = ({
164 booking,
165 isLoading,
166 onBack,
167 }) => {
168 if (isLoading) {
169 return (
170 <div className="space-y-6">
171 <div className="flex items-center justify-between">
172 <div>
173 <h2 className="text-2xl font-bold text-gray-900 dark:text-white">
174 {__("Booking Details", "yatra")}
175 </h2>
176 </div>
177 <div
178 role="button"
179 tabIndex={0}
180 onClick={onBack}
181 className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
182 >
183 <ArrowRight className="w-4 h-4 rotate-180" />
184 {__("Back to Bookings", "yatra")}
185 </div>
186 </div>
187 <div className="bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm p-6">
188 <div className="animate-pulse space-y-4">
189 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
190 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
191 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-2/3"></div>
192 </div>
193 </div>
194 </div>
195 );
196 }
197
198 if (!booking) {
199 return (
200 <div className="space-y-6">
201 <div className="flex items-center justify-between">
202 <div>
203 <h2 className="text-2xl font-bold text-gray-900 dark:text-white">
204 {__("Booking Details", "yatra")}
205 </h2>
206 </div>
207 <div
208 role="button"
209 tabIndex={0}
210 onClick={onBack}
211 className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
212 >
213 <ArrowRight className="w-4 h-4 rotate-180" />
214 {__("Back to Bookings", "yatra")}
215 </div>
216 </div>
217 <div className="bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm p-6 text-center text-red-500">
218 {__("Booking not found or error loading booking", "yatra")}
219 </div>
220 </div>
221 );
222 }
223
224 const normalizeRecord = (value: any): Record<string, any> | null => {
225 if (!value) return null;
226 if (typeof value === "string") {
227 try {
228 const parsed = JSON.parse(value);
229 if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
230 return parsed as Record<string, any>;
231 } catch {
232 return null;
233 }
234 return null;
235 }
236 if (typeof value === "object" && !Array.isArray(value)) {
237 return value as Record<string, any>;
238 }
239 return null;
240 };
241
242 const emergencyContact = normalizeRecord((booking as any).emergency_contact);
243 const contactData = normalizeRecord((booking as any).contact_data);
244
245 return (
246 <div className="space-y-6">
247 <div className="flex items-center justify-between">
248 <div>
249 <h2 className="text-2xl font-bold text-gray-900 dark:text-white">
250 {__("Booking Details", "yatra")}
251 </h2>
252 <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
253 {__("Complete booking information", "yatra")}
254 </p>
255 </div>
256 <div
257 role="button"
258 tabIndex={0}
259 onClick={onBack}
260 className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
261 >
262 <ArrowRight className="w-4 h-4 rotate-180" />
263 {__("Back to Bookings", "yatra")}
264 </div>
265 </div>
266
267 <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
268 {/* Main Content */}
269 <div className="lg:col-span-2 space-y-6">
270 {/* Booking Overview.
271 The header used to render two bare status badges side-by-side
272 — `booking_status` and `payment_status` — which both read
273 "Pending" with no label, so the user couldn't tell which was
274 which. Both statuses now live in the right sidebar with
275 explicit "Booking Status" / "Payment Status" labels (see
276 the Payment Information card below), so we drop the
277 ambiguous badges from this header. */}
278 <div className="bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm p-6">
279 <div className="flex items-center justify-between mb-4">
280 <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
281 {__("Booking Overview", "yatra")}
282 </h3>
283 </div>
284 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
285 <div>
286 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
287 {__("Booking Number", "yatra")}
288 </div>
289 <div className="text-lg font-semibold text-gray-900 dark:text-white">
290 {booking.booking_number || `#${booking.id}`}
291 </div>
292 </div>
293 <div>
294 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
295 {__("Trip", "yatra")}
296 </div>
297 <div className="text-lg font-semibold text-gray-900 dark:text-white">
298 {booking.trip_title}
299 </div>
300 {booking.trip_url ? (
301 <a
302 href={booking.trip_url}
303 className="mt-2 inline-flex items-center gap-2 text-sm text-yatra-primary dark:text-yatra-on-dark hover:underline"
304 >
305 <ExternalLink className="w-4 h-4" />
306 {__("View Trip", "yatra")}
307 </a>
308 ) : null}
309 </div>
310 <div>
311 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1 flex items-center gap-1">
312 <CalendarIcon className="w-3 h-3" />
313 {__("Booking Date", "yatra")}
314 </div>
315 <div className="text-sm text-gray-900 dark:text-white">
316 {formatDate(booking.booking_date || booking.created_at)}
317 </div>
318 </div>
319 <div>
320 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1 flex items-center gap-1">
321 <CalendarIcon className="w-3 h-3" />
322 {__("Travel Date", "yatra")}
323 </div>
324 <div className="text-sm text-gray-900 dark:text-white">
325 {formatTravelDateRange(booking.travel_date, booking.end_date)}
326 </div>
327 </div>
328 <div>
329 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1 flex items-center gap-1">
330 <Users className="w-3 h-3" />
331 {__("Number of Travelers", "yatra")}
332 </div>
333 <div className="text-sm text-gray-900 dark:text-white">
334 {(() => {
335 // The detail endpoint REPLACES `travelers` (which the
336 // list endpoint sent as an integer count) with an array
337 // of full traveler objects from getBookingTravelers().
338 // Always read `travelers_count` — it's preserved as an
339 // integer in both list and detail shapes, and falling
340 // back to `travelers.length` covers the array case if
341 // the count was missing from the payload.
342 const t: any = (booking as any).travelers;
343 const n =
344 Number(
345 (booking as any).travelers_count ??
346 (Array.isArray(t) ? t.length : t) ??
347 0,
348 ) || 0;
349 // Two __() strings instead of _n(): wp-cli make-pot's
350 // scan of the MINIFIED account-page bundle extracts __()
351 // calls but misses _n(), so a plural here never gets an
352 // account-page.js catalog entry and stays English on the
353 // frontend. Mirrors the traveler-list label below.
354 return n === 1
355 ? sprintf(__("%d Traveler", "yatra"), n)
356 : sprintf(__("%d Travelers", "yatra"), n);
357 })()}
358 </div>
359 </div>
360 </div>
361
362 {/* Payment Summary with Gross/Net and Tax Breakdown */}
363 <div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
364 <h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
365 {__("Payment Summary", "yatra")}
366 </h4>
367 <div className="space-y-2">
368 {/* Gross Total */}
369 <div className="flex justify-between text-sm">
370 <span className="text-gray-600 dark:text-gray-400">
371 {__("Gross Total", "yatra")}
372 </span>
373 <span className="text-gray-900 dark:text-white">
374 {formatPriceForBooking(
375 (booking as any).subtotal || booking.total_amount,
376 booking.currency,
377 )}
378 </span>
379 </div>
380
381 {/* Taxable Amount (SubTotal) */}
382 {typeof (booking as any).taxable_amount === "number" &&
383 (booking as any).taxable_amount > 0 &&
384 ((booking as any).tax_amount > 0 ||
385 (booking as any).tax_breakdown?.length > 0) && (
386 <div className="flex justify-between text-sm font-medium">
387 <span className="text-gray-600 dark:text-gray-400">
388 {__("Subtotal (Taxable Amount)", "yatra")}
389 </span>
390 <span className="text-gray-900 dark:text-white">
391 {formatPriceForBooking(
392 (booking as any).taxable_amount,
393 booking.currency,
394 )}
395 </span>
396 </div>
397 )}
398
399 {/* Discount if any */}
400 {booking.discount_amount && booking.discount_amount > 0 && (
401 <div className="flex justify-between text-sm">
402 <span className="text-gray-600 dark:text-gray-400">
403 {__("Discount", "yatra")}
404 </span>
405 <span className="text-green-600 dark:text-green-400">
406 -
407 {formatPriceForBooking(
408 booking.discount_amount,
409 booking.currency,
410 )}
411 </span>
412 </div>
413 )}
414
415 {/* Itinerary Costs */}
416 {booking.itinerary_costs &&
417 booking.itinerary_costs.length > 0 && (
418 <>
419 <div className="flex justify-between text-sm">
420 <span className="text-gray-600 dark:text-gray-400 font-medium">
421 {__("Itinerary Costs", "yatra")}
422 </span>
423 </div>
424 {booking.itinerary_costs.map((cost, index) => (
425 <div
426 key={index}
427 className="flex justify-between text-sm ml-4"
428 >
429 <span className="text-gray-600 dark:text-gray-400">
430 {cost.name}
431 {cost.price_per === "person" && (
432 <span className="text-xs text-gray-500 dark:text-gray-500 ml-1">
433 ({__("per person", "yatra")})
434 </span>
435 )}
436 {cost.price_per === "group" && (
437 <span className="text-xs text-gray-500 dark:text-gray-500 ml-1">
438 ({__("per booking", "yatra")})
439 </span>
440 )}
441 </span>
442 <span className="text-gray-900 dark:text-white">
443 {formatPriceForBooking(
444 cost.total_cost,
445 booking.currency,
446 )}
447 </span>
448 </div>
449 ))}
450 </>
451 )}
452
453 {/* Tax Breakdown */}
454 {(booking as any).tax_amount > 0 && (
455 <>
456 {(() => {
457 // Use tax_breakdown if available (already parsed by backend)
458 const taxBreakdown = (booking as any).tax_breakdown;
459
460 if (
461 Array.isArray(taxBreakdown) &&
462 taxBreakdown.length > 0
463 ) {
464 return taxBreakdown.map((tax: any, index: number) => (
465 <div
466 key={index}
467 className="flex justify-between text-sm"
468 >
469 <span className="text-gray-600 dark:text-gray-400">
470 {tax.name} ({tax.rate}%)
471 </span>
472 <span className="text-gray-900 dark:text-white">
473 {formatPriceForBooking(
474 tax.amount,
475 booking.currency,
476 )}
477 </span>
478 </div>
479 ));
480 }
481
482 // Fallback: try parsing tax_details JSON string
483 const taxDetails = (booking as any).tax_details;
484 if (taxDetails) {
485 try {
486 const taxes =
487 typeof taxDetails === "string"
488 ? JSON.parse(taxDetails)
489 : taxDetails;
490
491 if (Array.isArray(taxes) && taxes.length > 0) {
492 return taxes.map((tax: any, index: number) => (
493 <div
494 key={index}
495 className="flex justify-between text-sm"
496 >
497 <span className="text-gray-600 dark:text-gray-400">
498 {tax.name} ({tax.rate}%)
499 </span>
500 <span className="text-gray-900 dark:text-white">
501 {formatPriceForBooking(
502 tax.amount,
503 booking.currency,
504 )}
505 </span>
506 </div>
507 ));
508 }
509 } catch (e) {
510 console.error("Failed to parse tax_details:", e);
511 }
512 }
513
514 // Final fallback: show single tax line
515 if ((booking as any).tax_amount > 0) {
516 return (
517 <div className="flex justify-between text-sm">
518 <span className="text-gray-600 dark:text-gray-400">
519 {__("Tax", "yatra")} ({(booking as any).tax_rate}
520 %)
521 </span>
522 <span className="text-gray-900 dark:text-white">
523 {formatPriceForBooking(
524 (booking as any).tax_amount,
525 booking.currency,
526 )}
527 </span>
528 </div>
529 );
530 }
531
532 return null;
533 })()}
534 </>
535 )}
536
537 {/* Net Amount (Total with tax) */}
538 <div className="flex justify-between text-base font-semibold pt-2 border-t border-gray-200 dark:border-gray-700">
539 <span className="text-gray-900 dark:text-white">
540 {__("Net Amount", "yatra")}
541 </span>
542 <span className="text-gray-900 dark:text-white">
543 {formatPriceForBooking(
544 booking.total_amount,
545 booking.currency,
546 )}
547 </span>
548 </div>
549 </div>
550 </div>
551 </div>
552
553 {/* Customer Information.
554 The API (CustomerService::getBookingDetailsForUser) returns
555 the booking's CONTACT fields — `contact_first_name`,
556 `contact_last_name`, `contact_email`, `contact_phone`,
557 `contact_country` — straight from the bookings table. This
558 card was previously reading `customer_name`/`customer_email`
559 /`customer_phone`, which the API never set, so the section
560 was always blank.
561
562 Also fall back to the trip's lead-traveler fields (first
563 entry in `travelers_data`) when the contact_* columns are
564 empty, since some legacy flows wrote the lead traveler's
565 info there instead. */}
566 <div className="bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm p-6">
567 <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
568 {__("Customer Information", "yatra")}
569 </h3>
570 {(() => {
571 const leadTraveler =
572 Array.isArray(booking.travelers_data) &&
573 booking.travelers_data.length > 0
574 ? booking.travelers_data[0]
575 : null;
576 const leadFields =
577 leadTraveler && typeof leadTraveler === "object"
578 ? (leadTraveler as any).fields || (leadTraveler as any)
579 : null;
580
581 const firstName =
582 (booking as any).contact_first_name ||
583 (leadFields && leadFields.first_name) ||
584 "";
585 const lastName =
586 (booking as any).contact_last_name ||
587 (leadFields && leadFields.last_name) ||
588 "";
589 const fullName =
590 [firstName, lastName].filter(Boolean).join(" ").trim() ||
591 (booking as any).customer_name ||
592 "";
593 const email =
594 (booking as any).contact_email ||
595 (booking as any).customer_email ||
596 (leadFields && leadFields.email) ||
597 "";
598 const phone =
599 (booking as any).contact_phone ||
600 (booking as any).customer_phone ||
601 (leadFields && leadFields.phone) ||
602 "";
603 const country =
604 (booking as any).contact_country ||
605 (leadFields && leadFields.country) ||
606 "";
607
608 const hasAny = fullName || email || phone || country;
609
610 if (!hasAny) {
611 return (
612 <p className="text-sm text-gray-500 dark:text-gray-400">
613 {__("No customer information recorded.", "yatra")}
614 </p>
615 );
616 }
617
618 return (
619 <div className="space-y-3">
620 <div>
621 {fullName && (
622 <div className="text-sm font-medium text-gray-900 dark:text-white mb-1">
623 {fullName}
624 </div>
625 )}
626 <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-600 dark:text-gray-400">
627 {email && (
628 <div className="flex items-center gap-1.5">
629 <Mail className="w-4 h-4" />
630 <a
631 href={`mailto:${email}`}
632 className="hover:text-yatra-primary dark:hover:text-yatra-on-dark"
633 >
634 {email}
635 </a>
636 </div>
637 )}
638 {phone && (
639 <div className="flex items-center gap-1.5">
640 <PhoneIcon className="w-4 h-4" />
641 <a
642 href={`tel:${String(phone).replace(/\s+/g, "")}`}
643 className="hover:text-yatra-primary dark:hover:text-yatra-on-dark"
644 >
645 {phone}
646 </a>
647 </div>
648 )}
649 {country && <div>{getCountryName(country)}</div>}
650 </div>
651 </div>
652
653 {/* Custom / extra contact fields (nationality, address, and
654 any Dynamic Form custom contact field), pulled from the
655 contact_data JSON. Core fields above are excluded. */}
656 {(() => {
657 const CORE = [
658 "first_name",
659 "last_name",
660 "email",
661 "phone",
662 "country",
663 ];
664 const extras = contactData
665 ? Object.entries(contactData).filter(
666 ([k, v]) =>
667 !CORE.includes(k) &&
668 v != null &&
669 String(v).trim() !== "",
670 )
671 : [];
672 if (extras.length === 0) return null;
673 return (
674 <div className="grid grid-cols-2 md:grid-cols-3 gap-3 pt-3 border-t border-gray-100 dark:border-gray-700">
675 {extras.map(([fieldId, value]) => {
676 const label = fieldLabel(fieldId);
677 let displayValue = String(value);
678 if (
679 (fieldId === "nationality" ||
680 fieldId === "country") &&
681 typeof value === "string" &&
682 value.length === 2
683 ) {
684 displayValue = getCountryName(value);
685 }
686 return (
687 <div key={fieldId}>
688 <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5">
689 {label}
690 </div>
691 <div className="text-sm text-gray-900 dark:text-white">
692 {displayValue}
693 </div>
694 </div>
695 );
696 })}
697 </div>
698 );
699 })()}
700 </div>
701 );
702 })()}
703 </div>
704
705 {/* Travelers Information */}
706 {booking.travelers_data && booking.travelers_data.length > 0 && (
707 <div className="bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm p-6">
708 <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
709 <Users className="w-5 h-5" />
710 {__("Travelers Information", "yatra")}
711 <span className="text-sm font-normal text-gray-500 dark:text-gray-400">
712 ({booking.travelers_data.length}{" "}
713 {booking.travelers_data.length === 1
714 ? __("traveler", "yatra")
715 : __("travelers", "yatra")}
716 )
717 </span>
718 </h3>
719 <div className="space-y-4">
720 {booking.travelers_data.map((traveler: any, index: number) => {
721 // Extract fields from traveler - handle both flat structure and nested fields property
722 const travelerFieldsData = traveler.fields || traveler;
723 const systemFields = [
724 "id",
725 "booking_id",
726 "traveller_index",
727 "is_lead",
728 "created_at",
729 "updated_at",
730 "fields",
731 ];
732
733 // Get all non-empty fields from the traveler data, excluding system fields
734 const travelerEntries = Object.entries(
735 travelerFieldsData,
736 ).filter(([key, value]) => {
737 // Exclude system fields
738 if (systemFields.includes(key)) return false;
739 // Exclude empty values
740 if (
741 !value ||
742 (typeof value === "string" && value.trim() === "")
743 )
744 return false;
745 // Exclude objects (they should be in fields)
746 if (typeof value === "object" && !Array.isArray(value))
747 return false;
748 return true;
749 });
750
751 // Get name from fields or direct properties
752 const firstName =
753 travelerFieldsData.first_name || traveler.first_name || "";
754 const lastName =
755 travelerFieldsData.last_name || traveler.last_name || "";
756
757 return (
758 <div
759 key={index}
760 className={`p-4 rounded-lg ${index === 0 ? "bg-yatra-soft dark:bg-yatra-surface-dark-muted border border-yatra-border-subtle dark:border-yatra-border-dark" : "bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700"}`}
761 >
762 <div className="flex items-center justify-between mb-3">
763 <h4 className="text-sm font-semibold text-gray-900 dark:text-white">
764 {index === 0
765 ? __("Lead Traveler", "yatra")
766 : `${__("Traveler", "yatra")} ${index + 1}`}
767 {(firstName || lastName) && (
768 <span className="font-normal text-gray-500 dark:text-gray-400 ml-2">
769 -{" "}
770 {[firstName, lastName].filter(Boolean).join(" ")}
771 </span>
772 )}
773 </h4>
774 {index === 0 && (
775 <span className="text-xs bg-yatra-chip-bg text-yatra-primary-dark dark:bg-yatra-surface-dark dark:text-yatra-primary-light px-2 py-0.5 rounded">
776 {__("Primary Contact", "yatra")}
777 </span>
778 )}
779 </div>
780 <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
781 {travelerEntries.map(([fieldId, fieldValue]) => {
782 if (
783 fieldId === "first_name" ||
784 fieldId === "last_name"
785 )
786 return null;
787 const label = fieldLabel(fieldId);
788 let displayValue = String(fieldValue);
789 if (
790 fieldId.includes("date") ||
791 fieldId.includes("expiry")
792 ) {
793 try {
794 displayValue = formatDate(fieldValue as string);
795 } catch {
796 displayValue = String(fieldValue);
797 }
798 }
799
800 // Format country/nationality fields - convert code to full name
801 if (
802 (fieldId === "nationality" ||
803 fieldId === "country") &&
804 fieldValue &&
805 typeof fieldValue === "string" &&
806 fieldValue.length === 2
807 ) {
808 displayValue = getCountryName(fieldValue);
809 }
810 return (
811 <div key={fieldId}>
812 <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5">
813 {label}
814 </div>
815 <div
816 className={`text-sm text-gray-900 dark:text-white ${fieldId === "passport" ? "font-mono" : ""}`}
817 >
818 {displayValue}
819 </div>
820 </div>
821 );
822 })}
823 </div>
824 </div>
825 );
826 })}
827 </div>
828 </div>
829 )}
830
831 {/* Emergency Contact */}
832 {emergencyContact &&
833 Object.values(emergencyContact).some(
834 (v: any) => v && String(v).trim() !== "",
835 ) && (
836 <div className="bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm p-6">
837 <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
838 <AlertCircle className="w-5 h-5" />
839 {__("Emergency Contact", "yatra")}
840 </h3>
841 <div className="grid grid-cols-1 md:grid-cols-3 gap-3">
842 {Object.entries(emergencyContact)
843 .filter(
844 ([_, value]) => value && String(value).trim() !== "",
845 )
846 .map(([fieldId, fieldValue]) => {
847 const label = fieldLabel(fieldId);
848 return (
849 <div key={fieldId}>
850 <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5">
851 {label}
852 </div>
853 <div className="text-sm font-medium text-gray-900 dark:text-white">
854 {String(fieldValue)}
855 </div>
856 </div>
857 );
858 })}
859 </div>
860 </div>
861 )}
862
863 {/* Special Requests */}
864 {booking.notes && (
865 <div className="bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm p-6">
866 <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
867 <FileTextIcon className="w-5 h-5" />
868 {__("Special Requests", "yatra")}
869 </h3>
870 <p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
871 {booking.notes}
872 </p>
873 </div>
874 )}
875
876 {/* Downloads */}
877 {Array.isArray((booking as any).downloads) &&
878 (booking as any).downloads.length > 0 && (
879 <div className="bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm p-6">
880 <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
881 <Download className="w-5 h-5" />
882 {__("Downloads", "yatra")}
883 </h3>
884 <div className="space-y-3">
885 {(booking as any).downloads.map((d: any) => (
886 <div
887 key={d.id}
888 className="flex items-center justify-between gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700"
889 >
890 <div className="min-w-0">
891 <div className="text-sm font-medium text-gray-900 dark:text-white truncate">
892 {d.title}
893 </div>
894 {d.access_label ? (
895 <div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
896 {d.access_label}
897 </div>
898 ) : null}
899 {d.locked && d.locked_reason ? (
900 <div className="text-xs text-amber-700 dark:text-amber-300 mt-1">
901 {d.locked_reason}
902 </div>
903 ) : null}
904 </div>
905 {d.locked || !d.url ? (
906 <div className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 text-sm font-medium cursor-not-allowed">
907 <Download className="w-4 h-4" />
908 {__("Not Available", "yatra")}
909 </div>
910 ) : (
911 <a
912 href={d.url}
913 target="_blank"
914 rel="noreferrer"
915 className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-yatra-primary text-white hover:bg-yatra-primary-dark transition-colors text-sm font-medium"
916 >
917 <Download className="w-4 h-4" />
918 {__("Download", "yatra")}
919 </a>
920 )}
921 </div>
922 ))}
923 </div>
924 </div>
925 )}
926 </div>
927
928 {/* Sidebar */}
929 <div className="space-y-6">
930 {/* Payment Information.
931 Hosts BOTH the booking-status and the payment-status
932 badges, each with an explicit label, so the previous
933 "two unlabelled Pending pills in the overview header"
934 ambiguity is gone — the user can tell at a glance which
935 status is which. */}
936 <div className="bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm p-6">
937 <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
938 {__("Payment Information", "yatra")}
939 </h3>
940 <div className="space-y-3">
941 <div>
942 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
943 {__("Booking Status", "yatra")}
944 </div>
945 <div className="mt-1">
946 <span className={getBadge(booking.booking_status)}>
947 {getStatusLabel(booking.booking_status)}
948 </span>
949 </div>
950 </div>
951 <div>
952 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
953 {__("Payment Status", "yatra")}
954 </div>
955 <div className="mt-1">
956 <span className={getBadge(booking.payment_status)}>
957 {getStatusLabel(booking.payment_status)}
958 </span>
959 </div>
960 </div>
961 {booking.payment_method && (
962 <div>
963 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1 flex items-center gap-1">
964 <CreditCard className="w-3 h-3" />
965 {__("Payment Method", "yatra")}
966 </div>
967 <div className="text-sm text-gray-900 dark:text-white">
968 {booking.payment_method}
969 </div>
970 </div>
971 )}
972 <div>
973 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
974 {__("Total Amount", "yatra")}
975 </div>
976 <div className="text-lg font-semibold text-gray-900 dark:text-white">
977 {formatPriceForBooking(
978 booking.total_amount,
979 booking.currency,
980 )}
981 </div>
982 </div>
983 {booking.amount_paid > 0 && (
984 <div>
985 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
986 {__("Amount Paid", "yatra")}
987 </div>
988 <div className="text-sm text-gray-900 dark:text-white">
989 {formatPriceForBooking(
990 booking.amount_paid,
991 booking.currency,
992 )}
993 </div>
994 </div>
995 )}
996 {booking.amount_due > 0 && (
997 <div>
998 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
999 {__("Amount Due", "yatra")}
1000 </div>
1001 <div className="text-sm font-semibold text-amber-600 dark:text-amber-400">
1002 {formatPriceForBooking(
1003 booking.amount_due,
1004 booking.currency,
1005 )}
1006 </div>
1007 </div>
1008 )}
1009 </div>
1010 </div>
1011
1012 {/* Timeline */}
1013 <div className="bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm p-6">
1014 <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
1015 {__("Timeline", "yatra")}
1016 </h3>
1017 <div className="space-y-3">
1018 <div>
1019 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
1020 {__("Created", "yatra")}
1021 </div>
1022 <div className="text-sm text-gray-900 dark:text-white">
1023 {formatDate(booking.created_at || booking.booking_date)}
1024 </div>
1025 </div>
1026 {booking.updated_at &&
1027 booking.updated_at !== booking.created_at && (
1028 <div>
1029 <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
1030 {__("Last Updated", "yatra")}
1031 </div>
1032 <div className="text-sm text-gray-900 dark:text-white">
1033 {formatDate(booking.updated_at)}
1034 </div>
1035 </div>
1036 )}
1037 </div>
1038 </div>
1039 </div>
1040 </div>
1041 </div>
1042 );
1043 };
1044
1045 export default BookingDetails;
1046