mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 07:08:18 +00:00
Update price
This commit is contained in:
@@ -1,16 +1,16 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from "next/navigation";
|
||||||
import { useBookingStore } from '@/lib/booking-store';
|
import { useBookingStore } from "@/lib/booking-store";
|
||||||
import { usePaymentStore } from '@/lib/payment-store';
|
import { usePaymentStore } from "@/lib/payment-store";
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from "@/lib/api-client";
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import { CheckCircle, Clock, Copy, Train, FileText } from 'lucide-react';
|
import { CheckCircle, Clock, Copy, Train, FileText } from "lucide-react";
|
||||||
import { format } from 'date-fns';
|
import { format } from "date-fns";
|
||||||
import { isChild, isFirstChild } from '@/utils/fare-utils';
|
import { isChild, isFirstChild } from "@/utils/fare-utils";
|
||||||
|
|
||||||
type BookingWithTicket = {
|
type BookingWithTicket = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -38,11 +38,24 @@ type BookingWithTicket = {
|
|||||||
|
|
||||||
export default function ConfirmationPage() {
|
export default function ConfirmationPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, searchCriteria, passengers, clearBooking, packageName, packageId, reviewedTotalMinor, reviewedPassengerFares } = useBookingStore();
|
const {
|
||||||
|
bookingId,
|
||||||
|
pnr,
|
||||||
|
selectedSchedule,
|
||||||
|
outboundSchedule,
|
||||||
|
inboundSchedule,
|
||||||
|
searchCriteria,
|
||||||
|
passengers,
|
||||||
|
clearBooking,
|
||||||
|
packageName,
|
||||||
|
packageId,
|
||||||
|
reviewedTotalMinor,
|
||||||
|
reviewedPassengerFares,
|
||||||
|
} = useBookingStore();
|
||||||
// The currency/amount actually confirmed for the payment option the user selected —
|
// The currency/amount actually confirmed for the payment option the user selected —
|
||||||
// null when no payment step ran (e.g. a fully-discounted, zero-amount booking).
|
// null when no payment step ran (e.g. a fully-discounted, zero-amount booking).
|
||||||
const { selectedCurrency: paidCurrency, paidAmountMinor } = usePaymentStore();
|
const { selectedCurrency: paidCurrency, paidAmountMinor } = usePaymentStore();
|
||||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
const isRoundTrip = searchCriteria?.tripType === "ROUND_TRIP";
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
|
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
|
||||||
|
|
||||||
@@ -51,20 +64,23 @@ export default function ConfirmationPage() {
|
|||||||
// too long after the originating click's synchronous execution window is silently
|
// too long after the originating click's synchronous execution window is silently
|
||||||
// blocked, and awaiting a cold dynamic import is enough to fall outside that window.
|
// blocked, and awaiting a cold dynamic import is enough to fall outside that window.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
import('@/lib/generate-voucher');
|
import("@/lib/generate-voucher");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { data: _booking } = useQuery<BookingWithTicket>({
|
const { data: _booking } = useQuery<BookingWithTicket>({
|
||||||
queryKey: ['booking', bookingId],
|
queryKey: ["booking", bookingId],
|
||||||
queryFn: async (): Promise<BookingWithTicket> => {
|
queryFn: async (): Promise<BookingWithTicket> => {
|
||||||
try {
|
try {
|
||||||
return await apiClient.get(`/bookings/${bookingId}`);
|
return await apiClient.get(`/bookings/${bookingId}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
id: bookingId || '',
|
id: bookingId || "",
|
||||||
pnr: pnr || undefined,
|
pnr: pnr || undefined,
|
||||||
status: 'PENDING_PAYMENT',
|
status: "PENDING_PAYMENT",
|
||||||
totalMinor: passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0),
|
totalMinor: passengers.reduce(
|
||||||
|
(sum) => sum + (selectedSchedule?.baseFareAdult || 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -76,7 +92,7 @@ export default function ConfirmationPage() {
|
|||||||
// Ticket generation itself is never triggered from this page — the payment webhook
|
// Ticket generation itself is never triggered from this page — the payment webhook
|
||||||
// generates it server-side (for every payment method, wallet included); this page only
|
// generates it server-side (for every payment method, wallet included); this page only
|
||||||
// ever fetches and displays whatever the booking query above already returns.
|
// ever fetches and displays whatever the booking query above already returns.
|
||||||
const isConfirmed = _booking?.status === 'CONFIRMED';
|
const isConfirmed = _booking?.status === "CONFIRMED";
|
||||||
|
|
||||||
const copyPNR = () => {
|
const copyPNR = () => {
|
||||||
if (pnr) {
|
if (pnr) {
|
||||||
@@ -88,46 +104,53 @@ export default function ConfirmationPage() {
|
|||||||
|
|
||||||
const handleDownloadVoucher = async () => {
|
const handleDownloadVoucher = async () => {
|
||||||
if (!pnr) {
|
if (!pnr) {
|
||||||
alert('Booking data not available. Please try again.');
|
alert("Booking data not available. Please try again.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!passengers.length) {
|
if (!passengers.length) {
|
||||||
alert('No passenger data found.');
|
alert("No passenger data found.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsGeneratingVoucher(true);
|
setIsGeneratingVoucher(true);
|
||||||
try {
|
try {
|
||||||
const { generatePassengerVoucherPDF } = await import('@/lib/generate-voucher');
|
const { generatePassengerVoucherPDF } =
|
||||||
|
await import("@/lib/generate-voucher");
|
||||||
|
|
||||||
const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
|
const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
|
||||||
// The server-confirmed settled amount/currency (what was actually charged) is
|
// The server-confirmed settled amount/currency (what was actually charged) is
|
||||||
// authoritative — prefer it over the ETB booking fare once it's available.
|
// authoritative — prefer it over the ETB booking fare once it's available.
|
||||||
const settledAmountMinor = _booking?.payment?.amountMinor;
|
const settledAmountMinor = _booking?.payment?.amountMinor;
|
||||||
const settledCurrency = _booking?.payment?.currency;
|
const settledCurrency = _booking?.payment?.currency;
|
||||||
const voucherCurrency = settledCurrency || 'ETB';
|
const voucherCurrency = settledCurrency || "ETB";
|
||||||
const createdAt = _booking?.createdAt || new Date().toISOString();
|
const createdAt = _booking?.createdAt || new Date().toISOString();
|
||||||
const status = _booking?.status || 'CONFIRMED';
|
const status = _booking?.status || "CONFIRMED";
|
||||||
|
|
||||||
// Compute per-passenger fares (in ETB) using the same logic as the review/payment
|
// Compute per-passenger fares (in ETB) using the same logic as the review/payment
|
||||||
// pages. reviewedPassengerFares is the authoritative source; rebuild from package
|
// pages. reviewedPassengerFares is the authoritative source; rebuild from package
|
||||||
// context as a fallback so free children always show 0 on their voucher.
|
// context as a fallback so free children always show 0 on their voucher.
|
||||||
const { packageTierPriceMinor } = useBookingStore.getState();
|
const { packageTierPriceMinor } = useBookingStore.getState();
|
||||||
const isPackageBooking = packageTierPriceMinor != null;
|
const isPackageBooking = packageTierPriceMinor != null;
|
||||||
const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
|
const adultCount =
|
||||||
|
searchCriteria?.adultCount ??
|
||||||
|
passengers.filter((p) => !isChild(p)).length;
|
||||||
const pkgMultiplier = isPackageBooking ? 2 : 1;
|
const pkgMultiplier = isPackageBooking ? 2 : 1;
|
||||||
const pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgMultiplier : 0;
|
const pkgAdultFare = isPackageBooking
|
||||||
|
? packageTierPriceMinor! * pkgMultiplier
|
||||||
|
: 0;
|
||||||
const pkgChildFare = pkgAdultFare;
|
const pkgChildFare = pkgAdultFare;
|
||||||
|
|
||||||
const getEtbFare = (idx: number): number => {
|
const getEtbFare = (idx: number): number => {
|
||||||
if (reviewedPassengerFares?.[idx] != null) return reviewedPassengerFares[idx].fareMinor;
|
if (reviewedPassengerFares?.[idx] != null)
|
||||||
|
return reviewedPassengerFares[idx].fareMinor;
|
||||||
if (isPackageBooking) {
|
if (isPackageBooking) {
|
||||||
const isPkgChild = idx >= adultCount;
|
const isPkgChild = idx >= adultCount;
|
||||||
const isFreeChild = isPkgChild && (idx - adultCount) < adultCount;
|
const isFreeChild = isPkgChild && idx - adultCount < adultCount;
|
||||||
if (isFreeChild) return 0;
|
if (isFreeChild) return 0;
|
||||||
return isPkgChild ? pkgChildFare : pkgAdultFare;
|
return isPkgChild ? pkgChildFare : pkgAdultFare;
|
||||||
}
|
}
|
||||||
const totalFare = reviewedTotalMinor ?? paidAmountMinor ?? _booking?.totalMinor ?? 0;
|
const totalFare =
|
||||||
|
reviewedTotalMinor ?? paidAmountMinor ?? _booking?.totalMinor ?? 0;
|
||||||
return Math.round(totalFare / passengers.length);
|
return Math.round(totalFare / passengers.length);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -136,31 +159,54 @@ export default function ConfirmationPage() {
|
|||||||
// ETB-denominated numbers next to a foreign currency label.
|
// ETB-denominated numbers next to a foreign currency label.
|
||||||
const etbFares = passengers.map((_, idx) => getEtbFare(idx));
|
const etbFares = passengers.map((_, idx) => getEtbFare(idx));
|
||||||
const etbTotal = etbFares.reduce((sum, f) => sum + f, 0);
|
const etbTotal = etbFares.reduce((sum, f) => sum + f, 0);
|
||||||
const needsConversion = settledAmountMinor != null && settledCurrency && settledCurrency !== 'ETB' && etbTotal > 0;
|
const needsConversion =
|
||||||
|
settledAmountMinor != null &&
|
||||||
|
settledCurrency &&
|
||||||
|
settledCurrency !== "ETB" &&
|
||||||
|
etbTotal > 0;
|
||||||
const getVoucherFare = (idx: number): number => {
|
const getVoucherFare = (idx: number): number => {
|
||||||
if (!needsConversion) return etbFares[idx];
|
if (!needsConversion) return etbFares[idx];
|
||||||
return Math.round(etbFares[idx] * (settledAmountMinor! / etbTotal));
|
return Math.round(etbFares[idx] * (settledAmountMinor! / etbTotal));
|
||||||
};
|
};
|
||||||
|
|
||||||
const outbound = {
|
const outbound = {
|
||||||
trainNumber: activeSchedule?.trainNumber || 'N/A',
|
trainNumber: activeSchedule?.trainNumber || "N/A",
|
||||||
trainName: 'EDR Express',
|
trainName: "EDR Express",
|
||||||
origin: { name: activeSchedule?.origin || 'Origin', code: 'ORG', city: activeSchedule?.origin || 'Origin' },
|
origin: {
|
||||||
destination: { name: activeSchedule?.destination || 'Destination', code: 'DST', city: activeSchedule?.destination || 'Destination' },
|
name: activeSchedule?.origin || "Origin",
|
||||||
|
code: "ORG",
|
||||||
|
city: activeSchedule?.origin || "Origin",
|
||||||
|
},
|
||||||
|
destination: {
|
||||||
|
name: activeSchedule?.destination || "Destination",
|
||||||
|
code: "DST",
|
||||||
|
city: activeSchedule?.destination || "Destination",
|
||||||
|
},
|
||||||
departureAt: activeSchedule?.departureTime || new Date().toISOString(),
|
departureAt: activeSchedule?.departureTime || new Date().toISOString(),
|
||||||
arrivalAt: activeSchedule?.arrivalTime || new Date().toISOString(),
|
arrivalAt: activeSchedule?.arrivalTime || new Date().toISOString(),
|
||||||
seatClass: activeSchedule?.selectedSeatClassName,
|
seatClass: activeSchedule?.selectedSeatClassName,
|
||||||
};
|
};
|
||||||
|
|
||||||
const inbound = inboundSchedule ? {
|
const inbound = inboundSchedule
|
||||||
trainNumber: inboundSchedule.trainNumber || 'N/A',
|
? {
|
||||||
trainName: 'EDR Express',
|
trainNumber: inboundSchedule.trainNumber || "N/A",
|
||||||
origin: { name: inboundSchedule.origin, code: 'ORG', city: inboundSchedule.origin },
|
trainName: "EDR Express",
|
||||||
destination: { name: inboundSchedule.destination, code: 'DST', city: inboundSchedule.destination },
|
origin: {
|
||||||
departureAt: inboundSchedule.departureTime || new Date().toISOString(),
|
name: inboundSchedule.origin,
|
||||||
arrivalAt: inboundSchedule.arrivalTime || new Date().toISOString(),
|
code: "ORG",
|
||||||
seatClass: inboundSchedule.selectedSeatClassName,
|
city: inboundSchedule.origin,
|
||||||
} : undefined;
|
},
|
||||||
|
destination: {
|
||||||
|
name: inboundSchedule.destination,
|
||||||
|
code: "DST",
|
||||||
|
city: inboundSchedule.destination,
|
||||||
|
},
|
||||||
|
departureAt:
|
||||||
|
inboundSchedule.departureTime || new Date().toISOString(),
|
||||||
|
arrivalAt: inboundSchedule.arrivalTime || new Date().toISOString(),
|
||||||
|
seatClass: inboundSchedule.selectedSeatClassName,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Separate file per passenger, saved back-to-back with no macrotask (setTimeout)
|
// Separate file per passenger, saved back-to-back with no macrotask (setTimeout)
|
||||||
// between them — a setTimeout delay would push later saves outside the click's
|
// between them — a setTimeout delay would push later saves outside the click's
|
||||||
@@ -170,29 +216,33 @@ export default function ConfirmationPage() {
|
|||||||
// Same match-by-name-then-position as the on-screen ticket list above — no
|
// Same match-by-name-then-position as the on-screen ticket list above — no
|
||||||
// fabricated placeholder if there's no backend ticket data (see generate-voucher.ts).
|
// fabricated placeholder if there's no backend ticket data (see generate-voucher.ts).
|
||||||
const matchedTicket =
|
const matchedTicket =
|
||||||
_booking?.tickets?.find((t) => t.passengerName === p.name) ?? _booking?.tickets?.[i] ?? null;
|
_booking?.tickets?.find((t) => t.passengerName === p.name) ??
|
||||||
const ticketNumber = matchedTicket?.barcodePayload || 'Not yet issued';
|
_booking?.tickets?.[i] ??
|
||||||
|
null;
|
||||||
|
const ticketNumber = matchedTicket?.barcodePayload || "Not yet issued";
|
||||||
|
|
||||||
await generatePassengerVoucherPDF({
|
await generatePassengerVoucherPDF({
|
||||||
bookingRef: pnr,
|
bookingRef: pnr,
|
||||||
ticketNumber,
|
ticketNumber,
|
||||||
passengerName: p.name || `Passenger ${i + 1}`,
|
passengerName: p.name || `Passenger ${i + 1}`,
|
||||||
dateOfBirth: p.dateOfBirth,
|
dateOfBirth: p.dateOfBirth,
|
||||||
nationality: p.nationality,
|
nationality: p.nationality,
|
||||||
seatNumber: p.seatNumber,
|
seatNumber: p.seatNumber,
|
||||||
outboundSeatNumber: (p as any).outboundSeatNumber,
|
outboundSeatNumber: (p as any).outboundSeatNumber,
|
||||||
inboundSeatNumber: (p as any).inboundSeatNumber,
|
inboundSeatNumber: (p as any).inboundSeatNumber,
|
||||||
status,
|
status,
|
||||||
outboundSchedule: outbound,
|
outboundSchedule: outbound,
|
||||||
inboundSchedule: inbound,
|
inboundSchedule: inbound,
|
||||||
isRoundTrip,
|
isRoundTrip,
|
||||||
fareMinor: getVoucherFare(i),
|
fareMinor: getVoucherFare(i),
|
||||||
currency: voucherCurrency,
|
currency: voucherCurrency,
|
||||||
createdAt,
|
createdAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
alert(
|
||||||
|
`Failed to generate voucher: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsGeneratingVoucher(false);
|
setIsGeneratingVoucher(false);
|
||||||
}
|
}
|
||||||
@@ -200,12 +250,12 @@ export default function ConfirmationPage() {
|
|||||||
|
|
||||||
const handleNewBooking = () => {
|
const handleNewBooking = () => {
|
||||||
clearBooking();
|
clearBooking();
|
||||||
window.location.href = '/';
|
window.location.href = "/";
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bookingId || !pnr) {
|
if (!bookingId || !pnr) {
|
||||||
window.location.href = '/';
|
window.location.href = "/";
|
||||||
}
|
}
|
||||||
}, [bookingId, pnr, router]);
|
}, [bookingId, pnr, router]);
|
||||||
|
|
||||||
@@ -231,9 +281,13 @@ export default function ConfirmationPage() {
|
|||||||
{isConfirmed ? (
|
{isConfirmed ? (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-4xl font-bold text-green-600 dark:text-green-400 mb-2">
|
<h1 className="text-4xl font-bold text-green-600 dark:text-green-400 mb-2">
|
||||||
{packageName ? `${packageName} booking confirmed!` : 'Booking confirmed!'}
|
{packageName
|
||||||
|
? `${packageName} booking confirmed!`
|
||||||
|
: "Booking confirmed!"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400 text-lg">Your train tickets are ready</p>
|
<p className="text-gray-600 dark:text-gray-400 text-lg">
|
||||||
|
Your train tickets are ready
|
||||||
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -241,7 +295,8 @@ export default function ConfirmationPage() {
|
|||||||
Booking received — payment pending
|
Booking received — payment pending
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400 text-lg">
|
<p className="text-gray-600 dark:text-gray-400 text-lg">
|
||||||
We haven't confirmed your payment yet. Your tickets will be issued once payment is completed.
|
We haven't confirmed your payment yet. Your tickets will
|
||||||
|
be issued once payment is completed.
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -250,9 +305,13 @@ export default function ConfirmationPage() {
|
|||||||
{/* PNR Card */}
|
{/* PNR Card */}
|
||||||
<div className="card mb-6 bg-primary text-white">
|
<div className="card mb-6 bg-primary text-white">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm font-medium mb-2">Booking reference (PNR)</p>
|
<p className="text-sm font-medium mb-2">
|
||||||
|
Booking reference (PNR)
|
||||||
|
</p>
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex items-center justify-center gap-3">
|
||||||
<span className="text-5xl font-bold tracking-widest">{pnr}</span>
|
<span className="text-5xl font-bold tracking-widest">
|
||||||
|
{pnr}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={copyPNR}
|
onClick={copyPNR}
|
||||||
className="p-2 hover:bg-white hover:bg-opacity-20 rounded transition-colors"
|
className="p-2 hover:bg-white hover:bg-opacity-20 rounded transition-colors"
|
||||||
@@ -265,149 +324,231 @@ export default function ConfirmationPage() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium mt-2">Save this reference number for future use</p>
|
<p className="text-sm font-medium mt-2">
|
||||||
|
Save this reference number for future use
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Trip Details */}
|
{/* Trip Details */}
|
||||||
<div className="card mb-6">
|
<div className="card mb-6">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center">
|
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center">
|
||||||
<Train className="w-6 h-6 text-primary dark:text-primary-400" />
|
<Train className="w-6 h-6 text-primary dark:text-primary-400" />
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{isRoundTrip ? 'Round trip details' : 'Trip details'}
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{isRoundTrip ? "Round trip details" : "Trip details"}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Outbound journey (round trip) or single journey */}
|
{/* Outbound journey (round trip) or single journey */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const schedule = isRoundTrip ? outboundSchedule : selectedSchedule;
|
const schedule = isRoundTrip
|
||||||
if (!schedule) return null;
|
? outboundSchedule
|
||||||
return (
|
: selectedSchedule;
|
||||||
<div className="mb-4">
|
if (!schedule) return null;
|
||||||
{isRoundTrip && (
|
return (
|
||||||
<p className="text-xs font-bold uppercase tracking-wide text-primary mb-2">Outbound</p>
|
<div className="mb-4">
|
||||||
)}
|
{isRoundTrip && (
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<p className="text-xs font-bold uppercase tracking-wide text-primary mb-2">
|
||||||
<div className="space-y-3">
|
Outbound
|
||||||
<div>
|
</p>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">Train number</p>
|
)}
|
||||||
<p className="font-semibold text-lg text-gray-900 dark:text-gray-100">{schedule.trainNumber}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">Route</p>
|
|
||||||
<p className="font-semibold text-lg text-gray-900 dark:text-gray-100">{schedule.origin} → {schedule.destination}</p>
|
|
||||||
</div>
|
|
||||||
{schedule.selectedSeatClassName && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">Class</p>
|
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{schedule.selectedSeatClassName.replace(/_/g, ' ')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">Departure</p>
|
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{schedule.departureTime && format(new Date(schedule.departureTime), 'PPp')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">Arrival</p>
|
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{schedule.arrivalTime && format(new Date(schedule.arrivalTime), 'PPp')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">Duration</p>
|
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{schedule.duration}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Return journey (round trip only) */}
|
|
||||||
{isRoundTrip && inboundSchedule && (
|
|
||||||
<div className="border-t border-dashed border-gray-200 dark:border-gray-700 pt-4">
|
|
||||||
<p className="text-xs font-bold uppercase tracking-wide text-blue-500 mb-2">Return</p>
|
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">Train number</p>
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
<p className="font-semibold text-lg text-gray-900 dark:text-gray-100">{inboundSchedule.trainNumber}</p>
|
Train number
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-lg text-gray-900 dark:text-gray-100">
|
||||||
|
{schedule.trainNumber}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">Route</p>
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
<p className="font-semibold text-lg text-gray-900 dark:text-gray-100">{inboundSchedule.origin} → {inboundSchedule.destination}</p>
|
Route
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-lg text-gray-900 dark:text-gray-100">
|
||||||
|
{schedule.origin} → {schedule.destination}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{inboundSchedule.selectedSeatClassName && (
|
{schedule.selectedSeatClassName && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">Class</p>
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{inboundSchedule.selectedSeatClassName.replace(/_/g, ' ')}</p>
|
Class
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{schedule.selectedSeatClassName.replace(
|
||||||
|
/_/g,
|
||||||
|
" ",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">Departure</p>
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Departure
|
||||||
|
</p>
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{inboundSchedule.departureTime && format(new Date(inboundSchedule.departureTime), 'PPp')}
|
{schedule.departureTime &&
|
||||||
|
format(new Date(schedule.departureTime), "PPp")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">Arrival</p>
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Arrival
|
||||||
|
</p>
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{inboundSchedule.arrivalTime && format(new Date(inboundSchedule.arrivalTime), 'PPp')}
|
{schedule.arrivalTime &&
|
||||||
|
format(new Date(schedule.arrivalTime), "PPp")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">Duration</p>
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{inboundSchedule.duration}</p>
|
Duration
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{schedule.duration}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Return journey (round trip only) */}
|
||||||
|
{isRoundTrip && inboundSchedule && (
|
||||||
|
<div className="border-t border-dashed border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wide text-blue-500 mb-2">
|
||||||
|
Return
|
||||||
|
</p>
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Train number
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-lg text-gray-900 dark:text-gray-100">
|
||||||
|
{inboundSchedule.trainNumber}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Route
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-lg text-gray-900 dark:text-gray-100">
|
||||||
|
{inboundSchedule.origin} →{" "}
|
||||||
|
{inboundSchedule.destination}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{inboundSchedule.selectedSeatClassName && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Class
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{inboundSchedule.selectedSeatClassName.replace(
|
||||||
|
/_/g,
|
||||||
|
" ",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Departure
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{inboundSchedule.departureTime &&
|
||||||
|
format(
|
||||||
|
new Date(inboundSchedule.departureTime),
|
||||||
|
"PPp",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Arrival
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{inboundSchedule.arrivalTime &&
|
||||||
|
format(
|
||||||
|
new Date(inboundSchedule.arrivalTime),
|
||||||
|
"PPp",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Duration
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{inboundSchedule.duration}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Booking date & payment summary */}
|
{/* Booking date & payment summary */}
|
||||||
<div className="card mb-6">
|
<div className="card mb-6">
|
||||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Booking details</h2>
|
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
||||||
|
Booking details
|
||||||
|
</h2>
|
||||||
<div className="grid sm:grid-cols-2 gap-4">
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">Booking date</p>
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Booking date
|
||||||
|
</p>
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{format(new Date(_booking?.createdAt || new Date()), 'PPp')}
|
{format(new Date(_booking?.createdAt || new Date()), "PPp")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">Status</p>
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
<p className={`font-semibold ${isConfirmed ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'}`}>
|
Status
|
||||||
{_booking?.status || 'PENDING_PAYMENT'}
|
</p>
|
||||||
|
<p
|
||||||
|
className={`font-semibold ${isConfirmed ? "text-green-600 dark:text-green-400" : "text-amber-600 dark:text-amber-400"}`}
|
||||||
|
>
|
||||||
|
{_booking?.status || "PENDING_PAYMENT"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">Passengers</p>
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{passengers.length}</p>
|
Passengers
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{passengers.length}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">Total paid</p>
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Total paid
|
||||||
|
</p>
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{(() => {
|
{(() => {
|
||||||
// The server-confirmed settled amount is authoritative — prefer it over
|
// The server-confirmed settled amount is authoritative — prefer it over
|
||||||
// any client-side session state, which can go stale (e.g. after a refresh).
|
// any client-side session state, which can go stale (e.g. after a refresh).
|
||||||
if (_booking?.payment?.amountMinor != null) {
|
if (_booking?.payment?.amountMinor != null) {
|
||||||
return `${_booking.payment.currency || 'ETB'} ${(_booking.payment.amountMinor / 100).toFixed(2)}`;
|
return `${_booking.payment.currency || "ETB"} ${_booking.payment.amountMinor}`;
|
||||||
}
|
}
|
||||||
if (reviewedTotalMinor != null) return `ETB ${(reviewedTotalMinor / 100).toFixed(2)}`;
|
if (reviewedTotalMinor != null)
|
||||||
if (paidAmountMinor != null) return `${paidCurrency} ${(paidAmountMinor / 100).toFixed(2)}`;
|
return `ETB ${(reviewedTotalMinor / 100).toFixed(2)}`;
|
||||||
if (_booking?.totalMinor != null) return `ETB ${(_booking.totalMinor / 100).toFixed(2)}`;
|
if (paidAmountMinor != null)
|
||||||
return 'ETB 0.00';
|
return `${paidCurrency} ${(paidAmountMinor / 100).toFixed(2)}`;
|
||||||
|
if (_booking?.totalMinor != null)
|
||||||
|
return `ETB ${(_booking.totalMinor / 100).toFixed(2)}`;
|
||||||
|
return "ETB 0.00";
|
||||||
})()}
|
})()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -416,70 +557,132 @@ export default function ConfirmationPage() {
|
|||||||
|
|
||||||
{/* Tickets */}
|
{/* Tickets */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-2xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Your tickets</h2>
|
<h2 className="text-2xl font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
||||||
|
Your tickets
|
||||||
|
</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{passengers.map((passenger, index) => {
|
{passengers.map((passenger, index) => {
|
||||||
// Match by name first (tickets aren't necessarily created/ordered the same
|
// Match by name first (tickets aren't necessarily created/ordered the same
|
||||||
// way as this passengers array) — fall back to position if no name match.
|
// way as this passengers array) — fall back to position if no name match.
|
||||||
const backendTicket =
|
const backendTicket =
|
||||||
_booking?.tickets?.find((t) => t.passengerName === passenger.name) ??
|
_booking?.tickets?.find(
|
||||||
|
(t) => t.passengerName === passenger.name,
|
||||||
|
) ??
|
||||||
_booking?.tickets?.[index] ??
|
_booking?.tickets?.[index] ??
|
||||||
null;
|
null;
|
||||||
// No fabricated placeholder — a made-up TKT-... number reads as real and is
|
// No fabricated placeholder — a made-up TKT-... number reads as real and is
|
||||||
// misleading if it doesn't match what's actually on file.
|
// misleading if it doesn't match what's actually on file.
|
||||||
const ticketNumber = isConfirmed ? backendTicket?.barcodePayload || null : null;
|
const ticketNumber = isConfirmed
|
||||||
|
? backendTicket?.barcodePayload || null
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className="card hover:shadow-lg transition-shadow">
|
<div
|
||||||
|
key={index}
|
||||||
|
className="card hover:shadow-lg transition-shadow"
|
||||||
|
>
|
||||||
{/* Ticket Info */}
|
{/* Ticket Info */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-bold text-gray-900 dark:text-gray-100">{passenger.name}</h3>
|
<h3 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">Passenger {index + 1}</p>
|
{passenger.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Passenger {index + 1}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isConfirmed ? (
|
{isConfirmed ? (
|
||||||
<span className="badge badge-success">CONFIRMED</span>
|
<span className="badge badge-success">CONFIRMED</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="badge badge-warning">AWAITING PAYMENT</span>
|
<span className="badge badge-warning">
|
||||||
|
AWAITING PAYMENT
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-600 dark:text-gray-400">Ticket Number</p>
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Ticket Number
|
||||||
|
</p>
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{ticketNumber || (isConfirmed ? 'Not yet issued' : 'Pending payment')}
|
{ticketNumber ||
|
||||||
|
(isConfirmed
|
||||||
|
? "Not yet issued"
|
||||||
|
: "Pending payment")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-600 dark:text-gray-400">Date of Birth</p>
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{format(new Date(passenger.dateOfBirth), 'PP')}</p>
|
Date of Birth
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{format(new Date(passenger.dateOfBirth), "PP")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-600 dark:text-gray-400">Nationality</p>
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{passenger.nationality}</p>
|
Nationality
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{passenger.nationality}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-600 dark:text-gray-400">Seat(s)</p>
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Seat(s)
|
||||||
|
</p>
|
||||||
{(() => {
|
{(() => {
|
||||||
const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
|
const adultCount =
|
||||||
|
searchCriteria?.adultCount ??
|
||||||
|
passengers.filter((p) => !isChild(p)).length;
|
||||||
const isFreeChild = packageId
|
const isFreeChild = packageId
|
||||||
? index >= adultCount && (index - adultCount) < adultCount
|
? index >= adultCount &&
|
||||||
: isChild(passenger) && isFirstChild(passengers, index);
|
index - adultCount < adultCount
|
||||||
if (isFreeChild) return <p className="font-semibold text-gray-900 dark:text-gray-100">—</p>;
|
: isChild(passenger) &&
|
||||||
|
isFirstChild(passengers, index);
|
||||||
|
if (isFreeChild)
|
||||||
|
return (
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
—
|
||||||
|
</p>
|
||||||
|
);
|
||||||
return isRoundTrip ? (
|
return isRoundTrip ? (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
Outbound: {(passenger as any).outboundCoachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>{(passenger as any).outboundCoachNumber}</span>} — {(passenger as any).outboundSeatNumber || 'Auto-assigned at boarding'}
|
Outbound:{" "}
|
||||||
|
{(passenger as any).outboundCoachNumber && (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 ml-1">
|
||||||
|
{(passenger as any).outboundCoachNumber}
|
||||||
|
</span>
|
||||||
|
)}{" "}
|
||||||
|
—{" "}
|
||||||
|
{(passenger as any).outboundSeatNumber ||
|
||||||
|
"Auto-assigned at boarding"}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
Return: {(passenger as any).inboundCoachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>{(passenger as any).inboundCoachNumber}</span>} — {(passenger as any).inboundSeatNumber || 'Auto-assigned at boarding'}
|
Return:{" "}
|
||||||
|
{(passenger as any).inboundCoachNumber && (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 ml-1">
|
||||||
|
{(passenger as any).inboundCoachNumber}
|
||||||
|
</span>
|
||||||
|
)}{" "}
|
||||||
|
—{" "}
|
||||||
|
{(passenger as any).inboundSeatNumber ||
|
||||||
|
"Auto-assigned at boarding"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{passenger.coachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>(Coach {passenger.coachNumber})</span>} — {passenger.seatNumber || 'Auto-assigned at boarding'}
|
{passenger.coachNumber && (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 ml-1">
|
||||||
|
(Coach {passenger.coachNumber})
|
||||||
|
</span>
|
||||||
|
)}{" "}
|
||||||
|
—{" "}
|
||||||
|
{passenger.seatNumber ||
|
||||||
|
"Auto-assigned at boarding"}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -528,12 +731,14 @@ export default function ConfirmationPage() {
|
|||||||
<div className="mt-6 space-y-3">
|
<div className="mt-6 space-y-3">
|
||||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg">
|
<div className="p-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||||
<p className="text-sm text-blue-800 dark:text-blue-300">
|
<p className="text-sm text-blue-800 dark:text-blue-300">
|
||||||
📧 A confirmation email with your tickets has been sent to your registered email address.
|
📧 A confirmation email with your tickets has been sent to your
|
||||||
|
registered email address.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
|
<div className="p-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
|
||||||
<p className="text-sm text-green-800 dark:text-green-300">
|
<p className="text-sm text-green-800 dark:text-green-300">
|
||||||
✅ Please arrive at the station at least 30 minutes before departure.
|
✅ Please arrive at the station at least 30 minutes before
|
||||||
|
departure.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from "react";
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from "@/lib/api-client";
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
Users,
|
Users,
|
||||||
@@ -18,32 +18,40 @@ import {
|
|||||||
Smartphone,
|
Smartphone,
|
||||||
Loader2,
|
Loader2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
} from 'lucide-react';
|
} from "lucide-react";
|
||||||
import { format } from 'date-fns';
|
import { format } from "date-fns";
|
||||||
import { formatTime, getTimePeriod } from '@/utils/format';
|
import { formatTime, getTimePeriod } from "@/utils/format";
|
||||||
import { formatFare } from '@/utils/fare-utils';
|
import { formatFare } from "@/utils/fare-utils";
|
||||||
import { markManageBookingPaymentReturn, consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
|
import {
|
||||||
import QRCode from 'qrcode.react';
|
markManageBookingPaymentReturn,
|
||||||
|
consumeManageBookingPaymentReturn,
|
||||||
|
} from "@/utils/manage-booking-return";
|
||||||
|
import QRCode from "qrcode.react";
|
||||||
|
|
||||||
// Same convention as /booking/payment — payment methods are ETB-settled by default;
|
// Same convention as /booking/payment — payment methods are ETB-settled by default;
|
||||||
// a method only needs a currency conversion when its own currency differs.
|
// a method only needs a currency conversion when its own currency differs.
|
||||||
const displayCurrency = 'ETB' as const;
|
const displayCurrency = "ETB" as const;
|
||||||
|
|
||||||
const getIconForMethod = (methodType: string) => {
|
const getIconForMethod = (methodType: string) => {
|
||||||
if (methodType.includes('CARD')) return CreditCard;
|
if (methodType.includes("CARD")) return CreditCard;
|
||||||
if (methodType.includes('WALLET')) return Wallet;
|
if (methodType.includes("WALLET")) return Wallet;
|
||||||
return Smartphone;
|
return Smartphone;
|
||||||
};
|
};
|
||||||
|
|
||||||
function BookingDetailContent() {
|
function BookingDetailContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const bookingRef = searchParams.get('ref') || searchParams.get('bookingRef') || searchParams.get('pnr');
|
const bookingRef =
|
||||||
|
searchParams.get("ref") ||
|
||||||
|
searchParams.get("bookingRef") ||
|
||||||
|
searchParams.get("pnr");
|
||||||
|
|
||||||
// Mirrors /booking/payment's state shape: selectedMethod is the PaymentMethod `type`
|
// Mirrors /booking/payment's state shape: selectedMethod is the PaymentMethod `type`
|
||||||
// (used both for lookup and to decide provider-specific redirect handling), not the id.
|
// (used both for lookup and to decide provider-specific redirect handling), not the id.
|
||||||
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
||||||
const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null);
|
const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
const [paymentError, setPaymentError] = useState<string | null>(null);
|
const [paymentError, setPaymentError] = useState<string | null>(null);
|
||||||
const [copiedPNR, setCopiedPNR] = useState(false);
|
const [copiedPNR, setCopiedPNR] = useState(false);
|
||||||
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
|
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
|
||||||
@@ -53,13 +61,18 @@ function BookingDetailContent() {
|
|||||||
// too long after the originating click's synchronous execution window is silently
|
// too long after the originating click's synchronous execution window is silently
|
||||||
// blocked, and awaiting a cold dynamic import is enough to fall outside that window.
|
// blocked, and awaiting a cold dynamic import is enough to fall outside that window.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
import('@/lib/generate-voucher');
|
import("@/lib/generate-voucher");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { data: booking, isLoading, error, refetch } = useQuery({
|
const {
|
||||||
queryKey: ['booking-detail', bookingRef],
|
data: booking,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["booking-detail", bookingRef],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!bookingRef) throw new Error('No booking reference provided');
|
if (!bookingRef) throw new Error("No booking reference provided");
|
||||||
const response = await apiClient.get(`/bookings/${bookingRef}`);
|
const response = await apiClient.get(`/bookings/${bookingRef}`);
|
||||||
// Handle wrapped response
|
// Handle wrapped response
|
||||||
return (response as any)?.data || response;
|
return (response as any)?.data || response;
|
||||||
@@ -69,20 +82,29 @@ function BookingDetailContent() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data: paymentMethods } = useQuery<any[]>({
|
const { data: paymentMethods } = useQuery<any[]>({
|
||||||
queryKey: ['payment-methods'],
|
queryKey: ["payment-methods"],
|
||||||
queryFn: () => apiClient.get('/payments/methods'),
|
queryFn: () => apiClient.get("/payments/methods"),
|
||||||
enabled: booking?.status === 'PENDING_PAYMENT' || booking?.status === 'DRAFT',
|
enabled:
|
||||||
|
booking?.status === "PENDING_PAYMENT" || booking?.status === "DRAFT",
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedPaymentMethod = (paymentMethods || []).find((m: any) => m.type === selectedMethod) || null;
|
const selectedPaymentMethod =
|
||||||
|
(paymentMethods || []).find((m: any) => m.type === selectedMethod) || null;
|
||||||
|
|
||||||
// Same conversion logic as /booking/payment: only hit the booking-amount-changer API
|
// Same conversion logic as /booking/payment: only hit the booking-amount-changer API
|
||||||
// when the selected method actually settles in a different currency than ETB.
|
// when the selected method actually settles in a different currency than ETB.
|
||||||
const isConversionNeeded = !!selectedMethodCurrency && selectedMethodCurrency !== displayCurrency;
|
const isConversionNeeded =
|
||||||
const amountCurrency = isConversionNeeded ? selectedMethodCurrency! : displayCurrency;
|
!!selectedMethodCurrency && selectedMethodCurrency !== displayCurrency;
|
||||||
|
const amountCurrency = isConversionNeeded
|
||||||
|
? selectedMethodCurrency!
|
||||||
|
: displayCurrency;
|
||||||
|
|
||||||
const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({
|
const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{
|
||||||
queryKey: ['bookingAmount', booking?.id, amountCurrency],
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
booking_id: string;
|
||||||
|
}>({
|
||||||
|
queryKey: ["bookingAmount", booking?.id, amountCurrency],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const url = `/payments/booking-amount?bookingId=${booking?.id}¤cy=${amountCurrency}`;
|
const url = `/payments/booking-amount?bookingId=${booking?.id}¤cy=${amountCurrency}`;
|
||||||
const response: any = await apiClient.get(url);
|
const response: any = await apiClient.get(url);
|
||||||
@@ -92,24 +114,32 @@ function BookingDetailContent() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const totalAmountDisplay = isConversionNeeded
|
const totalAmountDisplay = isConversionNeeded
|
||||||
? (bookingAmountData != null ? bookingAmountData.amount : null)
|
? bookingAmountData != null
|
||||||
: ((booking?.totalMinor ?? 0) / 100);
|
? bookingAmountData.amount
|
||||||
const confirmedCurrency = isConversionNeeded ? (bookingAmountData?.currency || amountCurrency) : displayCurrency;
|
: null
|
||||||
const awaitingAmount = isConversionNeeded && loadingAmount && totalAmountDisplay === null;
|
: (booking?.totalMinor ?? 0) / 100;
|
||||||
|
const confirmedCurrency = isConversionNeeded
|
||||||
|
? bookingAmountData?.currency || amountCurrency
|
||||||
|
: displayCurrency;
|
||||||
|
const awaitingAmount =
|
||||||
|
isConversionNeeded && loadingAmount && totalAmountDisplay === null;
|
||||||
|
|
||||||
const paymentMutation = useMutation({
|
const paymentMutation = useMutation({
|
||||||
mutationFn: async (data: any) => {
|
mutationFn: async (data: any) => {
|
||||||
return await apiClient.post('/payments/initiate', {
|
return await apiClient.post("/payments/initiate", {
|
||||||
bookingId: data.bookingId,
|
bookingId: data.bookingId,
|
||||||
method: data.method,
|
method: data.method,
|
||||||
paymentMethodId: data.paymentMethodId,
|
paymentMethodId: data.paymentMethodId,
|
||||||
platform: 'web',
|
platform: "web",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: async (data: any) => {
|
onSuccess: async (data: any) => {
|
||||||
setPaymentError(null);
|
setPaymentError(null);
|
||||||
|
|
||||||
if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI') && data?.clientAction?.type === 'REDIRECT') {
|
if (
|
||||||
|
(selectedMethod === "TELEBIRR" || selectedMethod === "WAAFI") &&
|
||||||
|
data?.clientAction?.type === "REDIRECT"
|
||||||
|
) {
|
||||||
window.location.href = data.clientAction.url;
|
window.location.href = data.clientAction.url;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -121,20 +151,20 @@ function BookingDetailContent() {
|
|||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
setPaymentError(
|
setPaymentError(
|
||||||
error?.response?.data?.message ||
|
error?.response?.data?.message ||
|
||||||
error?.message ||
|
error?.message ||
|
||||||
'Payment failed. Please try again.',
|
"Payment failed. Please try again.",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handlePayment = () => {
|
const handlePayment = () => {
|
||||||
if (!selectedMethod || !booking?.id) {
|
if (!selectedMethod || !booking?.id) {
|
||||||
setPaymentError('Please select a payment method');
|
setPaymentError("Please select a payment method");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedPaymentMethod) {
|
if (!selectedPaymentMethod) {
|
||||||
setPaymentError('Invalid payment method selected');
|
setPaymentError("Invalid payment method selected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,16 +189,18 @@ function BookingDetailContent() {
|
|||||||
|
|
||||||
const handleDownloadVoucher = async () => {
|
const handleDownloadVoucher = async () => {
|
||||||
if (!booking || !booking.bookingRef) {
|
if (!booking || !booking.bookingRef) {
|
||||||
alert('Booking data not available. Please try again.');
|
alert("Booking data not available. Please try again.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsGeneratingVoucher(true);
|
setIsGeneratingVoucher(true);
|
||||||
try {
|
try {
|
||||||
const { generateVoucherPDF } = await import('@/lib/generate-voucher');
|
const { generateVoucherPDF } = await import("@/lib/generate-voucher");
|
||||||
await generateVoucherPDF(booking as any);
|
await generateVoucherPDF(booking as any);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
alert(
|
||||||
|
`Failed to generate voucher: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsGeneratingVoucher(false);
|
setIsGeneratingVoucher(false);
|
||||||
}
|
}
|
||||||
@@ -179,8 +211,12 @@ function BookingDetailContent() {
|
|||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center max-w-md w-full">
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center max-w-md w-full">
|
||||||
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Loading Booking Details</h2>
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">Fetching your booking information...</p>
|
Loading Booking Details
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Fetching your booking information...
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -193,40 +229,75 @@ function BookingDetailContent() {
|
|||||||
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<AlertCircle className="w-8 h-8 text-red-500" />
|
<AlertCircle className="w-8 h-8 text-red-500" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Booking Not Found</h2>
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Booking Not Found
|
||||||
|
</h2>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||||
{!bookingRef
|
{!bookingRef
|
||||||
? 'No booking reference provided in the URL.'
|
? "No booking reference provided in the URL."
|
||||||
: `Unable to find booking with reference: ${bookingRef}`
|
: `Unable to find booking with reference: ${bookingRef}`}
|
||||||
}
|
|
||||||
</p>
|
</p>
|
||||||
<button onClick={() => refetch()} className="btn-secondary mb-2">Try Again</button>
|
<button onClick={() => refetch()} className="btn-secondary mb-2">
|
||||||
<button onClick={() => router.push('/booking/search')} className="btn-primary">New Booking</button>
|
Try Again
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/booking/search")}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
New Booking
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPendingPayment = booking.status === 'PENDING_PAYMENT' || booking.status === 'DRAFT';
|
const isPendingPayment =
|
||||||
const isConfirmed = booking.status === 'TICKETED' || booking.status === 'CONFIRMED';
|
booking.status === "PENDING_PAYMENT" || booking.status === "DRAFT";
|
||||||
const isExpired = booking.status === 'EXPIRED';
|
const isConfirmed =
|
||||||
const isCancelled = booking.status === 'CANCELLED';
|
booking.status === "TICKETED" || booking.status === "CONFIRMED";
|
||||||
|
const isExpired = booking.status === "EXPIRED";
|
||||||
|
const isCancelled = booking.status === "CANCELLED";
|
||||||
|
|
||||||
const StatusBadge = () => {
|
const StatusBadge = () => {
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
PENDING_PAYMENT: { color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', label: 'Pending Payment' },
|
PENDING_PAYMENT: {
|
||||||
DRAFT: { color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', label: 'Pending Payment' },
|
color:
|
||||||
CONFIRMED: { color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', label: 'Confirmed' },
|
"bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
|
||||||
TICKETED: { color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', label: 'Ticketed' },
|
label: "Pending Payment",
|
||||||
EXPIRED: { color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', label: 'Expired' },
|
},
|
||||||
CANCELLED: { color: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300', label: 'Cancelled' },
|
DRAFT: {
|
||||||
|
color:
|
||||||
|
"bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
|
||||||
|
label: "Pending Payment",
|
||||||
|
},
|
||||||
|
CONFIRMED: {
|
||||||
|
color:
|
||||||
|
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||||
|
label: "Confirmed",
|
||||||
|
},
|
||||||
|
TICKETED: {
|
||||||
|
color:
|
||||||
|
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||||
|
label: "Ticketed",
|
||||||
|
},
|
||||||
|
EXPIRED: {
|
||||||
|
color: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
||||||
|
label: "Expired",
|
||||||
|
},
|
||||||
|
CANCELLED: {
|
||||||
|
color: "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300",
|
||||||
|
label: "Cancelled",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = statusConfig[booking.status as keyof typeof statusConfig] || statusConfig.DRAFT;
|
const config =
|
||||||
|
statusConfig[booking.status as keyof typeof statusConfig] ||
|
||||||
|
statusConfig.DRAFT;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-semibold ${config.color}`}>
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-semibold ${config.color}`}
|
||||||
|
>
|
||||||
{isConfirmed && <CheckCircle2 className="w-4 h-4" />}
|
{isConfirmed && <CheckCircle2 className="w-4 h-4" />}
|
||||||
{config.label}
|
{config.label}
|
||||||
</span>
|
</span>
|
||||||
@@ -238,16 +309,33 @@ function BookingDetailContent() {
|
|||||||
// combined row per passenger with an Outbound/Return sub-split. Group leg rows back
|
// combined row per passenger with an Outbound/Return sub-split. Group leg rows back
|
||||||
// together here so both pages present the same per-passenger total, not a doubled list
|
// together here so both pages present the same per-passenger total, not a doubled list
|
||||||
// of half-fare rows.
|
// of half-fare rows.
|
||||||
const isRoundTripBooking = booking.bookingType === 'ROUND_TRIP';
|
const isRoundTripBooking = booking.bookingType === "ROUND_TRIP";
|
||||||
const farePassengers = (() => {
|
const farePassengers = (() => {
|
||||||
const rows: any[] = booking.passengers || [];
|
const rows: any[] = booking.passengers || [];
|
||||||
if (!isRoundTripBooking) {
|
if (!isRoundTripBooking) {
|
||||||
return rows.map((p) => ({ fullName: p.fullName, category: p.category, fareMinor: p.fareMinor ?? 0 }));
|
return rows.map((p) => ({
|
||||||
|
fullName: p.fullName,
|
||||||
|
category: p.category,
|
||||||
|
fareMinor: p.fareMinor ?? 0,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
const grouped = new Map<string, { fullName: string; category: string; outboundFareMinor: number; returnFareMinor: number }>();
|
const grouped = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
fullName: string;
|
||||||
|
category: string;
|
||||||
|
outboundFareMinor: number;
|
||||||
|
returnFareMinor: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
rows.forEach((p) => {
|
rows.forEach((p) => {
|
||||||
const key = `${p.fullName}|${p.dateOfBirth}|${p.category}`;
|
const key = `${p.fullName}|${p.dateOfBirth}|${p.category}`;
|
||||||
const entry = grouped.get(key) || { fullName: p.fullName, category: p.category, outboundFareMinor: 0, returnFareMinor: 0 };
|
const entry = grouped.get(key) || {
|
||||||
|
fullName: p.fullName,
|
||||||
|
category: p.category,
|
||||||
|
outboundFareMinor: 0,
|
||||||
|
returnFareMinor: 0,
|
||||||
|
};
|
||||||
if (p.leg === 2) entry.returnFareMinor = p.fareMinor ?? 0;
|
if (p.leg === 2) entry.returnFareMinor = p.fareMinor ?? 0;
|
||||||
else entry.outboundFareMinor = p.fareMinor ?? 0;
|
else entry.outboundFareMinor = p.fareMinor ?? 0;
|
||||||
grouped.set(key, entry);
|
grouped.set(key, entry);
|
||||||
@@ -269,23 +357,34 @@ function BookingDetailContent() {
|
|||||||
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100 pb-2 border-b border-gray-100 dark:border-gray-800">
|
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100 pb-2 border-b border-gray-100 dark:border-gray-800">
|
||||||
Order summary
|
Order summary
|
||||||
<span className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
|
<span className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||||
Ref: <span className="font-bold text-gray-900 dark:text-gray-100">{booking.bookingRef}</span>
|
Ref:{" "}
|
||||||
|
<span className="font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{booking.bookingRef}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">Fare breakdown</h3>
|
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Fare breakdown
|
||||||
|
</h3>
|
||||||
{farePassengers.map((passenger: any, idx: number) => {
|
{farePassengers.map((passenger: any, idx: number) => {
|
||||||
const isChildPassenger = passenger.category === 'CHILD';
|
const isChildPassenger = passenger.category === "CHILD";
|
||||||
const isFreeChild = isChildPassenger && (passenger.fareMinor ?? 0) === 0;
|
const isFreeChild =
|
||||||
|
isChildPassenger && (passenger.fareMinor ?? 0) === 0;
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0"
|
||||||
|
>
|
||||||
<div className="flex justify-between mb-0.5">
|
<div className="flex justify-between mb-0.5">
|
||||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[60%]">
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[60%]">
|
||||||
{passenger.fullName || `Passenger ${idx + 1}`}
|
{passenger.fullName || `Passenger ${idx + 1}`}
|
||||||
{isChildPassenger && (
|
{isChildPassenger && (
|
||||||
<span className={`text-xs font-semibold ml-1 ${isFreeChild ? 'text-green-600' : 'text-blue-600'}`}>
|
<span
|
||||||
({isFreeChild ? 'CHILD - FREE' : 'CHILD - FULL FARE'})
|
className={`text-xs font-semibold ml-1 ${isFreeChild ? "text-green-600" : "text-blue-600"}`}
|
||||||
|
>
|
||||||
|
({isFreeChild ? "CHILD - FREE" : "CHILD - FULL FARE"})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -297,11 +396,21 @@ function BookingDetailContent() {
|
|||||||
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
|
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Outbound</span>
|
<span>Outbound</span>
|
||||||
<span>{formatFare(passenger.outboundFareMinor ?? 0, displayCurrency)}</span>
|
<span>
|
||||||
|
{formatFare(
|
||||||
|
passenger.outboundFareMinor ?? 0,
|
||||||
|
displayCurrency,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Return</span>
|
<span>Return</span>
|
||||||
<span>{formatFare(passenger.returnFareMinor ?? 0, displayCurrency)}</span>
|
<span>
|
||||||
|
{formatFare(
|
||||||
|
passenger.returnFareMinor ?? 0,
|
||||||
|
displayCurrency,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -312,18 +421,24 @@ function BookingDetailContent() {
|
|||||||
|
|
||||||
<div className="pt-2 border-t-2 border-gray-200 dark:border-gray-700">
|
<div className="pt-2 border-t-2 border-gray-200 dark:border-gray-700">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="font-bold text-gray-900 dark:text-gray-100">Total</span>
|
<span className="font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Total
|
||||||
|
</span>
|
||||||
<span className="text-xl font-bold text-primary flex items-center gap-1.5">
|
<span className="text-xl font-bold text-primary flex items-center gap-1.5">
|
||||||
{awaitingAmount ? (
|
{awaitingAmount ? (
|
||||||
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||||
) : (
|
) : (
|
||||||
<>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}</>
|
<>
|
||||||
|
{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{selectedPaymentMethod && !awaitingAmount && (
|
{selectedPaymentMethod && !awaitingAmount && (
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-right mt-1">
|
<p className="text-xs text-gray-500 dark:text-gray-400 text-right mt-1">
|
||||||
You will be charged {confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)} via {selectedPaymentMethod.displayName}
|
You will be charged {confirmedCurrency}{" "}
|
||||||
|
{(totalAmountDisplay ?? 0).toFixed(2)} via{" "}
|
||||||
|
{selectedPaymentMethod.displayName}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -331,11 +446,15 @@ function BookingDetailContent() {
|
|||||||
{/* Pay + back buttons — desktop sidebar only */}
|
{/* Pay + back buttons — desktop sidebar only */}
|
||||||
<div className="hidden lg:flex flex-col gap-2 pt-1">
|
<div className="hidden lg:flex flex-col gap-2 pt-1">
|
||||||
{paymentError && (
|
{paymentError && (
|
||||||
<p className="text-red-600 dark:text-red-400 text-xs">⚠️ {paymentError}</p>
|
<p className="text-red-600 dark:text-red-400 text-xs">
|
||||||
|
⚠️ {paymentError}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handlePayment}
|
onClick={handlePayment}
|
||||||
disabled={!selectedMethod || paymentMutation.isPending || awaitingAmount}
|
disabled={
|
||||||
|
!selectedMethod || paymentMutation.isPending || awaitingAmount
|
||||||
|
}
|
||||||
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{paymentMutation.isPending ? (
|
{paymentMutation.isPending ? (
|
||||||
@@ -350,7 +469,11 @@ function BookingDetailContent() {
|
|||||||
`Pay ${confirmedCurrency} ${(totalAmountDisplay ?? 0).toFixed(2)}`
|
`Pay ${confirmedCurrency} ${(totalAmountDisplay ?? 0).toFixed(2)}`
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => router.push('/booking/lookup')} disabled={paymentMutation.isPending} className="btn-secondary w-full flex items-center justify-center gap-2">
|
<button
|
||||||
|
onClick={() => router.push("/booking/lookup")}
|
||||||
|
disabled={paymentMutation.isPending}
|
||||||
|
className="btn-secondary w-full flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
@@ -366,17 +489,23 @@ function BookingDetailContent() {
|
|||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6 pb-28 lg:pb-10">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6 pb-28 lg:pb-10">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">Complete payment</h1>
|
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">
|
||||||
|
Complete payment
|
||||||
|
</h1>
|
||||||
|
|
||||||
<div className="card mb-4 flex items-start justify-between gap-3">
|
<div className="card mb-4 flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Booking Reference: <span className="font-mono font-semibold text-gray-900 dark:text-gray-100">{booking.bookingRef}</span>
|
Booking Reference:{" "}
|
||||||
|
<span className="font-mono font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{booking.bookingRef}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{booking.createdAt && (
|
{booking.createdAt && (
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-1.5">
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-1.5">
|
||||||
<Clock className="w-3.5 h-3.5" />
|
<Clock className="w-3.5 h-3.5" />
|
||||||
Booking created on {format(new Date(booking.createdAt), 'PPpp')}
|
Booking created on{" "}
|
||||||
|
{format(new Date(booking.createdAt), "PPpp")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -385,16 +514,18 @@ function BookingDetailContent() {
|
|||||||
|
|
||||||
{/* Two-column grid — matches /booking/payment's layout */}
|
{/* Two-column grid — matches /booking/payment's layout */}
|
||||||
<div className="lg:grid lg:grid-cols-3 lg:gap-6 lg:items-start">
|
<div className="lg:grid lg:grid-cols-3 lg:gap-6 lg:items-start">
|
||||||
|
|
||||||
{/* Left column — trip/payment method (2/3 width) */}
|
{/* Left column — trip/payment method (2/3 width) */}
|
||||||
<div className="lg:col-span-2 space-y-4">
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Trip Summary</h2>
|
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Trip Summary
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<div className="w-2 h-2 bg-primary rounded-full" />
|
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Your Journey</span>
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
Your Journey
|
||||||
|
</span>
|
||||||
{booking.passengers?.[0]?.seat?.seatClass && (
|
{booking.passengers?.[0]?.seat?.seatClass && (
|
||||||
<span className="ml-auto text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full font-medium">
|
<span className="ml-auto text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full font-medium">
|
||||||
{booking.passengers[0].seat.seatClass}
|
{booking.passengers[0].seat.seatClass}
|
||||||
@@ -419,10 +550,14 @@ function BookingDetailContent() {
|
|||||||
{/* Origin */}
|
{/* Origin */}
|
||||||
<div className="pb-8">
|
<div className="pb-8">
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
{booking.schedule?.departureAt ? formatTime(booking.schedule.departureAt) : '--:--'}
|
{booking.schedule?.departureAt
|
||||||
|
? formatTime(booking.schedule.departureAt)
|
||||||
|
: "--:--"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
{booking.schedule?.departureAt ? `${format(new Date(booking.schedule.departureAt), 'EEE, MMM d')} · ${getTimePeriod(booking.schedule.departureAt)}` : 'N/A'}
|
{booking.schedule?.departureAt
|
||||||
|
? `${format(new Date(booking.schedule.departureAt), "EEE, MMM d")} · ${getTimePeriod(booking.schedule.departureAt)}`
|
||||||
|
: "N/A"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||||
{booking.schedule?.origin?.name}
|
{booking.schedule?.origin?.name}
|
||||||
@@ -436,10 +571,22 @@ function BookingDetailContent() {
|
|||||||
<div className="pb-8">
|
<div className="pb-8">
|
||||||
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-medium">Train {booking.schedule?.trainNumber}</span>
|
<span className="font-medium">
|
||||||
|
Train {booking.schedule?.trainNumber}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{booking.schedule?.trainName && (
|
{booking.schedule?.trainName && (
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
@@ -452,10 +599,14 @@ function BookingDetailContent() {
|
|||||||
{/* Destination */}
|
{/* Destination */}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
{booking.schedule?.arrivalAt ? formatTime(booking.schedule.arrivalAt) : '--:--'}
|
{booking.schedule?.arrivalAt
|
||||||
|
? formatTime(booking.schedule.arrivalAt)
|
||||||
|
: "--:--"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
{booking.schedule?.arrivalAt ? `${format(new Date(booking.schedule.arrivalAt), 'EEE, MMM d')} · ${getTimePeriod(booking.schedule.arrivalAt)}` : 'N/A'}
|
{booking.schedule?.arrivalAt
|
||||||
|
? `${format(new Date(booking.schedule.arrivalAt), "EEE, MMM d")} · ${getTimePeriod(booking.schedule.arrivalAt)}`
|
||||||
|
: "N/A"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||||
{booking.schedule?.destination?.name}
|
{booking.schedule?.destination?.name}
|
||||||
@@ -475,32 +626,44 @@ function BookingDetailContent() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{booking.passengers?.map((passenger: any, idx: number) => (
|
{booking.passengers?.map(
|
||||||
<div key={idx} className="flex items-center justify-between text-sm py-2 px-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
(passenger: any, idx: number) => (
|
||||||
<div>
|
<div
|
||||||
<div className="text-gray-900 dark:text-white font-medium">{passenger.fullName}</div>
|
key={idx}
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
className="flex items-center justify-between text-sm py-2 px-3 bg-gray-50 dark:bg-gray-900 rounded-lg"
|
||||||
{passenger.category} • Coach {passenger.seat?.coach}
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-900 dark:text-white font-medium">
|
||||||
|
{passenger.fullName}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{passenger.category} • Coach{" "}
|
||||||
|
{passenger.seat?.coach}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
Seat {passenger.seat?.number}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{passenger.seat?.seatClass}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
),
|
||||||
<div className="font-semibold text-gray-900 dark:text-white">
|
)}
|
||||||
Seat {passenger.seat?.number}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{passenger.seat?.seatClass}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Select payment method</h2>
|
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Select payment method
|
||||||
|
</h2>
|
||||||
|
|
||||||
{paymentMethods && Array.isArray(paymentMethods) && paymentMethods.length > 0 ? (
|
{paymentMethods &&
|
||||||
|
Array.isArray(paymentMethods) &&
|
||||||
|
paymentMethods.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{paymentMethods.map((method: any) => {
|
{paymentMethods.map((method: any) => {
|
||||||
const Icon = getIconForMethod(method.type);
|
const Icon = getIconForMethod(method.type);
|
||||||
@@ -508,21 +671,37 @@ function BookingDetailContent() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={method.id}
|
key={method.id}
|
||||||
onClick={() => { setSelectedMethod(method.type); setSelectedMethodCurrency(method.currency ?? null); }}
|
onClick={() => {
|
||||||
disabled={paymentMutation.isPending || method.enabled === false}
|
setSelectedMethod(method.type);
|
||||||
|
setSelectedMethodCurrency(
|
||||||
|
method.currency ?? null,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
paymentMutation.isPending ||
|
||||||
|
method.enabled === false
|
||||||
|
}
|
||||||
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
|
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-primary bg-primary/8 dark:bg-primary/15 shadow-md'
|
? "border-primary bg-primary/8 dark:bg-primary/15 shadow-md"
|
||||||
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary/50'
|
: "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary/50"
|
||||||
} ${paymentMutation.isPending || method.enabled === false ? 'opacity-50 cursor-not-allowed' : ''}`}
|
} ${paymentMutation.isPending || method.enabled === false ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`w-11 h-11 rounded-lg flex items-center justify-center flex-shrink-0 ${isSelected ? 'bg-primary' : 'bg-gray-100 dark:bg-gray-700'}`}>
|
<div
|
||||||
<Icon className={`w-5 h-5 ${isSelected ? 'text-white' : 'text-primary'}`} />
|
className={`w-11 h-11 rounded-lg flex items-center justify-center flex-shrink-0 ${isSelected ? "bg-primary" : "bg-gray-100 dark:bg-gray-700"}`}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={`w-5 h-5 ${isSelected ? "text-white" : "text-primary"}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{method.displayName}</p>
|
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">{method.region} · {method.currency}</p>
|
{method.displayName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{method.region} · {method.currency}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<CheckCircle2 className="w-5 h-5 text-primary flex-shrink-0" />
|
<CheckCircle2 className="w-5 h-5 text-primary flex-shrink-0" />
|
||||||
@@ -551,34 +730,46 @@ function BookingDetailContent() {
|
|||||||
<OrderSummary />
|
<OrderSummary />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>{/* end grid */}
|
{/* end grid */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile sticky bottom bar */}
|
{/* Mobile sticky bottom bar */}
|
||||||
<div className="lg:hidden fixed bottom-0 inset-x-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-4 py-3 z-40 shadow-lg">
|
<div className="lg:hidden fixed bottom-0 inset-x-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-4 py-3 z-40 shadow-lg">
|
||||||
<div className="flex items-center justify-between mb-2.5">
|
<div className="flex items-center justify-between mb-2.5">
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400">Total</span>
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Total
|
||||||
|
</span>
|
||||||
<span className="text-lg font-bold text-primary flex items-center gap-1.5">
|
<span className="text-lg font-bold text-primary flex items-center gap-1.5">
|
||||||
{awaitingAmount ? (
|
{awaitingAmount ? (
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}</>
|
<>
|
||||||
|
{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{paymentError && (
|
{paymentError && (
|
||||||
<p className="text-red-600 dark:text-red-400 text-xs mb-2">⚠️ {paymentError}</p>
|
<p className="text-red-600 dark:text-red-400 text-xs mb-2">
|
||||||
|
⚠️ {paymentError}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button onClick={() => router.push('/booking/lookup')} disabled={paymentMutation.isPending} className="btn-secondary flex-1 py-2.5 flex items-center justify-center gap-2">
|
<button
|
||||||
|
onClick={() => router.push("/booking/lookup")}
|
||||||
|
disabled={paymentMutation.isPending}
|
||||||
|
className="btn-secondary flex-1 py-2.5 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handlePayment}
|
onClick={handlePayment}
|
||||||
disabled={!selectedMethod || paymentMutation.isPending || awaitingAmount}
|
disabled={
|
||||||
|
!selectedMethod || paymentMutation.isPending || awaitingAmount
|
||||||
|
}
|
||||||
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{paymentMutation.isPending ? (
|
{paymentMutation.isPending ? (
|
||||||
@@ -604,9 +795,10 @@ function BookingDetailContent() {
|
|||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 mb-6 text-center border border-gray-200 dark:border-gray-700">
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 mb-6 text-center border border-gray-200 dark:border-gray-700">
|
||||||
<div className={`w-20 h-20 ${isConfirmed ? 'bg-green-100 dark:bg-green-900/30' : 'bg-gray-100 dark:bg-gray-700'} rounded-full flex items-center justify-center mx-auto mb-4`}>
|
<div
|
||||||
|
className={`w-20 h-20 ${isConfirmed ? "bg-green-100 dark:bg-green-900/30" : "bg-gray-100 dark:bg-gray-700"} rounded-full flex items-center justify-center mx-auto mb-4`}
|
||||||
|
>
|
||||||
{isConfirmed ? (
|
{isConfirmed ? (
|
||||||
<CheckCircle2 className="w-10 h-10 text-green-600 dark:text-green-400" />
|
<CheckCircle2 className="w-10 h-10 text-green-600 dark:text-green-400" />
|
||||||
) : (
|
) : (
|
||||||
@@ -614,35 +806,54 @@ function BookingDetailContent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
{isConfirmed ? 'Booking Confirmed!' : isCancelled ? 'Booking Cancelled' : 'Booking Expired'}
|
{isConfirmed
|
||||||
|
? "Booking Confirmed!"
|
||||||
|
: isCancelled
|
||||||
|
? "Booking Cancelled"
|
||||||
|
: "Booking Expired"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
{isConfirmed ? 'Your tickets have been generated successfully' : isCancelled ? 'This booking has been cancelled' : 'This booking has expired'}
|
{isConfirmed
|
||||||
|
? "Your tickets have been generated successfully"
|
||||||
|
: isCancelled
|
||||||
|
? "This booking has been cancelled"
|
||||||
|
: "This booking has expired"}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="inline-flex items-center gap-3 bg-gray-50 dark:bg-gray-900 rounded-xl px-6 py-4">
|
<div className="inline-flex items-center gap-3 bg-gray-50 dark:bg-gray-900 rounded-xl px-6 py-4">
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Booking Reference</div>
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||||
<div className="text-2xl font-mono font-bold text-primary">{booking.bookingRef}</div>
|
Booking Reference
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-mono font-bold text-primary">
|
||||||
|
{booking.bookingRef}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={copyPNR}
|
onClick={copyPNR}
|
||||||
className="w-10 h-10 rounded-lg bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 flex items-center justify-center transition-all"
|
className="w-10 h-10 rounded-lg bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 flex items-center justify-center transition-all"
|
||||||
>
|
>
|
||||||
{copiedPNR ? <Check className="w-5 h-5 text-green-600" /> : <Copy className="w-5 h-5 text-gray-600 dark:text-gray-400" />}
|
{copiedPNR ? (
|
||||||
|
<Check className="w-5 h-5 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isConfirmed && (
|
{isConfirmed && (
|
||||||
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
Total paid:{' '}
|
Total paid:{" "}
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{booking?.payment?.amountMinor != null
|
{booking?.payment?.amountMinor != null
|
||||||
? `${booking.payment.currency || 'ETB'} ${(booking.payment.amountMinor / 100).toFixed(2)}`
|
? `${booking.payment.currency || "ETB"} ${booking.payment.amountMinor}`
|
||||||
: `ETB ${((booking?.totalMinor ?? 0) / 100).toFixed(2)}`}
|
: `ETB ${((booking?.totalMinor ?? 0) / 100).toFixed(2)}`}
|
||||||
</span>
|
</span>
|
||||||
{booking?.payment?.method && (
|
{booking?.payment?.method && (
|
||||||
<span className="text-gray-500 dark:text-gray-400"> via {booking.payment.method}</span>
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
{" "}
|
||||||
|
via {booking.payment.method}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -673,11 +884,15 @@ function BookingDetailContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 mb-6 border border-gray-200 dark:border-gray-700">
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 mb-6 border border-gray-200 dark:border-gray-700">
|
||||||
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Journey Details</h2>
|
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Journey Details
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<div className="w-2 h-2 bg-primary rounded-full" />
|
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Your Journey</span>
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
Your Journey
|
||||||
|
</span>
|
||||||
{booking.passengers?.[0]?.seat?.seatClass && (
|
{booking.passengers?.[0]?.seat?.seatClass && (
|
||||||
<span className="ml-auto text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full font-medium">
|
<span className="ml-auto text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full font-medium">
|
||||||
{booking.passengers[0].seat.seatClass}
|
{booking.passengers[0].seat.seatClass}
|
||||||
@@ -702,10 +917,14 @@ function BookingDetailContent() {
|
|||||||
{/* Origin */}
|
{/* Origin */}
|
||||||
<div className="pb-8">
|
<div className="pb-8">
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
{booking.schedule?.departureAt ? formatTime(booking.schedule.departureAt) : '--:--'}
|
{booking.schedule?.departureAt
|
||||||
|
? formatTime(booking.schedule.departureAt)
|
||||||
|
: "--:--"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
{booking.schedule?.departureAt ? `${format(new Date(booking.schedule.departureAt), 'EEE, MMM d')} · ${getTimePeriod(booking.schedule.departureAt)}` : 'N/A'}
|
{booking.schedule?.departureAt
|
||||||
|
? `${format(new Date(booking.schedule.departureAt), "EEE, MMM d")} · ${getTimePeriod(booking.schedule.departureAt)}`
|
||||||
|
: "N/A"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||||
{booking.schedule?.origin?.name}
|
{booking.schedule?.origin?.name}
|
||||||
@@ -719,10 +938,22 @@ function BookingDetailContent() {
|
|||||||
<div className="pb-8">
|
<div className="pb-8">
|
||||||
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-medium">Train {booking.schedule?.trainNumber}</span>
|
<span className="font-medium">
|
||||||
|
Train {booking.schedule?.trainNumber}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{booking.schedule?.trainName && (
|
{booking.schedule?.trainName && (
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
@@ -735,10 +966,14 @@ function BookingDetailContent() {
|
|||||||
{/* Destination */}
|
{/* Destination */}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
{booking.schedule?.arrivalAt ? formatTime(booking.schedule.arrivalAt) : '--:--'}
|
{booking.schedule?.arrivalAt
|
||||||
|
? formatTime(booking.schedule.arrivalAt)
|
||||||
|
: "--:--"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
{booking.schedule?.arrivalAt ? `${format(new Date(booking.schedule.arrivalAt), 'EEE, MMM d')} · ${getTimePeriod(booking.schedule.arrivalAt)}` : 'N/A'}
|
{booking.schedule?.arrivalAt
|
||||||
|
? `${format(new Date(booking.schedule.arrivalAt), "EEE, MMM d")} · ${getTimePeriod(booking.schedule.arrivalAt)}`
|
||||||
|
: "N/A"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||||
{booking.schedule?.destination?.name}
|
{booking.schedule?.destination?.name}
|
||||||
@@ -758,14 +993,19 @@ function BookingDetailContent() {
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{booking.passengers?.map((passenger: any, idx: number) => (
|
{booking.passengers?.map((passenger: any, idx: number) => (
|
||||||
<div key={idx} className="border border-gray-200 dark:border-gray-700 rounded-xl p-4">
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="border border-gray-200 dark:border-gray-700 rounded-xl p-4"
|
||||||
|
>
|
||||||
<div className="flex flex-col md:flex-row md:items-center gap-4">
|
<div className="flex flex-col md:flex-row md:items-center gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span className="w-6 h-6 bg-primary text-white rounded-full flex items-center justify-center text-xs font-bold">
|
<span className="w-6 h-6 bg-primary text-white rounded-full flex items-center justify-center text-xs font-bold">
|
||||||
{idx + 1}
|
{idx + 1}
|
||||||
</span>
|
</span>
|
||||||
<h3 className="font-bold text-gray-900 dark:text-white">{passenger.fullName}</h3>
|
<h3 className="font-bold text-gray-900 dark:text-white">
|
||||||
|
{passenger.fullName}
|
||||||
|
</h3>
|
||||||
<span className="text-xs px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-full">
|
<span className="text-xs px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-full">
|
||||||
{passenger.category}
|
{passenger.category}
|
||||||
</span>
|
</span>
|
||||||
@@ -773,21 +1013,27 @@ function BookingDetailContent() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500 dark:text-gray-400">Coach:</span>
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
Coach:
|
||||||
|
</span>
|
||||||
<div className="font-mono font-semibold text-gray-900 dark:text-white">
|
<div className="font-mono font-semibold text-gray-900 dark:text-white">
|
||||||
{passenger.seat?.coach || 'N/A'}
|
{passenger.seat?.coach || "N/A"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500 dark:text-gray-400">Seat Number:</span>
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
Seat Number:
|
||||||
|
</span>
|
||||||
<div className="font-semibold text-gray-900 dark:text-white">
|
<div className="font-semibold text-gray-900 dark:text-white">
|
||||||
{passenger.seat?.number || 'N/A'}
|
{passenger.seat?.number || "N/A"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<span className="text-gray-500 dark:text-gray-400">Class:</span>
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
Class:
|
||||||
|
</span>
|
||||||
<div className="font-medium text-gray-900 dark:text-white">
|
<div className="font-medium text-gray-900 dark:text-white">
|
||||||
{passenger.seat?.seatClass || 'N/A'}
|
{passenger.seat?.seatClass || "N/A"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -811,7 +1057,10 @@ function BookingDetailContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
<button onClick={() => router.push('/booking/search')} className="btn-primary">
|
<button
|
||||||
|
onClick={() => router.push("/booking/search")}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
Book Another Trip
|
Book Another Trip
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -828,11 +1077,18 @@ function BookingDetailContent() {
|
|||||||
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<AlertCircle className="w-8 h-8 text-gray-500" />
|
<AlertCircle className="w-8 h-8 text-gray-500" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Unknown Booking Status</h2>
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Unknown Booking Status
|
||||||
|
</h2>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||||
Booking status: {booking.status}
|
Booking status: {booking.status}
|
||||||
</p>
|
</p>
|
||||||
<button onClick={() => router.push('/booking/search')} className="btn-primary">New Booking</button>
|
<button
|
||||||
|
onClick={() => router.push("/booking/search")}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
New Booking
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -840,14 +1096,18 @@ function BookingDetailContent() {
|
|||||||
|
|
||||||
export default function BookingDetailPage() {
|
export default function BookingDetailPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={
|
<Suspense
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
|
fallback={
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center max-w-md w-full">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
|
||||||
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center max-w-md w-full">
|
||||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Loading...</h2>
|
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Loading...
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
}>
|
>
|
||||||
<BookingDetailContent />
|
<BookingDetailContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user