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 ? (
+
+
+ {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() {
)}
-