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/results/loading.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/loading.tsx new file mode 100644 index 000000000..72a278a1a --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/results/loading.tsx @@ -0,0 +1,103 @@ +// Next.js renders this automatically the instant navigation to /booking/results +// begins — before the route's JS has even finished downloading/compiling and +// well before the page component mounts or its data fetch starts. That closes +// the "I clicked Search and nothing happened" gap: previously there was no +// visual feedback at all until the route fully loaded and hit its own isLoading +// state. Mirrors that same isLoading skeleton so the transition is seamless. +export default function ResultsLoading() { + return ( +
+
+
+
+
+
+
+
+
+
+

+ Searching for trains... +

+

+ Finding the best options for your journey +

+
+
+
+
+
+
+
+ +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+
+
+
+ ); +} 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 d1d686142..30e5393f9 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 @@ -552,6 +552,17 @@ export default function SearchPage() { "origin" | "destination" | null >(null); const [hasInteracted, setHasInteracted] = useState(false); + // Immediate feedback the moment Search is clicked — router.push() itself + // doesn't paint anything until the target route's JS has loaded, which + // otherwise reads as a dead click. + const [isSearching, setIsSearching] = useState(false); + + // Warms the results route's JS chunk ahead of time so clicking Search + // doesn't have to wait for it to download/compile on top of the actual + // search request. + useEffect(() => { + router.prefetch("/booking/results"); + }, [router]); const [recentStationIds, setRecentStationIds] = useState(() => { try { return JSON.parse(localStorage.getItem("edr_recent_stations") || "[]"); @@ -689,6 +700,7 @@ export default function SearchPage() { const onSubmit = (data: SearchForm) => { setHasInteracted(true); + setIsSearching(true); // Clear previous booking selections and search cache before starting a new search clearBooking(); setSearchCriteria(data); @@ -715,6 +727,7 @@ export default function SearchPage() { // origin/destination/date errors, which is noisier than fixing things one step at a time. const onInvalid = (formErrors: typeof errors) => { setHasInteracted(true); + setIsSearching(false); const hasOtherErrors = Object.keys(formErrors).some((k) => k !== "nationality"); if (formErrors.nationality && !hasOtherErrors) { setPassengerModalOpen(true); @@ -793,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 -

@@ -884,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 */}
@@ -1207,11 +1238,20 @@ export default function SearchPage() { {/* Search */}
) : ( @@ -1282,7 +1322,7 @@ export default function SearchPage() { trigger("returnDate"); }} minDate={new Date()} - placeholder="Select date" + placeholder="Departure date" /> {errors.departureDate && (

{errors.departureDate.message}

@@ -1298,7 +1338,7 @@ export default function SearchPage() { trigger("returnDate"); }} minDate={departureDate ? new Date(departureDate + "T00:00:00") : new Date()} - placeholder="Select date" + placeholder="Return date" /> {errors.returnDate && (

{errors.returnDate.message}

@@ -1328,11 +1368,20 @@ export default function SearchPage() { {/* Search */}
)} diff --git a/apps/edr-passenger-web/portal/src/app/contact/page.tsx b/apps/edr-passenger-web/portal/src/app/contact/page.tsx index 669a99d0b..9f0d2d474 100644 --- a/apps/edr-passenger-web/portal/src/app/contact/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/contact/page.tsx @@ -275,8 +275,8 @@ export default function Contact() { }; const contactInfo = [ - { icon: Phone, title: t('contact.phone'), value: '+251 911 000 000', link: 'tel:+251911000000' }, - { icon: Mail, title: t('contact.email'), value: 'support@edr.et', link: 'mailto:support@edr.et' }, + { icon: Phone, title: t('contact.phone'), value: '9546', link: 'tel:9546' }, + { icon: Mail, title: t('contact.email'), value: 'edr_@edrsc.com', link: 'mailto:edr_@edrsc.com' }, { icon: MapPin, title: t('contact.address'), value: 'Addis Ababa, Ethiopia', link: '#' }, ]; diff --git a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx index 6d47b8b5a..0acb5d9ea 100644 --- a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx +++ b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx @@ -14,12 +14,18 @@ import { } from 'lucide-react'; import Link from 'next/link'; import Image from 'next/image'; +import dynamic from 'next/dynamic'; import { usePathname } from 'next/navigation'; import { useEffect, useState } from 'react'; import { useAuthStore } from '@/lib/auth-store'; -import ChangePasswordModal from '@/components/ChangePasswordModal'; import { BOOKING_STEPS } from '@/components/ProgressIndicator'; +// AppSidebar renders on every page via the root layout, so anything imported +// here ships to every visitor's first load — but this modal is only ever +// reachable by an already-authenticated user opening the account dropdown. +// Code-split it out instead of paying for it on every page/every visitor. +const ChangePasswordModal = dynamic(() => import('@/components/ChangePasswordModal'), { ssr: false }); + // Mirrors booking/layout.tsx's stepMap — the linear booking flow routes that // get a vertical step list instead of the standard nav highlighting. const BOOKING_STEP_MAP: Record = { @@ -203,10 +209,12 @@ export default function AppSidebar() { )}
- setShowChangePassword(false)} - /> + {showChangePassword && ( + setShowChangePassword(false)} + /> + )} ); } diff --git a/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx b/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx index 674755516..20923278c 100644 --- a/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx +++ b/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx @@ -286,14 +286,14 @@ export default function ModernDatePicker({ 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 6ff31aeaf..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); @@ -358,16 +373,14 @@ function drawFooter(doc: jsPDF, createdAt: string): void { hairline(doc, PAGE_MARGIN, footerY, pageWidth - PAGE_MARGIN); doc.setFontSize(7.5); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal'); - doc.text('support@edr.com · +251-11-XXX-XXXX · www.edr.com', pageWidth / 2, footerY + 6, { align: 'center' }); + doc.text('edr_@edrsc.com · 9546 · www.edr.com', pageWidth / 2, footerY + 6, { align: 'center' }); doc.setFontSize(6.5); doc.text(`Issued ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 10.5, { align: 'center' }); } // ─── 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)); } };