PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
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
← All changes | resources/js/pages/ViewBooking.tsx +273 -42 3.0.5 → 3.0.16 View file →
@@ -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,8 +23,9 @@
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";
27 29 import { getCountryName } from "../lib/countries";
28 30 import { Button } from "../components/ui/button";
29 31 import { PageHeader } from "../components/common/PageHeader";
@@ -33,8 +35,10 @@
33 35 CardHeader,
34 36 CardTitle,
35 37 } from "../components/ui/card";
36 38 import { ConditionalRender } from "../components/ui/conditional-render";
39 +import { ResendEmailMenu } from "../components/bookings/ResendEmailMenu";
40 +import { downloadAdminBookingInvoice } from "../lib/invoice-download";
37 41 import { Skeleton } from "../components/ui/skeleton";
38 42 import { formatYatraMoney } from "../lib/currency-display";
39 43
40 44 interface GoogleCalendarSyncInfo {
@@ -53,10 +57,28 @@
53 57 label: string;
54 58 enabled: boolean;
55 59 order: number;
56 60 section?: string;
61 + options?: { value: string; label: string }[];
57 62 }
58 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 +
59 81 interface FormSectionConfig {
60 82 title: string;
61 83 enabled: boolean;
62 84 fields: FormFieldConfig[];
@@ -76,37 +98,8 @@
76 98 const params = new URLSearchParams(window.location.search);
77 99 return params.get("id") ? parseInt(params.get("id") || "0") : null;
78 100 }, []);
79 101
80 - // Fetch booking form configuration for dynamic field labels
81 - const { data: formConfig } = useQuery<BookingFormConfig>({
82 - queryKey: ["booking-form-config"],
83 - queryFn: async () => {
84 - const response = await apiService.getSettings();
85 - return (
86 - response?.data?.booking_form_config ||
87 - response?.booking_form_config ||
88 - null
89 - );
90 - },
91 - });
92 -
93 - // Get enabled traveler fields from config
94 - const travelerFields = useMemo(() => {
95 - if (!formConfig?.traveler_form?.fields) return [];
96 - return formConfig.traveler_form.fields
97 - .filter((field) => field.enabled)
98 - .sort((a, b) => a.order - b.order);
99 - }, [formConfig]);
100 -
101 - // Get enabled emergency contact fields
102 - const emergencyFields = useMemo(() => {
103 - if (!formConfig?.emergency_contact_form?.fields) return [];
104 - return formConfig.emergency_contact_form.fields
105 - .filter((field) => field.enabled)
106 - .sort((a, b) => a.order - b.order);
107 - }, [formConfig]);
108 -
109 102 // Helper to get field label by ID
110 103 const getFieldLabel = (
111 104 fieldId: string,
112 105 fields: FormFieldConfig[],
@@ -210,8 +203,50 @@
210 203 },
211 204 enabled: !!bookingId && can("yatra_view_bookings"),
212 205 });
213 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 +
214 249 // Fetch consent status for this booking (only if Pro is active)
215 250 const isPro = !!(window as any).yatraAdmin?.isPro;
216 251 const { data: consentStatus } = useQuery({
217 252 queryKey: ["booking-consent-status", bookingId],
@@ -319,8 +354,29 @@
319 354 const handleEdit = () => {
320 355 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings&action=edit&id=${bookingId}`;
321 356 };
322 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 +
323 379 if (isLoading) {
324 380 return (
325 381 <div className="space-y-3">
326 382 {/* Header Skeleton */}
@@ -470,9 +526,26 @@
470 526 title={__("Booking Details", "yatra")}
471 527 description={__("View complete booking information", "yatra")}
472 528 actions={
473 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>
474 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">
475 548 <Button onClick={handleEdit} className="flex items-center gap-2">
476 549 {__("Edit Booking", "yatra")}
477 550 </Button>
478 551 </ConditionalRender>
@@ -487,8 +560,18 @@
487 560 </div>
488 561 }
489 562 />
490 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 +
491 574 <ConditionalRender capability="yatra_view_bookings">
492 575 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
493 576 {/* Main Content */}
494 577 <div className="lg:col-span-2 space-y-3">
@@ -876,8 +959,81 @@
876 959 </div>
877 960 </CardContent>
878 961 </Card>
879 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 +
880 1036 {/* Customer Information */}
881 1037 <Card>
882 1038 <CardHeader className="pb-2">
883 1039 <CardTitle className="text-base">
@@ -896,13 +1052,69 @@
896 1052 </div>
897 1053 {booking.customer_phone && (
898 1054 <div className="flex items-center gap-1.5">
899 1055 <Phone className="w-4 h-4" />
900 - {booking.customer_phone}
1056 + <PhoneDisplay value={booking.customer_phone} />
901 1057 </div>
902 1058 )}
903 1059 </div>
904 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 + })()}
905 1117 </CardContent>
906 1118 </Card>
907 1119
908 1120 {/* Travelers Information - Dynamic Fields */}
@@ -1011,22 +1223,20 @@
1011 1223 const isLongField =
1012 1224 fieldConfig?.type === "textarea" ||
1013 1225 String(fieldValue).length > 50;
1014 1226
1015 - // 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.
1016 1230 let displayValue = String(fieldValue);
1017 1231 if (
1018 1232 fieldConfig?.type === "date" ||
1019 1233 fieldId.includes("date") ||
1234 + fieldId.includes("birth") ||
1235 + fieldId.includes("dob") ||
1020 1236 fieldId.includes("expiry")
1021 1237 ) {
1022 - try {
1023 - displayValue = new Date(
1024 - fieldValue as string,
1025 - ).toLocaleDateString();
1026 - } catch {
1027 - displayValue = String(fieldValue);
1028 - }
1238 + displayValue = formatDate(fieldValue as string);
1029 1239 }
1030 1240
1031 1241 // Format country/nationality fields - convert code to full name
1032 1242 if (
@@ -1038,8 +1248,20 @@
1038 1248 ) {
1039 1249 displayValue = getCountryName(fieldValue);
1040 1250 }
1041 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 +
1042 1264 return (
1043 1265 <div
1044 1266 key={fieldId}
1045 1267 className={
@@ -1051,9 +1273,9 @@
1051 1273 <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5">
1052 1274 {label}
1053 1275 </div>
1054 1276 <div
1055 - 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"}`}
1056 1278 >
1057 1279 {displayValue}
1058 1280 </div>
1059 1281 </div>
@@ -1097,15 +1319,24 @@
1097 1319 ([_, value]) => value && String(value).trim() !== "",
1098 1320 )
1099 1321 .map(([fieldId, fieldValue]) => {
1100 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 + );
1101 1330 return (
1102 1331 <div key={fieldId}>
1103 1332 <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5">
1104 1333 {label}
1105 1334 </div>
1106 - <div className="text-sm font-medium text-gray-900 dark:text-white capitalize">
1107 - {String(fieldValue)}
1335 + <div
1336 + className={`text-sm font-medium text-gray-900 dark:text-white ${emergencyDisplay !== String(fieldValue) ? "" : "capitalize"}`}
1337 + >
1338 + {emergencyDisplay}
1108 1339 </div>
1109 1340 </div>
1110 1341 );
1111 1342 })}