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

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