PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.6
Yatra – Travel Booking & Tour Operator Software v3.0.6
3.0.17 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 All 85 releases
yatra / resources / js / pages / account / Bookings.tsx

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

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