mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
@@ -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,
|
||||
})) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -552,6 +552,17 @@ export default function SearchPage() {
|
||||
"origin" | "destination" | null
|
||||
>(null);
|
||||
const [hasInteracted, setHasInteracted] = useState(false);
|
||||
// Immediate feedback the moment Search is clicked — router.push() itself
|
||||
// doesn't paint anything until the target route's JS has loaded, which
|
||||
// otherwise reads as a dead click.
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// Warms the results route's JS chunk ahead of time so clicking Search
|
||||
// doesn't have to wait for it to download/compile on top of the actual
|
||||
// search request.
|
||||
useEffect(() => {
|
||||
router.prefetch("/booking/results");
|
||||
}, [router]);
|
||||
const [recentStationIds, setRecentStationIds] = useState<string[]>(() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("edr_recent_stations") || "[]");
|
||||
@@ -689,6 +700,7 @@ export default function SearchPage() {
|
||||
|
||||
const onSubmit = (data: SearchForm) => {
|
||||
setHasInteracted(true);
|
||||
setIsSearching(true);
|
||||
// Clear previous booking selections and search cache before starting a new search
|
||||
clearBooking();
|
||||
setSearchCriteria(data);
|
||||
@@ -715,6 +727,7 @@ export default function SearchPage() {
|
||||
// origin/destination/date errors, which is noisier than fixing things one step at a time.
|
||||
const onInvalid = (formErrors: typeof errors) => {
|
||||
setHasInteracted(true);
|
||||
setIsSearching(false);
|
||||
const hasOtherErrors = Object.keys(formErrors).some((k) => k !== "nationality");
|
||||
if (formErrors.nationality && !hasOtherErrors) {
|
||||
setPassengerModalOpen(true);
|
||||
@@ -793,8 +806,12 @@ export default function SearchPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 90vh hero with banner image ── */}
|
||||
<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
|
||||
@@ -837,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">
|
||||
@@ -884,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"
|
||||
@@ -1066,11 +1088,20 @@ export default function SearchPage() {
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="btn-primary w-full text-sm"
|
||||
disabled={isLoading || isSearching}
|
||||
className="btn-primary w-full text-sm flex items-center justify-center gap-2 disabled:opacity-80"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
{isSearching ? (
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -1207,11 +1238,20 @@ export default function SearchPage() {
|
||||
{/* Search */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
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"
|
||||
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-80"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
{isSearching ? (
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1282,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>
|
||||
@@ -1298,7 +1338,7 @@ export default function SearchPage() {
|
||||
trigger("returnDate");
|
||||
}}
|
||||
minDate={departureDate ? new Date(departureDate + "T00:00:00") : new Date()}
|
||||
placeholder="Select date"
|
||||
placeholder="Return date"
|
||||
/>
|
||||
{errors.returnDate && (
|
||||
<p className="text-xs text-red-500">{errors.returnDate.message}</p>
|
||||
@@ -1328,11 +1368,20 @@ export default function SearchPage() {
|
||||
{/* Search */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
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"
|
||||
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-80"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
{isSearching ? (
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -275,8 +275,8 @@ export default function Contact() {
|
||||
};
|
||||
|
||||
const contactInfo = [
|
||||
{ icon: Phone, title: t('contact.phone'), value: '+251 911 000 000', link: 'tel:+251911000000' },
|
||||
{ icon: Mail, title: t('contact.email'), value: 'support@edr.et', link: 'mailto:support@edr.et' },
|
||||
{ icon: Phone, title: t('contact.phone'), value: '9546', link: 'tel:9546' },
|
||||
{ icon: Mail, title: t('contact.email'), value: 'edr_@edrsc.com', link: 'mailto:edr_@edrsc.com' },
|
||||
{ icon: MapPin, title: t('contact.address'), value: 'Addis Ababa, Ethiopia', link: '#' },
|
||||
];
|
||||
|
||||
|
||||
@@ -14,12 +14,18 @@ import {
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import ChangePasswordModal from '@/components/ChangePasswordModal';
|
||||
import { BOOKING_STEPS } from '@/components/ProgressIndicator';
|
||||
|
||||
// AppSidebar renders on every page via the root layout, so anything imported
|
||||
// here ships to every visitor's first load — but this modal is only ever
|
||||
// reachable by an already-authenticated user opening the account dropdown.
|
||||
// Code-split it out instead of paying for it on every page/every visitor.
|
||||
const ChangePasswordModal = dynamic(() => import('@/components/ChangePasswordModal'), { ssr: false });
|
||||
|
||||
// Mirrors booking/layout.tsx's stepMap — the linear booking flow routes that
|
||||
// get a vertical step list instead of the standard nav highlighting.
|
||||
const BOOKING_STEP_MAP: Record<string, string> = {
|
||||
@@ -203,10 +209,12 @@ export default function AppSidebar() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChangePasswordModal
|
||||
isOpen={showChangePassword}
|
||||
onClose={() => setShowChangePassword(false)}
|
||||
/>
|
||||
{showChangePassword && (
|
||||
<ChangePasswordModal
|
||||
isOpen={showChangePassword}
|
||||
onClose={() => setShowChangePassword(false)}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -358,16 +373,14 @@ function drawFooter(doc: jsPDF, createdAt: string): void {
|
||||
|
||||
hairline(doc, PAGE_MARGIN, footerY, pageWidth - PAGE_MARGIN);
|
||||
doc.setFontSize(7.5); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal');
|
||||
doc.text('support@edr.com · +251-11-XXX-XXXX · www.edr.com', pageWidth / 2, footerY + 6, { align: 'center' });
|
||||
doc.text('edr_@edrsc.com · 9546 · www.edr.com', pageWidth / 2, footerY + 6, { align: 'center' });
|
||||
doc.setFontSize(6.5);
|
||||
doc.text(`Issued ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 10.5, { align: 'center' });
|
||||
}
|
||||
|
||||
// ─── public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Generates and downloads one PDF voucher for a single passenger. */
|
||||
export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise<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));
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user