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: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
paymentIntent: true, tickets: { take: 1 },
paymentIntent: true, tickets: true,
priceTier: { select: { priceMinor: true } },
},
});
@@ -1518,7 +1518,7 @@ export class BookingsService {
payment: (pkgBooking as any).paymentIntent
? { method: (pkgBooking as any).paymentIntent.method, status: (pkgBooking as any).paymentIntent.status }
: undefined,
ticket: undefined,
tickets: [],
};
}
@@ -1557,7 +1557,15 @@ export class BookingsService {
},
})),
payment: (booking as any).paymentIntent ? { method: (booking as any).paymentIntent.method, status: (booking as any).paymentIntent.status } : undefined,
ticket: (booking as any).tickets?.[0] ? { id: (booking as any).tickets[0].id, qrPayload: (booking as any).tickets[0].qrPayload, barcodePayload: (booking as any).tickets[0].barcodePayload, status: (booking as any).tickets[0].status } : undefined,
// One ticket per passenger — matched on the frontend by passengerName, not array
// position, since tickets are grouped/created independently of the passengers array.
tickets: (booking as any).tickets?.map((t: any) => ({
id: t.id,
passengerName: t.passengerName,
qrPayload: t.qrPayload,
barcodePayload: t.barcodePayload,
status: t.status,
})) ?? [],
};
}

View File

@@ -19,10 +19,13 @@ type BookingWithTicket = {
totalMinor?: number;
createdAt?: string;
paymentMethod?: string;
ticket?: {
// One ticket per passenger — match by passengerName, not array position (see
// bookings.service.ts's getByRef).
tickets?: Array<{
passengerName?: string;
barcodePayload?: string;
qrPayload?: string;
};
}>;
};
export default function ConfirmationPage() {
@@ -36,6 +39,14 @@ export default function ConfirmationPage() {
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
const confirmAttempted = useRef(false);
// Warms the code-split voucher module ahead of the click so the handler's own
// `await import(...)` resolves near-instantly — on iOS Safari, a file save triggered
// too long after the originating click's synchronous execution window is silently
// blocked, and awaiting a cold dynamic import is enough to fall outside that window.
useEffect(() => {
import('@/lib/generate-voucher');
}, []);
const { data: _booking } = useQuery<BookingWithTicket>({
queryKey: ['booking', bookingId],
queryFn: async (): Promise<BookingWithTicket> => {
@@ -142,9 +153,16 @@ export default function ConfirmationPage() {
seatClass: inboundSchedule.selectedSeatClassName,
} : undefined;
// Separate file per passenger, saved back-to-back with no macrotask (setTimeout)
// between them — a setTimeout delay would push later saves outside the click's
// synchronous user-activation window and risk iOS Safari silently blocking them.
for (let i = 0; i < passengers.length; i++) {
const p = passengers[i];
const ticketNumber = `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(i + 1).toString().padStart(2, '0')}`;
// Same match-by-name-then-position as the on-screen ticket list above — no
// fabricated placeholder if there's no backend ticket data (see generate-voucher.ts).
const matchedTicket =
_booking?.tickets?.find((t) => t.passengerName === p.name) ?? _booking?.tickets?.[i] ?? null;
const ticketNumber = matchedTicket?.barcodePayload || 'Not yet issued';
await generatePassengerVoucherPDF({
bookingRef: pnr,
@@ -163,9 +181,6 @@ export default function ConfirmationPage() {
currency: voucherCurrency,
createdAt,
});
// brief pause between downloads so browsers don't block them
if (i < passengers.length - 1) await new Promise(r => setTimeout(r, 400));
}
} catch (error) {
alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -390,10 +405,15 @@ export default function ConfirmationPage() {
<h2 className="text-2xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Your tickets</h2>
<div className="space-y-4">
{passengers.map((passenger, index) => {
const backendTicket = _booking?.ticket || null;
const ticketNumber = isConfirmed
? backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`
: null;
// Match by name first (tickets aren't necessarily created/ordered the same
// way as this passengers array) — fall back to position if no name match.
const backendTicket =
_booking?.tickets?.find((t) => t.passengerName === passenger.name) ??
_booking?.tickets?.[index] ??
null;
// No fabricated placeholder — a made-up TKT-... number reads as real and is
// misleading if it doesn't match what's actually on file.
const ticketNumber = isConfirmed ? backendTicket?.barcodePayload || null : null;
return (
<div key={index} className="card hover:shadow-lg transition-shadow">
@@ -414,7 +434,9 @@ export default function ConfirmationPage() {
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-600 dark:text-gray-400">Ticket Number</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{ticketNumber || 'Pending payment'}</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
{ticketNumber || (isConfirmed ? 'Not yet issued' : 'Pending payment')}
</p>
</div>
<div>
<p className="text-gray-600 dark:text-gray-400">Date of Birth</p>

View File

@@ -4,14 +4,13 @@ import { Suspense } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { useQuery, useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
Clock,
Users,
CheckCircle2,
AlertCircle,
Download,
Share2,
Copy,
Check,
CreditCard,
@@ -49,6 +48,14 @@ function BookingDetailContent() {
const [copiedPNR, setCopiedPNR] = useState(false);
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
// Warms the code-split voucher module ahead of the click so the handler's own
// `await import(...)` resolves near-instantly — on iOS Safari, a file save triggered
// too long after the originating click's synchronous execution window is silently
// blocked, and awaiting a cold dynamic import is enough to fall outside that window.
useEffect(() => {
import('@/lib/generate-voucher');
}, []);
const { data: booking, isLoading, error, refetch } = useQuery({
queryKey: ['booking-detail', bookingRef],
queryFn: async () => {
@@ -629,7 +636,7 @@ function BookingDetailContent() {
<div className="flex flex-wrap gap-3 justify-center mt-6">
{isConfirmed && (
<>
<button
<button
onClick={handleDownloadVoucher}
disabled={isGeneratingVoucher}
className="btn-primary flex items-center gap-2"
@@ -646,14 +653,6 @@ function BookingDetailContent() {
</>
)}
</button>
<button className="btn-secondary flex items-center gap-2">
<Download className="w-4 h-4" />
Download Tickets
</button>
<button className="btn-secondary flex items-center gap-2">
<Share2 className="w-4 h-4" />
Share
</button>
</>
)}
</div>

View File

@@ -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
// For package bookings, free children (first child per adult, no seat assigned)
// are excluded from the passengers array — the backend derives them from adultCount/childCount.
const bookingPassengers = passengers.filter((p, i) => {
// Package bookings only: free children (first child per adult) don't go through
// seat selection and have no seatId, so they're excluded here — the backend derives
// them from adultCount/childCount instead. Regular bookings DO seat every passenger
// (including the free child, who still gets a real seatId and a $0 fare handled by
// the backend), so they must stay in the array or that passenger — and their
// ticket/seat/childCount — silently never gets created.
const bookingPassengers = passengers.filter((_p, i) => {
if (packageId) {
const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount;
return !isFreePkgChild;
}
return !(isChild(p) && isFirstChild(passengers, i));
return true;
});
bookingData = {
@@ -371,14 +375,18 @@ export default function ReviewPage() {
if (priceTierId) bookingData.priceTierId = priceTierId;
} else {
// For guests: send full passenger details array
// For package bookings, free children (first child per adult, no seat assigned)
// are excluded from the passengers array — the backend derives them from adultCount/childCount.
const guestBookingPassengers = passengers.filter((p, i) => {
// Package bookings only: free children (first child per adult) don't go through
// seat selection and have no seatId, so they're excluded here — the backend derives
// them from adultCount/childCount instead. Regular bookings DO seat every passenger
// (including the free child, who still gets a real seatId and a $0 fare handled by
// the backend), so they must stay in the array or that passenger — and their
// ticket/seat/childCount — silently never gets created.
const guestBookingPassengers = passengers.filter((_p, i) => {
if (packageId) {
const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount;
return !isFreePkgChild;
}
return !(isChild(p) && isFirstChild(passengers, i));
return true;
});
bookingData = {

View File

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

View File

@@ -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: '#' },
];

View File

@@ -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>
);
}

View File

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

View File

@@ -103,6 +103,29 @@ function hairline(doc: jsPDF, x1: number, y: number, x2: number): void {
// ─── header ────────────────────────────────────────────────────────────────
// Fetched once and reused for the lifetime of the page — re-fetching this same static
// asset on every passenger/every voucher adds a real network round-trip in the middle of
// what needs to stay close to the original click's synchronous execution window (iOS
// Safari silently blocks a file save triggered too long after user activation).
let logoCache: Promise<{ dataUrl: string; width: number; height: number }> | null = null;
function loadLogo(): Promise<{ dataUrl: string; width: number; height: number }> {
if (!logoCache) {
logoCache = (async () => {
const logoImg = await fetch('/edr-logo.png');
const logoBlob = await logoImg.blob();
const dataUrl = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(logoBlob);
});
const img = new Image();
await new Promise((resolve) => { img.onload = resolve; img.src = dataUrl; });
return { dataUrl, width: img.width, height: img.height };
})();
}
return logoCache;
}
async function drawHeader(doc: jsPDF, margin: number): Promise<number> {
const pageWidth = doc.internal.pageSize.getWidth();
const bandHeight = 24;
@@ -111,17 +134,9 @@ async function drawHeader(doc: jsPDF, margin: number): Promise<number> {
doc.rect(0, 0, pageWidth, bandHeight, 'F');
try {
const logoImg = await fetch('/edr-logo.png');
const logoBlob = await logoImg.blob();
const logoDataUrl = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(logoBlob);
});
const img = new Image();
await new Promise((resolve) => { img.onload = resolve; img.src = logoDataUrl; });
const { dataUrl: logoDataUrl, width, height } = await loadLogo();
const logoH = 13;
const logoW = (img.width / img.height) * logoH;
const logoW = (width / height) * logoH;
const textX = margin + logoW + 5;
doc.addImage(logoDataUrl, 'PNG', margin, (bandHeight - logoH) / 2, logoW, logoH);
doc.setTextColor(255, 255, 255);
@@ -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));
}
};