Merge pull request #610 from Tria-plc/alpha

Fix voucher ticket number
This commit is contained in:
Abubeker Yasin
2026-07-11 00:52:32 +03:00
committed by GitHub
10 changed files with 443 additions and 215 deletions

View File

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

View File

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

View File

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

View File

@@ -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 (
<div className="booking-page">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<div className="mb-8">
<div className="card p-6">
<div className="flex items-center gap-4">
<div className="relative">
<div className="w-12 h-12 rounded-full border-4 border-primary/20 border-t-primary animate-spin" />
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-1">
Searching for trains...
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Finding the best options for your journey
</p>
</div>
</div>
<div className="mt-4 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{
animation: "progressBar 2s ease-in-out infinite",
}}
/>
</div>
</div>
</div>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="card animate-pulse">
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
<div className="flex-1">
<div className="flex items-center gap-3 mb-4">
<div
className="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-lg"
style={{ animation: "shimmer 1.5s ease-in-out infinite" }}
/>
<div className="flex-1 space-y-2">
<div
className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-24"
style={{ animation: "shimmer 1.5s ease-in-out infinite" }}
/>
<div
className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32"
style={{ animation: "shimmer 1.5s ease-in-out infinite" }}
/>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-center space-y-2">
<div
className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16"
style={{ animation: "shimmer 1.5s ease-in-out infinite" }}
/>
<div
className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-12"
style={{ animation: "shimmer 1.5s ease-in-out infinite" }}
/>
</div>
<div className="flex-1 h-0.5 bg-gray-200 dark:bg-gray-700" />
<div className="text-center space-y-2">
<div
className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16"
style={{ animation: "shimmer 1.5s ease-in-out infinite" }}
/>
<div
className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-12"
style={{ animation: "shimmer 1.5s ease-in-out infinite" }}
/>
</div>
</div>
</div>
<div className="lg:border-l dark:border-gray-700 lg:pl-6 lg:min-w-[220px]">
<div className="text-center lg:text-right space-y-2">
<div
className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mx-auto lg:ml-auto lg:mr-0"
style={{ animation: "shimmer 1.5s ease-in-out infinite" }}
/>
<div
className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-24 mx-auto lg:ml-auto lg:mr-0"
style={{ animation: "shimmer 1.5s ease-in-out infinite" }}
/>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -552,6 +552,17 @@ export default function SearchPage() {
"origin" | "destination" | null "origin" | "destination" | null
>(null); >(null);
const [hasInteracted, setHasInteracted] = useState(false); 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<string[]>(() => { const [recentStationIds, setRecentStationIds] = useState<string[]>(() => {
try { try {
return JSON.parse(localStorage.getItem("edr_recent_stations") || "[]"); return JSON.parse(localStorage.getItem("edr_recent_stations") || "[]");
@@ -689,6 +700,7 @@ export default function SearchPage() {
const onSubmit = (data: SearchForm) => { const onSubmit = (data: SearchForm) => {
setHasInteracted(true); setHasInteracted(true);
setIsSearching(true);
// Clear previous booking selections and search cache before starting a new search // Clear previous booking selections and search cache before starting a new search
clearBooking(); clearBooking();
setSearchCriteria(data); 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. // origin/destination/date errors, which is noisier than fixing things one step at a time.
const onInvalid = (formErrors: typeof errors) => { const onInvalid = (formErrors: typeof errors) => {
setHasInteracted(true); setHasInteracted(true);
setIsSearching(false);
const hasOtherErrors = Object.keys(formErrors).some((k) => k !== "nationality"); const hasOtherErrors = Object.keys(formErrors).some((k) => k !== "nationality");
if (formErrors.nationality && !hasOtherErrors) { if (formErrors.nationality && !hasOtherErrors) {
setPassengerModalOpen(true); 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
<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
@@ -837,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">
@@ -884,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"
@@ -1066,11 +1088,20 @@ export default function SearchPage() {
)} )}
<button <button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading || isSearching}
className="btn-primary w-full text-sm" className="btn-primary w-full text-sm flex items-center justify-center gap-2 disabled:opacity-80"
> >
<Search className="w-5 h-5" /> {isSearching ? (
Search <>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Searching...
</>
) : (
<>
<Search className="w-5 h-5" />
Search
</>
)}
</button> </button>
</div> </div>
@@ -1207,11 +1238,20 @@ export default function SearchPage() {
{/* Search */} {/* Search */}
<button <button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading || isSearching}
className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all transform hover:-translate-y-0.5 shadow-lg hover:shadow-xl disabled:opacity-50" className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all transform hover:-translate-y-0.5 shadow-lg hover:shadow-xl disabled:opacity-80"
> >
<Search className="w-5 h-5" /> {isSearching ? (
Search <>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Searching...
</>
) : (
<>
<Search className="w-5 h-5" />
Search
</>
)}
</button> </button>
</div> </div>
) : ( ) : (
@@ -1282,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>
@@ -1298,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>
@@ -1328,11 +1368,20 @@ export default function SearchPage() {
{/* Search */} {/* Search */}
<button <button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading || isSearching}
className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50" className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-80"
> >
<Search className="w-5 h-5" /> {isSearching ? (
Search <>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Searching...
</>
) : (
<>
<Search className="w-5 h-5" />
Search
</>
)}
</button> </button>
</div> </div>
)} )}

View File

@@ -275,8 +275,8 @@ export default function Contact() {
}; };
const contactInfo = [ const contactInfo = [
{ icon: Phone, title: t('contact.phone'), value: '+251 911 000 000', link: 'tel:+251911000000' }, { icon: Phone, title: t('contact.phone'), value: '9546', link: 'tel:9546' },
{ icon: Mail, title: t('contact.email'), value: 'support@edr.et', link: 'mailto:support@edr.et' }, { 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: '#' }, { icon: MapPin, title: t('contact.address'), value: 'Addis Ababa, Ethiopia', link: '#' },
]; ];

View File

@@ -14,12 +14,18 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import dynamic from 'next/dynamic';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import ChangePasswordModal from '@/components/ChangePasswordModal';
import { BOOKING_STEPS } from '@/components/ProgressIndicator'; 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 // Mirrors booking/layout.tsx's stepMap — the linear booking flow routes that
// get a vertical step list instead of the standard nav highlighting. // get a vertical step list instead of the standard nav highlighting.
const BOOKING_STEP_MAP: Record<string, string> = { const BOOKING_STEP_MAP: Record<string, string> = {
@@ -203,10 +209,12 @@ export default function AppSidebar() {
)} )}
</div> </div>
<ChangePasswordModal {showChangePassword && (
isOpen={showChangePassword} <ChangePasswordModal
onClose={() => setShowChangePassword(false)} isOpen={showChangePassword}
/> onClose={() => setShowChangePassword(false)}
/>
)}
</aside> </aside>
); );
} }

View File

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

View File

@@ -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);
@@ -358,16 +373,14 @@ function drawFooter(doc: jsPDF, createdAt: string): void {
hairline(doc, PAGE_MARGIN, footerY, pageWidth - PAGE_MARGIN); hairline(doc, PAGE_MARGIN, footerY, pageWidth - PAGE_MARGIN);
doc.setFontSize(7.5); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal'); 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.setFontSize(6.5);
doc.text(`Issued ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 10.5, { align: 'center' }); doc.text(`Issued ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 10.5, { align: 'center' });
} }
// ─── 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));
} }
}; };