From ac3aa348ce0e98397425618b3f2007547eda79eb Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Wed, 10 Jun 2026 18:49:40 +0300 Subject: [PATCH] Update booking portal UI/UX --- .../portal/src/app/booking/layout.tsx | 4 +- .../src/app/booking/passengers/page.tsx | 498 ++++++-- .../portal/src/app/booking/results/page.tsx | 302 ++--- .../portal/src/app/booking/search/page.tsx | 1054 ++++++++++++----- .../portal/src/app/booking/seats/page.tsx | 286 +++-- .../portal/src/app/globals.css | 18 + .../portal/src/app/login/page.tsx | 2 +- .../portal/src/components/Footer.tsx | 42 +- .../src/components/ModernDatePicker.tsx | 412 +++---- .../src/components/ProgressIndicator.tsx | 154 +-- .../portal/src/components/SearchWidget.tsx | 2 +- .../portal/src/lib/booking-store.ts | 1 + 12 files changed, 1777 insertions(+), 998 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/layout.tsx b/apps/edr-passenger-web/portal/src/app/booking/layout.tsx index 276a3af51..e326bd376 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/layout.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/layout.tsx @@ -28,9 +28,7 @@ export default function BookingLayout({
{showProgress && (
-
- -
+
)} {children} diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index 79649c4b0..c5c9b4e06 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -7,16 +7,338 @@ import { useRouter } from 'next/navigation'; import { useBookingStore } from '@/lib/booking-store'; import { useAuthStore } from '@/lib/auth-store'; import { apiClient } from '@/lib/api-client'; -import { useState, useEffect } from 'react'; -import { CheckCircle, ExternalLink, Loader2 } from 'lucide-react'; +import { useState, useEffect, useRef } from 'react'; +import { CheckCircle, ExternalLink, Loader2, CalendarDays, X, Globe } from 'lucide-react'; +import { gregorianToEthiopian, ethiopianToGregorian, ETHIOPIAN_MONTHS, getDaysInEthiopianMonth } from '@/lib/ethiopian-calendar'; + +const GC_MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December']; + +function daysInGCMonth(y: number, m: number) { + return new Date(y, m, 0).getDate(); +} + +function DobPickerModal({ + value, + onChange, + error, +}: { + value: string; + onChange: (iso: string) => void; + error?: string; +}) { + const [open, setOpen] = useState(false); + const [manualMode, setManualMode] = useState(false); + const [calType, setCalType] = useState<'gregorian' | 'ethiopian'>('gregorian'); + const currentYear = new Date().getFullYear(); + const currentEthYear = gregorianToEthiopian(new Date()).year; + + // Parse stored ISO value (always Gregorian) + const parsed = value ? value.split('-') : []; + const initGCYear = parsed[0] ? parseInt(parsed[0]) : currentYear - 25; + const initGCMonth = parsed[1] ? parseInt(parsed[1]) : 1; + const initGCDay = parsed[2] ? parseInt(parsed[2]) : 1; + + // Gregorian drum state + const [selGCYear, setSelGCYear] = useState(initGCYear); + const [selGCMonth, setSelGCMonth] = useState(initGCMonth); + const [selGCDay, setSelGCDay] = useState(initGCDay); + + // Ethiopian drum state — initialise from parsed value if present + const initEth = value ? gregorianToEthiopian(new Date(initGCYear, initGCMonth - 1, initGCDay)) : { year: currentEthYear - 25, month: 1, day: 1 }; + const [selEthYear, setSelEthYear] = useState(initEth.year); + const [selEthMonth, setSelEthMonth] = useState(initEth.month); + const [selEthDay, setSelEthDay] = useState(initEth.day); + + // Manual inputs + const [manDay, setManDay] = useState(parsed[2] ? String(parseInt(parsed[2])) : ''); + const [manMonth, setManMonth] = useState(parsed[1] ? String(parseInt(parsed[1])) : ''); + const [manYear, setManYear] = useState(parsed[0] || ''); + + // Computed + const gcMaxDay = daysInGCMonth(selGCYear, selGCMonth); + const ethMaxDay = getDaysInEthiopianMonth(selEthYear, selEthMonth); + const gcSafeDay = Math.min(selGCDay, gcMaxDay); + const ethSafeDay = Math.min(selEthDay, ethMaxDay); + + const gcYears = Array.from({ length: 100 }, (_, i) => currentYear - i); + const ethYears = Array.from({ length: 100 }, (_, i) => currentEthYear - i); + const gcMonths = GC_MONTHS.map((m, i) => ({ label: m, value: i + 1 })); + const ethMonths = ETHIOPIAN_MONTHS.map((m, i) => ({ label: m, value: i + 1 })); + const gcDays = Array.from({ length: gcMaxDay }, (_, i) => i + 1); + const ethDays = Array.from({ length: ethMaxDay }, (_, i) => i + 1); + + const dayRef = useRef(null); + const monthRef = useRef(null); + const yearRef = useRef(null); + const ITEM_H = 48; + + const scrollTo = (ref: React.RefObject, idx: number) => { + ref.current?.scrollTo({ top: Math.max(0, idx) * ITEM_H, behavior: 'smooth' }); + }; + + // Scroll drums to current selection when opened or calType changed + useEffect(() => { + if (!open || manualMode) return; + setTimeout(() => { + if (calType === 'gregorian') { + scrollTo(dayRef, gcSafeDay - 1); + scrollTo(monthRef, selGCMonth - 1); + scrollTo(yearRef, gcYears.indexOf(selGCYear)); + } else { + scrollTo(dayRef, ethSafeDay - 1); + scrollTo(monthRef, selEthMonth - 1); + scrollTo(yearRef, ethYears.indexOf(selEthYear)); + } + }, 60); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, manualMode, calType]); + + const makeScrollHandler = ( + ref: React.RefObject, + setter: (v: number) => void, + getList: () => number[], + ) => { + let timer: ReturnType; + return () => { + clearTimeout(timer); + timer = setTimeout(() => { + const el = ref.current; + if (!el) return; + const list = getList(); + const idx = Math.round(el.scrollTop / ITEM_H); + const clamped = Math.max(0, Math.min(idx, list.length - 1)); + setter(list[clamped]); + el.scrollTo({ top: clamped * ITEM_H, behavior: 'smooth' }); + }, 150); + }; + }; + + const gcDayHandler = useRef(makeScrollHandler(dayRef, setSelGCDay, () => Array.from({ length: daysInGCMonth(selGCYear, selGCMonth) }, (_, i) => i + 1))).current; + const gcMonthHandler = useRef(makeScrollHandler(monthRef, setSelGCMonth, () => GC_MONTHS.map((_, i) => i + 1))).current; + const gcYearHandler = useRef(makeScrollHandler(yearRef, setSelGCYear, () => Array.from({ length: 100 }, (_, i) => new Date().getFullYear() - i))).current; + + const ethDayHandler = useRef(makeScrollHandler(dayRef, setSelEthDay, () => Array.from({ length: getDaysInEthiopianMonth(selEthYear, selEthMonth) }, (_, i) => i + 1))).current; + const ethMonthHandler = useRef(makeScrollHandler(monthRef, setSelEthMonth, () => ETHIOPIAN_MONTHS.map((_, i) => i + 1))).current; + const ethYearHandler = useRef(makeScrollHandler(yearRef, setSelEthYear, () => Array.from({ length: 100 }, (_, i) => gregorianToEthiopian(new Date()).year - i))).current; + + const confirm = () => { + let gregDate: Date; + if (calType === 'gregorian') { + const d = Math.min(selGCDay, daysInGCMonth(selGCYear, selGCMonth)); + gregDate = new Date(selGCYear, selGCMonth - 1, d); + } else { + const d = Math.min(selEthDay, getDaysInEthiopianMonth(selEthYear, selEthMonth)); + gregDate = ethiopianToGregorian({ year: selEthYear, month: selEthMonth, day: d }); + } + const iso = `${gregDate.getFullYear()}-${String(gregDate.getMonth() + 1).padStart(2,'0')}-${String(gregDate.getDate()).padStart(2,'0')}`; + onChange(iso); + setOpen(false); + }; + + const confirmManual = () => { + const d = parseInt(manDay), m = parseInt(manMonth), y = parseInt(manYear); + if (!d || !m || !y || m < 1 || m > 12 || d < 1 || d > daysInGCMonth(y, m) || y < currentYear - 110 || y > currentYear) return; + onChange(`${y}-${String(m).padStart(2,'0')}-${String(d).padStart(2,'0')}`); + setOpen(false); + }; + + const manualValid = (() => { + const d = parseInt(manDay), m = parseInt(manMonth), y = parseInt(manYear); + return d >= 1 && m >= 1 && m <= 12 && y >= currentYear - 110 && y <= currentYear && d <= daysInGCMonth(y, m); + })(); + + // Display: always show Gregorian ISO as human-readable, with calendar label + const displayValue = (() => { + if (!value) return ''; + const [y, mo, d] = value.split('-').map(Number); + const gcStr = `${GC_MONTHS[mo - 1]} ${d}, ${y}`; + if (calType === 'ethiopian') { + const eth = gregorianToEthiopian(new Date(y, mo - 1, d)); + return `${ETHIOPIAN_MONTHS[eth.month - 1]} ${eth.day}, ${eth.year} (${gcStr})`; + } + return gcStr; + })(); + + // Confirm button label + const confirmLabel = (() => { + if (manualMode) return manualValid ? `Confirm — ${manDay}/${manMonth}/${manYear}` : 'Confirm'; + if (calType === 'gregorian') { + const d = Math.min(selGCDay, gcMaxDay); + return `Confirm — ${GC_MONTHS[selGCMonth - 1]} ${d}, ${selGCYear}`; + } else { + const d = Math.min(selEthDay, ethMaxDay); + const gcDate = ethiopianToGregorian({ year: selEthYear, month: selEthMonth, day: d }); + return `Confirm — ${ETHIOPIAN_MONTHS[selEthMonth - 1]} ${d}, ${selEthYear} (GC: ${GC_MONTHS[gcDate.getMonth()]} ${gcDate.getDate()}, ${gcDate.getFullYear()})`; + } + })(); + + const col = ( + ref: React.RefObject, + list: Array<{ label: string; value: number }>, + selected: number, + setter: (v: number) => void, + scrollHandler: () => void, + ) => ( +
+
+
+ {list.map((item, idx) => ( +
{ setter(item.value); ref.current?.scrollTo({ top: idx * ITEM_H, behavior: 'smooth' }); }} + className={`flex items-center justify-center text-sm font-medium transition-all select-none ${ + item.value === selected ? 'text-primary font-bold text-base' : 'text-gray-400 dark:text-gray-500' + }`} + > + {item.label} +
+ ))} +
+
+
+ ); + + return ( +
+ + {error &&

{error}

} + + {open && ( + <> +
setOpen(false)} /> +
+ {/* Header */} +
+
+

Date of Birth

+ {!manualMode && ( + + )} + +
+ +
+ + {/* Calendar type label */} + {!manualMode && ( +
+

+ {calType === 'gregorian' ? 'Gregorian Calendar' : 'Ethiopian Calendar (ኢትዮጵያ)'} +

+
+ )} + + {manualMode ? ( +
+
+
+ + setManDay(e.target.value)} placeholder="DD" className="input-field text-center text-lg font-semibold" /> +
+
+ + setManMonth(e.target.value)} placeholder="MM" className="input-field text-center text-lg font-semibold" /> +
+
+ + setManYear(e.target.value)} placeholder="YYYY" className="input-field text-center text-lg font-semibold" /> +
+
+ {manDay && manMonth && manYear && !manualValid && ( +

Please enter a valid Gregorian date

+ )} +
+ ) : ( + <> +
+ {['Day', 'Month', 'Year'].map(l => ( +
{l}
+ ))} +
+
+
+
+ {calType === 'gregorian' ? ( + <> + {col(dayRef, gcDays.map(d => ({ label: String(d), value: d })), gcSafeDay, setSelGCDay, gcDayHandler)} + {col(monthRef, gcMonths, selGCMonth, setSelGCMonth, gcMonthHandler)} + {col(yearRef, gcYears.map(y => ({ label: String(y), value: y })), selGCYear, setSelGCYear, gcYearHandler)} + + ) : ( + <> + {col(dayRef, ethDays.map(d => ({ label: String(d), value: d })), ethSafeDay, setSelEthDay, ethDayHandler)} + {col(monthRef, ethMonths, selEthMonth, setSelEthMonth, ethMonthHandler)} + {col(yearRef, ethYears.map(y => ({ label: String(y), value: y })), selEthYear, setSelEthYear, ethYearHandler)} + + )} +
+
+ + )} + +
+ +
+
+ + + )} +
+ ); +} const passengerSchema = z.object({ - name: z.string().min(2, 'Name is required'), + name: z.string().min(2, 'Full name is required (min 2 characters)'), dateOfBirth: z.string().min(1, 'Date of birth is required'), - gender: z.enum(['Male', 'Female']).optional(), + gender: z.string().min(1, 'Gender is required'), nationality: z.string().min(1, 'Nationality is required'), - phone: z.string().optional(), - email: z.string().email('Invalid email').optional().or(z.literal('')), + phone: z.string().min(1, 'Phone number is required'), + email: z.string().optional(), nationalId: z.string().optional(), passportNumber: z.string().optional(), passportCountry: z.string().optional(), @@ -26,15 +348,25 @@ const passengerSchema = z.object({ faydaVerified: z.boolean().optional(), faydaSub: z.string().optional(), formExpanded: z.boolean().optional(), -}).refine((data) => { - if (data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian') { - return data.passportNumber && data.passportNumber.length > 0 && - data.passportCountry && data.passportCountry.length > 0; +}).superRefine((data, ctx) => { + if (data.gender !== 'Male' && data.gender !== 'Female') { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Gender is required', path: ['gender'] }); + } + if (data.email && data.email.trim().length > 0) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(data.email)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['email'] }); + } + } + const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian'; + if (isNonEthiopian) { + if (!data.passportNumber || data.passportNumber.trim().length === 0) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number is required', path: ['passportNumber'] }); + } + if (!data.passportCountry || data.passportCountry.trim().length === 0) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Issuing country is required', path: ['passportCountry'] }); + } } - return true; -}, { - message: 'Passport number and country are required for non-Ethiopian passengers', - path: ['passportNumber'], }); const formSchema = z.object({ @@ -57,7 +389,8 @@ export default function PassengersPage() { const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0); const { register, control, handleSubmit, setValue, watch, formState: { errors } } = useForm({ - resolver: zodResolver(formSchema), + resolver: zodResolver(formSchema as any), + mode: 'onChange', defaultValues: { passengers: Array.from({ length: totalPassengers }, () => ({ name: '', @@ -418,82 +751,80 @@ export default function PassengersPage() { )}
+ {/* Full Name */}
setValue(`passengers.${index}.name`, e.target.value)} /> {errors.passengers?.[index]?.name && ( -

{errors.passengers[index]?.name?.message}

+

{errors.passengers[index]?.name?.message}

)}
+ {/* Date of Birth */}
- setValue(`passengers.${index}.dateOfBirth`, e.target.value)} + onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })} + error={errors.passengers?.[index]?.dateOfBirth?.message} /> - {errors.passengers?.[index]?.dateOfBirth && ( -

{errors.passengers[index]?.dateOfBirth?.message}

- )}
+ {/* Gender */}
- + + {errors.passengers?.[index]?.gender && ( +

{errors.passengers[index]?.gender?.message}

+ )}
+ {/* Nationality (read-only) */}
- +
+ {/* Phone */}
- + setValue(`passengers.${index}.phone`, e.target.value)} /> + {errors.passengers?.[index]?.phone && ( +

{errors.passengers[index]?.phone?.message}

+ )}
+ {/* Email */}
setValue(`passengers.${index}.email`, e.target.value)} /> {errors.passengers?.[index]?.email && ( -

{errors.passengers[index]?.email?.message}

+

{errors.passengers[index]?.email?.message}

)}
@@ -501,113 +832,109 @@ export default function PassengersPage() { ) : ( <>
+ {/* Full Name */}
setValue(`passengers.${index}.name`, e.target.value)} /> {errors.passengers?.[index]?.name && ( -

{errors.passengers[index]?.name?.message}

+

{errors.passengers[index]?.name?.message}

)}
+ {/* Date of Birth */}
- setValue(`passengers.${index}.dateOfBirth`, e.target.value)} + onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })} + error={errors.passengers?.[index]?.dateOfBirth?.message} /> - {errors.passengers?.[index]?.dateOfBirth && ( -

{errors.passengers[index]?.dateOfBirth?.message}

- )}
+ {/* Gender */}
- + + {errors.passengers?.[index]?.gender && ( +

{errors.passengers[index]?.gender?.message}

+ )}
+ {/* Nationality (read-only) */}
- +
+ {/* Phone */}
- + setValue(`passengers.${index}.phone`, e.target.value)} /> + {errors.passengers?.[index]?.phone && ( +

{errors.passengers[index]?.phone?.message}

+ )}
+ {/* Email */}
setValue(`passengers.${index}.email`, e.target.value)} /> {errors.passengers?.[index]?.email && ( -

{errors.passengers[index]?.email?.message}

+

{errors.passengers[index]?.email?.message}

)}
+ {/* Passport fields */}
+

Passport Details

setValue(`passengers.${index}.passportNumber`, e.target.value)} /> {errors.passengers?.[index]?.passportNumber && ( -

{errors.passengers[index]?.passportNumber?.message}

+

{errors.passengers[index]?.passportNumber?.message}

)}
- + setValue(`passengers.${index}.passportCountry`, e.target.value)} + className={`input-field ${errors.passengers?.[index]?.passportCountry ? 'border-red-500' : ''}`} + placeholder="e.g., Djibouti" /> {errors.passengers?.[index]?.passportCountry && ( -

{errors.passengers[index]?.passportCountry?.message}

+

{errors.passengers[index]?.passportCountry?.message}

)}
@@ -617,8 +944,6 @@ export default function PassengersPage() { type="date" {...register(`passengers.${index}.passportIssueDate`)} className="input-field" - value={passengers[index]?.passportIssueDate || ''} - onChange={(e) => setValue(`passengers.${index}.passportIssueDate`, e.target.value)} />
@@ -628,8 +953,6 @@ export default function PassengersPage() { type="date" {...register(`passengers.${index}.passportExpiryDate`)} className="input-field" - value={passengers[index]?.passportExpiryDate || ''} - onChange={(e) => setValue(`passengers.${index}.passportExpiryDate`, e.target.value)} />
@@ -652,7 +975,18 @@ export default function PassengersPage() { )}
- +
+ + {/* Class grid */} +
+ {classModal.faresByClass && Array.isArray(classModal.faresByClass) && classModal.faresByClass.length > 0 ? ( +
+ {classModal.faresByClass.map((fareClass: any) => { + const isSelected = selectedClass === fareClass.seatClassName; + const availableSeats = classModal.availabilityByClass?.[fareClass.seatClassName] || 0; + const isAvailable = availableSeats > 0; + const isBedClass = fareClass.seatClassName.toLowerCase().includes('bed'); + return ( + + ); + })} +
+ ) : ( +

No seat classes available

+ )} +
+ + {/* Footer */} +
+ + {!selectedClass && ( +

Please select a class to continue

+ )} +
+
+ + + ); + })()} + {/* Promo Notification */} {promoData && (
-
- -
+

Promo code applied!

- {promoData.code} - {promoData.message} + {promoData.code} — {promoData.message}

)}
- -

Available trains

-
+

Available schedules

+
{searchData.date ? format(new Date(searchData.date), 'EEEE, MMMM d, yyyy') : 'Date not specified'} @@ -225,26 +332,21 @@ export default function ResultsPage() {
{results.map((schedule) => { const scheduleId = schedule.scheduleId || schedule.id || ''; - const isExpanded = expandedSchedules[scheduleId]; const selectedClass = selectedClasses[scheduleId]; - const lowestFare = schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0 ? Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0)) : null; - const hours = Math.floor((schedule.durationMinutes || 0) / 60); const minutes = (schedule.durationMinutes || 0) % 60; const durationStr = `${hours}h ${minutes}m`; - const departureDate = schedule.departureAt ? new Date(schedule.departureAt) : null; - const arrivalDate = schedule.arrivalAt ? new Date(schedule.arrivalAt) : null; - const isNextDay = departureDate && arrivalDate && - departureDate.toDateString() !== arrivalDate.toDateString(); - + const arrivalDate = schedule.arrivalAt ? new Date(schedule.arrivalAt) : null; + const isNextDay = departureDate && arrivalDate && departureDate.toDateString() !== arrivalDate.toDateString(); + return ( -
-
+
+ {/* Train info */}
@@ -255,7 +357,7 @@ export default function ResultsPage() {
{schedule.trainName || 'Express Service'}
- +
@@ -266,136 +368,62 @@ export default function ResultsPage() {
{schedule.origin?.name || 'Origin'}
- +
{durationStr}
-
-
-
-
- {schedule.stops && schedule.stops.length > 0 && ( - <> - - {schedule.stops.length - 2} stops - - )} +
+
+ {schedule.stops && schedule.stops.length > 0 && ( +
+ + {schedule.stops.length - 2} stops +
+ )}
- +
{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'HH:mm') : '--:--'}
{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'MMM d') : ''} - {isNextDay && ( - (+1) - )} + {isNextDay && (+1)}
{schedule.destination?.name || 'Destination'}
+ {/* Fare + action */}
Starting from
-
+
{lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
per adult
+ {selectedClass && ( +

+ {selectedClass.replace(/_/g, ' ')} selected +

+ )}
- - {isExpanded && ( -
-

Select Class

-
- {schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0 ? ( - schedule.faresByClass.map((fareClass: any) => { - const isSelected = selectedClass === fareClass.seatClassName; - const availableSeats = schedule.availabilityByClass?.[fareClass.seatClassName] || 0; - const isAvailable = availableSeats > 0; - const isBedClass = fareClass.seatClassName.toLowerCase().includes('bed'); - - return ( - - ); - }) - ) : ( -
- No seat classes available -
- )} -
- -
- -
-
- )}
-
- )})} + ); + })}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index 814ab7456..5b2ad3e9b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -9,8 +9,11 @@ import { useAuthStore } from '@/lib/auth-store'; import { apiClient } from '@/lib/api-client'; import { useBookingStore } from '@/lib/booking-store'; import { Station } from '@/types'; -import { Train, MapPin, ArrowRight, Plus, Minus, Search, Users, ChevronDown, Gift, Check } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { + Train, MapPin, ArrowRight, ArrowLeftRight, Plus, Minus, Search, + Users, ChevronDown, Gift, Check, X, ChevronLeft, Clock, Zap, +} from 'lucide-react'; +import { useEffect, useRef, useState, useCallback } from 'react'; import ModernDatePicker from '@/components/ModernDatePicker'; const searchSchema = z.object({ @@ -21,33 +24,385 @@ const searchSchema = z.object({ childCount: z.number().min(0).max(9), nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']), promoCode: z.string().optional(), -}).refine((data) => data.originStationId !== data.destinationStationId, { +}).refine((d) => d.originStationId !== d.destinationStationId, { message: 'Origin and destination must be different', path: ['destinationStationId'], }); type SearchForm = z.infer; +const POPULAR_ROUTES = [ + { from: 'Sebeta', to: 'Nagad', duration: '12h', icon: '🌆' }, + { from: 'Sebeta', to: 'Diredawa', duration: '8h', icon: '🏔️' }, + { from: 'Diredawa', to: 'Nagad', duration: '4h', icon: '🌊' }, +]; + +// ─── Station Modal ──────────────────────────────────────────────────────────── +function StationModal({ + stations, + title, + excludeId, + onSelect, + onClose, + recentIds, +}: { + stations: Station[]; + title: string; + excludeId?: string; + onSelect: (s: Station) => void; + onClose: () => void; + recentIds: string[]; +}) { + const [query, setQuery] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + setTimeout(() => inputRef.current?.focus(), 100); + }, []); + + const filtered = query.trim() + ? stations.filter( + (s) => + s.id !== excludeId && + (s.name.toLowerCase().includes(query.toLowerCase()) || + s.code?.toLowerCase().includes(query.toLowerCase())) + ) + : stations.filter((s) => s.id !== excludeId); + + const recentStations = recentIds + .map((id) => stations.find((s) => s.id === id)) + .filter(Boolean) as Station[]; + + return ( +
+ {/* Header */} +
+ +

{title}

+
+ + {/* Search input */} +
+
+ + setQuery(e.target.value)} + placeholder="Search stations..." + className="w-full pl-10 pr-10 py-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary text-gray-900 dark:text-white placeholder-gray-400" + /> + {query && ( + + )} +
+
+ + {/* Results */} +
+ {!query && recentStations.length > 0 && ( +
+

Recent

+ {recentStations.map((s) => ( + + ))} +
+ )} + +

+ {query ? 'Results' : 'All Stations'} +

+ {filtered.length === 0 ? ( +
+ +

No stations found

+
+ ) : ( + filtered.map((s) => ( + + )) + )} +
+
+ ); +} + +// ─── Passenger Modal (mobile bottom-sheet) ─────────────────────────────────── +function PassengerModal({ + adultCount, + childCount, + onChangeAdult, + onChangeChild, + onClose, +}: { + adultCount: number; + childCount: number; + onChangeAdult: (n: number) => void; + onChangeChild: (n: number) => void; + onClose: () => void; +}) { + const rows = [ + { label: 'Adults', sub: '≥ 5 years', val: adultCount, min: 1, max: 9, onChange: onChangeAdult }, + { label: 'Children', sub: '< 5 years • First child free', val: childCount, min: 0, max: 9, onChange: onChangeChild }, + ]; + + return ( + <> + {/* Backdrop */} +
+ {/* Bottom sheet */} +
+ {/* Handle */} +
+
+
+ {/* Header */} +
+
+ +

Passengers

+
+ +
+ {/* Rows */} +
+ {rows.map(({ label, sub, val, min, max, onChange }, i) => ( +
+ {i > 0 &&
} +
+
+

{label}

+

{sub}

+
+
+ + {val} + +
+
+
+ ))} +
+ {/* Done */} +
+ +
+ +
+ + ); +} + +// ─── Station Autocomplete (Desktop dropdown) ────────────────────────────────── +function StationDropdown({ + stations, + value, + excludeId, + placeholder, + onSelect, + error, + recentIds, +}: { + stations: Station[]; + value: string; + excludeId?: string; + placeholder: string; + onSelect: (s: Station) => void; + error?: string; + recentIds: string[]; +}) { + const [query, setQuery] = useState(''); + const [open, setOpen] = useState(false); + const ref = useRef(null); + const inputRef = useRef(null); + const selectedStation = stations.find((s) => s.id === value); + + useEffect(() => { + if (selectedStation && !open) setQuery(''); + }, [selectedStation, open]); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + const filtered = query.trim() + ? stations.filter( + (s) => + s.id !== excludeId && + (s.name.toLowerCase().includes(query.toLowerCase()) || + s.code?.toLowerCase().includes(query.toLowerCase())) + ) + : stations.filter((s) => s.id !== excludeId).slice(0, 8); + + const displayValue = open ? query : (selectedStation?.name ?? ''); + + return ( +
+
+ + { setQuery(e.target.value); setOpen(true); }} + onFocus={() => { setQuery(''); setOpen(true); }} + placeholder={placeholder} + className="w-full pl-10 pr-8 py-3.5 bg-transparent rounded-xl focus:outline-none text-sm text-gray-900 dark:text-white placeholder-gray-400" + /> + {value && ( + + )} +
+ + {open && ( +
+ {!query && recentIds.length > 0 && ( +
+

Recent

+ {recentIds + .map((id) => stations.find((s) => s.id === id)) + .filter(Boolean) + .map((s) => ( + + ))} +
+
+ )} + {filtered.length === 0 ? ( +

No stations found

+ ) : ( + filtered.map((s) => ( + + )) + )} +
+ )} +
+ ); +} + +// ─── Main Page ──────────────────────────────────────────────────────────────── export default function SearchPage() { const router = useRouter(); const searchParams = useSearchParams(); const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria); const { user, isAuthenticated } = useAuthStore(); - const [isPassengerOpen, setIsPassengerOpen] = useState(false); - const [promoCode, setPromoCode] = useState(''); - const [promoValidation, setPromoValidation] = useState<{ valid: boolean; message: string; discount?: string } | null>(null); - const [promoLoading, setPromoLoading] = useState(false); - const { data: stations, isLoading, error } = useQuery({ + const [isPassengerOpen, setIsPassengerOpen] = useState(false); + const [passengerModalOpen, setPassengerModalOpen] = useState(false); + const [promoVisible, setPromoVisible] = useState(false); + const [promoCode, setPromoCode] = useState(''); + const [promoValidation, setPromoValidation] = useState<{ valid: boolean; message: string } | null>(null); + const [promoLoading, setPromoLoading] = useState(false); + const [swapping, setSwapping] = useState(false); + const [stationModal, setStationModal] = useState<'origin' | 'destination' | null>(null); + const [recentStationIds, setRecentStationIds] = useState(() => { + try { return JSON.parse(localStorage.getItem('edr_recent_stations') || '[]'); } catch { return []; } + }); + const passengerRef = useRef(null); + + const { data: stations = [], isLoading, error } = useQuery({ queryKey: ['stations'], - queryFn: async (): Promise => { - const response = await apiClient.get('/stations') as Station[]; - return response; - }, + queryFn: async () => await apiClient.get('/stations') as Station[], }); const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm({ - resolver: zodResolver(searchSchema), + resolver: zodResolver(searchSchema as any), defaultValues: { adultCount: 1, childCount: 0, @@ -59,61 +414,72 @@ export default function SearchPage() { useEffect(() => { if (isAuthenticated && user?.nationality) { - const normalized = user.nationality.toUpperCase().trim(); - if (normalized.includes('DJIBOUTIAN') || normalized === 'DJIBOUTIAN') { - setValue('nationality', 'DJIBOUTIAN'); - } else if (normalized.includes('ETHIOPIAN') || normalized === 'ETHIOPIAN') { - setValue('nationality', 'ETHIOPIAN'); - } else { - setValue('nationality', 'OTHER'); - } + const n = user.nationality.toUpperCase().trim(); + setValue('nationality', n.includes('DJIBOUTIAN') ? 'DJIBOUTIAN' : n.includes('ETHIOPIAN') ? 'ETHIOPIAN' : 'OTHER'); } }, [isAuthenticated, user?.nationality, setValue]); useEffect(() => { - const origin = searchParams.get('origin'); - const destination = searchParams.get('destination'); + const o = searchParams.get('origin'); + const d = searchParams.get('destination'); const date = searchParams.get('date'); const adults = searchParams.get('adults'); const children = searchParams.get('children'); - const nationality = searchParams.get('nationality'); - - if (origin) setValue('originStationId', origin); - if (destination) setValue('destinationStationId', destination); + const nat = searchParams.get('nationality'); + if (o) setValue('originStationId', o); + if (d) setValue('destinationStationId', d); if (date) setValue('departureDate', date); if (adults) setValue('adultCount', parseInt(adults)); if (children) setValue('childCount', parseInt(children)); - if (nationality) setValue('nationality', nationality as 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER'); + if (nat) setValue('nationality', nat as 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER'); }, [searchParams, setValue]); + useEffect(() => { + const handler = (e: MouseEvent) => { + if (passengerRef.current && !passengerRef.current.contains(e.target as Node)) { + setIsPassengerOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + const originId = watch('originStationId'); + const destId = watch('destinationStationId'); const adultCount = watch('adultCount'); const childCount = watch('childCount'); + const departureDate = watch('departureDate'); + const totalPassengers = (adultCount || 1) + (childCount || 0); + + const saveRecent = useCallback((id: string) => { + setRecentStationIds((prev) => { + const next = [id, ...prev.filter((x) => x !== id)].slice(0, 5); + localStorage.setItem('edr_recent_stations', JSON.stringify(next)); + return next; + }); + }, []); + + const handleSwap = () => { + if (!originId || !destId) return; + setSwapping(true); + setTimeout(() => { + setValue('originStationId', destId); + setValue('destinationStationId', originId); + setSwapping(false); + }, 300); + }; const handleValidatePromo = async () => { - if (!promoCode.trim()) { - setPromoValidation(null); - return; - } - + if (!promoCode.trim()) return setPromoValidation(null); setPromoLoading(true); try { - const response = await apiClient.post('/promos/validate', { code: promoCode }) as any; - setPromoValidation({ - valid: response.applicable || response.valid, - message: response.message || (response.applicable ? 'Promo code applied successfully!' : 'Invalid promo code'), - discount: response.message, - }); - if (response.applicable || response.valid) { - setValue('promoCode', promoCode); - } else { - setPromoCode(''); - } + const res = await apiClient.post('/promos/validate', { code: promoCode }) as any; + const valid = res.applicable || res.valid; + setPromoValidation({ valid, message: res.message || (valid ? 'Promo applied!' : 'Invalid promo code') }); + if (valid) setValue('promoCode', promoCode); + else setPromoCode(''); } catch (err: any) { - setPromoValidation({ - valid: false, - message: err?.response?.data?.message || 'Promo code is invalid or expired', - }); + setPromoValidation({ valid: false, message: err?.response?.data?.message || 'Promo code is invalid or expired' }); setPromoCode(''); } finally { setPromoLoading(false); @@ -122,6 +488,8 @@ export default function SearchPage() { const onSubmit = (data: SearchForm) => { setSearchCriteria(data); + if (data.originStationId) saveRecent(data.originStationId); + if (data.destinationStationId) saveRecent(data.destinationStationId); const params = new URLSearchParams({ origin: data.originStationId, destination: data.destinationStationId, @@ -134,300 +502,380 @@ export default function SearchPage() { router.push(`/booking/results?${params}`); }; - const getStationByName = (name: string) => { - if (!stations) return null; - const exactMatch = stations.find(s => s.name.toLowerCase() === name.toLowerCase()); - if (exactMatch) return exactMatch; - return stations.find(s => s.name.toLowerCase().includes(name.toLowerCase())); - }; + const getStationById = (id: string) => stations.find((s) => s.id === id); + const originStation = getStationById(originId); + const destStation = getStationById(destId); const handlePopularRoute = (fromName: string, toName: string) => { - const origin = getStationByName(fromName); - const destination = getStationByName(toName); - - if (origin && destination) { + const origin = stations.find((s) => s.name.toLowerCase().includes(fromName.toLowerCase())); + const dest = stations.find((s) => s.name.toLowerCase().includes(toName.toLowerCase())); + if (origin && dest) { setValue('originStationId', origin.id); - setValue('destinationStationId', destination.id); + setValue('destinationStationId', dest.id); window.scrollTo({ top: 0, behavior: 'smooth' }); } }; - const popularRoutes = [ - { from: 'Sebeta', to: 'Nagad', duration: '12h' }, - { from: 'Sebeta', to: 'Diredawa', duration: '8h' }, - { from: 'Diredawa', to: 'Nagad', duration: '4h' }, - ]; - return ( -
- {/* Search Section */} -
+
+ {/* Passenger modal (mobile) */} + {passengerModalOpen && ( + setValue('adultCount', n)} + onChangeChild={(n) => setValue('childCount', n)} + onClose={() => setPassengerModalOpen(false)} + /> + )} + + {/* Station modals (mobile) */} + {stationModal === 'origin' && ( + { + if (s.id) { setValue('originStationId', s.id); saveRecent(s.id); } + setStationModal(null); + }} + onClose={() => setStationModal(null)} + /> + )} + {stationModal === 'destination' && ( + { + if (s.id) { setValue('destinationStationId', s.id); saveRecent(s.id); } + setStationModal(null); + }} + onClose={() => setStationModal(null)} + /> + )} + + {/* Hero Banner */} +
+
+
+
+
+
+
+
+

+ Where are you headed? +

+

Search and book train tickets fast & easy

+
+
+
+ + {/* Search Card — pulled up over the hero */} +
- {/* Search Card */} -
- {/* Header inside card */} -
-

- Start booking -

-

- Search for available trains and book your journey -

-
- {error && ( -
-
⚠️
-
-

Connection Error

-

Unable to load stations. Please check your connection and try again.

-
-
- )} - -
- {/* First Row: From, To, Date */} -
- {/* From */} -
- -
- - -
- {errors.originStationId && ( -

{errors.originStationId.message}

- )} + +
+ + {/* Error banner */} + {error && ( +
+ ⚠️ + Unable to load stations. Please check your connection.
+ )} - {/* To */} -
- -
- - -
- {errors.destinationStationId && ( -

{errors.destinationStationId.message}

- )} -
+
- {/* Date */} -
- - { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - setValue('departureDate', `${year}-${month}-${day}`); - }} - minDate={new Date()} - placeholder="Select date" - /> - {errors.departureDate && ( -

{errors.departureDate.message}

- )} -
-
+ {/* ── Row 1 (mobile stacked): Stations + Date ── */} - {/* Second Row: Passengers, Nationality */} -
- {/* Passengers Dropdown */} -
- - - - {/* Passenger Dropdown Menu */} - {isPassengerOpen && ( - <> -
setIsPassengerOpen(false)} /> -
- {/* Adults */} -
-
-
-
Adults
-
≥5 years
-
-
- - {adultCount || 1} - -
-
-
- - {/* Children */} -
-
-
-
Children
-
<5 years • First free
-
-
- - {childCount || 0} - -
-
-
+ {/* Mobile station fields */} +
+
+ +
- - {/* Nationality */} -
- - -
- - {/* Promo Code with Validation */} -
- -
-
- - { - setPromoCode(e.target.value.toUpperCase()); - if (promoValidation) setPromoValidation(null); - }} - placeholder="Enter code" - className="w-full pl-10 pr-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400" - onKeyPress={(e) => e.key === 'Enter' && handleValidatePromo()} + + {errors.originStationId &&

{errors.originStationId.message}

} +
+
+
+ + +
+ + {errors.destinationStationId &&

{errors.destinationStationId.message}

} +
+ {/* Date (mobile) */} +
+ +
+ setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} + minDate={new Date()} + placeholder="Select date" />
+ {errors.departureDate &&

{errors.departureDate.message}

} +
+
+ + {/* Desktop Row 1: From [swap] To + Date (3 equal cols) */} +
+ {/* From + To with swap */} +
+
+ + { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} /> + {errors.originStationId &&

{errors.originStationId.message}

} +
+
+ + { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} /> + {errors.destinationStationId &&

{errors.destinationStationId.message}

} +
- {promoValidation && ( -
- {promoValidation.valid && } - {promoValidation.message} + {/* Date */} +
+ +
+ setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} + minDate={new Date()} + placeholder="Select date" + /> +
+ {errors.departureDate &&

{errors.departureDate.message}

} +
+
+ + {/* Route preview pill */} + {originStation && destStation && ( +
+ + {originStation.name} + + {destStation.name} +
+ )} + + {/* ── Row 2: Passengers | Nationality | Search Button ── */} +
+ + {/* Passengers */} +
+ + + {/* Mobile: opens bottom-sheet modal */} + + + {/* sm+: inline dropdown */} +
+ + {isPassengerOpen && ( +
+ {[ + { label: 'Adults', sub: '≥ 5 years', key: 'adultCount' as const, val: adultCount || 1, min: 1, max: 9 }, + { label: 'Children', sub: '< 5 years • First free', key: 'childCount' as const, val: childCount || 0, min: 0, max: 9 }, + ].map(({ label, sub, key, val, min, max }, i) => ( +
+ {i > 0 &&
} +
+
+

{label}

+

{sub}

+
+
+ + {val} + +
+
+
+ ))} + +
+ )} +
+
+ + {/* Nationality */} +
+ + +
+ + {/* Search Button */} + +
+ + {/* ── Promo Code (collapsed by default) ── */} +
+ {!promoVisible ? ( + + ) : ( +
+
+
+ + { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }} + placeholder="Enter promo code" + onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())} + className="w-full pl-9 pr-3 py-2.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-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 transition-all" + autoFocus + /> +
+ + +
+ {promoValidation && ( +
+ {promoValidation.valid && } + {promoValidation.message} +
+ )}
)}
-
- {/* Third Row: Search Button */} -
-
- -
+
+ - {/* Popular Routes */} -
-

Popular Routes

-
- {popularRoutes.map((route, idx) => ( + {/* ── Popular Routes ── */} +
+
+ +

Popular Routes

+
+
+ {POPULAR_ROUTES.map((route, idx) => ( ))}
-
+ +
); } diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index adeae8102..fc165c4f4 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -224,6 +224,7 @@ export default function SeatsPage() { if (bookingId && selectedSeats.length > 0) { bookSeatsMutation.mutate(selectedSeats); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [bookingId]); const parseSeatArrangement = (arrangement: string | null): number[] => { @@ -394,6 +395,72 @@ export default function SeatsPage() { if (!selectedSchedule || !passengers.length) return null; + const allSelected = selectedSeats.length === passengers.length; + const isBedCoach = selectedCoachData?.seatClass?.toLowerCase().includes('bed') || selectedCoachData?.mode?.toLowerCase().includes('bed'); + + // Summary card content — shared between sidebar and mobile modal + const SummaryContent = () => ( + <> +
+

Selection Summary

+ + {selectedSeats.length}/{passengers.length} selected + +
+

+ {allSelected ? 'All seats selected — ready to continue' : `Select ${passengers.length - selectedSeats.length} more seat(s)`} +

+ + {/* Progress bar */} +
+
+
+ +
+ {passengers.map((p, i) => { + const assignedSeat = selectedSeats[i] ? validSeats?.find((s: any) => s.id === selectedSeats[i]) : null; + const seatLabel = assignedSeat ? (assignedSeat.number || assignedSeat.label || assignedSeat.seatNumber || '—') : '—'; + const bedLabel = assignedSeat ? getBedLabel(assignedSeat.bedPosition) : ''; + return ( +
+
+
{i + 1}
+ {p.name} +
+ + {assignedSeat ? `${seatLabel}${bedLabel}` : 'Not selected'} + +
+ ); + })} +
+ + + + + ); + return ( <> -
-
-
-
+ + {/* Mobile summary bottom-sheet */} + {selectedSeats.length > 0 && ( + <> +
+
+ +
+ + + )} + +
+ {/* Header */} +
+
+
+

Select Seats

+
+ {selectedSeats.length}/{passengers.length} +
- -

Select seats

+
+
-
-
+
+
+
+ + {/* ── Seat map panel ── */} +
{isLoading ? ( -
-
-

Loading seats...

-
+
+
+

Loading seat map...

) : error ? ( -
-
-

Error loading seats

-

{error?.message || 'Please try again'}

-
+
+

Error loading seats

+

{(error as any)?.message || 'Please try again'}

) : filteredCoaches.length === 0 ? ( -
-
-

No coaches available for {selectedSchedule?.selectedSeatClass}

-

Please select a different seat class

-
+
+

No coaches available for {selectedSchedule?.selectedSeatClass}

) : ( -
-
-

Select coach

-
- {filteredCoaches?.map((coach: any) => { + <> + {/* Coach selector */} +
+

Coach

+
+ {filteredCoaches.map((coach: any) => { const coachSeats = coach.seats?.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')) || []; - const isBedCoach = coach.seatClass?.toLowerCase().includes('bed') || coach.mode?.toLowerCase().includes('bed'); - let filteredSeats = coachSeats; - if (isBedCoach && selectedSchedule?.selectedSeatClass) { + const isBed = coach.seatClass?.toLowerCase().includes('bed') || coach.mode?.toLowerCase().includes('bed'); + let fSeats = coachSeats; + if (isBed && selectedSchedule?.selectedSeatClass) { const bedPos = getBedPosition(selectedSchedule.selectedSeatClass); - if (bedPos) { - filteredSeats = coachSeats.filter((s: any) => s.bedPosition === bedPos); - } + if (bedPos) fSeats = coachSeats.filter((s: any) => s.bedPosition === bedPos); } - const availableCount = filteredSeats.filter((s: any) => s.status === 'AVAILABLE').length || 0; - const seatClassName = selectedSchedule?.selectedSeatClass || (typeof coach.seatClass === 'string' ? coach.seatClass : (coach.seatClass?.name || coach.coachClass || '')); + const available = fSeats.filter((s: any) => s.status === 'AVAILABLE').length; + const isActive = selectedCoach === coach.id; return ( ); })}
-
-

- Seat map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber} -

- {selectedCoachData && ( -

- Arrangement: {selectedCoachData.seatArrangement} • Total: {selectedCoachData.totalSeats} seats -

- )} - -
-
-
- Available -
-
-
- Selected -
-
-
- Held -
-
-
- Booked -
+ {/* Seat map */} +
+
+

+ {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber} +

+ {selectedCoachData?.seatArrangement}
-
- {validSeats.length === 0 ? ( -
-

No seats available in this coach

-

Please select a different coach

+ {/* Legend */} +
+ {[ + { color: 'bg-green-500', label: 'Available' }, + { color: 'bg-[rgb(20,113,76)]', label: 'Selected' }, + { color: 'bg-yellow-500', label: 'Held' }, + { color: 'bg-gray-400', label: 'Booked' }, + ].map(({ color, label }) => ( +
+
+ {label}
- ) : ( - renderCoachSeats(selectedCoachData, (selectedCoachData.seatClass?.toLowerCase().includes('bed') || selectedCoachData.mode?.toLowerCase().includes('bed'))) - )} + ))} +
+ +
+
+ {validSeats.length === 0 ? ( +

No seats in this coach

+ ) : ( + renderCoachSeats(selectedCoachData, isBedCoach) + )} +
-
+ )}
-
-
-

Selection summary

-

- Select {passengers.length} seat(s) for your passengers -

-

- {selectedSeats.length} / {passengers.length} selected -

- -
- {passengers.map((p, i) => { - const assignedSeat = selectedSeats[i] ? validSeats?.find((s: any) => s.id === selectedSeats[i]) : null; - const seatLabel = assignedSeat ? (assignedSeat.number || assignedSeat.label || assignedSeat.seatNumber || '-') : '-'; - const bedLabel = assignedSeat ? getBedLabel(assignedSeat.bedPosition) : ''; - return ( -
- {p.name} - - {seatLabel}{bedLabel} - -
- ); - })} -
- - - + {/* ── Sidebar summary (desktop only) ── */} +
+
+
+
+ + {/* Mobile spacer so bottom-sheet doesn't cover last seat */} +
diff --git a/apps/edr-passenger-web/portal/src/app/globals.css b/apps/edr-passenger-web/portal/src/app/globals.css index 640db836b..cf9ebe719 100644 --- a/apps/edr-passenger-web/portal/src/app/globals.css +++ b/apps/edr-passenger-web/portal/src/app/globals.css @@ -157,4 +157,22 @@ .animate-slide-in-right { animation: slide-in-right 0.5s ease-out; } + + @keyframes slide-up { + from { transform: translateY(100%); opacity: 0; } + to { transform: translateY(0); opacity: 1; } + } + + .animate-slide-up { + animation: slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1); + } + + .scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; + } + + .scrollbar-hide::-webkit-scrollbar { + display: none; + } } diff --git a/apps/edr-passenger-web/portal/src/app/login/page.tsx b/apps/edr-passenger-web/portal/src/app/login/page.tsx index 77cd2fcad..86ac4dcce 100644 --- a/apps/edr-passenger-web/portal/src/app/login/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/login/page.tsx @@ -23,7 +23,7 @@ function LoginContent() { const [loading, setLoading] = useState(false); const { register, handleSubmit, formState: { errors } } = useForm({ - resolver: zodResolver(loginSchema), + resolver: zodResolver(loginSchema as any), }); const onSubmit = async (data: LoginForm) => { diff --git a/apps/edr-passenger-web/portal/src/components/Footer.tsx b/apps/edr-passenger-web/portal/src/components/Footer.tsx index 00784682f..8d1aa1394 100644 --- a/apps/edr-passenger-web/portal/src/components/Footer.tsx +++ b/apps/edr-passenger-web/portal/src/components/Footer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Facebook, Twitter, Instagram, Linkedin, Mail, Phone, MapPin } from 'lucide-react'; +import { Mail, Phone, MapPin } from 'lucide-react'; import Link from 'next/link'; import { useLanguage, getTranslation, Language } from '@/lib/i18n'; import { useEffect, useState } from 'react'; @@ -102,41 +102,17 @@ export function Footer() {

{t('footer.follow')}

diff --git a/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx b/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx index 4d9c90255..5ea145bba 100644 --- a/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx +++ b/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx @@ -28,16 +28,23 @@ export default function ModernDatePicker({ placeholder = 'Select date', }: ModernDatePickerProps) { const [isOpen, setIsOpen] = useState(false); + const [isMobileView, setIsMobileView] = useState(false); const [calendarType, setCalendarType] = useState<'gregorian' | 'ethiopian'>('gregorian'); - const [viewMonth, setViewMonth] = useState(value?.getMonth() || new Date().getMonth()); - const [viewYear, setViewYear] = useState(value?.getFullYear() || new Date().getFullYear()); - - // Initialize Ethiopian calendar with current date + const [viewMonth, setViewMonth] = useState(value?.getMonth() ?? new Date().getMonth()); + const [viewYear, setViewYear] = useState(value?.getFullYear() ?? new Date().getFullYear()); + const initialEthDate = gregorianToEthiopian(value || new Date()); const [ethViewMonth, setEthViewMonth] = useState(initialEthDate.month); const [ethViewYear, setEthViewYear] = useState(initialEthDate.year); const containerRef = useRef(null); + useEffect(() => { + const check = () => setIsMobileView(window.innerWidth < 768); + check(); + window.addEventListener('resize', check); + return () => window.removeEventListener('resize', check); + }, []); + useEffect(() => { if (value) { setViewMonth(value.getMonth()); @@ -48,39 +55,27 @@ export default function ModernDatePicker({ } }, [value]); + // Lock body scroll when modal is open (both mobile and desktop modal) useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setIsOpen(false); - } - }; - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; + return () => { document.body.style.overflow = ''; }; }, [isOpen]); const toggleCalendarType = () => { const newType = calendarType === 'gregorian' ? 'ethiopian' : 'gregorian'; - + const ref = value || new Date(); if (newType === 'ethiopian') { - // When switching to Ethiopian, show the Ethiopian equivalent of current Gregorian view - // Use today's date if no value is selected, otherwise use the selected value - const referenceDate = value || new Date(); - const ethDate = gregorianToEthiopian(referenceDate); + const ethDate = gregorianToEthiopian(ref); setEthViewMonth(ethDate.month); setEthViewYear(ethDate.year); } else { - // When switching to Gregorian, show the Gregorian equivalent of current Ethiopian view - const referenceDate = value || new Date(); - setViewMonth(referenceDate.getMonth()); - setViewYear(referenceDate.getFullYear()); + setViewMonth(ref.getMonth()); + setViewYear(ref.getFullYear()); } - setCalendarType(newType); }; @@ -90,102 +85,46 @@ export default function ModernDatePicker({ }; const handleEthiopianDateSelect = (ethDate: EthiopianDate) => { - const gregDate = ethiopianToGregorian(ethDate); - handleDateSelect(gregDate); + handleDateSelect(ethiopianToGregorian(ethDate)); }; const renderGregorianCalendar = () => { const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); - const firstDayOfMonth = new Date(viewYear, viewMonth, 1).getDay(); - const days: (number | null)[] = []; - - for (let i = 0; i < firstDayOfMonth; i++) { - days.push(null); - } - - for (let day = 1; day <= daysInMonth; day++) { - days.push(day); - } - - const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + const firstDay = new Date(viewYear, viewMonth, 1).getDay(); + const days: (number | null)[] = Array(firstDay).fill(null); + for (let d = 1; d <= daysInMonth; d++) days.push(d); + const monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December']; return (
- -
- {monthNames[viewMonth]} {viewYear} -
-
-
- {['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(day => ( -
- {day} -
+ {['Su','Mo','Tu','We','Th','Fr','Sa'].map(d => ( +
{d}
))}
-
- {days.map((day, index) => { - if (day === null) { - return
; - } - + {days.map((day, i) => { + if (day === null) return
; const date = new Date(viewYear, viewMonth, day); - const isSelected = value && - date.getDate() === value.getDate() && - date.getMonth() === value.getMonth() && - date.getFullYear() === value.getFullYear(); - const isToday = - date.getDate() === new Date().getDate() && - date.getMonth() === new Date().getMonth() && - date.getFullYear() === new Date().getFullYear(); - const isDisabled = - (minDate && date < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) || - (maxDate && date > new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate())); - + const isSelected = value && date.getDate() === value.getDate() && date.getMonth() === value.getMonth() && date.getFullYear() === value.getFullYear(); + const isToday = date.toDateString() === new Date().toDateString(); + const isDisabled = (minDate && date < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) || (maxDate && date > new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate())); return ( - ); @@ -197,133 +136,54 @@ export default function ModernDatePicker({ const renderEthiopianCalendar = () => { const daysInMonth = getDaysInEthiopianMonth(ethViewYear, ethViewMonth); - const days: number[] = []; - - for (let day = 1; day <= daysInMonth; day++) { - days.push(day); - } - const firstDate = ethiopianToGregorian({ year: ethViewYear, month: ethViewMonth, day: 1 }); const firstDayOfWeek = firstDate.getDay(); - - const daysWithOffset: (number | null)[] = []; - for (let i = 0; i < firstDayOfWeek; i++) { - daysWithOffset.push(null); - } - daysWithOffset.push(...days); - - // Get month name safely + const daysWithOffset: (number | null)[] = Array(firstDayOfWeek).fill(null); + for (let d = 1; d <= daysInMonth; d++) daysWithOffset.push(d); const monthName = ETHIOPIAN_MONTHS[ethViewMonth - 1] || `Month ${ethViewMonth}`; return (
- -
- {monthName} {ethViewYear} -
-
-
- {['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(day => ( -
- {day} -
+ {['Su','Mo','Tu','We','Th','Fr','Sa'].map(d => ( +
{d}
))}
-
- {daysWithOffset.map((day, index) => { - if (day === null) { - return
; - } - + {daysWithOffset.map((day, i) => { + if (day === null) return
; const ethDate: EthiopianDate = { year: ethViewYear, month: ethViewMonth, day }; const gregDate = ethiopianToGregorian(ethDate); - - const isSelected = value && - gregDate.getDate() === value.getDate() && - gregDate.getMonth() === value.getMonth() && - gregDate.getFullYear() === value.getFullYear(); - - const today = new Date(); - const todayEth = gregorianToEthiopian(today); - const isToday = - ethDate.day === todayEth.day && - ethDate.month === todayEth.month && - ethDate.year === todayEth.year; - + const isSelected = value && gregDate.getDate() === value.getDate() && gregDate.getMonth() === value.getMonth() && gregDate.getFullYear() === value.getFullYear(); + const todayEth = gregorianToEthiopian(new Date()); + const isToday = ethDate.day === todayEth.day && ethDate.month === todayEth.month && ethDate.year === todayEth.year; let isDisabled = false; if (minDate) { - const minYear = minDate.getFullYear(); - const minMonth = minDate.getMonth(); - const minDay = minDate.getDate(); - const gregYear = gregDate.getFullYear(); - const gregMonth = gregDate.getMonth(); - const gregDay = gregDate.getDate(); - - if (gregYear < minYear || - (gregYear === minYear && gregMonth < minMonth) || - (gregYear === minYear && gregMonth === minMonth && gregDay < minDay)) { - isDisabled = true; - } + const [mY, mM, mD] = [minDate.getFullYear(), minDate.getMonth(), minDate.getDate()]; + const [gY, gM, gD] = [gregDate.getFullYear(), gregDate.getMonth(), gregDate.getDate()]; + if (gY < mY || (gY === mY && gM < mM) || (gY === mY && gM === mM && gD < mD)) isDisabled = true; } if (maxDate && !isDisabled) { - const maxYear = maxDate.getFullYear(); - const maxMonth = maxDate.getMonth(); - const maxDay = maxDate.getDate(); - const gregYear = gregDate.getFullYear(); - const gregMonth = gregDate.getMonth(); - const gregDay = gregDate.getDate(); - - if (gregYear > maxYear || - (gregYear === maxYear && gregMonth > maxMonth) || - (gregYear === maxYear && gregMonth === maxMonth && gregDay > maxDay)) { - isDisabled = true; - } + const [mY, mM, mD] = [maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate()]; + const [gY, gM, gD] = [gregDate.getFullYear(), gregDate.getMonth(), gregDate.getDate()]; + if (gY > mY || (gY === mY && gM > mM) || (gY === mY && gM === mM && gD > mD)) isDisabled = true; } - return ( - ); @@ -333,62 +193,128 @@ export default function ModernDatePicker({ ); }; + const calendarFooter = value && ( +
+
+ Gregorian: + {format(value, 'MMMM d, yyyy')} +
+
+ Ethiopian: + {formatEthiopianDate(gregorianToEthiopian(value))} +
+
+ ); + + // Shared modal content (used by both mobile and desktop) + const modalContent = ( +
+ {/* Header row: title + close */} +
+
+ +

Select Date

+
+ +
+ + {/* Toggle row: always full-width, clearly visible */} +
+
+ + +
+
+ + {/* Calendar body */} +
+ {calendarType === 'gregorian' ? renderGregorianCalendar() : renderEthiopianCalendar()} + {calendarFooter} +
+
+ ); + return (
+ {/* Trigger button */} {isOpen && ( -
-
-
- - - {calendarType === 'gregorian' ? 'Gregorian Calendar' : 'Ethiopian Calendar'} - -
-
- - -
-
+ <> - {calendarType === 'gregorian' ? renderGregorianCalendar() : renderEthiopianCalendar()} - - {value && ( -
-
- Gregorian: - {format(value, 'MMMM d, yyyy')} -
-
- Ethiopian: - {formatEthiopianDate(gregorianToEthiopian(value))} -
+ {/* Mobile: full-screen takeover */} + {isMobileView ? ( +
+ {modalContent}
+ ) : ( + /* Desktop: centred modal with backdrop */ + <> +
setIsOpen(false)} /> +
+
+ {modalContent} +
+
+ )} -
+ + + )}
); diff --git a/apps/edr-passenger-web/portal/src/components/ProgressIndicator.tsx b/apps/edr-passenger-web/portal/src/components/ProgressIndicator.tsx index a9a1f4253..9d0215c3a 100644 --- a/apps/edr-passenger-web/portal/src/components/ProgressIndicator.tsx +++ b/apps/edr-passenger-web/portal/src/components/ProgressIndicator.tsx @@ -1,87 +1,93 @@ 'use client'; import { Check } from 'lucide-react'; +import { useEffect, useRef } from 'react'; -interface Step { - id: string; - name: string; - href: string; -} - -const steps: Step[] = [ - { id: 'search', name: 'Search', href: '/booking/search' }, - { id: 'results', name: 'Results', href: '/booking/results' }, - { id: 'passengers', name: 'Passengers', href: '/booking/passengers' }, - { id: 'seats', name: 'Seats', href: '/booking/seats' }, - { id: 'review', name: 'Review', href: '/booking/review' }, - { id: 'payment', name: 'Payment', href: '/booking/payment' }, - { id: 'confirmation', name: 'Confirmation', href: '/booking/confirmation' }, +const steps = [ + { id: 'search', name: 'Search' }, + { id: 'results', name: 'Results' }, + { id: 'passengers', name: 'Passengers' }, + { id: 'seats', name: 'Seats' }, + { id: 'review', name: 'Review' }, + { id: 'payment', name: 'Payment' }, + { id: 'confirmation', name: 'Done' }, ]; -interface ProgressIndicatorProps { - currentStep: string; -} - -export function ProgressIndicator({ currentStep }: ProgressIndicatorProps) { +export function ProgressIndicator({ currentStep }: { currentStep: string }) { const currentIndex = steps.findIndex((s) => s.id === currentStep); + const scrollRef = useRef(null); + const activeRef = useRef(null); + + // Auto-scroll active step to centre on mobile + useEffect(() => { + const container = scrollRef.current; + const active = activeRef.current; + if (!container || !active) return; + const offset = active.offsetLeft + active.offsetWidth / 2 - container.clientWidth / 2; + container.scrollTo({ left: offset, behavior: 'smooth' }); + }, [currentIndex]); + + const stepItem = (step: typeof steps[0], index: number) => { + const isComplete = index < currentIndex; + const isCurrent = index === currentIndex; + + return ( +
  • + {/* Line + Circle row */} +
    + {/* Left connector */} +
    + + {/* Circle */} +
    + {isComplete + ? + : {index + 1} + } +
    + + {/* Right connector */} +
    +
    + + {/* Label */} +

    + {step.name} +

    +
  • + ); + }; return ( - ); } diff --git a/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx b/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx index 5967fe331..180393aab 100644 --- a/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx +++ b/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx @@ -42,7 +42,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps) }); const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm({ - resolver: zodResolver(searchSchema), + resolver: zodResolver(searchSchema as any), defaultValues: { adultCount: 1, childCount: 0, diff --git a/apps/edr-passenger-web/portal/src/lib/booking-store.ts b/apps/edr-passenger-web/portal/src/lib/booking-store.ts index 6fdaaa53c..32f836ac3 100644 --- a/apps/edr-passenger-web/portal/src/lib/booking-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/booking-store.ts @@ -8,6 +8,7 @@ export interface SearchCriteria { adultCount: number; childCount: number; nationality: 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER'; + promoCode?: string; } export interface PassengerDetail {