Fix voucher ticket number

This commit is contained in:
Roba Boru
2026-07-11 00:45:14 +03:00
parent b94c4480ea
commit c1db1c8a99
7 changed files with 272 additions and 195 deletions

View File

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

View File

@@ -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<BookingWithTicket>({
queryKey: ['booking', bookingId],
queryFn: async (): Promise<BookingWithTicket> => {
@@ -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() {
<h2 className="text-2xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Your tickets</h2>
<div className="space-y-4">
{passengers.map((passenger, index) => {
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 (
<div key={index} className="card hover:shadow-lg transition-shadow">
@@ -414,7 +434,9 @@ export default function ConfirmationPage() {
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-600 dark:text-gray-400">Ticket Number</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{ticketNumber || 'Pending payment'}</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
{ticketNumber || (isConfirmed ? 'Not yet issued' : 'Pending payment')}
</p>
</div>
<div>
<p className="text-gray-600 dark:text-gray-400">Date of Birth</p>

View File

@@ -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() {
<div className="flex flex-wrap gap-3 justify-center mt-6">
{isConfirmed && (
<>
<button
<button
onClick={handleDownloadVoucher}
disabled={isGeneratingVoucher}
className="btn-primary flex items-center gap-2"
@@ -646,14 +653,6 @@ function BookingDetailContent() {
</>
)}
</button>
<button className="btn-secondary flex items-center gap-2">
<Download className="w-4 h-4" />
Download Tickets
</button>
<button className="btn-secondary flex items-center gap-2">
<Share2 className="w-4 h-4" />
Share
</button>
</>
)}
</div>

View File

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

View File

@@ -806,8 +806,12 @@ export default function SearchPage() {
/>
)}
{/* ── 90vh hero with banner image ── */}
<section className="relative h-[94vh] min-h-[560px]">
{/* ── 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. ── */}
<section className="relative md:h-[75vh] md:min-h-[500px]">
{/* Background image with zoom - fully isolated */}
<div className="absolute inset-0 overflow-hidden">
<div
@@ -850,12 +854,9 @@ export default function SearchPage() {
<div className="max-w-6xl mx-auto">
{/* Mobile-only heading — desktop keeps the version overlaid on the hero image above */}
<div className="md:hidden mb-3">
<h1 className="text-2xl font-extrabold text-gray-900 dark:text-gray-100 leading-tight">
<h1 className="text-lg font-extrabold text-gray-900 dark:text-gray-100 leading-tight">
Where are you headed today?
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Book your train journey across East Africa
</p>
</div>
<form onSubmit={handleSubmit(onSubmit, onInvalid)}>
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-none md:shadow-2xl border border-white/20 overflow-visible">
@@ -897,166 +898,174 @@ export default function SearchPage() {
</div>
</div>
{/* 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. */}
<div className="flex flex-col gap-3 md:hidden">
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
From
</label>
<button
type="button"
onClick={() => {
setHasInteracted(true);
window.scrollTo({
top: 0,
behavior: "instant" as ScrollBehavior,
});
setStationModal("origin");
}}
className="w-full"
>
<div
className={`flex items-center gap-2.5 px-3.5 py-3 border-2 rounded-xl transition-all ${
hasInteracted && errors.originStationId
? "border-red-400"
: originId
? "border-primary bg-primary/5"
: "border-gray-200 dark:border-gray-700"
}`}
style={{ backgroundColor: originId ? undefined : undefined }}
>
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
<span
style={{ color: originStation ? (dark ? '#ffffff' : '#111827') : (dark ? '#6b7280' : '#9ca3af') }}
className={`text-sm ${originStation ? 'font-semibold' : ''}`}
>
{originStation?.name ?? "Select departure"}
</span>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5 min-w-0">
<div className="h-5 flex items-center">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
From
</label>
</div>
</button>
{hasInteracted && errors.originStationId && (
<p className="text-xs text-red-500">
{errors.originStationId.message}
</p>
)}
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
To
</label>
<button
type="button"
onClick={handleSwap}
disabled={!originId || !destId}
className="flex items-center gap-1 text-xs text-primary font-medium disabled:opacity-30"
>
<ArrowLeftRight
className={`w-3.5 h-3.5 transition-transform duration-300 ${swapping ? "rotate-180" : ""}`}
/>
Swap
</button>
</div>
<button
type="button"
onClick={() => {
setHasInteracted(true);
window.scrollTo({
top: 0,
behavior: "instant" as ScrollBehavior,
});
setStationModal("destination");
}}
className="w-full"
>
<div
className={`flex items-center gap-2.5 px-3.5 py-3 border-2 rounded-xl transition-all ${
hasInteracted && errors.destinationStationId
? "border-red-400"
: destId
? "border-primary bg-primary/5"
: "border-gray-200 dark:border-gray-700"
}`}
>
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
<span
style={{ color: destStation ? (dark ? '#ffffff' : '#111827') : (dark ? '#6b7280' : '#9ca3af') }}
className={`text-sm ${destStation ? 'font-semibold' : ''}`}
>
{destStation?.name ?? "Select destination"}
</span>
</div>
</button>
{hasInteracted && errors.destinationStationId && (
<p className="text-xs text-red-500">
{errors.destinationStationId.message}
</p>
)}
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Date
</label>
<div>
<ModernDatePicker
value={
departureDate
? new Date(departureDate + "T00:00:00")
: undefined
}
onChange={(date) => {
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"
>
<div
className={`flex items-center gap-2 px-2.5 py-3 border-2 rounded-xl transition-all ${
hasInteracted && errors.originStationId
? "border-red-400"
: originId
? "border-primary bg-primary/5"
: "border-gray-200 dark:border-gray-700"
}`}
style={{ backgroundColor: originId ? undefined : undefined }}
>
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
<span
style={{ color: originStation ? (dark ? '#ffffff' : '#111827') : (dark ? '#6b7280' : '#9ca3af') }}
className={`text-sm truncate ${originStation ? 'font-semibold' : ''}`}
>
{originStation?.name ?? "Departure"}
</span>
</div>
</button>
{hasInteracted && errors.originStationId && (
<p className="text-xs text-red-500">
{errors.originStationId.message}
</p>
)}
</div>
<div className="space-y-1.5 min-w-0">
<div className="h-5 flex items-center justify-between">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
To
</label>
<button
type="button"
onClick={handleSwap}
disabled={!originId || !destId}
aria-label="Swap origin and destination"
className="flex items-center justify-center gap-1 text-xs text-primary font-medium disabled:opacity-30 p-0 h-5 w-5"
>
<ArrowLeftRight
className={`w-3.5 h-3.5 transition-transform duration-300 ${swapping ? "rotate-180" : ""}`}
/>
</button>
</div>
<button
type="button"
onClick={() => {
setHasInteracted(true);
window.scrollTo({
top: 0,
behavior: "instant" as ScrollBehavior,
});
setStationModal("destination");
}}
className="w-full"
>
<div
className={`flex items-center gap-2 px-2.5 py-3 border-2 rounded-xl transition-all ${
hasInteracted && errors.destinationStationId
? "border-red-400"
: destId
? "border-primary bg-primary/5"
: "border-gray-200 dark:border-gray-700"
}`}
>
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
<span
style={{ color: destStation ? (dark ? '#ffffff' : '#111827') : (dark ? '#6b7280' : '#9ca3af') }}
className={`text-sm truncate ${destStation ? 'font-semibold' : ''}`}
>
{destStation?.name ?? "Destination"}
</span>
</div>
</button>
{hasInteracted && errors.destinationStationId && (
<p className="text-xs text-red-500">
{errors.destinationStationId.message}
</p>
)}
</div>
{errors.departureDate && (
<p className="text-xs text-red-500">
{errors.departureDate.message}
</p>
)}
</div>
{tripType === "ROUND_TRIP" && (
<div className="space-y-1.5">
<div className={tripType === "ROUND_TRIP" ? "grid grid-cols-2 gap-3" : ""}>
<div className="space-y-1.5 min-w-0">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Return Date
Date
</label>
<div>
<ModernDatePicker
value={
returnDate
? new Date(returnDate + "T00:00:00")
departureDate
? new Date(departureDate + "T00:00:00")
: undefined
}
onChange={(date) => {
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}
/>
</div>
{errors.returnDate && (
{errors.departureDate && (
<p className="text-xs text-red-500">
{errors.returnDate.message}
{errors.departureDate.message}
</p>
)}
</div>
)}
{tripType === "ROUND_TRIP" && (
<div className="space-y-1.5 min-w-0">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Return Date
</label>
<div>
<ModernDatePicker
value={
returnDate
? new Date(returnDate + "T00:00:00")
: undefined
}
onChange={(date) => {
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}
/>
</div>
{errors.returnDate && (
<p className="text-xs text-red-500">
{errors.returnDate.message}
</p>
)}
</div>
)}
</div>
{/* Pax + Nationality combined trigger */}
<button
type="button"
@@ -1313,7 +1322,7 @@ export default function SearchPage() {
trigger("returnDate");
}}
minDate={new Date()}
placeholder="Select date"
placeholder="Departure date"
/>
{errors.departureDate && (
<p className="text-xs text-red-500">{errors.departureDate.message}</p>
@@ -1329,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 && (
<p className="text-xs text-red-500">{errors.returnDate.message}</p>

View File

@@ -286,14 +286,14 @@ export default function ModernDatePicker({
<button
type="button"
onClick={() => setIsOpen(true)}
className={`w-full px-3.5 py-3.5 border-2 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary text-left flex items-center justify-between bg-white dark:bg-gray-800 transition-all group ${
className={`w-full min-w-0 px-2.5 sm:px-3.5 py-3.5 border-2 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary text-left flex items-center justify-between gap-1.5 bg-white dark:bg-gray-800 transition-all group ${
error
? 'border-red-400 hover:border-red-400'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<span className={`text-sm ${value ? 'text-gray-900 dark:text-gray-100 font-medium' : 'text-gray-400'}`}>
{value ? format(value, 'EEE, MMM d, yyyy') : placeholder}
<span className={`text-sm whitespace-nowrap truncate ${value ? 'text-gray-900 dark:text-gray-100 font-medium' : 'text-gray-400'}`}>
{value ? format(value, 'MMM d, yyyy') : placeholder}
</span>
<CalendarIcon className="w-4 h-4 text-gray-400 group-hover:text-primary transition-colors flex-shrink-0" />
</button>

View File

@@ -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<string>((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<number> {
const pageWidth = doc.internal.pageSize.getWidth();
const bandHeight = 24;
@@ -111,17 +134,9 @@ async function drawHeader(doc: jsPDF, margin: number): Promise<number> {
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<string>((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<void> => {
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
async function drawPassengerVoucherPage(doc: jsPDF, data: PassengerVoucherData): Promise<void> {
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<void> => {
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<void> => {
// 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<void> =>
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));
}
};