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 / Bookings.tsx

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

567 lines 24.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useState } from "react";
2 import {
3 Package,
4 CheckCircle,
5 Calendar,
6 Clock,
7 DollarSign,
8 MapPin,
9 Plane,
10 } from "lucide-react";
11 import { __, sprintf } from "../../lib/i18n";
12 import { toDateValue } from "../../lib/dateFormat";
13 import {
14 formatDate,
15 formatTravelDateRange,
16 getBadge,
17 getStatusLabel,
18 currency,
19 phoneToTelHref,
20 } from "./utils";
21 import { downloadVoucher } from "./utils/downloads";
22 import type { Booking } from "./types";
23 import BookingDetails from "./BookingDetails";
24 import { useQuery } from "@tanstack/react-query";
25 import { apiClient } from "../../lib/api-client";
26 import { API_ENDPOINTS } from "../../lib/api-endpoints";
27
28 interface BookingsProps {
29 bookings: Booking[];
30 conciergePhone?: string;
31 conciergeEmail?: string;
32 onSectionChange: (section: string) => void;
33 /**
34 * Booking id to deep-link into on mount/update. Set by AccountPage
35 * when the user clicks an "Upcoming Trips" card on the Dashboard so
36 * we open the detail screen instead of dropping them on the list.
37 * Consumed (cleared back to null) on the same render so subsequent
38 * Back-to-list interactions stay local to this component.
39 */
40 initialBookingId?: number | null;
41 onBookingIdConsumed?: () => void;
42 }
43
44 const Bookings: React.FC<BookingsProps> = ({
45 bookings,
46 conciergePhone = "",
47 conciergeEmail = "",
48 onSectionChange,
49 initialBookingId = null,
50 onBookingIdConsumed,
51 }) => {
52 const conciergeTel = phoneToTelHref(conciergePhone);
53 const conciergeMail = String(conciergeEmail || "").trim();
54 const [searchTerm, setSearchTerm] = useState("");
55 const [bookingFilter, setBookingFilter] = useState<
56 "all" | "upcoming" | "pending" | "completed"
57 >("all");
58 const [selectedBookingId, setSelectedBookingId] = useState<number | null>(
59 initialBookingId,
60 );
61
62 // When the parent seeds a deep-link booking id (Dashboard "Upcoming
63 // Trips" click), adopt it and tell the parent we've consumed it so
64 // the user can navigate away without the id rebinding on every
65 // re-render.
66 React.useEffect(() => {
67 if (initialBookingId && initialBookingId !== selectedBookingId) {
68 setSelectedBookingId(initialBookingId);
69 onBookingIdConsumed?.();
70 }
71 // eslint-disable-next-line react-hooks/exhaustive-deps
72 }, [initialBookingId]);
73 const [payLoading, setPayLoading] = useState<number | null>(null);
74
75 const {
76 data: bookingDetailsData,
77 isPending: isBookingDetailLoading,
78 isError: isBookingDetailError,
79 } = useQuery({
80 queryKey: ["booking-details", selectedBookingId],
81 queryFn: async () => {
82 if (!selectedBookingId) return null;
83 const response = await apiClient.get(
84 API_ENDPOINTS.CUSTOMER_MY_BOOKING(selectedBookingId),
85 );
86 const inner =
87 response &&
88 typeof response === "object" &&
89 "data" in response &&
90 response.data &&
91 typeof response.data === "object"
92 ? response.data
93 : response;
94 return inner;
95 },
96 enabled: !!selectedBookingId,
97 });
98
99 const handleBookingSelect = (bookingId: number) => {
100 setSelectedBookingId(bookingId);
101 };
102
103 const handleBackToList = () => {
104 setSelectedBookingId(null);
105 };
106
107 const handleDownloadClick = async (bookingId: number) => {
108 try {
109 await downloadVoucher(bookingId);
110 } catch (e) {
111 const msg =
112 e instanceof Error ? e.message : __("Download failed.", "yatra");
113 window.alert(msg);
114 }
115 };
116
117 const startRemainingPaymentSession = async (bookingId: number) => {
118 setPayLoading(bookingId);
119 try {
120 const response = await apiClient.post(
121 API_ENDPOINTS.PAYMENT_REMAINING_SESSION,
122 { booking_id: bookingId },
123 );
124 const payload =
125 response &&
126 typeof response === "object" &&
127 "data" in response &&
128 response.data &&
129 typeof response.data === "object"
130 ? response.data
131 : response;
132 const checkoutUrl =
133 payload &&
134 typeof payload === "object" &&
135 "checkout_url" in payload &&
136 typeof (payload as { checkout_url?: string }).checkout_url === "string"
137 ? (payload as { checkout_url: string }).checkout_url
138 : "";
139
140 if (checkoutUrl) {
141 window.location.href = checkoutUrl;
142 } else {
143 window.alert(__("Could not start payment. Please try again.", "yatra"));
144 }
145 } catch (error) {
146 console.error("Error initiating payment:", error);
147 const msg =
148 error instanceof Error && error.message
149 ? error.message
150 : __("Could not start payment.", "yatra");
151 window.alert(msg);
152 } finally {
153 setPayLoading(null);
154 }
155 };
156
157 if (selectedBookingId) {
158 return (
159 <BookingDetails
160 booking={
161 isBookingDetailLoading || isBookingDetailError
162 ? null
163 : (bookingDetailsData as any)
164 }
165 isLoading={isBookingDetailLoading}
166 onBack={handleBackToList}
167 />
168 );
169 }
170
171 const displayBookings = bookings;
172
173 // Filter bookings based on selected filter and search term
174 let filteredDisplayBookings = displayBookings;
175
176 // Apply filter
177 if (bookingFilter === "upcoming") {
178 const today = new Date();
179 today.setHours(0, 0, 0, 0); // Set to start of day for accurate comparison
180 filteredDisplayBookings = filteredDisplayBookings.filter((b) => {
181 const travelDate = toDateValue(b.travel_date);
182 return travelDate >= today;
183 });
184 } else if (bookingFilter === "pending") {
185 filteredDisplayBookings = filteredDisplayBookings.filter(
186 (b) => b.payment_status === "pending" || b.booking_status === "pending",
187 );
188 } else if (bookingFilter === "completed") {
189 filteredDisplayBookings = filteredDisplayBookings.filter(
190 (b) => b.booking_status === "completed",
191 );
192 }
193
194 // Apply search
195 if (searchTerm.trim()) {
196 const searchLower = searchTerm.toLowerCase().trim();
197 filteredDisplayBookings = filteredDisplayBookings.filter(
198 (b: any) =>
199 (b.trip_title && b.trip_title.toLowerCase().includes(searchLower)) ||
200 (b.booking_number &&
201 b.booking_number.toLowerCase().includes(searchLower)) ||
202 (b.destination && b.destination.toLowerCase().includes(searchLower)) ||
203 (b.payment_status &&
204 b.payment_status.toLowerCase().includes(searchLower)) ||
205 (b.booking_status &&
206 b.booking_status.toLowerCase().includes(searchLower)),
207 );
208 }
209
210 // Calculate booking statistics
211 const bookingStats = {
212 total: displayBookings.length,
213 upcoming: displayBookings.filter(
214 (b) => toDateValue(b.travel_date) >= new Date(),
215 ).length,
216 pending: displayBookings.filter(
217 (b) => b.payment_status === "pending" || b.booking_status === "pending",
218 ).length,
219 completed: displayBookings.filter((b) => b.booking_status === "completed")
220 .length,
221 totalSpent: displayBookings.reduce((sum, b) => sum + b.total_amount, 0),
222 };
223
224 return (
225 <div className="yatra-bookings-page space-y-6">
226 {/* Header */}
227 <div className="yatra-bookings-header bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm p-6">
228 <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
229 <div>
230 <h2 className="text-lg font-bold text-gray-900 dark:text-white">
231 {__("Bookings", "yatra")}
232 </h2>
233 <p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
234 {__("Manage and view all your travel bookings", "yatra")}
235 </p>
236 </div>
237 <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:gap-4">
238 {/* Search Input */}
239 <div className="relative">
240 <input
241 placeholder={__("Search bookings...", "yatra")}
242 value={searchTerm}
243 onChange={(e) => setSearchTerm(e.target.value)}
244 className="w-full lg:w-64 pl-4 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-yatra-primary focus:border-transparent"
245 />
246 </div>
247 {/* Filter Dropdown */}
248 <div className="relative">
249 <select
250 value={bookingFilter}
251 onChange={(e) =>
252 setBookingFilter(
253 e.target.value as
254 | "all"
255 | "upcoming"
256 | "pending"
257 | "completed",
258 )
259 }
260 className="appearance-none bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 pr-4 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-yatra-primary focus:border-transparent cursor-pointer"
261 >
262 <option value="all">{__("All Bookings", "yatra")}</option>
263 <option value="upcoming">{__("Upcoming", "yatra")}</option>
264 <option value="pending">{__("Pending", "yatra")}</option>
265 <option value="completed">{__("Completed", "yatra")}</option>
266 </select>
267 </div>
268 </div>
269 </div>
270 </div>
271
272 {/* Stats Grid - Clean Dashboard Style */}
273 <div className="flex flex-nowrap gap-6 overflow-x-auto">
274 <div className="flex-shrink-0 bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-100 dark:border-gray-700 min-w-0 flex-1">
275 <div className="flex items-center justify-between">
276 <div>
277 <p className="text-xs text-gray-500 dark:text-gray-400 mb-1">
278 {__("Total Bookings", "yatra")}
279 </p>
280 <p className="text-xl font-bold text-gray-900 dark:text-white">
281 {bookingStats.total}
282 </p>
283 </div>
284 <div className="p-2 bg-yatra-soft dark:bg-yatra-surface-dark-muted rounded-lg">
285 <Package className="w-5 h-5 text-yatra-primary dark:text-yatra-on-dark" />
286 </div>
287 </div>
288 </div>
289 <div className="flex-shrink-0 bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-100 dark:border-gray-700 min-w-0 flex-1">
290 <div className="flex items-center justify-between">
291 <div>
292 <p className="text-xs text-gray-500 dark:text-gray-400 mb-1">
293 {__("Upcoming", "yatra")}
294 </p>
295 <p className="text-xl font-bold text-emerald-600 dark:text-emerald-400">
296 {bookingStats.upcoming}
297 </p>
298 </div>
299 <div className="p-2 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg">
300 <Calendar className="w-5 h-5 text-emerald-600 dark:text-emerald-400" />
301 </div>
302 </div>
303 </div>
304 <div className="flex-shrink-0 bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-100 dark:border-gray-700 min-w-0 flex-1">
305 <div className="flex items-center justify-between">
306 <div>
307 <p className="text-xs text-gray-500 dark:text-gray-400 mb-1">
308 {__("Pending", "yatra")}
309 </p>
310 <p className="text-xl font-bold text-amber-600 dark:text-amber-400">
311 {bookingStats.pending}
312 </p>
313 </div>
314 <div className="p-2 bg-amber-50 dark:bg-amber-900/20 rounded-lg">
315 <Clock className="w-5 h-5 text-amber-600 dark:text-amber-400" />
316 </div>
317 </div>
318 </div>
319 <div className="flex-shrink-0 bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-100 dark:border-gray-700 min-w-0 flex-1">
320 <div className="flex items-center justify-between">
321 <div>
322 <p className="text-xs text-gray-500 dark:text-gray-400 mb-1">
323 {__("Completed", "yatra")}
324 </p>
325 <p className="text-xl font-bold text-gray-600 dark:text-gray-400">
326 {bookingStats.completed}
327 </p>
328 </div>
329 <div className="p-2 bg-gray-50 dark:bg-gray-700 rounded-lg">
330 <CheckCircle className="w-5 h-5 text-gray-600 dark:text-gray-400" />
331 </div>
332 </div>
333 </div>
334 <div className="flex-shrink-0 bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-100 dark:border-gray-700 min-w-0 flex-1">
335 <div className="flex items-center justify-between">
336 <div>
337 <p className="text-xs text-gray-500 dark:text-gray-400 mb-1">
338 {__("Total Spent", "yatra")}
339 </p>
340 <p className="text-xl font-bold text-gray-900 dark:text-white">
341 {currency(bookingStats.totalSpent)}
342 </p>
343 </div>
344 <div className="p-2 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
345 <DollarSign className="w-5 h-5 text-purple-600 dark:text-purple-400" />
346 </div>
347 </div>
348 </div>
349 </div>
350
351 {/* Bookings List */}
352 {filteredDisplayBookings.length === 0 ? (
353 <div className="bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl p-12 text-center">
354 <Package className="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
355 <p className="text-gray-500 dark:text-gray-400 font-medium mb-1">
356 {__("No bookings found", "yatra")}
357 </p>
358 <p className="text-sm text-gray-400 dark:text-gray-500">
359 {__("Try adjusting your filters or check back later.", "yatra")}
360 </p>
361 </div>
362 ) : (
363 <div className="space-y-4">
364 {filteredDisplayBookings.map((booking) => {
365 const bookingId = Number(booking.id);
366 const paidNumeric =
367 typeof booking.amount_paid === "number"
368 ? booking.amount_paid
369 : parseFloat(booking.amount_paid || "0");
370 const totalNumeric =
371 typeof booking.total_amount === "number"
372 ? booking.total_amount
373 : parseFloat(booking.total_amount || "0");
374 const totalPaid = paidNumeric;
375 const amountDue = totalNumeric - totalPaid;
376 const canPayRemaining =
377 amountDue > 0 && booking.payment_status !== "paid";
378
379 return (
380 <div
381 key={booking.id}
382 className="group bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-sm hover:shadow-md transition-all overflow-hidden"
383 >
384 <div className="p-6">
385 {/* Header Row */}
386 <div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 mb-4">
387 <div className="flex-1">
388 <div className="flex items-center gap-3 mb-2">
389 {/* Trip icon — distinct from the destination pin below,
390 and a single consistent brand colour (booking status
391 is already shown by the badges on the right). */}
392 <div className="p-2 rounded-lg bg-yatra-soft dark:bg-yatra-surface-dark-muted">
393 <Plane className="w-5 h-5 text-yatra-primary dark:text-yatra-on-dark" />
394 </div>
395 <div className="flex-1">
396 <p className="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400 font-medium mb-1">
397 {booking.booking_number}
398 </p>
399 <h3 className="text-base font-bold text-gray-900 dark:text-white mb-1">
400 {booking.trip_title}
401 </h3>
402 <p className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-1">
403 <MapPin className="w-4 h-4 text-gray-600 dark:text-gray-400" />
404 {booking.destination ||
405 __("Multiple destinations", "yatra")}
406 </p>
407 </div>
408 </div>
409 </div>
410 <div className="flex flex-wrap gap-2 items-start">
411 <span className={getBadge(booking.booking_status)}>
412 {getStatusLabel(booking.booking_status)}
413 </span>
414 <span className={getBadge(booking.payment_status)}>
415 {getStatusLabel(booking.payment_status)}
416 </span>
417 </div>
418 </div>
419
420 {/* Details Grid */}
421 <div className="yatra-booking-details grid grid-cols-2 md:grid-cols-5 gap-4 p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg mb-4">
422 <div>
423 <p className="text-xs text-gray-500 dark:text-gray-400 mb-1">
424 {__("Travel Date", "yatra")}
425 </p>
426 <p className="text-sm font-semibold text-gray-900 dark:text-white">
427 {formatTravelDateRange(
428 booking.travel_date,
429 booking.end_date,
430 )}
431 </p>
432 </div>
433 <div>
434 <p className="text-xs text-gray-500 dark:text-gray-400 mb-1">
435 {__("Travelers", "yatra")}
436 </p>
437 <p className="text-sm font-semibold text-gray-900 dark:text-white">
438 {(() => {
439 // Read `travelers_count` first — `travelers` is an
440 // array on the booking-detail endpoint and would
441 // coerce to NaN. See Booking interface comments.
442 const n =
443 Number(
444 booking.travelers_count ?? booking.travelers ?? 0,
445 ) || 0;
446 // __() (not _n): make-pot misses _n() in the
447 // minified account bundle. See BookingDetails.
448 return n === 1
449 ? sprintf(__("%d traveler", "yatra"), n)
450 : sprintf(__("%d travelers", "yatra"), n);
451 })()}
452 </p>
453 </div>
454 <div>
455 <p className="text-xs text-gray-500 dark:text-gray-400 mb-1">
456 {__("Total Amount", "yatra")}
457 </p>
458 <p className="text-sm font-semibold text-gray-900 dark:text-white">
459 {currency(booking.total_amount)}
460 </p>
461 </div>
462 <div>
463 <p className="text-xs text-gray-500 dark:text-gray-400 mb-1">
464 {__("Paid / Due", "yatra")}
465 </p>
466 <p className="text-sm font-semibold text-gray-900 dark:text-white">
467 {currency(totalPaid ?? 0)}
468 {amountDue !== null && (
469 <span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1">
470 {__("Due:", "yatra")} {currency(amountDue)}
471 </span>
472 )}
473 </p>
474 </div>
475 <div>
476 <p className="text-xs text-gray-500 dark:text-gray-400 mb-1">
477 {__("Booked On", "yatra")}
478 </p>
479 <p className="text-sm font-semibold text-gray-900 dark:text-white">
480 {formatDate(booking.booking_date || booking.created_at)}
481 </p>
482 </div>
483 </div>
484
485 {/* Action Buttons */}
486 <div className="yatra-booking-actions flex flex-wrap gap-3">
487 <div
488 role="button"
489 tabIndex={0}
490 onClick={() => handleBookingSelect(bookingId)}
491 className="yatra-booking-action yatra-booking-action-view inline-flex items-center px-4 py-2 rounded-lg bg-yatra-primary text-white hover:bg-yatra-primary-dark transition-colors text-sm font-medium cursor-pointer"
492 >
493 {__("View Details", "yatra")}
494 </div>
495 <div
496 role="button"
497 tabIndex={0}
498 onClick={() => handleDownloadClick(bookingId)}
499 className="yatra-booking-action yatra-booking-action-download inline-flex items-center px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-sm font-medium cursor-pointer"
500 >
501 {__("Download Voucher", "yatra")}
502 </div>
503 <div
504 role="button"
505 tabIndex={0}
506 onClick={() => onSectionChange("payments")}
507 className="yatra-booking-action yatra-booking-action-payment inline-flex items-center px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-sm font-medium cursor-pointer"
508 >
509 {__("Payment", "yatra")}
510 </div>
511 {canPayRemaining && (
512 <button
513 type="button"
514 onClick={() =>
515 bookingId && startRemainingPaymentSession(bookingId)
516 }
517 disabled={payLoading === bookingId}
518 className="yatra-booking-action inline-flex items-center px-4 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 transition-colors text-sm font-medium disabled:opacity-50"
519 >
520 {payLoading === bookingId ? (
521 <svg
522 className="w-4 h-4 animate-spin"
523 viewBox="0 0 24 24"
524 fill="none"
525 stroke="currentColor"
526 strokeWidth="2"
527 >
528 <circle
529 cx="12"
530 cy="12"
531 r="10"
532 strokeOpacity="0.25"
533 />
534 <path d="M4 12a8 8 0 0 1 8-8" />
535 </svg>
536 ) : null}
537 {__("Pay Remaining Balance", "yatra")}
538 </button>
539 )}
540 {conciergeTel ? (
541 <a
542 href={conciergeTel}
543 className="yatra-booking-action yatra-booking-action-support inline-flex items-center px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-sm font-medium no-underline"
544 >
545 {__("Call us", "yatra")}
546 </a>
547 ) : conciergeMail ? (
548 <a
549 href={`mailto:${encodeURIComponent(conciergeMail)}`}
550 className="yatra-booking-action yatra-booking-action-support inline-flex items-center px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-sm font-medium no-underline"
551 >
552 {__("Email us", "yatra")}
553 </a>
554 ) : null}
555 </div>
556 </div>
557 </div>
558 );
559 })}
560 </div>
561 )}
562 </div>
563 );
564 };
565
566 export default Bookings;
567