mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
Fix voucher ticket number
This commit is contained in:
@@ -1460,7 +1460,7 @@ export class BookingsService {
|
|||||||
include: {
|
include: {
|
||||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||||
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
|
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
|
||||||
paymentIntent: true, tickets: { take: 1 },
|
paymentIntent: true, tickets: true,
|
||||||
priceTier: { select: { priceMinor: true } },
|
priceTier: { select: { priceMinor: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1518,7 +1518,7 @@ export class BookingsService {
|
|||||||
payment: (pkgBooking as any).paymentIntent
|
payment: (pkgBooking as any).paymentIntent
|
||||||
? { method: (pkgBooking as any).paymentIntent.method, status: (pkgBooking as any).paymentIntent.status }
|
? { method: (pkgBooking as any).paymentIntent.method, status: (pkgBooking as any).paymentIntent.status }
|
||||||
: undefined,
|
: 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,
|
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,
|
||||||
|
})) ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,13 @@ type BookingWithTicket = {
|
|||||||
totalMinor?: number;
|
totalMinor?: number;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
paymentMethod?: 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;
|
barcodePayload?: string;
|
||||||
qrPayload?: string;
|
qrPayload?: string;
|
||||||
};
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ConfirmationPage() {
|
export default function ConfirmationPage() {
|
||||||
@@ -36,6 +39,14 @@ export default function ConfirmationPage() {
|
|||||||
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
|
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
|
||||||
const confirmAttempted = useRef(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>({
|
const { data: _booking } = useQuery<BookingWithTicket>({
|
||||||
queryKey: ['booking', bookingId],
|
queryKey: ['booking', bookingId],
|
||||||
queryFn: async (): Promise<BookingWithTicket> => {
|
queryFn: async (): Promise<BookingWithTicket> => {
|
||||||
@@ -142,9 +153,16 @@ export default function ConfirmationPage() {
|
|||||||
seatClass: inboundSchedule.selectedSeatClassName,
|
seatClass: inboundSchedule.selectedSeatClassName,
|
||||||
} : undefined;
|
} : 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++) {
|
for (let i = 0; i < passengers.length; i++) {
|
||||||
const p = passengers[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({
|
await generatePassengerVoucherPDF({
|
||||||
bookingRef: pnr,
|
bookingRef: pnr,
|
||||||
@@ -163,9 +181,6 @@ export default function ConfirmationPage() {
|
|||||||
currency: voucherCurrency,
|
currency: voucherCurrency,
|
||||||
createdAt,
|
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) {
|
} catch (error) {
|
||||||
alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown 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>
|
<h2 className="text-2xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Your tickets</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{passengers.map((passenger, index) => {
|
{passengers.map((passenger, index) => {
|
||||||
const backendTicket = _booking?.ticket || null;
|
// Match by name first (tickets aren't necessarily created/ordered the same
|
||||||
const ticketNumber = isConfirmed
|
// way as this passengers array) — fall back to position if no name match.
|
||||||
? backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`
|
const backendTicket =
|
||||||
: null;
|
_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 (
|
return (
|
||||||
<div key={index} className="card hover:shadow-lg transition-shadow">
|
<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 className="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-600 dark:text-gray-400">Ticket Number</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-600 dark:text-gray-400">Date of Birth</p>
|
<p className="text-gray-600 dark:text-gray-400">Date of Birth</p>
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import { Suspense } from 'react';
|
|||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
Users,
|
Users,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Download,
|
Download,
|
||||||
Share2,
|
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
@@ -49,6 +48,14 @@ function BookingDetailContent() {
|
|||||||
const [copiedPNR, setCopiedPNR] = useState(false);
|
const [copiedPNR, setCopiedPNR] = useState(false);
|
||||||
const [isGeneratingVoucher, setIsGeneratingVoucher] = 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({
|
const { data: booking, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['booking-detail', bookingRef],
|
queryKey: ['booking-detail', bookingRef],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -629,7 +636,7 @@ function BookingDetailContent() {
|
|||||||
<div className="flex flex-wrap gap-3 justify-center mt-6">
|
<div className="flex flex-wrap gap-3 justify-center mt-6">
|
||||||
{isConfirmed && (
|
{isConfirmed && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={handleDownloadVoucher}
|
onClick={handleDownloadVoucher}
|
||||||
disabled={isGeneratingVoucher}
|
disabled={isGeneratingVoucher}
|
||||||
className="btn-primary flex items-center gap-2"
|
className="btn-primary flex items-center gap-2"
|
||||||
@@ -646,14 +653,6 @@ function BookingDetailContent() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -313,14 +313,18 @@ export default function ReviewPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build booking request for authenticated users
|
// Build booking request for authenticated users
|
||||||
// For package bookings, free children (first child per adult, no seat assigned)
|
// Package bookings only: free children (first child per adult) don't go through
|
||||||
// are excluded from the passengers array — the backend derives them from adultCount/childCount.
|
// seat selection and have no seatId, so they're excluded here — the backend derives
|
||||||
const bookingPassengers = passengers.filter((p, i) => {
|
// 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) {
|
if (packageId) {
|
||||||
const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount;
|
const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount;
|
||||||
return !isFreePkgChild;
|
return !isFreePkgChild;
|
||||||
}
|
}
|
||||||
return !(isChild(p) && isFirstChild(passengers, i));
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
bookingData = {
|
bookingData = {
|
||||||
@@ -371,14 +375,18 @@ export default function ReviewPage() {
|
|||||||
if (priceTierId) bookingData.priceTierId = priceTierId;
|
if (priceTierId) bookingData.priceTierId = priceTierId;
|
||||||
} else {
|
} else {
|
||||||
// For guests: send full passenger details array
|
// For guests: send full passenger details array
|
||||||
// For package bookings, free children (first child per adult, no seat assigned)
|
// Package bookings only: free children (first child per adult) don't go through
|
||||||
// are excluded from the passengers array — the backend derives them from adultCount/childCount.
|
// seat selection and have no seatId, so they're excluded here — the backend derives
|
||||||
const guestBookingPassengers = passengers.filter((p, i) => {
|
// 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) {
|
if (packageId) {
|
||||||
const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount;
|
const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount;
|
||||||
return !isFreePkgChild;
|
return !isFreePkgChild;
|
||||||
}
|
}
|
||||||
return !(isChild(p) && isFirstChild(passengers, i));
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
bookingData = {
|
bookingData = {
|
||||||
|
|||||||
@@ -806,8 +806,12 @@ export default function SearchPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 90vh hero with banner image ── */}
|
{/* ── Hero with banner image (desktop only — mobile is content-driven, no
|
||||||
<section className="relative h-[94vh] min-h-[560px]">
|
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 */}
|
{/* Background image with zoom - fully isolated */}
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
@@ -850,12 +854,9 @@ export default function SearchPage() {
|
|||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
{/* Mobile-only heading — desktop keeps the version overlaid on the hero image above */}
|
{/* Mobile-only heading — desktop keeps the version overlaid on the hero image above */}
|
||||||
<div className="md:hidden mb-3">
|
<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?
|
Where are you headed today?
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
Book your train journey across East Africa
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit(onSubmit, onInvalid)}>
|
<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">
|
<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>
|
||||||
</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="flex flex-col gap-3 md:hidden">
|
||||||
<div className="space-y-1.5">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
<div className="space-y-1.5 min-w-0">
|
||||||
From
|
<div className="h-5 flex items-center">
|
||||||
</label>
|
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
<button
|
From
|
||||||
type="button"
|
</label>
|
||||||
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>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSwap}
|
onClick={() => {
|
||||||
disabled={!originId || !destId}
|
setHasInteracted(true);
|
||||||
className="flex items-center gap-1 text-xs text-primary font-medium disabled:opacity-30"
|
window.scrollTo({
|
||||||
>
|
top: 0,
|
||||||
<ArrowLeftRight
|
behavior: "instant" as ScrollBehavior,
|
||||||
className={`w-3.5 h-3.5 transition-transform duration-300 ${swapping ? "rotate-180" : ""}`}
|
});
|
||||||
/>
|
setStationModal("origin");
|
||||||
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");
|
|
||||||
}}
|
}}
|
||||||
minDate={new Date()}
|
className="w-full"
|
||||||
placeholder="Select date"
|
>
|
||||||
error={!!errors.departureDate}
|
<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>
|
</div>
|
||||||
{errors.departureDate && (
|
|
||||||
<p className="text-xs text-red-500">
|
|
||||||
{errors.departureDate.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{tripType === "ROUND_TRIP" && (
|
<div className={tripType === "ROUND_TRIP" ? "grid grid-cols-2 gap-3" : ""}>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5 min-w-0">
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
Return Date
|
Date
|
||||||
</label>
|
</label>
|
||||||
<div>
|
<div>
|
||||||
<ModernDatePicker
|
<ModernDatePicker
|
||||||
value={
|
value={
|
||||||
returnDate
|
departureDate
|
||||||
? new Date(returnDate + "T00:00:00")
|
? new Date(departureDate + "T00:00:00")
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onChange={(date) => {
|
onChange={(date) => {
|
||||||
setValue(
|
setValue(
|
||||||
"returnDate",
|
"departureDate",
|
||||||
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
|
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
|
||||||
);
|
);
|
||||||
trigger("returnDate");
|
trigger("departureDate");
|
||||||
}}
|
}}
|
||||||
minDate={
|
minDate={new Date()}
|
||||||
departureDate
|
placeholder="Departure date"
|
||||||
? new Date(departureDate + "T00:00:00")
|
error={!!errors.departureDate}
|
||||||
: new Date()
|
|
||||||
}
|
|
||||||
placeholder="Select return date"
|
|
||||||
error={!!errors.returnDate}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.returnDate && (
|
{errors.departureDate && (
|
||||||
<p className="text-xs text-red-500">
|
<p className="text-xs text-red-500">
|
||||||
{errors.returnDate.message}
|
{errors.departureDate.message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Pax + Nationality combined trigger */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1313,7 +1322,7 @@ export default function SearchPage() {
|
|||||||
trigger("returnDate");
|
trigger("returnDate");
|
||||||
}}
|
}}
|
||||||
minDate={new Date()}
|
minDate={new Date()}
|
||||||
placeholder="Select date"
|
placeholder="Departure date"
|
||||||
/>
|
/>
|
||||||
{errors.departureDate && (
|
{errors.departureDate && (
|
||||||
<p className="text-xs text-red-500">{errors.departureDate.message}</p>
|
<p className="text-xs text-red-500">{errors.departureDate.message}</p>
|
||||||
@@ -1329,7 +1338,7 @@ export default function SearchPage() {
|
|||||||
trigger("returnDate");
|
trigger("returnDate");
|
||||||
}}
|
}}
|
||||||
minDate={departureDate ? new Date(departureDate + "T00:00:00") : new Date()}
|
minDate={departureDate ? new Date(departureDate + "T00:00:00") : new Date()}
|
||||||
placeholder="Select date"
|
placeholder="Return date"
|
||||||
/>
|
/>
|
||||||
{errors.returnDate && (
|
{errors.returnDate && (
|
||||||
<p className="text-xs text-red-500">{errors.returnDate.message}</p>
|
<p className="text-xs text-red-500">{errors.returnDate.message}</p>
|
||||||
|
|||||||
@@ -286,14 +286,14 @@ export default function ModernDatePicker({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(true)}
|
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
|
error
|
||||||
? 'border-red-400 hover:border-red-400'
|
? 'border-red-400 hover:border-red-400'
|
||||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
: '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'}`}>
|
<span className={`text-sm whitespace-nowrap truncate ${value ? 'text-gray-900 dark:text-gray-100 font-medium' : 'text-gray-400'}`}>
|
||||||
{value ? format(value, 'EEE, MMM d, yyyy') : placeholder}
|
{value ? format(value, 'MMM d, yyyy') : placeholder}
|
||||||
</span>
|
</span>
|
||||||
<CalendarIcon className="w-4 h-4 text-gray-400 group-hover:text-primary transition-colors flex-shrink-0" />
|
<CalendarIcon className="w-4 h-4 text-gray-400 group-hover:text-primary transition-colors flex-shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -103,6 +103,29 @@ function hairline(doc: jsPDF, x1: number, y: number, x2: number): void {
|
|||||||
|
|
||||||
// ─── header ────────────────────────────────────────────────────────────────
|
// ─── 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> {
|
async function drawHeader(doc: jsPDF, margin: number): Promise<number> {
|
||||||
const pageWidth = doc.internal.pageSize.getWidth();
|
const pageWidth = doc.internal.pageSize.getWidth();
|
||||||
const bandHeight = 24;
|
const bandHeight = 24;
|
||||||
@@ -111,17 +134,9 @@ async function drawHeader(doc: jsPDF, margin: number): Promise<number> {
|
|||||||
doc.rect(0, 0, pageWidth, bandHeight, 'F');
|
doc.rect(0, 0, pageWidth, bandHeight, 'F');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const logoImg = await fetch('/edr-logo.png');
|
const { dataUrl: logoDataUrl, width, height } = await loadLogo();
|
||||||
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 logoH = 13;
|
const logoH = 13;
|
||||||
const logoW = (img.width / img.height) * logoH;
|
const logoW = (width / height) * logoH;
|
||||||
const textX = margin + logoW + 5;
|
const textX = margin + logoW + 5;
|
||||||
doc.addImage(logoDataUrl, 'PNG', margin, (bandHeight - logoH) / 2, logoW, logoH);
|
doc.addImage(logoDataUrl, 'PNG', margin, (bandHeight - logoH) / 2, logoW, logoH);
|
||||||
doc.setTextColor(255, 255, 255);
|
doc.setTextColor(255, 255, 255);
|
||||||
@@ -365,9 +380,7 @@ function drawFooter(doc: jsPDF, createdAt: string): void {
|
|||||||
|
|
||||||
// ─── public API ──────────────────────────────────────────────────────────────
|
// ─── public API ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Generates and downloads one PDF voucher for a single passenger. */
|
async function drawPassengerVoucherPage(doc: jsPDF, data: PassengerVoucherData): Promise<void> {
|
||||||
export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise<void> => {
|
|
||||||
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
|
||||||
const pageW = doc.internal.pageSize.getWidth();
|
const pageW = doc.internal.pageSize.getWidth();
|
||||||
const margin = PAGE_MARGIN;
|
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);
|
y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW);
|
||||||
drawInstructions(doc, y, margin, pageW);
|
drawInstructions(doc, y, margin, pageW);
|
||||||
drawFooter(doc, data.createdAt);
|
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, '');
|
const safeName = (data.passengerName || 'Passenger').replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_-]/g, '');
|
||||||
doc.save(`Voucher_${safeName}.pdf`);
|
doc.save(`Voucher_${safeName}.pdf`);
|
||||||
@@ -404,14 +423,28 @@ interface VoucherData {
|
|||||||
currency: string;
|
currency: string;
|
||||||
bookingType: string;
|
bookingType: string;
|
||||||
createdAt: 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> => {
|
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++) {
|
for (let i = 0; i < booking.passengers.length; i++) {
|
||||||
const p = booking.passengers[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({
|
await generatePassengerVoucherPDF({
|
||||||
bookingRef: booking.bookingRef,
|
bookingRef: booking.bookingRef,
|
||||||
ticketNumber: `TKT-${booking.bookingRef}-${(i + 1).toString().padStart(2, '0')}`,
|
ticketNumber,
|
||||||
passengerName: p.fullName,
|
passengerName: p.fullName,
|
||||||
seatNumber: p.seat?.number,
|
seatNumber: p.seat?.number,
|
||||||
status: booking.status,
|
status: booking.status,
|
||||||
@@ -421,7 +454,5 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
|
|||||||
currency: booking.currency,
|
currency: booking.currency,
|
||||||
createdAt: booking.createdAt,
|
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));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user