diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 9c9384850..b80593cd5 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -1460,7 +1460,7 @@ export class BookingsService { include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, - paymentIntent: true, tickets: { take: 1 }, + paymentIntent: true, tickets: true, priceTier: { select: { priceMinor: true } }, }, }); @@ -1518,7 +1518,7 @@ export class BookingsService { payment: (pkgBooking as any).paymentIntent ? { method: (pkgBooking as any).paymentIntent.method, status: (pkgBooking as any).paymentIntent.status } : undefined, - ticket: undefined, + tickets: [], }; } @@ -1557,7 +1557,15 @@ export class BookingsService { }, })), payment: (booking as any).paymentIntent ? { method: (booking as any).paymentIntent.method, status: (booking as any).paymentIntent.status } : undefined, - ticket: (booking as any).tickets?.[0] ? { id: (booking as any).tickets[0].id, qrPayload: (booking as any).tickets[0].qrPayload, barcodePayload: (booking as any).tickets[0].barcodePayload, status: (booking as any).tickets[0].status } : undefined, + // One ticket per passenger — matched on the frontend by passengerName, not array + // position, since tickets are grouped/created independently of the passengers array. + tickets: (booking as any).tickets?.map((t: any) => ({ + id: t.id, + passengerName: t.passengerName, + qrPayload: t.qrPayload, + barcodePayload: t.barcodePayload, + status: t.status, + })) ?? [], }; } diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 719e80f18..8ac4bf046 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -19,10 +19,13 @@ type BookingWithTicket = { totalMinor?: number; createdAt?: string; paymentMethod?: string; - ticket?: { + // One ticket per passenger — match by passengerName, not array position (see + // bookings.service.ts's getByRef). + tickets?: Array<{ + passengerName?: string; barcodePayload?: string; qrPayload?: string; - }; + }>; }; export default function ConfirmationPage() { @@ -36,6 +39,14 @@ export default function ConfirmationPage() { const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false); const confirmAttempted = useRef(false); + // Warms the code-split voucher module ahead of the click so the handler's own + // `await import(...)` resolves near-instantly — on iOS Safari, a file save triggered + // 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'); + }, []); + const { data: _booking } = useQuery({ queryKey: ['booking', bookingId], queryFn: async (): Promise => { @@ -142,9 +153,16 @@ export default function ConfirmationPage() { 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 + // synchronous user-activation window and risk iOS Safari silently blocking them. for (let i = 0; i < passengers.length; i++) { const p = passengers[i]; - const ticketNumber = `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(i + 1).toString().padStart(2, '0')}`; + // 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'; await generatePassengerVoucherPDF({ bookingRef: pnr, @@ -163,9 +181,6 @@ export default function ConfirmationPage() { currency: voucherCurrency, createdAt, }); - - // brief pause between downloads so browsers don't block them - if (i < passengers.length - 1) await new Promise(r => setTimeout(r, 400)); } } catch (error) { alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`); @@ -390,10 +405,15 @@ export default function ConfirmationPage() {

Your tickets

{passengers.map((passenger, index) => { - const backendTicket = _booking?.ticket || null; - const ticketNumber = isConfirmed - ? backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}` - : null; + // 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?.[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; return (
@@ -414,7 +434,9 @@ export default function ConfirmationPage() {

Ticket Number

-

{ticketNumber || 'Pending payment'}

+

+ {ticketNumber || (isConfirmed ? 'Not yet issued' : 'Pending payment')} +

Date of Birth

diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index a3e7f06fc..92df23f23 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -4,14 +4,13 @@ import { Suspense } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import { useQuery, useMutation } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Clock, Users, CheckCircle2, AlertCircle, Download, - Share2, Copy, Check, CreditCard, @@ -49,6 +48,14 @@ function BookingDetailContent() { const [copiedPNR, setCopiedPNR] = useState(false); const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false); + // Warms the code-split voucher module ahead of the click so the handler's own + // `await import(...)` resolves near-instantly — on iOS Safari, a file save triggered + // 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'); + }, []); + const { data: booking, isLoading, error, refetch } = useQuery({ queryKey: ['booking-detail', bookingRef], queryFn: async () => { @@ -629,7 +636,7 @@ function BookingDetailContent() {
{isConfirmed && ( <> - - - )}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index e4ab0a913..ab93c1cf6 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -313,14 +313,18 @@ export default function ReviewPage() { } // Build booking request for authenticated users - // For package bookings, free children (first child per adult, no seat assigned) - // are excluded from the passengers array — the backend derives them from adultCount/childCount. - const bookingPassengers = passengers.filter((p, i) => { + // Package bookings only: free children (first child per adult) don't go through + // seat selection and have no seatId, so they're excluded here — the backend derives + // them from adultCount/childCount instead. Regular bookings DO seat every passenger + // (including the free child, who still gets a real seatId and a $0 fare handled by + // the backend), so they must stay in the array or that passenger — and their + // ticket/seat/childCount — silently never gets created. + const bookingPassengers = passengers.filter((_p, i) => { if (packageId) { const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount; return !isFreePkgChild; } - return !(isChild(p) && isFirstChild(passengers, i)); + return true; }); bookingData = { @@ -371,14 +375,18 @@ export default function ReviewPage() { if (priceTierId) bookingData.priceTierId = priceTierId; } else { // For guests: send full passenger details array - // For package bookings, free children (first child per adult, no seat assigned) - // are excluded from the passengers array — the backend derives them from adultCount/childCount. - const guestBookingPassengers = passengers.filter((p, i) => { + // Package bookings only: free children (first child per adult) don't go through + // seat selection and have no seatId, so they're excluded here — the backend derives + // them from adultCount/childCount instead. Regular bookings DO seat every passenger + // (including the free child, who still gets a real seatId and a $0 fare handled by + // the backend), so they must stay in the array or that passenger — and their + // ticket/seat/childCount — silently never gets created. + const guestBookingPassengers = passengers.filter((_p, i) => { if (packageId) { const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount; return !isFreePkgChild; } - return !(isChild(p) && isFirstChild(passengers, i)); + return true; }); bookingData = { diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index d8e485fd6..a18aab817 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -806,8 +806,12 @@ export default function SearchPage() { /> )} - {/* ── 90vh hero with banner image ── */} -
+ {/* ── Hero with banner image (desktop only — mobile is content-driven, no + forced height, so it doesn't push the Packages section below the fold). + Desktop height is intentionally short of a full viewport so the Packages + section peeks into view without scrolling — a full 94vh hero was hiding + it entirely on common screen sizes. ── */} +
{/* Background image with zoom - fully isolated */}
{/* Mobile-only heading — desktop keeps the version overlaid on the hero image above */}
-

+

Where are you headed today?

-

- Book your train journey across East Africa -

@@ -897,166 +898,174 @@ export default function SearchPage() {
- {/* Mobile: stacked */} + {/* Mobile: stacked, but From/To and Date/Return Date pair up into two + columns each to save vertical space (station names/dates truncate + rather than wrap) — same fields, same behavior, just denser. */}
-
- - - {hasInteracted && errors.originStationId && ( -

- {errors.originStationId.message} -

- )} -
-
-
- -
- - {hasInteracted && errors.destinationStationId && ( -

- {errors.destinationStationId.message} -

- )} -
-
- -
- { - setValue( - "departureDate", - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, - ); - trigger("departureDate"); + onClick={() => { + setHasInteracted(true); + window.scrollTo({ + top: 0, + behavior: "instant" as ScrollBehavior, + }); + setStationModal("origin"); }} - minDate={new Date()} - placeholder="Select date" - error={!!errors.departureDate} - /> + className="w-full" + > +
+ + + {originStation?.name ?? "Departure"} + +
+ + {hasInteracted && errors.originStationId && ( +

+ {errors.originStationId.message} +

+ )} +
+
+
+ + +
+ + {hasInteracted && errors.destinationStationId && ( +

+ {errors.destinationStationId.message} +

+ )}
- {errors.departureDate && ( -

- {errors.departureDate.message} -

- )}
- {tripType === "ROUND_TRIP" && ( -
+
+
{ setValue( - "returnDate", + "departureDate", `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, ); - trigger("returnDate"); + trigger("departureDate"); }} - minDate={ - departureDate - ? new Date(departureDate + "T00:00:00") - : new Date() - } - placeholder="Select return date" - error={!!errors.returnDate} + minDate={new Date()} + placeholder="Departure date" + error={!!errors.departureDate} />
- {errors.returnDate && ( + {errors.departureDate && (

- {errors.returnDate.message} + {errors.departureDate.message}

)}
- )} + {tripType === "ROUND_TRIP" && ( +
+ +
+ { + setValue( + "returnDate", + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, + ); + trigger("returnDate"); + }} + minDate={ + departureDate + ? new Date(departureDate + "T00:00:00") + : new Date() + } + placeholder="Return date" + error={!!errors.returnDate} + /> +
+ {errors.returnDate && ( +

+ {errors.returnDate.message} +

+ )} +
+ )} +
{/* Pax + Nationality combined trigger */} diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts index ca22538be..7ab72e1a5 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -103,6 +103,29 @@ function hairline(doc: jsPDF, x1: number, y: number, x2: number): void { // ─── header ──────────────────────────────────────────────────────────────── +// Fetched once and reused for the lifetime of the page — re-fetching this same static +// asset on every passenger/every voucher adds a real network round-trip in the middle of +// what needs to stay close to the original click's synchronous execution window (iOS +// Safari silently blocks a file save triggered too long after user activation). +let logoCache: Promise<{ dataUrl: string; width: number; height: number }> | null = null; +function loadLogo(): Promise<{ dataUrl: string; width: number; height: number }> { + if (!logoCache) { + logoCache = (async () => { + const logoImg = await fetch('/edr-logo.png'); + const logoBlob = await logoImg.blob(); + const dataUrl = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.readAsDataURL(logoBlob); + }); + const img = new Image(); + await new Promise((resolve) => { img.onload = resolve; img.src = dataUrl; }); + return { dataUrl, width: img.width, height: img.height }; + })(); + } + return logoCache; +} + async function drawHeader(doc: jsPDF, margin: number): Promise { const pageWidth = doc.internal.pageSize.getWidth(); const bandHeight = 24; @@ -111,17 +134,9 @@ async function drawHeader(doc: jsPDF, margin: number): Promise { doc.rect(0, 0, pageWidth, bandHeight, 'F'); try { - const logoImg = await fetch('/edr-logo.png'); - const logoBlob = await logoImg.blob(); - const logoDataUrl = await new Promise((resolve) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result as string); - reader.readAsDataURL(logoBlob); - }); - const img = new Image(); - await new Promise((resolve) => { img.onload = resolve; img.src = logoDataUrl; }); + const { dataUrl: logoDataUrl, width, height } = await loadLogo(); const logoH = 13; - const logoW = (img.width / img.height) * logoH; + const logoW = (width / height) * logoH; const textX = margin + logoW + 5; doc.addImage(logoDataUrl, 'PNG', margin, (bandHeight - logoH) / 2, logoW, logoH); doc.setTextColor(255, 255, 255); @@ -365,9 +380,7 @@ function drawFooter(doc: jsPDF, createdAt: string): void { // ─── public API ────────────────────────────────────────────────────────────── -/** Generates and downloads one PDF voucher for a single passenger. */ -export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise => { - const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); +async function drawPassengerVoucherPage(doc: jsPDF, data: PassengerVoucherData): Promise { const pageW = doc.internal.pageSize.getWidth(); const margin = PAGE_MARGIN; @@ -388,6 +401,12 @@ export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): P y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW); drawInstructions(doc, y, margin, pageW); drawFooter(doc, data.createdAt); +} + +/** Generates and downloads one PDF voucher for a single passenger. */ +export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise => { + const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); + await drawPassengerVoucherPage(doc, data); const safeName = (data.passengerName || 'Passenger').replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_-]/g, ''); doc.save(`Voucher_${safeName}.pdf`); @@ -404,14 +423,28 @@ interface VoucherData { currency: string; bookingType: string; createdAt: string; + // One ticket per passenger, matched below by passengerName — see bookings.service.ts's + // getByRef(). Optional/absent falls back to a client-generated placeholder number. + tickets?: Array<{ passengerName?: string; barcodePayload?: string }>; } export const generateVoucherPDF = async (booking: VoucherData): Promise => { + // Separate file per passenger, saved back-to-back with no macrotask (setTimeout) between + // them — a setTimeout delay here would push later saves outside the click's synchronous + // user-activation window and risk iOS Safari silently blocking them. The awaited work + // inside generatePassengerVoucherPDF is itself just microtasks (cached logo, QR encode), + // which doesn't have that effect. for (let i = 0; i < booking.passengers.length; i++) { const p = booking.passengers[i]; + const matchedTicket = + booking.tickets?.find((t) => t.passengerName === p.fullName) ?? booking.tickets?.[i] ?? 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 = matchedTicket?.barcodePayload || 'Not yet issued'; + await generatePassengerVoucherPDF({ bookingRef: booking.bookingRef, - ticketNumber: `TKT-${booking.bookingRef}-${(i + 1).toString().padStart(2, '0')}`, + ticketNumber, passengerName: p.fullName, seatNumber: p.seat?.number, status: booking.status, @@ -421,7 +454,5 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise => currency: booking.currency, createdAt: booking.createdAt, }); - // small delay so browsers don't block multiple sequential downloads - if (i < booking.passengers.length - 1) await new Promise(r => setTimeout(r, 400)); } };