Merge pull request #631 from Tria-plc/alpha

Alpha
This commit is contained in:
Abubeker Yasin
2026-07-11 12:51:55 +03:00
committed by GitHub
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 { 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&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>
</>
)}
@@ -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