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

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