mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 16:40:56 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/user_management_UI
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 { useBookingStore } from '@/lib/booking-store';
|
||||
import { usePaymentStore } from '@/lib/payment-store';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CheckCircle, Clock, Copy, Train, FileText } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { isChild, isFirstChild } from '@/utils/fare-utils';
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useBookingStore } from "@/lib/booking-store";
|
||||
import { usePaymentStore } from "@/lib/payment-store";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CheckCircle, Clock, Copy, Train, FileText } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { isChild, isFirstChild } from "@/utils/fare-utils";
|
||||
|
||||
type BookingWithTicket = {
|
||||
id: string;
|
||||
@@ -38,11 +38,24 @@ type BookingWithTicket = {
|
||||
|
||||
export default function ConfirmationPage() {
|
||||
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 —
|
||||
// null when no payment step ran (e.g. a fully-discounted, zero-amount booking).
|
||||
const { selectedCurrency: paidCurrency, paidAmountMinor } = usePaymentStore();
|
||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||
const isRoundTrip = searchCriteria?.tripType === "ROUND_TRIP";
|
||||
const [copied, setCopied] = 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
|
||||
// blocked, and awaiting a cold dynamic import is enough to fall outside that window.
|
||||
useEffect(() => {
|
||||
import('@/lib/generate-voucher');
|
||||
import("@/lib/generate-voucher");
|
||||
}, []);
|
||||
|
||||
const { data: _booking } = useQuery<BookingWithTicket>({
|
||||
queryKey: ['booking', bookingId],
|
||||
queryKey: ["booking", bookingId],
|
||||
queryFn: async (): Promise<BookingWithTicket> => {
|
||||
try {
|
||||
return await apiClient.get(`/bookings/${bookingId}`);
|
||||
} catch (error) {
|
||||
return {
|
||||
id: bookingId || '',
|
||||
id: bookingId || "",
|
||||
pnr: pnr || undefined,
|
||||
status: 'PENDING_PAYMENT',
|
||||
totalMinor: passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0),
|
||||
status: "PENDING_PAYMENT",
|
||||
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
|
||||
// generates it server-side (for every payment method, wallet included); this page only
|
||||
// ever fetches and displays whatever the booking query above already returns.
|
||||
const isConfirmed = _booking?.status === 'CONFIRMED';
|
||||
const isConfirmed = _booking?.status === "CONFIRMED";
|
||||
|
||||
const copyPNR = () => {
|
||||
if (pnr) {
|
||||
@@ -88,46 +104,53 @@ export default function ConfirmationPage() {
|
||||
|
||||
const handleDownloadVoucher = async () => {
|
||||
if (!pnr) {
|
||||
alert('Booking data not available. Please try again.');
|
||||
alert("Booking data not available. Please try again.");
|
||||
return;
|
||||
}
|
||||
if (!passengers.length) {
|
||||
alert('No passenger data found.');
|
||||
alert("No passenger data found.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingVoucher(true);
|
||||
try {
|
||||
const { generatePassengerVoucherPDF } = await import('@/lib/generate-voucher');
|
||||
const { generatePassengerVoucherPDF } =
|
||||
await import("@/lib/generate-voucher");
|
||||
|
||||
const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
|
||||
// The server-confirmed settled amount/currency (what was actually charged) is
|
||||
// authoritative — prefer it over the ETB booking fare once it's available.
|
||||
const settledAmountMinor = _booking?.payment?.amountMinor;
|
||||
const settledCurrency = _booking?.payment?.currency;
|
||||
const voucherCurrency = settledCurrency || 'ETB';
|
||||
const voucherCurrency = settledCurrency || "ETB";
|
||||
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
|
||||
// pages. reviewedPassengerFares is the authoritative source; rebuild from package
|
||||
// context as a fallback so free children always show 0 on their voucher.
|
||||
const { packageTierPriceMinor } = useBookingStore.getState();
|
||||
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 pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgMultiplier : 0;
|
||||
const pkgAdultFare = isPackageBooking
|
||||
? packageTierPriceMinor! * pkgMultiplier
|
||||
: 0;
|
||||
const pkgChildFare = pkgAdultFare;
|
||||
|
||||
const getEtbFare = (idx: number): number => {
|
||||
if (reviewedPassengerFares?.[idx] != null) return reviewedPassengerFares[idx].fareMinor;
|
||||
if (reviewedPassengerFares?.[idx] != null)
|
||||
return reviewedPassengerFares[idx].fareMinor;
|
||||
if (isPackageBooking) {
|
||||
const isPkgChild = idx >= adultCount;
|
||||
const isFreeChild = isPkgChild && (idx - adultCount) < adultCount;
|
||||
const isFreeChild = isPkgChild && idx - adultCount < adultCount;
|
||||
if (isFreeChild) return 0;
|
||||
return isPkgChild ? pkgChildFare : pkgAdultFare;
|
||||
}
|
||||
const totalFare = reviewedTotalMinor ?? paidAmountMinor ?? _booking?.totalMinor ?? 0;
|
||||
const totalFare =
|
||||
reviewedTotalMinor ?? paidAmountMinor ?? _booking?.totalMinor ?? 0;
|
||||
return Math.round(totalFare / passengers.length);
|
||||
};
|
||||
|
||||
@@ -136,31 +159,54 @@ export default function ConfirmationPage() {
|
||||
// ETB-denominated numbers next to a foreign currency label.
|
||||
const etbFares = passengers.map((_, idx) => getEtbFare(idx));
|
||||
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 => {
|
||||
if (!needsConversion) return etbFares[idx];
|
||||
return Math.round(etbFares[idx] * (settledAmountMinor! / etbTotal));
|
||||
};
|
||||
|
||||
const outbound = {
|
||||
trainNumber: activeSchedule?.trainNumber || 'N/A',
|
||||
trainName: 'EDR Express',
|
||||
origin: { name: activeSchedule?.origin || 'Origin', code: 'ORG', city: activeSchedule?.origin || 'Origin' },
|
||||
destination: { name: activeSchedule?.destination || 'Destination', code: 'DST', city: activeSchedule?.destination || 'Destination' },
|
||||
trainNumber: activeSchedule?.trainNumber || "N/A",
|
||||
trainName: "EDR Express",
|
||||
origin: {
|
||||
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(),
|
||||
arrivalAt: activeSchedule?.arrivalTime || new Date().toISOString(),
|
||||
seatClass: activeSchedule?.selectedSeatClassName,
|
||||
arrivalAt: activeSchedule?.arrivalTime || new Date().toISOString(),
|
||||
seatClass: activeSchedule?.selectedSeatClassName,
|
||||
};
|
||||
|
||||
const inbound = inboundSchedule ? {
|
||||
trainNumber: inboundSchedule.trainNumber || 'N/A',
|
||||
trainName: 'EDR Express',
|
||||
origin: { name: inboundSchedule.origin, code: 'ORG', city: inboundSchedule.origin },
|
||||
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;
|
||||
const inbound = inboundSchedule
|
||||
? {
|
||||
trainNumber: inboundSchedule.trainNumber || "N/A",
|
||||
trainName: "EDR Express",
|
||||
origin: {
|
||||
name: inboundSchedule.origin,
|
||||
code: "ORG",
|
||||
city: inboundSchedule.origin,
|
||||
},
|
||||
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)
|
||||
// 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
|
||||
// fabricated placeholder if there's no backend ticket data (see generate-voucher.ts).
|
||||
const matchedTicket =
|
||||
_booking?.tickets?.find((t) => t.passengerName === p.name) ?? _booking?.tickets?.[i] ?? null;
|
||||
const ticketNumber = matchedTicket?.barcodePayload || 'Not yet issued';
|
||||
_booking?.tickets?.find((t) => t.passengerName === p.name) ??
|
||||
_booking?.tickets?.[i] ??
|
||||
null;
|
||||
const ticketNumber = matchedTicket?.barcodePayload || "Not yet issued";
|
||||
|
||||
await generatePassengerVoucherPDF({
|
||||
bookingRef: pnr,
|
||||
bookingRef: pnr,
|
||||
ticketNumber,
|
||||
passengerName: p.name || `Passenger ${i + 1}`,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
nationality: p.nationality,
|
||||
seatNumber: p.seatNumber,
|
||||
outboundSeatNumber: (p as any).outboundSeatNumber,
|
||||
inboundSeatNumber: (p as any).inboundSeatNumber,
|
||||
passengerName: p.name || `Passenger ${i + 1}`,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
nationality: p.nationality,
|
||||
seatNumber: p.seatNumber,
|
||||
outboundSeatNumber: (p as any).outboundSeatNumber,
|
||||
inboundSeatNumber: (p as any).inboundSeatNumber,
|
||||
status,
|
||||
outboundSchedule: outbound,
|
||||
inboundSchedule: inbound,
|
||||
outboundSchedule: outbound,
|
||||
inboundSchedule: inbound,
|
||||
isRoundTrip,
|
||||
fareMinor: getVoucherFare(i),
|
||||
currency: voucherCurrency,
|
||||
fareMinor: getVoucherFare(i),
|
||||
currency: voucherCurrency,
|
||||
createdAt,
|
||||
});
|
||||
}
|
||||
} 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 {
|
||||
setIsGeneratingVoucher(false);
|
||||
}
|
||||
@@ -200,12 +250,12 @@ export default function ConfirmationPage() {
|
||||
|
||||
const handleNewBooking = () => {
|
||||
clearBooking();
|
||||
window.location.href = '/';
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!bookingId || !pnr) {
|
||||
window.location.href = '/';
|
||||
window.location.href = "/";
|
||||
}
|
||||
}, [bookingId, pnr, router]);
|
||||
|
||||
@@ -231,9 +281,13 @@ export default function ConfirmationPage() {
|
||||
{isConfirmed ? (
|
||||
<>
|
||||
<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>
|
||||
<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
|
||||
</h1>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
@@ -250,11 +305,15 @@ export default function ConfirmationPage() {
|
||||
{/* PNR Card */}
|
||||
<div className="card mb-6 bg-primary text-white">
|
||||
<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">
|
||||
<span className="text-5xl font-bold tracking-widest">{pnr}</span>
|
||||
<button
|
||||
onClick={copyPNR}
|
||||
<span className="text-5xl font-bold tracking-widest">
|
||||
{pnr}
|
||||
</span>
|
||||
<button
|
||||
onClick={copyPNR}
|
||||
className="p-2 hover:bg-white hover:bg-opacity-20 rounded transition-colors"
|
||||
title="Copy PNR"
|
||||
>
|
||||
@@ -265,149 +324,231 @@ export default function ConfirmationPage() {
|
||||
)}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
{/* Trip Details */}
|
||||
<div className="card mb-6">
|
||||
<div>
|
||||
<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">
|
||||
<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 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">
|
||||
<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>
|
||||
|
||||
{/* Outbound journey (round trip) or single journey */}
|
||||
{(() => {
|
||||
const schedule = isRoundTrip ? outboundSchedule : selectedSchedule;
|
||||
if (!schedule) return null;
|
||||
return (
|
||||
<div className="mb-4">
|
||||
{isRoundTrip && (
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-primary mb-2">Outbound</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">{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>
|
||||
{/* Outbound journey (round trip) or single journey */}
|
||||
{(() => {
|
||||
const schedule = isRoundTrip
|
||||
? outboundSchedule
|
||||
: selectedSchedule;
|
||||
if (!schedule) return null;
|
||||
return (
|
||||
<div className="mb-4">
|
||||
{isRoundTrip && (
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-primary mb-2">
|
||||
Outbound
|
||||
</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>
|
||||
<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">{inboundSchedule.origin} → {inboundSchedule.destination}</p>
|
||||
<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>
|
||||
{inboundSchedule.selectedSeatClassName && (
|
||||
{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">{inboundSchedule.selectedSeatClassName.replace(/_/g, ' ')}</p>
|
||||
<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="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')}
|
||||
{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="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')}
|
||||
{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">{inboundSchedule.duration}</p>
|
||||
<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="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>
|
||||
|
||||
{/* Booking date & payment summary */}
|
||||
<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>
|
||||
<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">
|
||||
{format(new Date(_booking?.createdAt || new Date()), 'PPp')}
|
||||
{format(new Date(_booking?.createdAt || new Date()), "PPp")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Status</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 className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Status
|
||||
</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>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Passengers</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{passengers.length}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Passengers
|
||||
</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{passengers.length}
|
||||
</p>
|
||||
</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">
|
||||
{(() => {
|
||||
// The server-confirmed settled amount is authoritative — prefer it over
|
||||
// any client-side session state, which can go stale (e.g. after a refresh).
|
||||
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 (paidAmountMinor != null) return `${paidCurrency} ${(paidAmountMinor / 100).toFixed(2)}`;
|
||||
if (_booking?.totalMinor != null) return `ETB ${(_booking.totalMinor / 100).toFixed(2)}`;
|
||||
return 'ETB 0.00';
|
||||
if (reviewedTotalMinor != null)
|
||||
return `ETB ${(reviewedTotalMinor / 100).toFixed(2)}`;
|
||||
if (paidAmountMinor != null)
|
||||
return `${paidCurrency} ${(paidAmountMinor / 100).toFixed(2)}`;
|
||||
if (_booking?.totalMinor != null)
|
||||
return `ETB ${(_booking.totalMinor / 100).toFixed(2)}`;
|
||||
return "ETB 0.00";
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
@@ -416,70 +557,132 @@ export default function ConfirmationPage() {
|
||||
|
||||
{/* Tickets */}
|
||||
<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">
|
||||
{passengers.map((passenger, index) => {
|
||||
// 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.
|
||||
const backendTicket =
|
||||
_booking?.tickets?.find((t) => t.passengerName === passenger.name) ??
|
||||
_booking?.tickets?.find(
|
||||
(t) => t.passengerName === passenger.name,
|
||||
) ??
|
||||
_booking?.tickets?.[index] ??
|
||||
null;
|
||||
// No fabricated placeholder — a made-up TKT-... number reads as real and is
|
||||
// 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 (
|
||||
<div key={index} className="card hover:shadow-lg transition-shadow">
|
||||
<div
|
||||
key={index}
|
||||
className="card hover:shadow-lg transition-shadow"
|
||||
>
|
||||
{/* Ticket Info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-gray-100">{passenger.name}</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Passenger {index + 1}</p>
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{passenger.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Passenger {index + 1}
|
||||
</p>
|
||||
</div>
|
||||
{isConfirmed ? (
|
||||
<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 className="grid grid-cols-2 gap-4 text-sm">
|
||||
<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">
|
||||
{ticketNumber || (isConfirmed ? 'Not yet issued' : 'Pending payment')}
|
||||
{ticketNumber ||
|
||||
(isConfirmed
|
||||
? "Not yet issued"
|
||||
: "Pending payment")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Date of Birth</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{format(new Date(passenger.dateOfBirth), 'PP')}</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Date of Birth
|
||||
</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{format(new Date(passenger.dateOfBirth), "PP")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Nationality</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{passenger.nationality}</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Nationality
|
||||
</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{passenger.nationality}
|
||||
</p>
|
||||
</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
|
||||
? index >= adultCount && (index - adultCount) < adultCount
|
||||
: isChild(passenger) && isFirstChild(passengers, index);
|
||||
if (isFreeChild) return <p className="font-semibold text-gray-900 dark:text-gray-100">—</p>;
|
||||
? index >= adultCount &&
|
||||
index - adultCount < adultCount
|
||||
: isChild(passenger) &&
|
||||
isFirstChild(passengers, index);
|
||||
if (isFreeChild)
|
||||
return (
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
—
|
||||
</p>
|
||||
);
|
||||
return isRoundTrip ? (
|
||||
<div className="space-y-0.5">
|
||||
<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 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>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
})()}
|
||||
@@ -517,8 +720,8 @@ export default function ConfirmationPage() {
|
||||
)}
|
||||
|
||||
{/* New Booking Button */}
|
||||
<button
|
||||
onClick={handleNewBooking}
|
||||
<button
|
||||
onClick={handleNewBooking}
|
||||
className="btn-primary w-full py-4 text-lg font-semibold"
|
||||
>
|
||||
Book another trip
|
||||
@@ -528,12 +731,14 @@ export default function ConfirmationPage() {
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
✅ Please arrive at the station at least 30 minutes before departure.
|
||||
✅ Please arrive at the station at least 30 minutes before
|
||||
departure.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user