mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -188,7 +188,7 @@ export default function ConfirmationPage() {
|
||||
if (!bookingId || !pnr) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Success Header */}
|
||||
@@ -456,26 +456,29 @@ export default function ConfirmationPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={handleDownloadVoucher}
|
||||
disabled={isGeneratingVoucher}
|
||||
className="btn-primary w-full flex items-center justify-center gap-2 relative"
|
||||
>
|
||||
{isGeneratingVoucher ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<span>Generating...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>Download Voucher</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{/* Action Buttons — voucher/ticket only makes sense once the booking is
|
||||
actually confirmed; a pending-payment booking has no real ticket yet. */}
|
||||
{isConfirmed && (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={handleDownloadVoucher}
|
||||
disabled={isGeneratingVoucher}
|
||||
className="btn-primary w-full flex items-center justify-center gap-2 relative"
|
||||
>
|
||||
{isGeneratingVoucher ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<span>Generating...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>Download Voucher</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Booking Button */}
|
||||
<button
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ProgressIndicator } from '@/components/ProgressIndicator';
|
||||
import { PageTransition } from '@/components/PageTransition';
|
||||
|
||||
export default function BookingLayout({
|
||||
children,
|
||||
@@ -31,7 +32,7 @@ export default function BookingLayout({
|
||||
<ProgressIndicator currentStep={currentStep} />
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
<PageTransition>{children}</PageTransition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1024,7 +1024,7 @@ export default function PassengersPage() {
|
||||
|
||||
if (!formInitialized || faydaCompleting) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-lg mx-auto text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin mx-auto text-blue-600" />
|
||||
@@ -1043,7 +1043,7 @@ export default function PassengersPage() {
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Passenger details</h1>
|
||||
<h1 className="section-title">Passenger details</h1>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit, onInvalid)} className="space-y-6">
|
||||
{fields.map((field, index) => {
|
||||
|
||||
@@ -188,10 +188,10 @@ export default function PaymentPage() {
|
||||
|
||||
if (!bookingId && !pnr) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12 flex items-center justify-center">
|
||||
<div className="booking-page flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 text-primary animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">Loading payment details...</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading payment details...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -372,10 +372,10 @@ export default function PaymentPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6 pb-28 lg:pb-10">
|
||||
<div className="booking-page pb-28 lg:pb-10">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">Complete payment</h1>
|
||||
<h1 className="section-title">Complete payment</h1>
|
||||
|
||||
{/* Reservation confirmation banner */}
|
||||
<div className="card bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 mb-4 flex items-start gap-3">
|
||||
|
||||
@@ -534,7 +534,7 @@ export default function ResultsPage() {
|
||||
e.stopPropagation();
|
||||
handleSelect(classModal, isOutbound);
|
||||
}}
|
||||
className="mt-3 w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all shadow-md shadow-primary/30 hover:shadow-lg active:scale-[0.98]"
|
||||
className="btn-primary w-full mt-3 text-sm active:scale-[0.98]"
|
||||
>
|
||||
<span>
|
||||
{isRoundTrip && isOutbound
|
||||
@@ -652,9 +652,9 @@ export default function ResultsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 md:gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
<div className="text-xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 whitespace-nowrap">
|
||||
{schedule.departureAt
|
||||
? formatTime(schedule.departureAt)
|
||||
: "--:--"}
|
||||
@@ -669,9 +669,9 @@ export default function ResultsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center">
|
||||
<div className="flex items-center gap-2 mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<Clock className="w-4 h-4" />
|
||||
<div className="flex-1 min-w-0 flex flex-col items-center">
|
||||
<div className="flex items-center gap-1 md:gap-2 mb-2 text-xs md:text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||
<Clock className="w-3.5 h-3.5 md:w-4 md:h-4 flex-shrink-0" />
|
||||
<span>{durationStr}</span>
|
||||
</div>
|
||||
<div className="w-full h-0.5 bg-gray-200 dark:bg-gray-700 relative">
|
||||
@@ -679,15 +679,15 @@ export default function ResultsPage() {
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full" />
|
||||
</div>
|
||||
{schedule.stops && schedule.stops.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<div className="flex items-center gap-1 mt-2 text-xs md:text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||
<MapPin className="w-3.5 h-3.5 md:w-4 md:h-4 flex-shrink-0" />
|
||||
<span>{schedule.stops.length - 2} stops</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
<div className="text-xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 whitespace-nowrap">
|
||||
{schedule.arrivalAt
|
||||
? formatTime(schedule.arrivalAt)
|
||||
: "--:--"}
|
||||
@@ -749,7 +749,7 @@ export default function ResultsPage() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Progress Header */}
|
||||
@@ -952,7 +952,7 @@ export default function ResultsPage() {
|
||||
(results?.alternativeInbound || []).length === 0;
|
||||
if (isRoundTripNoResults) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||
@@ -974,7 +974,7 @@ export default function ResultsPage() {
|
||||
const hasAlternatives = alternativeOutbound.length > 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="booking-page">
|
||||
{renderClassModal()}
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
@@ -1013,7 +1013,7 @@ export default function ResultsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||
@@ -1032,7 +1032,7 @@ export default function ResultsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{renderClassModal()}
|
||||
|
||||
@@ -636,10 +636,10 @@ export default function ReviewPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6 pb-28 lg:pb-10">
|
||||
<div className="booking-page pb-28 lg:pb-10">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">
|
||||
<h1 className="section-title">
|
||||
{packageName ? `Review your ${packageName} booking` : 'Review your booking'}
|
||||
</h1>
|
||||
|
||||
|
||||
@@ -608,6 +608,12 @@ export default function SearchPage() {
|
||||
reValidateMode: "onSubmit",
|
||||
defaultValues: {
|
||||
tripType: "ONE_WAY",
|
||||
// originStationId/destinationStationId must default to "" rather than being omitted —
|
||||
// Zod's base string type check runs before .min(1, "..."), so an `undefined` value hits
|
||||
// its generic "Required" message instead of our custom one. An empty string is still a
|
||||
// string, so .min() (and its custom message) is what actually fires.
|
||||
originStationId: "",
|
||||
destinationStationId: "",
|
||||
adultCount: 1,
|
||||
childCount: 0,
|
||||
// No default nationality — the user must explicitly pick one. Left blank (not a valid
|
||||
@@ -827,19 +833,21 @@ export default function SearchPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 90vh hero with banner image ── */}
|
||||
{/* Round trip stacks an extra Return Date field into the widget on mobile, which grows
|
||||
{/* ── 90vh hero with banner image (desktop) / top-aligned widget only (mobile) ── */}
|
||||
{/* Round trip stacks an extra Return Date field into the widget on desktop, which grows
|
||||
upward from its bottom-anchored position — give the hero extra height there so the
|
||||
widget's top edge doesn't creep up into the sticky header. */}
|
||||
widget's top edge doesn't creep up into the sticky header. On mobile the widget is
|
||||
in normal flow (not bottom-anchored), so this only applies at md: and up. */}
|
||||
<section
|
||||
className={`relative ${
|
||||
tripType === "ROUND_TRIP"
|
||||
? "h-[calc(90vh+60px)] min-h-[670px] md:h-[90vh] md:min-h-[560px]"
|
||||
: "h-[94vh] min-h-[560px]"
|
||||
? "md:h-[90vh] md:min-h-[560px]"
|
||||
: "md:h-[94vh] md:min-h-[560px]"
|
||||
}`}
|
||||
>
|
||||
{/* Background image with zoom - fully isolated */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
{/* Background image with zoom — desktop only; mobile drops the hero image entirely
|
||||
so the booking widget can sit at the top and use the available space. */}
|
||||
<div className="hidden md:block absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
className="w-full h-full bg-cover bg-center animate-bg-zoom"
|
||||
style={{
|
||||
@@ -852,12 +860,12 @@ export default function SearchPage() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Gradient overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-black/70 via-black/40 to-transparent" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
|
||||
{/* Gradient overlay — desktop only (exists to keep the hero text readable over the image) */}
|
||||
<div className="hidden md:block absolute inset-0 bg-gradient-to-br from-black/70 via-black/40 to-transparent" />
|
||||
<div className="hidden md:block absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
|
||||
|
||||
{/* Hero headline — top area */}
|
||||
<div className="relative z-10 pt-16 md:pt-20 px-6 md:px-12 max-w-6xl mx-auto">
|
||||
{/* Hero headline — desktop only; removed on mobile along with the banner image */}
|
||||
<div className="hidden md:block relative z-10 pt-16 md:pt-20 px-6 md:px-12 max-w-6xl mx-auto">
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white leading-tight drop-shadow-lg max-w-2xl animate-fade-in-up">
|
||||
Where are you
|
||||
<br className="hidden sm:block" /> headed today?
|
||||
@@ -867,14 +875,28 @@ export default function SearchPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Widget — absolutely positioned at bottom with margin ── */}
|
||||
{/* ── Widget — top-aligned, normal flow on mobile; absolutely positioned at
|
||||
bottom on desktop over the hero image. z-[35] sits above the floating
|
||||
support chat launcher (z-30) but below the sidebar/tab bar (z-40), so it
|
||||
never covers either. Modals opened from inside the widget (date picker
|
||||
etc.) are portaled to <body> — see ModernDatePicker — so they aren't
|
||||
capped by this wrapper's own stacking context. ── */}
|
||||
<div
|
||||
className="absolute bottom-8 left-0 right-0 z-[50] px-4 md:px-6"
|
||||
className="relative pt-4 pb-4 md:pt-0 md:pb-0 md:absolute md:bottom-8 md:left-0 md:right-0 z-[35] px-4 md:px-6"
|
||||
ref={widgetRef}
|
||||
>
|
||||
<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">
|
||||
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-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">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-5 py-3 bg-red-50 text-red-600 text-sm border-b border-red-100 rounded-t-2xl">
|
||||
<span>⚠️</span>
|
||||
@@ -950,6 +972,11 @@ export default function SearchPage() {
|
||||
</span>
|
||||
</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">
|
||||
@@ -998,6 +1025,11 @@ export default function SearchPage() {
|
||||
</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">
|
||||
@@ -1019,8 +1051,14 @@ export default function SearchPage() {
|
||||
}}
|
||||
minDate={new Date()}
|
||||
placeholder="Select date"
|
||||
error={!!errors.departureDate}
|
||||
/>
|
||||
</div>
|
||||
{errors.departureDate && (
|
||||
<p className="text-xs text-red-500">
|
||||
{errors.departureDate.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{tripType === "ROUND_TRIP" && (
|
||||
<div className="space-y-1.5">
|
||||
@@ -1047,6 +1085,7 @@ export default function SearchPage() {
|
||||
: new Date()
|
||||
}
|
||||
placeholder="Select return date"
|
||||
error={!!errors.returnDate}
|
||||
/>
|
||||
</div>
|
||||
{errors.returnDate && (
|
||||
@@ -1079,7 +1118,7 @@ export default function SearchPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center justify-center gap-2 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 disabled:opacity-50"
|
||||
className="btn-primary w-full text-sm"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
@@ -1182,6 +1221,7 @@ export default function SearchPage() {
|
||||
}}
|
||||
minDate={new Date()}
|
||||
placeholder="Departure"
|
||||
error={!!errors.departureDate}
|
||||
/>
|
||||
</div>
|
||||
{errors.departureDate && (
|
||||
@@ -1219,7 +1259,7 @@ export default function SearchPage() {
|
||||
<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"
|
||||
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"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
@@ -1324,6 +1364,7 @@ export default function SearchPage() {
|
||||
}}
|
||||
minDate={new Date()}
|
||||
placeholder="Select date"
|
||||
error={!!errors.departureDate}
|
||||
/>
|
||||
</div>
|
||||
{errors.departureDate && (
|
||||
@@ -1357,6 +1398,7 @@ export default function SearchPage() {
|
||||
: new Date()
|
||||
}
|
||||
placeholder="Select date"
|
||||
error={!!errors.returnDate}
|
||||
/>
|
||||
</div>
|
||||
{errors.returnDate && (
|
||||
@@ -1482,7 +1524,7 @@ export default function SearchPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center gap-2 px-6 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 items-center justify-center gap-2 px-6 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"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Armchair, Bed, ChevronLeft, ChevronDown, Train, TrainFront, X } from "l
|
||||
import Image from "next/image";
|
||||
|
||||
import CustomModal from "@/components/CustomModal";
|
||||
import { Skeleton } from "@/components/Skeleton";
|
||||
import { isChild } from "@/utils/fare-utils";
|
||||
|
||||
const BED_POSITION_SUFFIX: Record<string, string> = { lower: 'L', middle: 'M', upper: 'U' };
|
||||
@@ -2187,11 +2188,16 @@ export default function SeatsPage() {
|
||||
</button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">
|
||||
<div className="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-3" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading seat map...
|
||||
</p>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-8 w-24 rounded-lg" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{Array.from({ length: 24 }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-square rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">
|
||||
|
||||
@@ -3,16 +3,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getTranslation, Language, useLanguage } from '@/lib/i18n';
|
||||
import { Phone, Mail, MapPin, Send, Loader } from 'lucide-react';
|
||||
import { Footer } from '@/components/Footer';
|
||||
|
||||
const styles = `
|
||||
.contact-hero {
|
||||
padding: 60px 20px;
|
||||
background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent);
|
||||
background: #ffffff;
|
||||
text-align: center;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
|
||||
.dark .contact-hero {
|
||||
background: #111827;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
@@ -370,6 +372,7 @@ export default function Contact() {
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,19 +26,17 @@
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply text-[rgb(20_113_76)] font-medium py-2 px-4 rounded-lg transition-colors;
|
||||
@apply text-[rgb(20_113_76)] font-medium py-2 px-4 rounded-lg transition-colors hover:bg-[rgba(20,113,76,0.1)] dark:hover:bg-[rgba(20,113,76,0.2)];
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background-color: rgba(20, 113, 76, 0.1);
|
||||
|
||||
.booking-page {
|
||||
@apply min-h-screen bg-gray-50 dark:bg-gray-900 py-6 md:py-8;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.btn-ghost:hover {
|
||||
background-color: rgba(20, 113, 76, 0.2);
|
||||
}
|
||||
|
||||
.booking-container {
|
||||
@apply container mx-auto px-4 max-w-6xl;
|
||||
}
|
||||
|
||||
|
||||
.input-field {
|
||||
@apply w-full px-4 py-3 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-[rgb(20_113_76)] focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed transition-all duration-200 text-base bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100;
|
||||
}
|
||||
@@ -162,7 +160,18 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-bounce-in {
|
||||
animation: bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
@@ -188,6 +197,10 @@
|
||||
animation: slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.35s ease-out;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useSearchParams } from "next/navigation";
|
||||
* hop is browser-dependent and can be stripped) must NOT be used here.
|
||||
*
|
||||
* It fires immediately (no delay) and paints a bare white full-screen cover
|
||||
* above the sticky header (z-[60]) — no portal chrome, no text on the happy
|
||||
* above the sidebar/tab bar (z-40) — no portal chrome, no text on the happy
|
||||
* path. A short message shows only when the link is missing/untrusted.
|
||||
*
|
||||
* `?url=` MUST be percent-encoded by the caller (Uri.encodeComponent() in the
|
||||
|
||||
@@ -2,10 +2,11 @@ import type { Metadata } from 'next';
|
||||
import { headers } from 'next/headers';
|
||||
import './globals.css';
|
||||
import { Providers } from './providers';
|
||||
import AppHeader from '@/components/AppHeader';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import AppSidebar from '@/components/AppSidebar';
|
||||
import MobileTopBar from '@/components/MobileTopBar';
|
||||
import BottomTabBar from '@/components/BottomTabBar';
|
||||
import { LoadingIndicator } from '@/components/LoadingIndicator';
|
||||
import SupportWidget from '@/features/support/SupportWidget';
|
||||
import SupportWidget from '@/features/support/SupportWidgetLazy';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'EDR Passenger Portal - Book your train journey',
|
||||
@@ -21,7 +22,7 @@ export default function RootLayout({
|
||||
const nonce = headers().get('x-nonce') ?? undefined;
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="font-sans antialiased flex flex-col min-h-screen">
|
||||
<body className="font-sans antialiased">
|
||||
<script
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{
|
||||
@@ -47,11 +48,14 @@ export default function RootLayout({
|
||||
/>
|
||||
<Providers>
|
||||
<LoadingIndicator />
|
||||
<AppHeader />
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
<AppSidebar />
|
||||
<div className="lg:pl-64 flex flex-col min-h-screen">
|
||||
<MobileTopBar />
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<BottomTabBar />
|
||||
</div>
|
||||
<SupportWidget />
|
||||
</Providers>
|
||||
</body>
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
import { Suspense } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
|
||||
import SearchPage from '@/app/booking/search/page';
|
||||
import PackagesSection from '@/components/PackagesSection';
|
||||
|
||||
export default async function Home() {
|
||||
// Below-the-fold on the landing page — code-split out of the initial bundle
|
||||
// needed to render/hydrate the (above-the-fold) search form.
|
||||
const PackagesSection = dynamic(() => import('@/components/PackagesSection'));
|
||||
|
||||
// Skeleton for the search form while stations are prefetched server-side —
|
||||
// keeps first paint on a slow connection from being a blank page, and
|
||||
// approximates the real form's height to avoid layout shift once it streams in.
|
||||
function SearchFormSkeleton() {
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto animate-pulse">
|
||||
<div className="rounded-2xl border border-gray-100 dark:border-gray-800 p-6 space-y-4">
|
||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-1/3" />
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="h-12 bg-gray-200 dark:bg-gray-700 rounded-xl" />
|
||||
<div className="h-12 bg-gray-200 dark:bg-gray-700 rounded-xl" />
|
||||
</div>
|
||||
<div className="h-12 bg-gray-200 dark:bg-gray-700 rounded-xl" />
|
||||
<div className="h-12 bg-gray-300 dark:bg-gray-600 rounded-xl w-40" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Isolates the server-side stations prefetch behind its own Suspense boundary
|
||||
// (a real async Server Component, unlike a plain `await` in the page body) so
|
||||
// Next.js can stream: the shell flushes immediately with SearchFormSkeleton,
|
||||
// then the real search form + hydrated stations data stream in once ready —
|
||||
// instead of blocking the whole page's TTFB on that one fetch.
|
||||
async function StationsPrefetch({ children }: { children: React.ReactNode }) {
|
||||
const queryClient = new QueryClient();
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
|
||||
@@ -18,10 +46,18 @@ export default async function Home() {
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<Suspense>
|
||||
<SearchPage />
|
||||
<PackagesSection />
|
||||
</Suspense>
|
||||
{children}
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Suspense fallback={<SearchFormSkeleton />}>
|
||||
<StationsPrefetch>
|
||||
<SearchPage />
|
||||
<PackagesSection />
|
||||
</StationsPrefetch>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Menu, X, Moon, Sun, HelpCircle, KeyRound, LogOut, ChevronDown } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuthStore } from "@/lib/auth-store";
|
||||
import ChangePasswordModal from "@/components/ChangePasswordModal";
|
||||
|
||||
export default function AppHeader() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||
const { user, isAuthenticated, initialize, logout } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
const isDarkMode = document.documentElement.classList.contains("dark");
|
||||
setIsDark(isDarkMode);
|
||||
initialize();
|
||||
}, [initialize]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const html = document.documentElement;
|
||||
const isDarkMode = html.classList.contains("dark");
|
||||
if (isDarkMode) {
|
||||
html.classList.remove("dark");
|
||||
setIsDark(false);
|
||||
localStorage.setItem("theme", "light");
|
||||
} else {
|
||||
html.classList.add("dark");
|
||||
setIsDark(true);
|
||||
localStorage.setItem("theme", "dark");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-[60] bg-[rgb(20_113_76)] dark:bg-gray-900 border-b border-[rgb(16_89_60)] dark:border-gray-800 shadow-sm">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Image
|
||||
src="/edr-logo.png"
|
||||
alt="Ethio-Djibouti Railway"
|
||||
width={140}
|
||||
height={48}
|
||||
className="h-14 w-auto"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Menu */}
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
<Link
|
||||
href="/booking/lookup"
|
||||
className="text-sm font-medium text-gray-100 hover:text-white transition-colors"
|
||||
>
|
||||
My Booking
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Right Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Help Link */}
|
||||
<Link
|
||||
href="/help"
|
||||
className="hidden sm:flex items-center justify-center p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
|
||||
title="Help & FAQ"
|
||||
>
|
||||
<HelpCircle className="w-5 h-5" />
|
||||
</Link>
|
||||
|
||||
{/* Theme Toggler */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
|
||||
title={isDark ? "Light mode" : "Dark mode"}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="w-5 h-5" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Auth */}
|
||||
{isAuthenticated && user ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
className="flex items-center gap-2 p-1.5 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-white text-[rgb(20_113_76)] font-semibold text-sm">
|
||||
{user.fullName?.toUpperCase().charAt(0) || 'U'}
|
||||
</div>
|
||||
<ChevronDown className="hidden sm:block w-4 h-4 text-gray-100" />
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<div className="absolute right-0 top-full mt-2 w-56 z-50 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-xl">
|
||||
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{user.fullName}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{user.email}</p>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
setShowChangePassword(true);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Change Password
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
logout();
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-3 py-1.5 text-sm font-medium text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="px-3 py-1.5 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="md:hidden p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg"
|
||||
>
|
||||
{isOpen ? (
|
||||
<X className="w-5 h-5" />
|
||||
) : (
|
||||
<Menu className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{isOpen && (
|
||||
<div className="md:hidden border-t border-white border-opacity-20 dark:border-gray-700 py-4 space-y-2 animate-in slide-in-from-top-2 duration-200">
|
||||
<Link
|
||||
href="/booking/lookup"
|
||||
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
My Booking
|
||||
</Link>
|
||||
<Link
|
||||
href="/help"
|
||||
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Help
|
||||
</Link>
|
||||
{!isAuthenticated && (
|
||||
<>
|
||||
<Link
|
||||
href="/login"
|
||||
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChangePasswordModal
|
||||
isOpen={showChangePassword}
|
||||
onClose={() => setShowChangePassword(false)}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
212
apps/edr-passenger-web/portal/src/components/AppSidebar.tsx
Normal file
212
apps/edr-passenger-web/portal/src/components/AppSidebar.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Home,
|
||||
Phone,
|
||||
Ticket,
|
||||
HelpCircle,
|
||||
Moon,
|
||||
Sun,
|
||||
KeyRound,
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
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';
|
||||
|
||||
// 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> = {
|
||||
'/booking/search': 'search',
|
||||
'/booking/results': 'results',
|
||||
'/booking/auth-check': 'passengers',
|
||||
'/booking/passengers': 'passengers',
|
||||
'/booking/seats': 'seats',
|
||||
'/booking/review': 'review',
|
||||
'/booking/payment': 'payment',
|
||||
'/booking/confirmation': 'confirmation',
|
||||
};
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ href: '/', label: 'Home', icon: Home },
|
||||
{ href: '/booking/lookup', label: 'My Bookings', icon: Ticket },
|
||||
{ href: '/contact', label: 'Contact', icon: Phone },
|
||||
{ href: '/help', label: 'Help', icon: HelpCircle },
|
||||
];
|
||||
|
||||
export default function AppSidebar() {
|
||||
const pathname = usePathname();
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||
const { user, isAuthenticated, initialize, logout } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
initialize();
|
||||
}, [initialize]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const html = document.documentElement;
|
||||
const nextDark = !html.classList.contains('dark');
|
||||
html.classList.toggle('dark', nextDark);
|
||||
localStorage.setItem('theme', nextDark ? 'dark' : 'light');
|
||||
setIsDark(nextDark);
|
||||
};
|
||||
|
||||
const currentStepId = BOOKING_STEP_MAP[pathname ?? ''];
|
||||
const currentStepIndex = currentStepId ? BOOKING_STEPS.findIndex((s) => s.id === currentStepId) : -1;
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:flex w-64 fixed inset-y-0 left-0 z-40 flex-col bg-[rgb(20_113_76)] dark:bg-gray-900 border-r border-[rgb(16_89_60)] dark:border-gray-800">
|
||||
<Link href="/" className="flex items-center px-5 h-20 flex-shrink-0 hover:opacity-80 transition-opacity">
|
||||
<Image
|
||||
src="/edr-logo.png"
|
||||
alt="Ethio-Djibouti Railway"
|
||||
width={140}
|
||||
height={48}
|
||||
className="h-16 w-auto"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-1">
|
||||
{NAV_LINKS.map(({ href, label, icon: Icon }) => {
|
||||
const isActive = href === '/' ? pathname === '/' : pathname?.startsWith(href);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-white/15 text-white'
|
||||
: 'text-gray-100 hover:bg-white/10 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{currentStepIndex >= 0 && (
|
||||
<div className="mt-6 pt-4 border-t border-white/15">
|
||||
<p className="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-gray-200/80">
|
||||
Your booking
|
||||
</p>
|
||||
<ol className="space-y-1">
|
||||
{BOOKING_STEPS.map((step, index) => {
|
||||
const isComplete = index < currentStepIndex;
|
||||
const isCurrent = index === currentStepIndex;
|
||||
return (
|
||||
<li key={step.id} className="flex items-center gap-2.5 px-3 py-1.5">
|
||||
<span
|
||||
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full text-[10px] font-bold transition-colors ${
|
||||
isComplete
|
||||
? 'bg-white text-[rgb(20,113,76)]'
|
||||
: isCurrent
|
||||
? 'border-2 border-white text-white'
|
||||
: 'border border-white/30 text-white/50'
|
||||
}`}
|
||||
>
|
||||
{isComplete ? <Check className="w-3 h-3" /> : index + 1}
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
isCurrent ? 'text-white font-semibold' : isComplete ? 'text-gray-100' : 'text-white/50'
|
||||
}`}
|
||||
>
|
||||
{step.name}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="flex-shrink-0 border-t border-white/15 p-3 space-y-1">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex w-full items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-100 hover:bg-white/10 hover:text-white transition-colors"
|
||||
>
|
||||
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
{isDark ? 'Light mode' : 'Dark mode'}
|
||||
</button>
|
||||
|
||||
{isAuthenticated && user ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUserMenu((v) => !v)}
|
||||
className="flex w-full items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-white text-[rgb(20_113_76)] font-semibold text-sm">
|
||||
{user.fullName?.toUpperCase().charAt(0) || 'U'}
|
||||
</div>
|
||||
<span className="flex-1 text-left text-sm font-medium text-white truncate">{user.fullName}</span>
|
||||
<ChevronDown className={`w-4 h-4 text-gray-200 transition-transform ${showUserMenu ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<div className="absolute bottom-full left-0 mb-2 w-full rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-xl overflow-hidden">
|
||||
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{user.fullName}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{user.email}</p>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
setShowChangePassword(true);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Change Password
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
logout();
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-1 pt-1">
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex-1 text-center px-3 py-2 text-sm font-medium text-gray-100 hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="flex-1 text-center px-3 py-2 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChangePasswordModal
|
||||
isOpen={showChangePassword}
|
||||
onClose={() => setShowChangePassword(false)}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { Home, Phone, Ticket, User } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
|
||||
// The linear, one-screen-at-a-time booking flow — each of these pages already
|
||||
// has its own sticky mobile CTA bar (and the mobile step strip at the top),
|
||||
// so the tab bar hides rather than stacking a second bottom bar underneath.
|
||||
const LINEAR_FLOW_PREFIXES = [
|
||||
'/booking/search',
|
||||
'/booking/results',
|
||||
'/booking/seats',
|
||||
'/booking/passengers',
|
||||
'/booking/auth-check',
|
||||
'/booking/review',
|
||||
'/booking/payment',
|
||||
'/booking/confirmation',
|
||||
];
|
||||
|
||||
export default function BottomTabBar() {
|
||||
const pathname = usePathname() ?? '';
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
const isInLinearFlow = LINEAR_FLOW_PREFIXES.some((p) => pathname.startsWith(p));
|
||||
if (isInLinearFlow) return null;
|
||||
|
||||
const tabs = [
|
||||
{ href: '/', label: 'Home', icon: Home, match: (p: string) => p === '/' },
|
||||
{ href: '/booking/lookup', label: 'Bookings', icon: Ticket, match: (p: string) => p.startsWith('/booking/lookup') || p.startsWith('/booking/detail') },
|
||||
{ href: '/contact', label: 'Contact', icon: Phone, match: (p: string) => p.startsWith('/contact') },
|
||||
{
|
||||
href: isAuthenticated ? '/profile' : '/login',
|
||||
label: isAuthenticated ? 'Account' : 'Sign in',
|
||||
icon: User,
|
||||
match: (p: string) => p.startsWith('/profile') || p.startsWith('/login') || p.startsWith('/register'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* In-flow spacer so page content/Footer can't end up hidden behind the
|
||||
fixed bar below — self-contained here so it only exists when the
|
||||
bar itself is actually rendered. */}
|
||||
<div className="lg:hidden h-16" aria-hidden="true" />
|
||||
<nav className="lg:hidden fixed bottom-0 inset-x-0 z-40 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800 shadow-[0_-2px_10px_rgba(0,0,0,0.05)]">
|
||||
<ul className="flex items-stretch">
|
||||
{tabs.map(({ href, label, icon: Icon, match }) => {
|
||||
const isActive = match(pathname);
|
||||
return (
|
||||
<li key={href} className="flex-1">
|
||||
<Link
|
||||
href={href}
|
||||
className={`flex flex-col items-center justify-center gap-0.5 py-2.5 text-[11px] font-medium transition-colors ${
|
||||
isActive ? 'text-primary' : 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 ${isActive ? 'text-primary' : ''}`} strokeWidth={isActive ? 2.5 : 2} />
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Train, Menu, X, Moon, Sun, HelpCircle } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
import { getTranslation, Language } from '@/lib/i18n';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
|
||||
export function LandingNav() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [lang, setLang] = useState<Language>('en');
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { getLang } = useLanguage();
|
||||
const t = (key: string) => getTranslation(lang, key);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
setLang(getLang());
|
||||
const handleLanguageChange = (e: any) => setLang(e.detail);
|
||||
window.addEventListener('languageChange', handleLanguageChange);
|
||||
return () => window.removeEventListener('languageChange', handleLanguageChange);
|
||||
}, [getLang]);
|
||||
|
||||
useEffect(() => {
|
||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||
setIsDark(isDarkMode);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const html = document.documentElement;
|
||||
const isDarkMode = html.classList.contains('dark');
|
||||
if (isDarkMode) {
|
||||
html.classList.remove('dark');
|
||||
setIsDark(false);
|
||||
localStorage.setItem('theme', 'light');
|
||||
} else {
|
||||
html.classList.add('dark');
|
||||
setIsDark(true);
|
||||
localStorage.setItem('theme', 'dark');
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{ label: t('nav.home'), href: '/' },
|
||||
{ label: t('nav.services'), href: '/services' },
|
||||
{ label: t('nav.about'), href: '/about' },
|
||||
{ label: t('nav.contact'), href: '/contact' },
|
||||
];
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-40 bg-[rgb(20_113_76)] border-b border-[rgb(16_89_60)] shadow-sm">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
|
||||
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center">
|
||||
<Train className="w-6 h-6 text-[rgb(20_113_76)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">
|
||||
Ethio-Djibouti Railway
|
||||
</h1>
|
||||
<p className="text-xs text-gray-100">
|
||||
Book your train journey with us
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Menu */}
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
{menuItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="text-sm font-medium text-gray-100 hover:text-white transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
<LanguageSwitcher />
|
||||
|
||||
{/* Help Link */}
|
||||
<Link
|
||||
href="/help"
|
||||
className="hidden sm:flex items-center gap-1.5 px-3 py-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
|
||||
title="Help & FAQ"
|
||||
>
|
||||
<HelpCircle className="w-5 h-5" />
|
||||
</Link>
|
||||
|
||||
{/* Theme Toggler */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
|
||||
title={isDark ? 'Light mode' : 'Dark mode'}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="w-5 h-5" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href="/booking/search"
|
||||
className="hidden sm:block px-4 py-2 bg-white text-[rgb(20_113_76)] text-sm font-medium rounded-lg transition-all duration-200 transform hover:scale-105 hover:bg-gray-100"
|
||||
>
|
||||
{t('nav.bookNow')}
|
||||
</Link>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="md:hidden p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg"
|
||||
>
|
||||
{isOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{isOpen && (
|
||||
<div className="md:hidden border-t border-white border-opacity-20 py-4 space-y-2 animate-in slide-in-from-top-2 duration-200">
|
||||
{menuItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="block px-4 py-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<Link
|
||||
href="/help"
|
||||
className="block px-4 py-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
{t('nav.help')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/booking/search"
|
||||
className="block px-4 py-2 bg-white text-[rgb(20_113_76)] font-medium rounded-lg transition-colors hover:bg-gray-100"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
{t('nav.bookNow')}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
|
||||
export function LoadingIndicator() {
|
||||
// Minimum time the bar stays visible once shown, so very fast local
|
||||
// transitions don't flash imperceptibly (standard practice for top-loading
|
||||
// progress bars).
|
||||
const MIN_VISIBLE_MS = 300;
|
||||
|
||||
function LoadingIndicatorInner() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const shownAtRef = useRef<number | null>(null);
|
||||
|
||||
// Detects the START of a navigation: a same-origin link click gives
|
||||
// instant feedback on tap, before the RSC fetch even begins. Browser
|
||||
// back/forward (popstate) is covered the same way.
|
||||
useEffect(() => {
|
||||
const handleStart = () => setIsVisible(true);
|
||||
const handleEnd = () => setIsVisible(false);
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (e.defaultPrevented || e.button !== 0) return;
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
|
||||
window.addEventListener('beforeunload', handleStart);
|
||||
window.addEventListener('load', handleEnd);
|
||||
const anchor = (e.target as HTMLElement | null)?.closest('a');
|
||||
if (!anchor) return;
|
||||
if (anchor.target && anchor.target !== '_self') return;
|
||||
if (anchor.hasAttribute('download')) return;
|
||||
|
||||
const href = anchor.getAttribute('href');
|
||||
if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return;
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(href, window.location.href);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (url.origin !== window.location.origin) return;
|
||||
if (url.pathname + url.search === window.location.pathname + window.location.search) return;
|
||||
|
||||
shownAtRef.current = Date.now();
|
||||
setIsVisible(true);
|
||||
};
|
||||
|
||||
const handlePopState = () => {
|
||||
shownAtRef.current = Date.now();
|
||||
setIsVisible(true);
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClick, true);
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleStart);
|
||||
window.removeEventListener('load', handleEnd);
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Detects the END of a navigation: pathname/search settling to a new value
|
||||
// means the route transition has completed.
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
const elapsed = shownAtRef.current ? Date.now() - shownAtRef.current : MIN_VISIBLE_MS;
|
||||
const remaining = Math.max(0, MIN_VISIBLE_MS - elapsed);
|
||||
const timer = setTimeout(() => setIsVisible(false), remaining);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
@@ -28,3 +77,11 @@ export function LoadingIndicator() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingIndicator() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoadingIndicatorInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
|
||||
// AppSidebar (which carries the logo) is `hidden lg:flex` — invisible on
|
||||
// mobile, so mobile pages had no branding/way back to home at all. This is
|
||||
// the mobile-only equivalent: a slim bar with just the logo. Deliberately
|
||||
// not sticky — it scrolls away with the page content instead of staying
|
||||
// pinned to the top.
|
||||
export default function MobileTopBar() {
|
||||
return (
|
||||
<div className="lg:hidden relative bg-[rgb(20_113_76)] dark:bg-gray-900 border-b border-[rgb(16_89_60)] dark:border-gray-800 px-4 h-16 flex items-center flex-shrink-0">
|
||||
<Link href="/" className="flex items-center hover:opacity-80 transition-opacity" aria-label="Go to home">
|
||||
<Image
|
||||
src="/edr-logo.png"
|
||||
alt="Ethio-Djibouti Railway"
|
||||
width={140}
|
||||
height={48}
|
||||
className="h-14 w-auto"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Globe, X } from 'lucide-react';
|
||||
import {
|
||||
gregorianToEthiopian,
|
||||
@@ -18,6 +19,7 @@ interface ModernDatePickerProps {
|
||||
minDate?: Date;
|
||||
maxDate?: Date;
|
||||
placeholder?: string;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export default function ModernDatePicker({
|
||||
@@ -26,6 +28,7 @@ export default function ModernDatePicker({
|
||||
minDate,
|
||||
maxDate,
|
||||
placeholder = 'Select date',
|
||||
error = false,
|
||||
}: ModernDatePickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMobileView, setIsMobileView] = useState(false);
|
||||
@@ -259,6 +262,21 @@ export default function ModernDatePicker({
|
||||
{calendarType === 'gregorian' ? renderGregorianCalendar() : renderEthiopianCalendar()}
|
||||
{calendarFooter}
|
||||
</div>
|
||||
|
||||
{/* Mobile-only: a large, unmistakable Cancel action in addition to the header
|
||||
X — the mobile view is a full-screen takeover with no backdrop to tap, so
|
||||
this is the fallback way out without having to pick a date. */}
|
||||
{isMobileView && (
|
||||
<div className="flex-shrink-0 border-t border-gray-200 dark:border-gray-700 p-3 bg-white dark:bg-gray-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="w-full py-3 rounded-xl border-2 border-gray-200 dark:border-gray-700 text-sm font-semibold text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -268,7 +286,11 @@ export default function ModernDatePicker({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="w-full px-3.5 py-3.5 border-2 border-gray-200 dark:border-gray-700 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 hover:border-gray-300 dark:hover:border-gray-600 transition-all group"
|
||||
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 ${
|
||||
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}
|
||||
@@ -276,7 +298,7 @@ export default function ModernDatePicker({
|
||||
<CalendarIcon className="w-4 h-4 text-gray-400 group-hover:text-primary transition-colors flex-shrink-0" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
{isOpen && createPortal(
|
||||
<>
|
||||
|
||||
{/* Mobile: full-screen takeover */}
|
||||
@@ -314,7 +336,8 @@ export default function ModernDatePicker({
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
// Keying by pathname forces React to remount this wrapper on every route
|
||||
// change, which restarts the CSS animation — a subtle fade/slide-up on each
|
||||
// step of the booking flow, no animation library required.
|
||||
export function PageTransition({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<div key={pathname} className="animate-fade-in-up">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
import { Check } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const steps = [
|
||||
// Shared source of truth for the booking flow's step order/labels — also
|
||||
// consumed by AppSidebar's vertical step list on desktop.
|
||||
export const BOOKING_STEPS = [
|
||||
{ id: 'search', name: 'Search' },
|
||||
{ id: 'results', name: 'Results' },
|
||||
{ id: 'passengers', name: 'Passengers' },
|
||||
@@ -12,6 +14,7 @@ const steps = [
|
||||
{ id: 'payment', name: 'Payment' },
|
||||
{ id: 'confirmation', name: 'Done' },
|
||||
];
|
||||
const steps = BOOKING_STEPS;
|
||||
|
||||
export function ProgressIndicator({ currentStep }: { currentStep: string }) {
|
||||
const currentIndex = steps.findIndex((s) => s.id === currentStep);
|
||||
@@ -70,24 +73,18 @@ export function ProgressIndicator({ currentStep }: { currentStep: string }) {
|
||||
);
|
||||
};
|
||||
|
||||
// Desktop shows the vertical step list embedded in AppSidebar instead —
|
||||
// this component is mobile-only now (the sidebar doesn't exist below lg).
|
||||
return (
|
||||
<nav aria-label="Progress">
|
||||
{/* Mobile: full-bleed scrollable */}
|
||||
<nav aria-label="Progress" className="lg:hidden">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="md:hidden overflow-x-auto scrollbar-hide px-4 py-3"
|
||||
className="overflow-x-auto scrollbar-hide px-4 py-3"
|
||||
>
|
||||
<ol className="flex items-start min-w-full">
|
||||
{steps.map((s, i) => stepItem(s, i))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Desktop: full container width matching header/footer */}
|
||||
<div className="hidden md:block container mx-auto px-4 py-4">
|
||||
<ol className="flex items-start max-w-6xl mx-auto">
|
||||
{steps.map((s, i) => stepItem(s, i))}
|
||||
</ol>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
10
apps/edr-passenger-web/portal/src/components/Skeleton.tsx
Normal file
10
apps/edr-passenger-web/portal/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Shared pulse-block primitive for loading placeholders — replaces the
|
||||
// hand-rolled `animate-pulse` divs duplicated across PackagesSection,
|
||||
// results/page.tsx, and the home page's search skeleton.
|
||||
export function Skeleton({ className = '' }: SkeletonProps) {
|
||||
return <div className={`animate-pulse rounded-lg bg-gray-200 dark:bg-gray-700 ${className}`} />;
|
||||
}
|
||||
@@ -23,7 +23,11 @@ export function SupportWidget() {
|
||||
useSupportSocket(true);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-[100] flex flex-col items-end gap-3">
|
||||
// Kept below every modal/drawer tier (z-90 through z-[120]) and the mobile sticky
|
||||
// bottom action bars (z-40) used on booking flow pages, so the floating launcher never
|
||||
// paints over dialog content or a page's "Continue" bar — it only sits above ordinary
|
||||
// in-page content.
|
||||
<div className="fixed bottom-6 right-6 z-30 flex flex-col items-end gap-3">
|
||||
{open && <SupportPanel onClose={() => setOpen(false)} />}
|
||||
{!open && (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
// Code-split the support widget out of the initial page bundle — it pulls in
|
||||
// socket.io-client and opens a live WebSocket connection on mount (see
|
||||
// useSupportSocket), which otherwise happens on every single page load before
|
||||
// the user ever opens the chat. next/dynamic + ssr:false must be called from a
|
||||
// Client Component (disallowed directly in a Server Component like layout.tsx),
|
||||
// hence this thin wrapper.
|
||||
const SupportWidget = dynamic(() => import('./SupportWidget'), { ssr: false });
|
||||
|
||||
export default SupportWidget;
|
||||
Reference in New Issue
Block a user