PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.7
Yatra – Travel Booking & Tour Operator Software v3.0.7
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.7, at resources/js/pages/account/Bookings.tsx

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