update seatmap and voucher

This commit is contained in:
Roba Boru
2026-07-06 06:12:52 +03:00
parent fd6151bb17
commit dd4f2dcba9
10 changed files with 891 additions and 417 deletions

View File

@@ -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 */}

View File

@@ -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();

View File

@@ -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>

View File

@@ -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();

View File

@@ -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

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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,
}),
}));