PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.8
Yatra – Travel Booking & Tour Operator Software v3.0.8
3.0.17 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 All 85 releases
yatra / resources / js / pages / account / BookingDetails.tsx

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

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