| @@ -2,11 +2,12 @@ | ||
| 2 | 2 | * View Booking Page |
| 3 | 3 | * Display booking details in a clean, minimal SaaS-style design |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | -import React, { useMemo } from "react"; | |
| 6 | +import React, { useMemo, useState } from "react"; | |
| 7 | 7 | import { useQuery } from "@tanstack/react-query"; |
| 8 | 8 | import { |
| 9 | + Download, | |
| 9 | 10 | ArrowLeft, |
| 10 | 11 | Mail, |
| 11 | 12 | Phone, |
| 12 | 13 | Calendar, |
| @@ -22,9 +23,11 @@ | ||
| 22 | 23 | } from "lucide-react"; |
| 23 | 24 | import { apiClient, apiService } from "../lib/api-client"; |
| 24 | 25 | import { __ } from "../lib/i18n"; |
| 25 | 26 | import { formatDate as formatDateUtil } from "../lib/dateFormat"; |
| 27 | +import PhoneDisplay from "../components/common/PhoneDisplay"; | |
| 26 | 28 | import { usePermissions } from "../hooks/usePermissions"; |
| 29 | +import { getCountryName } from "../lib/countries"; | |
| 27 | 30 | import { Button } from "../components/ui/button"; |
| 28 | 31 | import { PageHeader } from "../components/common/PageHeader"; |
| 29 | 32 | import { |
| 30 | 33 | Card, |
| @@ -32,8 +35,10 @@ | ||
| 32 | 35 | CardHeader, |
| 33 | 36 | CardTitle, |
| 34 | 37 | } from "../components/ui/card"; |
| 35 | 38 | import { ConditionalRender } from "../components/ui/conditional-render"; |
| 39 | +import { ResendEmailMenu } from "../components/bookings/ResendEmailMenu"; | |
| 40 | +import { downloadAdminBookingInvoice } from "../lib/invoice-download"; | |
| 36 | 41 | import { Skeleton } from "../components/ui/skeleton"; |
| 37 | 42 | import { formatYatraMoney } from "../lib/currency-display"; |
| 38 | 43 | |
| 39 | 44 | interface GoogleCalendarSyncInfo { |
| @@ -52,10 +57,28 @@ | ||
| 52 | 57 | label: string; |
| 53 | 58 | enabled: boolean; |
| 54 | 59 | order: number; |
| 55 | 60 | section?: string; |
| 61 | + options?: { value: string; label: string }[]; | |
| 56 | 62 | } |
| 57 | 63 | |
| 64 | +/** | |
| 65 | + * Dropdown answers are stored as the option VALUE (e.g. "spouse"); show the | |
| 66 | + * option's label from the form the trip used ("Spouse/Partner") when we have | |
| 67 | + * it, otherwise the raw value as before. | |
| 68 | + */ | |
| 69 | +const optionLabel = ( | |
| 70 | + field: FormFieldConfig | undefined, | |
| 71 | + value: unknown, | |
| 72 | +): string => { | |
| 73 | + const raw = String(value ?? ""); | |
| 74 | + if (field?.type === "select" && Array.isArray(field.options)) { | |
| 75 | + const match = field.options.find((o) => String(o.value) === raw); | |
| 76 | + if (match?.label) return match.label; | |
| 77 | + } | |
| 78 | + return raw; | |
| 79 | +}; | |
| 80 | + | |
| 58 | 81 | interface FormSectionConfig { |
| 59 | 82 | title: string; |
| 60 | 83 | enabled: boolean; |
| 61 | 84 | fields: FormFieldConfig[]; |
| @@ -75,37 +98,8 @@ | ||
| 75 | 98 | const params = new URLSearchParams(window.location.search); |
| 76 | 99 | return params.get("id") ? parseInt(params.get("id") || "0") : null; |
| 77 | 100 | }, []); |
| 78 | 101 | |
| 79 | - // Fetch booking form configuration for dynamic field labels | |
| 80 | - const { data: formConfig } = useQuery<BookingFormConfig>({ | |
| 81 | - queryKey: ["booking-form-config"], | |
| 82 | - queryFn: async () => { | |
| 83 | - const response = await apiService.getSettings(); | |
| 84 | - return ( | |
| 85 | - response?.data?.booking_form_config || | |
| 86 | - response?.booking_form_config || | |
| 87 | - null | |
| 88 | - ); | |
| 89 | - }, | |
| 90 | - }); | |
| 91 | - | |
| 92 | - // Get enabled traveler fields from config | |
| 93 | - const travelerFields = useMemo(() => { | |
| 94 | - if (!formConfig?.traveler_form?.fields) return []; | |
| 95 | - return formConfig.traveler_form.fields | |
| 96 | - .filter((field) => field.enabled) | |
| 97 | - .sort((a, b) => a.order - b.order); | |
| 98 | - }, [formConfig]); | |
| 99 | - | |
| 100 | - // Get enabled emergency contact fields | |
| 101 | - const emergencyFields = useMemo(() => { | |
| 102 | - if (!formConfig?.emergency_contact_form?.fields) return []; | |
| 103 | - return formConfig.emergency_contact_form.fields | |
| 104 | - .filter((field) => field.enabled) | |
| 105 | - .sort((a, b) => a.order - b.order); | |
| 106 | - }, [formConfig]); | |
| 107 | - | |
| 108 | 102 | // Helper to get field label by ID |
| 109 | 103 | const getFieldLabel = ( |
| 110 | 104 | fieldId: string, |
| 111 | 105 | fields: FormFieldConfig[], |
| @@ -209,8 +203,50 @@ | ||
| 209 | 203 | }, |
| 210 | 204 | enabled: !!bookingId && can("yatra_view_bookings"), |
| 211 | 205 | }); |
| 212 | 206 | |
| 207 | + // Fetch booking form configuration for dynamic field labels, resolved for | |
| 208 | + // this booking's trip so per-trip form versions (Pro conditions) label the | |
| 209 | + // fields the customer actually saw. Waits for the booking to know its trip. | |
| 210 | + const bookingTripId = booking?.trip_id ? Number(booking.trip_id) : null; | |
| 211 | + const { data: formConfig } = useQuery<BookingFormConfig>({ | |
| 212 | + queryKey: ["booking-form-config", bookingTripId], | |
| 213 | + queryFn: async () => { | |
| 214 | + const response = await apiService.getBookingFormConfig(bookingTripId); | |
| 215 | + return ( | |
| 216 | + response?.data?.booking_form_config || | |
| 217 | + response?.booking_form_config || | |
| 218 | + null | |
| 219 | + ); | |
| 220 | + }, | |
| 221 | + enabled: !!booking, | |
| 222 | + }); | |
| 223 | + | |
| 224 | + // Get enabled traveler fields from config | |
| 225 | + const travelerFields = useMemo(() => { | |
| 226 | + if (!formConfig?.traveler_form?.fields) return []; | |
| 227 | + return formConfig.traveler_form.fields | |
| 228 | + .filter((field) => field.enabled) | |
| 229 | + .sort((a, b) => a.order - b.order); | |
| 230 | + }, [formConfig]); | |
| 231 | + | |
| 232 | + // Get enabled emergency contact fields | |
| 233 | + const emergencyFields = useMemo(() => { | |
| 234 | + if (!formConfig?.emergency_contact_form?.fields) return []; | |
| 235 | + return formConfig.emergency_contact_form.fields | |
| 236 | + .filter((field) => field.enabled) | |
| 237 | + .sort((a, b) => a.order - b.order); | |
| 238 | + }, [formConfig]); | |
| 239 | + | |
| 240 | + // Get enabled contact (lead traveler) fields — used to label and format the | |
| 241 | + // country / nationality / address / custom contact fields in the summary. | |
| 242 | + const contactFields = useMemo(() => { | |
| 243 | + if (!formConfig?.contact_form?.fields) return []; | |
| 244 | + return formConfig.contact_form.fields | |
| 245 | + .filter((field) => field.enabled) | |
| 246 | + .sort((a, b) => a.order - b.order); | |
| 247 | + }, [formConfig]); | |
| 248 | + | |
| 213 | 249 | // Fetch consent status for this booking (only if Pro is active) |
| 214 | 250 | const isPro = !!(window as any).yatraAdmin?.isPro; |
| 215 | 251 | const { data: consentStatus } = useQuery({ |
| 216 | 252 | queryKey: ["booking-consent-status", bookingId], |
| @@ -228,216 +264,12 @@ | ||
| 228 | 264 | return formatDateUtil(dateString); |
| 229 | 265 | }; |
| 230 | 266 | |
| 231 | 267 | const formatPrice = (price: number, currencyCode: string = "USD") => |
| 232 | - formatYatraMoney(Number(price) || 0, currencyCode, { zeroAsUnknown: false }); | |
| 268 | + formatYatraMoney(Number(price) || 0, currencyCode, { | |
| 269 | + zeroAsUnknown: false, | |
| 270 | + }); | |
| 233 | 271 | |
| 234 | - // Country code to name mapping | |
| 235 | - const COUNTRY_NAMES: Record<string, string> = { | |
| 236 | - AF: "Afghanistan", | |
| 237 | - AL: "Albania", | |
| 238 | - DZ: "Algeria", | |
| 239 | - AD: "Andorra", | |
| 240 | - AO: "Angola", | |
| 241 | - AG: "Antigua and Barbuda", | |
| 242 | - AR: "Argentina", | |
| 243 | - AM: "Armenia", | |
| 244 | - AU: "Australia", | |
| 245 | - AT: "Austria", | |
| 246 | - AZ: "Azerbaijan", | |
| 247 | - BS: "Bahamas", | |
| 248 | - BH: "Bahrain", | |
| 249 | - BD: "Bangladesh", | |
| 250 | - BB: "Barbados", | |
| 251 | - BY: "Belarus", | |
| 252 | - BE: "Belgium", | |
| 253 | - BZ: "Belize", | |
| 254 | - BJ: "Benin", | |
| 255 | - BT: "Bhutan", | |
| 256 | - BO: "Bolivia", | |
| 257 | - BA: "Bosnia and Herzegovina", | |
| 258 | - BW: "Botswana", | |
| 259 | - BR: "Brazil", | |
| 260 | - BN: "Brunei", | |
| 261 | - BG: "Bulgaria", | |
| 262 | - BF: "Burkina Faso", | |
| 263 | - BI: "Burundi", | |
| 264 | - KH: "Cambodia", | |
| 265 | - CM: "Cameroon", | |
| 266 | - CA: "Canada", | |
| 267 | - CV: "Cape Verde", | |
| 268 | - CF: "Central African Republic", | |
| 269 | - TD: "Chad", | |
| 270 | - CL: "Chile", | |
| 271 | - CN: "China", | |
| 272 | - CO: "Colombia", | |
| 273 | - KM: "Comoros", | |
| 274 | - CG: "Congo", | |
| 275 | - CD: "DR Congo", | |
| 276 | - CR: "Costa Rica", | |
| 277 | - CI: "Ivory Coast", | |
| 278 | - HR: "Croatia", | |
| 279 | - CU: "Cuba", | |
| 280 | - CY: "Cyprus", | |
| 281 | - CZ: "Czech Republic", | |
| 282 | - DK: "Denmark", | |
| 283 | - DJ: "Djibouti", | |
| 284 | - DM: "Dominica", | |
| 285 | - DO: "Dominican Republic", | |
| 286 | - EC: "Ecuador", | |
| 287 | - EG: "Egypt", | |
| 288 | - SV: "El Salvador", | |
| 289 | - GQ: "Equatorial Guinea", | |
| 290 | - ER: "Eritrea", | |
| 291 | - EE: "Estonia", | |
| 292 | - SZ: "Eswatini", | |
| 293 | - ET: "Ethiopia", | |
| 294 | - FJ: "Fiji", | |
| 295 | - FI: "Finland", | |
| 296 | - FR: "France", | |
| 297 | - GA: "Gabon", | |
| 298 | - GM: "Gambia", | |
| 299 | - GE: "Georgia", | |
| 300 | - DE: "Germany", | |
| 301 | - GH: "Ghana", | |
| 302 | - GR: "Greece", | |
| 303 | - GD: "Grenada", | |
| 304 | - GT: "Guatemala", | |
| 305 | - GN: "Guinea", | |
| 306 | - GW: "Guinea-Bissau", | |
| 307 | - GY: "Guyana", | |
| 308 | - HT: "Haiti", | |
| 309 | - HN: "Honduras", | |
| 310 | - HU: "Hungary", | |
| 311 | - IS: "Iceland", | |
| 312 | - IN: "India", | |
| 313 | - ID: "Indonesia", | |
| 314 | - IR: "Iran", | |
| 315 | - IQ: "Iraq", | |
| 316 | - IE: "Ireland", | |
| 317 | - IL: "Israel", | |
| 318 | - IT: "Italy", | |
| 319 | - JM: "Jamaica", | |
| 320 | - JP: "Japan", | |
| 321 | - JO: "Jordan", | |
| 322 | - KZ: "Kazakhstan", | |
| 323 | - KE: "Kenya", | |
| 324 | - KI: "Kiribati", | |
| 325 | - KP: "North Korea", | |
| 326 | - KR: "South Korea", | |
| 327 | - KW: "Kuwait", | |
| 328 | - KG: "Kyrgyzstan", | |
| 329 | - LA: "Laos", | |
| 330 | - LV: "Latvia", | |
| 331 | - LB: "Lebanon", | |
| 332 | - LS: "Lesotho", | |
| 333 | - LR: "Liberia", | |
| 334 | - LY: "Libya", | |
| 335 | - LI: "Liechtenstein", | |
| 336 | - LT: "Lithuania", | |
| 337 | - LU: "Luxembourg", | |
| 338 | - MG: "Madagascar", | |
| 339 | - MW: "Malawi", | |
| 340 | - MY: "Malaysia", | |
| 341 | - MV: "Maldives", | |
| 342 | - ML: "Mali", | |
| 343 | - MT: "Malta", | |
| 344 | - MH: "Marshall Islands", | |
| 345 | - MR: "Mauritania", | |
| 346 | - MU: "Mauritius", | |
| 347 | - MX: "Mexico", | |
| 348 | - FM: "Micronesia", | |
| 349 | - MD: "Moldova", | |
| 350 | - MC: "Monaco", | |
| 351 | - MN: "Mongolia", | |
| 352 | - ME: "Montenegro", | |
| 353 | - MA: "Morocco", | |
| 354 | - MZ: "Mozambique", | |
| 355 | - MM: "Myanmar", | |
| 356 | - NA: "Namibia", | |
| 357 | - NR: "Nauru", | |
| 358 | - NP: "Nepal", | |
| 359 | - NL: "Netherlands", | |
| 360 | - NZ: "New Zealand", | |
| 361 | - NI: "Nicaragua", | |
| 362 | - NE: "Niger", | |
| 363 | - NG: "Nigeria", | |
| 364 | - MK: "North Macedonia", | |
| 365 | - NO: "Norway", | |
| 366 | - OM: "Oman", | |
| 367 | - PK: "Pakistan", | |
| 368 | - PW: "Palau", | |
| 369 | - PS: "Palestine", | |
| 370 | - PA: "Panama", | |
| 371 | - PG: "Papua New Guinea", | |
| 372 | - PY: "Paraguay", | |
| 373 | - PE: "Peru", | |
| 374 | - PH: "Philippines", | |
| 375 | - PL: "Poland", | |
| 376 | - PT: "Portugal", | |
| 377 | - QA: "Qatar", | |
| 378 | - RO: "Romania", | |
| 379 | - RU: "Russia", | |
| 380 | - RW: "Rwanda", | |
| 381 | - KN: "Saint Kitts and Nevis", | |
| 382 | - LC: "Saint Lucia", | |
| 383 | - VC: "Saint Vincent and the Grenadines", | |
| 384 | - WS: "Samoa", | |
| 385 | - SM: "San Marino", | |
| 386 | - ST: "Sao Tome and Principe", | |
| 387 | - SA: "Saudi Arabia", | |
| 388 | - SN: "Senegal", | |
| 389 | - RS: "Serbia", | |
| 390 | - SC: "Seychelles", | |
| 391 | - SL: "Sierra Leone", | |
| 392 | - SG: "Singapore", | |
| 393 | - SK: "Slovakia", | |
| 394 | - SI: "Slovenia", | |
| 395 | - SB: "Solomon Islands", | |
| 396 | - SO: "Somalia", | |
| 397 | - ZA: "South Africa", | |
| 398 | - SS: "South Sudan", | |
| 399 | - ES: "Spain", | |
| 400 | - LK: "Sri Lanka", | |
| 401 | - SD: "Sudan", | |
| 402 | - SR: "Suriname", | |
| 403 | - SE: "Sweden", | |
| 404 | - CH: "Switzerland", | |
| 405 | - SY: "Syria", | |
| 406 | - TW: "Taiwan", | |
| 407 | - TJ: "Tajikistan", | |
| 408 | - TZ: "Tanzania", | |
| 409 | - TH: "Thailand", | |
| 410 | - TL: "Timor-Leste", | |
| 411 | - TG: "Togo", | |
| 412 | - TO: "Tonga", | |
| 413 | - TT: "Trinidad and Tobago", | |
| 414 | - TN: "Tunisia", | |
| 415 | - TR: "Turkey", | |
| 416 | - TM: "Turkmenistan", | |
| 417 | - TV: "Tuvalu", | |
| 418 | - UG: "Uganda", | |
| 419 | - UA: "Ukraine", | |
| 420 | - AE: "United Arab Emirates", | |
| 421 | - GB: "United Kingdom", | |
| 422 | - US: "United States", | |
| 423 | - UY: "Uruguay", | |
| 424 | - UZ: "Uzbekistan", | |
| 425 | - VU: "Vanuatu", | |
| 426 | - VA: "Vatican City", | |
| 427 | - VE: "Venezuela", | |
| 428 | - VN: "Vietnam", | |
| 429 | - YE: "Yemen", | |
| 430 | - ZM: "Zambia", | |
| 431 | - ZW: "Zimbabwe", | |
| 432 | - }; | |
| 433 | - | |
| 434 | - const getCountryName = (code: string): string => { | |
| 435 | - if (!code) return ""; | |
| 436 | - const upperCode = code.toUpperCase(); | |
| 437 | - return COUNTRY_NAMES[upperCode] || code; | |
| 438 | - }; | |
| 439 | - | |
| 440 | 272 | const getBookingStatusBadge = (status: string) => { |
| 441 | 273 | const statusMap: Record<string, { className: string; label: string }> = { |
| 442 | 274 | confirmed: { |
| 443 | 275 | className: |
| @@ -522,8 +354,29 @@ | ||
| 522 | 354 | const handleEdit = () => { |
| 523 | 355 | window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings&action=edit&id=${bookingId}`; |
| 524 | 356 | }; |
| 525 | 357 | |
| 358 | + // The booking invoice works for unpaid bookings too — it renders as a | |
| 359 | + // pro-forma invoice with the gateway's payment instructions. This page has | |
| 360 | + // no toast provider, so a failure is rendered as an inline banner under the | |
| 361 | + // header; a tooltip alone would leave a failed download looking like nothing | |
| 362 | + // happened. | |
| 363 | + const [invoiceError, setInvoiceError] = useState<string>(""); | |
| 364 | + const handleDownloadInvoice = async () => { | |
| 365 | + const id = Number(booking?.id ?? 0); | |
| 366 | + if (!id) { | |
| 367 | + return; | |
| 368 | + } | |
| 369 | + setInvoiceError(""); | |
| 370 | + try { | |
| 371 | + await downloadAdminBookingInvoice(id); | |
| 372 | + } catch (error: any) { | |
| 373 | + setInvoiceError( | |
| 374 | + error?.message || __("Failed to download invoice", "yatra"), | |
| 375 | + ); | |
| 376 | + } | |
| 377 | + }; | |
| 378 | + | |
| 526 | 379 | if (isLoading) { |
| 527 | 380 | return ( |
| 528 | 381 | <div className="space-y-3"> |
| 529 | 382 | {/* Header Skeleton */} |
| @@ -673,9 +526,26 @@ | ||
| 673 | 526 | title={__("Booking Details", "yatra")} |
| 674 | 527 | description={__("View complete booking information", "yatra")} |
| 675 | 528 | actions={ |
| 676 | 529 | <div className="flex gap-2"> |
| 530 | + <ConditionalRender capability="yatra_view_bookings"> | |
| 531 | + <Button | |
| 532 | + variant="outline" | |
| 533 | + onClick={handleDownloadInvoice} | |
| 534 | + className="flex items-center gap-2" | |
| 535 | + > | |
| 536 | + <Download className="w-4 h-4" /> | |
| 537 | + {__("Download invoice", "yatra")} | |
| 538 | + </Button> | |
| 539 | + </ConditionalRender> | |
| 677 | 540 | <ConditionalRender capability="yatra_edit_bookings"> |
| 541 | + <ResendEmailMenu | |
| 542 | + bookingId={Number(booking.id)} | |
| 543 | + status={booking.booking_status} | |
| 544 | + amountPaid={Number(booking.amount_paid) || 0} | |
| 545 | + /> | |
| 546 | + </ConditionalRender> | |
| 547 | + <ConditionalRender capability="yatra_edit_bookings"> | |
| 678 | 548 | <Button onClick={handleEdit} className="flex items-center gap-2"> |
| 679 | 549 | {__("Edit Booking", "yatra")} |
| 680 | 550 | </Button> |
| 681 | 551 | </ConditionalRender> |
| @@ -690,8 +560,18 @@ | ||
| 690 | 560 | </div> |
| 691 | 561 | } |
| 692 | 562 | /> |
| 693 | 563 | |
| 564 | + {invoiceError && ( | |
| 565 | + <div | |
| 566 | + role="alert" | |
| 567 | + className="flex items-center gap-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400" | |
| 568 | + > | |
| 569 | + <AlertCircle className="w-4 h-4 shrink-0" /> | |
| 570 | + {invoiceError} | |
| 571 | + </div> | |
| 572 | + )} | |
| 573 | + | |
| 694 | 574 | <ConditionalRender capability="yatra_view_bookings"> |
| 695 | 575 | <div className="grid grid-cols-1 lg:grid-cols-3 gap-3"> |
| 696 | 576 | {/* Main Content */} |
| 697 | 577 | <div className="lg:col-span-2 space-y-3"> |
| @@ -1079,8 +959,81 @@ | ||
| 1079 | 959 | </div> |
| 1080 | 960 | </CardContent> |
| 1081 | 961 | </Card> |
| 1082 | 962 | |
| 963 | + {/* Payments recorded against this booking (gateway captures and | |
| 964 | + manual entries). Only completed rows count towards Amount | |
| 965 | + Paid — a pending row is money expected, not received. */} | |
| 966 | + {Array.isArray(booking.payments) && booking.payments.length > 0 && ( | |
| 967 | + <Card data-testid="booking-payments-card"> | |
| 968 | + <CardHeader className="pb-2"> | |
| 969 | + <CardTitle className="text-base"> | |
| 970 | + {__("Payments", "yatra")} | |
| 971 | + </CardTitle> | |
| 972 | + </CardHeader> | |
| 973 | + <CardContent> | |
| 974 | + <div className="divide-y divide-gray-100 dark:divide-gray-800"> | |
| 975 | + {booking.payments.map((payment: any) => { | |
| 976 | + const status = String( | |
| 977 | + payment.status || payment.payment_status || "pending", | |
| 978 | + ); | |
| 979 | + const statusClass = | |
| 980 | + status === "completed" | |
| 981 | + ? "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400" | |
| 982 | + : status === "pending" | |
| 983 | + ? "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400" | |
| 984 | + : status === "refunded" | |
| 985 | + ? "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400" | |
| 986 | + : "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400"; | |
| 987 | + const statusLabel: Record<string, string> = { | |
| 988 | + completed: __("Completed", "yatra"), | |
| 989 | + pending: __("Pending", "yatra"), | |
| 990 | + failed: __("Failed", "yatra"), | |
| 991 | + refunded: __("Refunded", "yatra"), | |
| 992 | + cancelled: __("Cancelled", "yatra"), | |
| 993 | + }; | |
| 994 | + return ( | |
| 995 | + <div | |
| 996 | + key={payment.id} | |
| 997 | + className="py-2 flex items-start justify-between gap-3 text-sm" | |
| 998 | + > | |
| 999 | + <div className="min-w-0"> | |
| 1000 | + <div className="font-medium text-gray-900 dark:text-white"> | |
| 1001 | + {formatPrice( | |
| 1002 | + Number(payment.amount || 0), | |
| 1003 | + payment.currency || booking.currency, | |
| 1004 | + )} | |
| 1005 | + <span className="ml-2 font-normal text-gray-500 dark:text-gray-400"> | |
| 1006 | + {payment.payment_method_label || | |
| 1007 | + payment.gateway || | |
| 1008 | + ""} | |
| 1009 | + </span> | |
| 1010 | + </div> | |
| 1011 | + <div className="text-xs text-gray-500 dark:text-gray-400 truncate"> | |
| 1012 | + {payment.payment_date | |
| 1013 | + ? formatDate(payment.payment_date) | |
| 1014 | + : ""} | |
| 1015 | + {payment.transaction_id | |
| 1016 | + ? ` · ${payment.transaction_id}` | |
| 1017 | + : ""} | |
| 1018 | + {payment.payment_number | |
| 1019 | + ? ` · ${payment.payment_number}` | |
| 1020 | + : ""} | |
| 1021 | + </div> | |
| 1022 | + </div> | |
| 1023 | + <span | |
| 1024 | + className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium whitespace-nowrap ${statusClass}`} | |
| 1025 | + > | |
| 1026 | + {statusLabel[status] || status} | |
| 1027 | + </span> | |
| 1028 | + </div> | |
| 1029 | + ); | |
| 1030 | + })} | |
| 1031 | + </div> | |
| 1032 | + </CardContent> | |
| 1033 | + </Card> | |
| 1034 | + )} | |
| 1035 | + | |
| 1083 | 1036 | {/* Customer Information */} |
| 1084 | 1037 | <Card> |
| 1085 | 1038 | <CardHeader className="pb-2"> |
| 1086 | 1039 | <CardTitle className="text-base"> |
| @@ -1099,13 +1052,69 @@ | ||
| 1099 | 1052 | </div> |
| 1100 | 1053 | {booking.customer_phone && ( |
| 1101 | 1054 | <div className="flex items-center gap-1.5"> |
| 1102 | 1055 | <Phone className="w-4 h-4" /> |
| 1103 | - {booking.customer_phone} | |
| 1056 | + <PhoneDisplay value={booking.customer_phone} /> | |
| 1104 | 1057 | </div> |
| 1105 | 1058 | )} |
| 1106 | 1059 | </div> |
| 1107 | 1060 | </div> |
| 1061 | + | |
| 1062 | + {/* Country + dynamic/custom contact fields (nationality, | |
| 1063 | + address, and any custom contact field). Mirrors the | |
| 1064 | + Emergency/Traveler dynamic-field display. */} | |
| 1065 | + {(() => { | |
| 1066 | + const cd = | |
| 1067 | + booking.contact_data && | |
| 1068 | + typeof booking.contact_data === "object" | |
| 1069 | + ? (booking.contact_data as Record<string, unknown>) | |
| 1070 | + : {}; | |
| 1071 | + const CORE = ["first_name", "last_name", "email", "phone"]; | |
| 1072 | + const extras = Object.entries(cd).filter( | |
| 1073 | + ([k, v]) => | |
| 1074 | + !CORE.includes(k) && | |
| 1075 | + k !== "country" && | |
| 1076 | + v != null && | |
| 1077 | + String(v).trim() !== "", | |
| 1078 | + ); | |
| 1079 | + const countryCode = | |
| 1080 | + booking.customer_country || | |
| 1081 | + (cd.country ? String(cd.country) : ""); | |
| 1082 | + if (!countryCode && extras.length === 0) return null; | |
| 1083 | + return ( | |
| 1084 | + <div className="grid grid-cols-1 md:grid-cols-3 gap-3 pt-1 border-t border-gray-100 dark:border-gray-700"> | |
| 1085 | + {countryCode && ( | |
| 1086 | + <div> | |
| 1087 | + <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5"> | |
| 1088 | + {getFieldLabel("country", contactFields)} | |
| 1089 | + </div> | |
| 1090 | + <div className="text-sm font-medium text-gray-900 dark:text-white"> | |
| 1091 | + {getCountryName(countryCode)} | |
| 1092 | + </div> | |
| 1093 | + </div> | |
| 1094 | + )} | |
| 1095 | + {extras.map(([fieldId, value]) => { | |
| 1096 | + const field = contactFields.find( | |
| 1097 | + (f) => f.id === fieldId, | |
| 1098 | + ); | |
| 1099 | + const display = | |
| 1100 | + field?.type === "country" | |
| 1101 | + ? getCountryName(String(value)) | |
| 1102 | + : optionLabel(field, value); | |
| 1103 | + return ( | |
| 1104 | + <div key={fieldId}> | |
| 1105 | + <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5"> | |
| 1106 | + {getFieldLabel(fieldId, contactFields)} | |
| 1107 | + </div> | |
| 1108 | + <div className="text-sm font-medium text-gray-900 dark:text-white"> | |
| 1109 | + {display} | |
| 1110 | + </div> | |
| 1111 | + </div> | |
| 1112 | + ); | |
| 1113 | + })} | |
| 1114 | + </div> | |
| 1115 | + ); | |
| 1116 | + })()} | |
| 1108 | 1117 | </CardContent> |
| 1109 | 1118 | </Card> |
| 1110 | 1119 | |
| 1111 | 1120 | {/* Travelers Information - Dynamic Fields */} |
| @@ -1214,22 +1223,20 @@ | ||
| 1214 | 1223 | const isLongField = |
| 1215 | 1224 | fieldConfig?.type === "textarea" || |
| 1216 | 1225 | String(fieldValue).length > 50; |
| 1217 | 1226 | |
| 1218 | - // Format date fields | |
| 1227 | + // Format date fields (e.g. Date of Birth) with the | |
| 1228 | + // global WP date-format setting via the shared | |
| 1229 | + // formatter — not the browser locale. | |
| 1219 | 1230 | let displayValue = String(fieldValue); |
| 1220 | 1231 | if ( |
| 1221 | 1232 | fieldConfig?.type === "date" || |
| 1222 | 1233 | fieldId.includes("date") || |
| 1234 | + fieldId.includes("birth") || | |
| 1235 | + fieldId.includes("dob") || | |
| 1223 | 1236 | fieldId.includes("expiry") |
| 1224 | 1237 | ) { |
| 1225 | - try { | |
| 1226 | - displayValue = new Date( | |
| 1227 | - fieldValue as string, | |
| 1228 | - ).toLocaleDateString(); | |
| 1229 | - } catch { | |
| 1230 | - displayValue = String(fieldValue); | |
| 1231 | - } | |
| 1238 | + displayValue = formatDate(fieldValue as string); | |
| 1232 | 1239 | } |
| 1233 | 1240 | |
| 1234 | 1241 | // Format country/nationality fields - convert code to full name |
| 1235 | 1242 | if ( |
| @@ -1241,8 +1248,20 @@ | ||
| 1241 | 1248 | ) { |
| 1242 | 1249 | displayValue = getCountryName(fieldValue); |
| 1243 | 1250 | } |
| 1244 | 1251 | |
| 1252 | + // Dropdown answers: option label instead of value. | |
| 1253 | + // The label is the operator's own text, so it is | |
| 1254 | + // shown as written (no title-casing). | |
| 1255 | + let asWritten = false; | |
| 1256 | + if (fieldConfig?.type === "select") { | |
| 1257 | + displayValue = optionLabel( | |
| 1258 | + fieldConfig, | |
| 1259 | + fieldValue, | |
| 1260 | + ); | |
| 1261 | + asWritten = displayValue !== String(fieldValue); | |
| 1262 | + } | |
| 1263 | + | |
| 1245 | 1264 | return ( |
| 1246 | 1265 | <div |
| 1247 | 1266 | key={fieldId} |
| 1248 | 1267 | className={ |
| @@ -1254,9 +1273,9 @@ | ||
| 1254 | 1273 | <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5"> |
| 1255 | 1274 | {label} |
| 1256 | 1275 | </div> |
| 1257 | 1276 | <div |
| 1258 | - className={`text-sm text-gray-900 dark:text-white ${fieldId === "passport" ? "font-mono" : ""} capitalize`} | |
| 1277 | + className={`text-sm text-gray-900 dark:text-white ${fieldId === "passport" ? "font-mono" : ""} ${asWritten ? "" : "capitalize"}`} | |
| 1259 | 1278 | > |
| 1260 | 1279 | {displayValue} |
| 1261 | 1280 | </div> |
| 1262 | 1281 | </div> |
| @@ -1300,15 +1319,24 @@ | ||
| 1300 | 1319 | ([_, value]) => value && String(value).trim() !== "", |
| 1301 | 1320 | ) |
| 1302 | 1321 | .map(([fieldId, fieldValue]) => { |
| 1303 | 1322 | const label = getFieldLabel(fieldId, emergencyFields); |
| 1323 | + const emergencyField = emergencyFields.find( | |
| 1324 | + (f) => f.id === fieldId, | |
| 1325 | + ); | |
| 1326 | + const emergencyDisplay = optionLabel( | |
| 1327 | + emergencyField, | |
| 1328 | + fieldValue, | |
| 1329 | + ); | |
| 1304 | 1330 | return ( |
| 1305 | 1331 | <div key={fieldId}> |
| 1306 | 1332 | <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5"> |
| 1307 | 1333 | {label} |
| 1308 | 1334 | </div> |
| 1309 | - <div className="text-sm font-medium text-gray-900 dark:text-white capitalize"> | |
| 1310 | - {String(fieldValue)} | |
| 1335 | + <div | |
| 1336 | + className={`text-sm font-medium text-gray-900 dark:text-white ${emergencyDisplay !== String(fieldValue) ? "" : "capitalize"}`} | |
| 1337 | + > | |
| 1338 | + {emergencyDisplay} | |
| 1311 | 1339 | </div> |
| 1312 | 1340 | </div> |
| 1313 | 1341 | ); |
| 1314 | 1342 | })} |