mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
update seatmap and voucher
This commit is contained in:
@@ -4,10 +4,11 @@ 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, useRef } from 'react';
|
||||
import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train, FileText } from 'lucide-react';
|
||||
import { CheckCircle, Copy, Train, FileText } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
type BookingWithTicket = {
|
||||
@@ -26,6 +27,9 @@ type BookingWithTicket = {
|
||||
export default function ConfirmationPage() {
|
||||
const router = useRouter();
|
||||
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, searchCriteria, passengers, clearBooking, packageName } = 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 [copied, setCopied] = useState(false);
|
||||
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
|
||||
@@ -85,8 +89,12 @@ export default function ConfirmationPage() {
|
||||
const { generatePassengerVoucherPDF } = await import('@/lib/generate-voucher');
|
||||
|
||||
const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
|
||||
const totalFare = _booking?.totalMinor
|
||||
|| passengers.reduce((s) => s + (activeSchedule?.baseFareAdult || 0), 0);
|
||||
// Prefer the amount/currency actually confirmed for the selected payment option;
|
||||
// only fall back to the ETB booking fare when no payment step ran (e.g. $0 total).
|
||||
const totalFare = paidAmountMinor
|
||||
?? _booking?.totalMinor
|
||||
?? passengers.reduce((s) => s + (activeSchedule?.baseFareAdult || 0), 0);
|
||||
const voucherCurrency = paidAmountMinor != null ? paidCurrency : 'ETB';
|
||||
const farePerPassenger = Math.round(totalFare / passengers.length);
|
||||
const createdAt = _booking?.createdAt || new Date().toISOString();
|
||||
const status = _booking?.status || 'CONFIRMED';
|
||||
@@ -129,7 +137,7 @@ export default function ConfirmationPage() {
|
||||
inboundSchedule: inbound,
|
||||
isRoundTrip,
|
||||
fareMinor: farePerPassenger,
|
||||
currency: 'ETB',
|
||||
currency: voucherCurrency,
|
||||
createdAt,
|
||||
});
|
||||
|
||||
@@ -143,14 +151,6 @@ export default function ConfirmationPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrintTickets = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
const handleEmailTickets = () => {
|
||||
alert('Tickets have been sent to your registered email address.');
|
||||
};
|
||||
|
||||
const handleNewBooking = () => {
|
||||
clearBooking();
|
||||
window.location.href = '/';
|
||||
@@ -182,9 +182,9 @@ export default function ConfirmationPage() {
|
||||
</div>
|
||||
|
||||
{/* PNR Card */}
|
||||
<div className="card mb-6 bg-gradient-to-r from-primary to-primary-600 dark:from-primary-700 dark:to-primary-900 text-white">
|
||||
<div className="card mb-6 bg-primary text-white">
|
||||
<div className="text-center">
|
||||
<p className="text-sm opacity-90 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
|
||||
@@ -199,7 +199,7 @@ export default function ConfirmationPage() {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm opacity-90 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>
|
||||
|
||||
@@ -330,7 +330,7 @@ export default function ConfirmationPage() {
|
||||
<div>
|
||||
<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">
|
||||
ETB {((_booking?.totalMinor || passengers.reduce((s) => s + (selectedSchedule?.baseFareAdult || 0), 0)) / 100).toFixed(2)}
|
||||
{paidAmountMinor != null ? paidCurrency : 'ETB'} {((paidAmountMinor ?? _booking?.totalMinor ?? passengers.reduce((s) => s + (selectedSchedule?.baseFareAdult || 0), 0)) / 100).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -395,50 +395,24 @@ export default function ConfirmationPage() {
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6">
|
||||
<button
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={handleDownloadVoucher}
|
||||
disabled={isGeneratingVoucher}
|
||||
className="btn-primary flex items-center justify-center gap-2 relative"
|
||||
className="btn-primary w-full flex items-center justify-center gap-2 relative"
|
||||
>
|
||||
{isGeneratingVoucher ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<span className="hidden sm:inline">Generating...</span>
|
||||
<span>Generating...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Download Voucher</span>
|
||||
<span className="sm:hidden">Voucher</span>
|
||||
<span>Download Voucher</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePrintTickets}
|
||||
className="btn-secondary flex items-center justify-center gap-2"
|
||||
>
|
||||
<Printer className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Print</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEmailTickets}
|
||||
className="btn-secondary flex items-center justify-center gap-2"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Email</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => alert('Tickets download will be available soon.')}
|
||||
className="btn-secondary flex items-center justify-center gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Download</span>
|
||||
</button>
|
||||
<button className="btn-secondary flex items-center justify-center gap-2">
|
||||
<Share2 className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Share</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* New Booking Button */}
|
||||
|
||||
@@ -901,7 +901,7 @@ export default function PassengersPage() {
|
||||
}
|
||||
} catch (error) {
|
||||
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
|
||||
setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to confirm verification status. Please try again or enter details manually.' }));
|
||||
setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to confirm verification status. Please try again.' }));
|
||||
} finally {
|
||||
setVerifyingIndex(null);
|
||||
clearPendingFaydaIndex();
|
||||
|
||||
@@ -29,7 +29,7 @@ const getIconForMethod = (methodId: string) => {
|
||||
export default function PaymentPage() {
|
||||
const router = useRouter();
|
||||
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName, packageTierPriceMinor } = useBookingStore();
|
||||
const { setPaymentIntent, updateStatus, setCurrency } = usePaymentStore();
|
||||
const { setPaymentIntent, updateStatus, setCurrency, setPaidAmount } = usePaymentStore();
|
||||
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
||||
const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
@@ -43,11 +43,6 @@ export default function PaymentPage() {
|
||||
|
||||
const displayCurrency = 'ETB' as const;
|
||||
|
||||
// Keep payment store in sync so the mutation picks up the right currency.
|
||||
useEffect(() => {
|
||||
setCurrency(displayCurrency);
|
||||
}, [displayCurrency, setCurrency]);
|
||||
|
||||
const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery<PaymentMethod[]>({
|
||||
queryKey: ['paymentMethods', displayCurrency],
|
||||
queryFn: async () => {
|
||||
@@ -77,27 +72,52 @@ export default function PaymentPage() {
|
||||
const pkgPerLegChildFare = isPackage ? Math.round(pkgPerLegAdultFare * 0.1) : 0;
|
||||
const pkgPerLegTotal = isPackage ? adultCount * pkgPerLegAdultFare + childCount * pkgPerLegChildFare : 0;
|
||||
|
||||
// Prefer each passenger's own seat fare (set during seat selection) over the schedule's
|
||||
// flat baseFareAdult — bed coaches price Upper/Middle/Lower berths differently, so a
|
||||
// single schedule-level fare can't correctly represent every passenger's actual seat.
|
||||
const outboundBaseFare = isPackage
|
||||
? pkgPerLegTotal
|
||||
: (isRoundTrip && outboundSchedule ? passengers.reduce((sum, _, i) => sum + calculatePassengerFare(passengers, i, outboundSchedule.baseFareAdult || 0), 0) : 0);
|
||||
: (isRoundTrip && outboundSchedule ? passengers.reduce((sum, p, i) => {
|
||||
const fare = (p as any).outboundSeatFareMinor ?? (outboundSchedule.baseFareAdult || 0);
|
||||
return sum + calculatePassengerFare(passengers, i, fare);
|
||||
}, 0) : 0);
|
||||
|
||||
const inboundBaseFare = isPackage
|
||||
? pkgPerLegTotal
|
||||
: (isRoundTrip && inboundSchedule ? passengers.reduce((sum, _, i) => sum + calculatePassengerFare(passengers, i, inboundSchedule.baseFareAdult || 0), 0) : 0);
|
||||
: (isRoundTrip && inboundSchedule ? passengers.reduce((sum, p, i) => {
|
||||
const fare = (p as any).inboundSeatFareMinor ?? (inboundSchedule.baseFareAdult || 0);
|
||||
return sum + calculatePassengerFare(passengers, i, fare);
|
||||
}, 0) : 0);
|
||||
|
||||
const baseFare = isPackage
|
||||
? adultCount * pkgAdultFare + childCount * pkgChildFare
|
||||
: isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, _, i) => {
|
||||
const farePerPassenger = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0;
|
||||
: isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, p, i) => {
|
||||
const scheduleFare = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0;
|
||||
const farePerPassenger = (p as any).seatFareMinor ?? scheduleFare;
|
||||
return sum + calculatePassengerFare(passengers, i, farePerPassenger);
|
||||
}, 0);
|
||||
|
||||
// API returns amount in major units (e.g. 11602.5 DJF); convert to minor for display consistency
|
||||
// Amount to show on screen: /payments/booking-amount already returns a ready-to-display
|
||||
// major-unit amount, so render it directly instead of round-tripping it through minor
|
||||
// units and back (× 100 to convert, ÷ 100 again to display).
|
||||
const totalAmountDisplay = bookingAmountData != null ? bookingAmountData.amount : baseFare / 100;
|
||||
|
||||
// Minor-unit form, kept only for the actual charge request and for persisting the
|
||||
// confirmed amount — the rest of the app's fare fields (baseFareMinor, fareMinor, etc.)
|
||||
// are minor-unit based, so this keeps that convention internally without affecting display.
|
||||
const totalAmount = bookingAmountData != null
|
||||
? Math.round(bookingAmountData.amount * 100)
|
||||
: baseFare;
|
||||
const confirmedCurrency = bookingAmountData?.currency || amountCurrency;
|
||||
|
||||
// Persist the amount/currency actually confirmed for the selected payment option so
|
||||
// downstream screens (e.g. the voucher) use it instead of a default ETB fare.
|
||||
useEffect(() => {
|
||||
if (bookingAmountData == null) return;
|
||||
setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD');
|
||||
setPaidAmount(totalAmount);
|
||||
}, [bookingAmountData, confirmedCurrency, totalAmount, setCurrency, setPaidAmount]);
|
||||
|
||||
const paymentMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
return await apiClient.post("/payments/initiate", {
|
||||
@@ -277,9 +297,11 @@ export default function PaymentPage() {
|
||||
if (isPackage) {
|
||||
passengerTotal = isChildPassenger ? pkgChildFare : pkgAdultFare;
|
||||
} else {
|
||||
const outFare = outboundSchedule?.baseFareAdult || 0;
|
||||
const inFare = inboundSchedule?.baseFareAdult || 0;
|
||||
const onewayFare = selectedSchedule?.baseFareAdult || 0;
|
||||
// Prefer this passenger's actual seat fare (varies by berth for bed coaches)
|
||||
// over the schedule's flat baseFareAdult.
|
||||
const outFare = (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0);
|
||||
const inFare = (p as any).inboundSeatFareMinor ?? (inboundSchedule?.baseFareAdult || 0);
|
||||
const onewayFare = (p as any).seatFareMinor ?? (selectedSchedule?.baseFareAdult || 0);
|
||||
const outboundFare = calculatePassengerFare(passengers, i, outFare);
|
||||
const inboundFare = calculatePassengerFare(passengers, i, inFare);
|
||||
const oneWayFare = calculatePassengerFare(passengers, i, onewayFare);
|
||||
@@ -311,7 +333,7 @@ export default function PaymentPage() {
|
||||
<span>{formatFare(
|
||||
isPackage
|
||||
? (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare)
|
||||
: calculatePassengerFare(passengers, i, outboundSchedule?.baseFareAdult || 0),
|
||||
: calculatePassengerFare(passengers, i, (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0)),
|
||||
displayCurrency
|
||||
)}</span>
|
||||
</div>
|
||||
@@ -320,7 +342,7 @@ export default function PaymentPage() {
|
||||
<span>{formatFare(
|
||||
isPackage
|
||||
? (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare)
|
||||
: calculatePassengerFare(passengers, i, inboundSchedule?.baseFareAdult || 0),
|
||||
: calculatePassengerFare(passengers, i, (p as any).inboundSeatFareMinor ?? (inboundSchedule?.baseFareAdult || 0)),
|
||||
displayCurrency
|
||||
)}</span>
|
||||
</div>
|
||||
@@ -338,7 +360,7 @@ export default function PaymentPage() {
|
||||
{loadingAmount && (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||
)}
|
||||
{confirmedCurrency} {(totalAmount / 100).toFixed(2)}
|
||||
{confirmedCurrency} {totalAmountDisplay.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,7 +384,7 @@ export default function PaymentPage() {
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Calculating amount...
|
||||
</span>
|
||||
) : (
|
||||
`Pay ${confirmedCurrency} ${(totalAmount / 100).toFixed(2)}`
|
||||
`Pay ${confirmedCurrency} ${totalAmountDisplay.toFixed(2)}`
|
||||
)}
|
||||
</button>
|
||||
<button onClick={() => router.back()} disabled={isProcessing} className="btn-secondary w-full flex items-center justify-center gap-2">
|
||||
@@ -405,8 +427,7 @@ export default function PaymentPage() {
|
||||
{paymentMutation.isSuccess ? (
|
||||
<>
|
||||
<CheckCircle className="w-14 h-14 text-green-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-bold mb-1 text-gray-900 dark:text-gray-100">Payment successful!</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">Redirecting to confirmation...</p>
|
||||
<h3 className="text-lg font-bold mb-4 text-gray-900 dark:text-gray-100">Loading...</h3>
|
||||
<Loader2 className="w-6 h-6 text-primary animate-spin mx-auto" />
|
||||
</>
|
||||
) : (
|
||||
@@ -498,7 +519,7 @@ export default function PaymentPage() {
|
||||
<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">
|
||||
{loadingAmount && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
{confirmedCurrency} {(totalAmount / 100).toFixed(2)}
|
||||
{confirmedCurrency} {totalAmountDisplay.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
{paymentError && (
|
||||
@@ -523,7 +544,7 @@ export default function PaymentPage() {
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Calculating...
|
||||
</span>
|
||||
) : (
|
||||
`Pay ${confirmedCurrency} ${(totalAmount / 100).toFixed(2)}`
|
||||
`Pay ${confirmedCurrency} ${totalAmountDisplay.toFixed(2)}`
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -199,6 +199,9 @@ export default function ResultsPage() {
|
||||
selectedCoachTypeCode: selectedCoachType.code,
|
||||
selectedCoachTypeName: selectedCoachType.name,
|
||||
seatClassName: (selectedCoachType as any).seatClassName || selectedCoachType.name,
|
||||
// Retained so the seat map's coach preview can price a switch to a different
|
||||
// coach type without needing a fresh API call.
|
||||
coachTypes: schedule.coachTypes || [],
|
||||
};
|
||||
|
||||
// For round trip, store outbound and advance to inbound step
|
||||
@@ -232,7 +235,8 @@ export default function ResultsPage() {
|
||||
const scheduleId = classModal.scheduleId || classModal.id || '';
|
||||
const selectedCoachType = selectedCoachTypes[scheduleId];
|
||||
const isOutbound = (classModal as any).isOutbound;
|
||||
const coachTypes = classModal.coachTypes || [];
|
||||
// Dining coaches aren't bookable seat/bed classes — exclude them from selection.
|
||||
const coachTypes = (classModal.coachTypes || []).filter((ct: any) => ct.coachTypeCode !== 'DPC');
|
||||
|
||||
const getCoachIcon = (typeName: string) => {
|
||||
const lower = typeName.toLowerCase();
|
||||
|
||||
@@ -393,14 +393,24 @@ export default function ReviewPage() {
|
||||
const scheduleSeatClassName = isRoundTrip
|
||||
? (outboundSchedule as any)?.seatClassName
|
||||
: (selectedSchedule as any)?.seatClassName;
|
||||
const seatClassId = seatClasses.find((sc: any) => sc.name === scheduleSeatClassName)?.id || seatClasses[0]?.id;
|
||||
if (!seatClassId) return;
|
||||
const fallbackSeatClassId = seatClasses.find((sc: any) => sc.name === scheduleSeatClassName)?.id || seatClasses[0]?.id;
|
||||
if (!fallbackSeatClassId) return;
|
||||
|
||||
// Bed coaches price Upper/Middle/Lower as separate classes, so a passenger's own
|
||||
// assigned berth (captured on the seats page) must resolve its own seatClassId here
|
||||
// — a single shared class can't correctly price passengers in different berths.
|
||||
const resolveSeatClassId = (p: any): string => {
|
||||
const bedPosition: string | undefined = isRoundTrip ? (p as any).outboundBedPosition : (p as any).bedPosition;
|
||||
if (!bedPosition) return fallbackSeatClassId;
|
||||
const match = seatClasses.find((sc: any) => sc.name?.toLowerCase().includes(bedPosition));
|
||||
return match?.id || fallbackSeatClassId;
|
||||
};
|
||||
|
||||
const passengersParam = JSON.stringify(
|
||||
passengers.map(p => ({
|
||||
passengerName: p.name,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
seatClassId,
|
||||
seatClassId: resolveSeatClassId(p),
|
||||
nationality: p.nationality,
|
||||
}))
|
||||
);
|
||||
@@ -444,9 +454,28 @@ export default function ReviewPage() {
|
||||
const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
|
||||
const childPassengerCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length;
|
||||
|
||||
// Per-seat fare captured on the seats page (bed-position-aware, computed locally from
|
||||
// the schedule's own coachTypes/classes) is guaranteed correct for berths, unlike the
|
||||
// backend /search/fare-breakdown call whose seatClassId matching for bed positions can't
|
||||
// be verified here. Prefer it whenever the passenger actually has an assigned seat.
|
||||
const getPassengerSeatFare = (p: any): number | null => {
|
||||
if (isRoundTrip) {
|
||||
if (p.outboundSeatFareMinor == null && p.inboundSeatFareMinor == null) return null;
|
||||
return (p.outboundSeatFareMinor ?? 0) + (p.inboundSeatFareMinor ?? 0);
|
||||
}
|
||||
return p.seatFareMinor ?? null;
|
||||
};
|
||||
|
||||
const total = isPackageBooking
|
||||
? adultPassengerCount * pkgAdultFare + childPassengerCount * pkgChildFare
|
||||
: (fareBreakdown?.totalMinor ?? 0);
|
||||
: passengers.reduce((sum, p, i) => {
|
||||
const isChildPassenger = isChild(p);
|
||||
const line = fareBreakdown?.passengers?.[i];
|
||||
const isFreeChild = line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i));
|
||||
if (isFreeChild) return sum;
|
||||
const seatFare = getPassengerSeatFare(p);
|
||||
return sum + (seatFare ?? line?.fareMinor ?? 0);
|
||||
}, 0);
|
||||
|
||||
// Shared fare sidebar — rendered in right column (desktop) and inline (mobile)
|
||||
const FareSidebar = () => (
|
||||
@@ -457,10 +486,11 @@ export default function ReviewPage() {
|
||||
{passengers.map((p, i) => {
|
||||
const line = fareBreakdown?.passengers?.[i];
|
||||
const isChildPassenger = isChild(p);
|
||||
const isFreeChild = !isPackageBooking && (line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i)));
|
||||
const seatFare = getPassengerSeatFare(p);
|
||||
const passengerTotal = isPackageBooking
|
||||
? (isChildPassenger ? pkgChildFare : pkgAdultFare)
|
||||
: (line?.fareMinor ?? 0);
|
||||
const isFreeChild = !isPackageBooking && (line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i)));
|
||||
: (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0));
|
||||
|
||||
return (
|
||||
<div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,11 @@ class ApiClient {
|
||||
return config;
|
||||
});
|
||||
|
||||
const PUBLIC_PREFIXES = ['/config/', '/auth/login', '/auth/register', '/passengers/me'];
|
||||
// /fayda/verification/* is used by guests filling out the passenger form, and a 401
|
||||
// there just means the user canceled/closed the Fayda popup without completing it (no
|
||||
// valid verification session) — that should surface as an inline error on the page,
|
||||
// not force-clear the session and redirect to /login out from under them.
|
||||
const PUBLIC_PREFIXES = ['/config/', '/auth/login', '/auth/register', '/passengers/me', '/fayda/verification'];
|
||||
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
|
||||
@@ -30,6 +30,15 @@ export interface PassengerDetail {
|
||||
seatId?: string;
|
||||
seatNumber?: string;
|
||||
coachNumber?: string;
|
||||
// Fare for this passenger's actual assigned seat (minor units). Bed coaches price
|
||||
// Upper/Middle/Lower differently, so this can differ from the schedule's flat
|
||||
// baseFareAdult (which is only the coach type's cheapest class) — review/payment
|
||||
// should prefer this per-seat fare when it's available.
|
||||
seatFareMinor?: number;
|
||||
// Raw bed position ("lower"/"middle"/"upper") of the assigned seat, if it's a berth —
|
||||
// used to resolve this passenger's actual seat class server-side (see review page's
|
||||
// fare-breakdown request), since a shared coach-level class can't distinguish berths.
|
||||
bedPosition?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
gender?: string;
|
||||
@@ -37,9 +46,13 @@ export interface PassengerDetail {
|
||||
outboundSeatId?: string;
|
||||
outboundSeatNumber?: string;
|
||||
outboundCoachNumber?: string;
|
||||
outboundSeatFareMinor?: number;
|
||||
outboundBedPosition?: string;
|
||||
inboundSeatId?: string;
|
||||
inboundSeatNumber?: string;
|
||||
inboundCoachNumber?: string;
|
||||
inboundSeatFareMinor?: number;
|
||||
inboundBedPosition?: string;
|
||||
returnSeatId?: string;
|
||||
returnSeatNumber?: string;
|
||||
}
|
||||
@@ -63,6 +76,21 @@ export interface SelectedSchedule {
|
||||
selectedCoachTypeId?: string;
|
||||
selectedCoachTypeCode?: string;
|
||||
selectedCoachTypeName?: string;
|
||||
// All coach types (with per-class fares) offered on this schedule at selection time —
|
||||
// kept so the seat map's coach preview can compute the fare difference before letting
|
||||
// a passenger switch to a different coach type.
|
||||
coachTypes?: Array<{
|
||||
coachId: string;
|
||||
coachTypeId: string;
|
||||
coachTypeName: string;
|
||||
coachTypeCode: string;
|
||||
classes: Array<{
|
||||
name: string;
|
||||
baseFareMinor: number;
|
||||
displayCurrency?: string;
|
||||
displayAmountMinor?: number;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SeatHold {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import jsPDF from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
interface ScheduleInfo {
|
||||
trainNumber: string;
|
||||
@@ -36,6 +37,40 @@ const DARK = [51, 51, 51] as const;
|
||||
const MED = [102, 102, 102] as const;
|
||||
const LIGHT = [200, 200, 200] as const;
|
||||
|
||||
// ─── QR code ───────────────────────────────────────────────────────────────
|
||||
// Encodes everything a gate scanner needs to verify this specific ticket without
|
||||
// a network round-trip: booking reference, ticket number, passenger, train, seat(s),
|
||||
// departure time and fare. Kept as compact JSON so any generic QR reader can parse it.
|
||||
function buildTicketQrPayload(data: PassengerVoucherData): string {
|
||||
return JSON.stringify({
|
||||
type: 'EDR_TICKET',
|
||||
pnr: data.bookingRef,
|
||||
ticket: data.ticketNumber,
|
||||
passenger: data.passengerName,
|
||||
status: data.status,
|
||||
train: data.outboundSchedule.trainNumber,
|
||||
seat: data.isRoundTrip
|
||||
? { outbound: data.outboundSeatNumber || null, inbound: data.inboundSeatNumber || null }
|
||||
: (data.seatNumber || null),
|
||||
departure: data.outboundSchedule.departureAt,
|
||||
fare: { amountMinor: data.fareMinor, currency: data.currency },
|
||||
});
|
||||
}
|
||||
|
||||
async function generateTicketQrDataUrl(data: PassengerVoucherData): Promise<string | null> {
|
||||
try {
|
||||
return await QRCode.toDataURL(buildTicketQrPayload(data), {
|
||||
width: 240,
|
||||
margin: 0,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: { dark: '#0f172a', light: '#ffffff' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to generate ticket QR code:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function drawHeader(doc: jsPDF, margin: number): Promise<number> {
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
|
||||
@@ -81,22 +116,41 @@ function drawStatusBadge(doc: jsPDF, status: string, y: number, pageWidth: numbe
|
||||
return y + 12;
|
||||
}
|
||||
|
||||
function drawBookingRefBox(doc: jsPDF, bookingRef: string, ticketNumber: string, y: number, margin: number, pageWidth: number): number {
|
||||
function drawBookingRefBox(doc: jsPDF, bookingRef: string, ticketNumber: string, qrDataUrl: string | null, y: number, margin: number, pageWidth: number): number {
|
||||
const boxHeight = 32;
|
||||
const qrSize = 24;
|
||||
const qrPad = 3;
|
||||
const qrBlockWidth = qrDataUrl ? qrSize + qrPad * 2 + 5 : 0;
|
||||
|
||||
doc.setFillColor(245, 245, 245);
|
||||
doc.rect(margin, y, pageWidth - margin * 2, 22, 'F');
|
||||
doc.roundedRect(margin, y, pageWidth - margin * 2, boxHeight, 2, 2, 'F');
|
||||
|
||||
// Booking reference (top-left)
|
||||
doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal');
|
||||
doc.text('BOOKING REFERENCE', margin + 5, y + 6);
|
||||
doc.setTextColor(...PRIMARY); doc.setFontSize(16); doc.setFont('helvetica', 'bold');
|
||||
doc.text(bookingRef, margin + 5, y + 14);
|
||||
doc.text('BOOKING REFERENCE', margin + 5, y + 8);
|
||||
doc.setTextColor(...PRIMARY); doc.setFontSize(18); doc.setFont('helvetica', 'bold');
|
||||
doc.text(bookingRef, margin + 5, y + 18);
|
||||
|
||||
const rightX = pageWidth - margin - 5;
|
||||
// Ticket number, stacked below — leaves room on the right for the QR block
|
||||
const textRightBound = pageWidth - margin - qrBlockWidth - 5;
|
||||
doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal');
|
||||
doc.text('TICKET NUMBER', rightX, y + 6, { align: 'right' });
|
||||
doc.text('TICKET NUMBER', textRightBound, y + 8, { align: 'right' });
|
||||
doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold');
|
||||
doc.text(ticketNumber, rightX, y + 14, { align: 'right' });
|
||||
doc.text(ticketNumber, textRightBound, y + 16, { align: 'right' });
|
||||
|
||||
return y + 28;
|
||||
// QR code — clean white card with a thin border, right-aligned in the box
|
||||
if (qrDataUrl) {
|
||||
const cardSize = qrSize + qrPad * 2;
|
||||
const cardX = pageWidth - margin - cardSize - 3;
|
||||
const cardY = y + (boxHeight - cardSize) / 2;
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.setDrawColor(...LIGHT);
|
||||
doc.setLineWidth(0.4);
|
||||
doc.roundedRect(cardX, cardY, cardSize, cardSize, 2, 2, 'FD');
|
||||
doc.addImage(qrDataUrl, 'PNG', cardX + qrPad, cardY + qrPad, qrSize, qrSize);
|
||||
}
|
||||
|
||||
return y + boxHeight + 6;
|
||||
}
|
||||
|
||||
function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, label: string | null, y: number, margin: number, pageWidth: number): number {
|
||||
@@ -244,6 +298,7 @@ export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): P
|
||||
const margin = 15;
|
||||
|
||||
let y = await drawHeader(doc, margin);
|
||||
const qrDataUrl = await generateTicketQrDataUrl(data);
|
||||
|
||||
// Title
|
||||
doc.setTextColor(...DARK); doc.setFontSize(18); doc.setFont('helvetica', 'bold');
|
||||
@@ -251,7 +306,7 @@ export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): P
|
||||
y += 10;
|
||||
|
||||
y = drawStatusBadge(doc, data.status, y, pageW);
|
||||
y = drawBookingRefBox(doc, data.bookingRef, data.ticketNumber, y, margin, pageW);
|
||||
y = drawBookingRefBox(doc, data.bookingRef, data.ticketNumber, qrDataUrl, y, margin, pageW);
|
||||
y = drawJourneyLeg(doc, data.outboundSchedule, data.isRoundTrip ? 'Outbound' : null, y, margin, pageW);
|
||||
|
||||
if (data.isRoundTrip && data.inboundSchedule) {
|
||||
|
||||
@@ -3,11 +3,17 @@ import { create } from 'zustand';
|
||||
interface PaymentState {
|
||||
paymentIntentId: string | null;
|
||||
paymentStatus: 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED' | null;
|
||||
// The currency actually confirmed for the selected payment option (from
|
||||
// /payments/booking-amount), not just a default — kept in sync by the payment page.
|
||||
selectedCurrency: 'ETB' | 'DJF' | 'USD';
|
||||
|
||||
// The exact minor-unit amount confirmed for that currency/payment option. Downstream
|
||||
// screens (e.g. the voucher) should use this instead of recomputing a default ETB fare.
|
||||
paidAmountMinor: number | null;
|
||||
|
||||
setPaymentIntent: (id: string) => void;
|
||||
updateStatus: (status: 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED') => void;
|
||||
setCurrency: (currency: 'ETB' | 'DJF' | 'USD') => void;
|
||||
setPaidAmount: (amountMinor: number) => void;
|
||||
clearPayment: () => void;
|
||||
}
|
||||
|
||||
@@ -15,13 +21,16 @@ export const usePaymentStore = create<PaymentState>((set) => ({
|
||||
paymentIntentId: null,
|
||||
paymentStatus: null,
|
||||
selectedCurrency: 'ETB',
|
||||
paidAmountMinor: null,
|
||||
|
||||
setPaymentIntent: (id) => set({ paymentIntentId: id }),
|
||||
updateStatus: (status) => set({ paymentStatus: status }),
|
||||
setCurrency: (currency) => set({ selectedCurrency: currency }),
|
||||
setPaidAmount: (amountMinor) => set({ paidAmountMinor: amountMinor }),
|
||||
clearPayment: () => set({
|
||||
paymentIntentId: null,
|
||||
paymentStatus: null,
|
||||
selectedCurrency: 'ETB',
|
||||
paidAmountMinor: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user