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 +218 -52 3.0.7 → 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,46 +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 - // Get enabled contact (lead traveler) fields — used to label and format the
110 - // country / nationality / address / custom contact fields in the summary.
111 - const contactFields = useMemo(() => {
112 - if (!formConfig?.contact_form?.fields) return [];
113 - return formConfig.contact_form.fields
114 - .filter((field) => field.enabled)
115 - .sort((a, b) => a.order - b.order);
116 - }, [formConfig]);
117 -
118 102 // Helper to get field label by ID
119 103 const getFieldLabel = (
120 104 fieldId: string,
121 105 fields: FormFieldConfig[],
@@ -219,8 +203,50 @@
219 203 },
220 204 enabled: !!bookingId && can("yatra_view_bookings"),
221 205 });
222 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 +
223 249 // Fetch consent status for this booking (only if Pro is active)
224 250 const isPro = !!(window as any).yatraAdmin?.isPro;
225 251 const { data: consentStatus } = useQuery({
226 252 queryKey: ["booking-consent-status", bookingId],
@@ -328,8 +354,29 @@
328 354 const handleEdit = () => {
329 355 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings&action=edit&id=${bookingId}`;
330 356 };
331 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 +
332 379 if (isLoading) {
333 380 return (
334 381 <div className="space-y-3">
335 382 {/* Header Skeleton */}
@@ -479,9 +526,26 @@
479 526 title={__("Booking Details", "yatra")}
480 527 description={__("View complete booking information", "yatra")}
481 528 actions={
482 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>
483 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">
484 548 <Button onClick={handleEdit} className="flex items-center gap-2">
485 549 {__("Edit Booking", "yatra")}
486 550 </Button>
487 551 </ConditionalRender>
@@ -496,8 +560,18 @@
496 560 </div>
497 561 }
498 562 />
499 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 +
500 574 <ConditionalRender capability="yatra_view_bookings">
501 575 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
502 576 {/* Main Content */}
503 577 <div className="lg:col-span-2 space-y-3">
@@ -885,8 +959,81 @@
885 959 </div>
886 960 </CardContent>
887 961 </Card>
888 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 +
889 1036 {/* Customer Information */}
890 1037 <Card>
891 1038 <CardHeader className="pb-2">
892 1039 <CardTitle className="text-base">
@@ -905,9 +1052,9 @@
905 1052 </div>
906 1053 {booking.customer_phone && (
907 1054 <div className="flex items-center gap-1.5">
908 1055 <Phone className="w-4 h-4" />
909 - {booking.customer_phone}
1056 + <PhoneDisplay value={booking.customer_phone} />
910 1057 </div>
911 1058 )}
912 1059 </div>
913 1060 </div>
@@ -951,9 +1098,9 @@
951 1098 );
952 1099 const display =
953 1100 field?.type === "country"
954 1101 ? getCountryName(String(value))
955 - : String(value);
1102 + : optionLabel(field, value);
956 1103 return (
957 1104 <div key={fieldId}>
958 1105 <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5">
959 1106 {getFieldLabel(fieldId, contactFields)}
@@ -1076,22 +1223,20 @@
1076 1223 const isLongField =
1077 1224 fieldConfig?.type === "textarea" ||
1078 1225 String(fieldValue).length > 50;
1079 1226
1080 - // 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.
1081 1230 let displayValue = String(fieldValue);
1082 1231 if (
1083 1232 fieldConfig?.type === "date" ||
1084 1233 fieldId.includes("date") ||
1234 + fieldId.includes("birth") ||
1235 + fieldId.includes("dob") ||
1085 1236 fieldId.includes("expiry")
1086 1237 ) {
1087 - try {
1088 - displayValue = new Date(
1089 - fieldValue as string,
1090 - ).toLocaleDateString();
1091 - } catch {
1092 - displayValue = String(fieldValue);
1093 - }
1238 + displayValue = formatDate(fieldValue as string);
1094 1239 }
1095 1240
1096 1241 // Format country/nationality fields - convert code to full name
1097 1242 if (
@@ -1103,8 +1248,20 @@
1103 1248 ) {
1104 1249 displayValue = getCountryName(fieldValue);
1105 1250 }
1106 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 +
1107 1264 return (
1108 1265 <div
1109 1266 key={fieldId}
1110 1267 className={
@@ -1116,9 +1273,9 @@
1116 1273 <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5">
1117 1274 {label}
1118 1275 </div>
1119 1276 <div
1120 - 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"}`}
1121 1278 >
1122 1279 {displayValue}
1123 1280 </div>
1124 1281 </div>
@@ -1162,15 +1319,24 @@
1162 1319 ([_, value]) => value && String(value).trim() !== "",
1163 1320 )
1164 1321 .map(([fieldId, fieldValue]) => {
1165 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 + );
1166 1330 return (
1167 1331 <div key={fieldId}>
1168 1332 <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5">
1169 1333 {label}
1170 1334 </div>
1171 - <div className="text-sm font-medium text-gray-900 dark:text-white capitalize">
1172 - {String(fieldValue)}
1335 + <div
1336 + className={`text-sm font-medium text-gray-900 dark:text-white ${emergencyDisplay !== String(fieldValue) ? "" : "capitalize"}`}
1337 + >
1338 + {emergencyDisplay}
1173 1339 </div>
1174 1340 </div>
1175 1341 );
1176 1342 })}