Update price

This commit is contained in:
Roba Boru
2026-07-11 12:50:51 +03:00
parent 25ee55b8b3
commit 88a87afdf6
2 changed files with 823 additions and 358 deletions

View File

@@ -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&apos;t confirmed your payment yet. Your tickets will be issued once payment is completed. We haven&apos;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>

View File

@@ -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}&currency=${amountCurrency}`; const url = `/payments/booking-amount?bookingId=${booking?.id}&currency=${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>
); );