Merge pull request #133 from Tria-plc/alpha

This commit is contained in:
Eyob T.
2026-06-10 19:14:08 +03:00
committed by Eyob
14 changed files with 1779 additions and 1000 deletions

File diff suppressed because one or more lines are too long

View File

@@ -28,9 +28,7 @@ export default function BookingLayout({
<div> <div>
{showProgress && ( {showProgress && (
<div className="bg-white dark:bg-gray-800 border-b dark:border-gray-700"> <div className="bg-white dark:bg-gray-800 border-b dark:border-gray-700">
<div className="container mx-auto px-4 py-4"> <ProgressIndicator currentStep={currentStep} />
<ProgressIndicator currentStep={currentStep} />
</div>
</div> </div>
)} )}
{children} {children}

View File

@@ -7,16 +7,338 @@ import { useRouter } from 'next/navigation';
import { useBookingStore } from '@/lib/booking-store'; import { useBookingStore } from '@/lib/booking-store';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { CheckCircle, ExternalLink, Loader2 } from 'lucide-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<HTMLDivElement>(null);
const monthRef = useRef<HTMLDivElement>(null);
const yearRef = useRef<HTMLDivElement>(null);
const ITEM_H = 48;
const scrollTo = (ref: React.RefObject<HTMLDivElement>, 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<HTMLDivElement>,
setter: (v: number) => void,
getList: () => number[],
) => {
let timer: ReturnType<typeof setTimeout>;
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<HTMLDivElement>,
list: Array<{ label: string; value: number }>,
selected: number,
setter: (v: number) => void,
scrollHandler: () => void,
) => (
<div className="flex-1 flex flex-col items-center">
<div
ref={ref}
onScroll={scrollHandler}
className="h-full overflow-y-auto scrollbar-hide"
style={{ scrollSnapType: 'y mandatory' }}
>
<div style={{ height: ITEM_H * 2 }} />
{list.map((item, idx) => (
<div
key={item.value}
style={{ height: ITEM_H, scrollSnapAlign: 'center', cursor: 'pointer' }}
onClick={() => { 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}
</div>
))}
<div style={{ height: ITEM_H * 2 }} />
</div>
</div>
);
return (
<div>
<button
type="button"
onClick={() => setOpen(true)}
className={`input-field w-full text-left flex items-center justify-between ${
error ? 'border-red-500' : ''
}`}
>
<span className={displayValue ? 'text-gray-900 dark:text-white text-sm' : 'text-gray-400 text-sm'}>
{displayValue || 'Select date of birth'}
</span>
<CalendarDays className="w-4 h-4 text-gray-400 flex-shrink-0" />
</button>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{open && (
<>
<div className="fixed inset-0 z-[99] bg-black/50" onClick={() => setOpen(false)} />
<div
className="fixed inset-x-0 bottom-0 z-[100] bg-white dark:bg-gray-900 rounded-t-2xl shadow-2xl"
style={{ animation: 'dob-slide-up 0.25s cubic-bezier(0.32,0.72,0,1)' }}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-gray-800">
<div className="flex items-center gap-3">
<h2 className="text-base font-bold text-gray-900 dark:text-white">Date of Birth</h2>
{!manualMode && (
<button
type="button"
onClick={() => setCalType(c => c === 'gregorian' ? 'ethiopian' : 'gregorian')}
className="flex items-center gap-1 text-xs font-semibold px-2.5 py-1 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
>
<Globe className="w-3 h-3" />
{calType === 'gregorian' ? 'ET' : 'GC'}
</button>
)}
<button
type="button"
onClick={() => setManualMode(!manualMode)}
className="text-xs text-gray-500 font-medium hover:underline"
>
{manualMode ? 'Use scroll' : 'Enter manually'}
</button>
</div>
<button type="button" onClick={() => setOpen(false)} className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800">
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* Calendar type label */}
{!manualMode && (
<div className="px-5 pt-2 pb-0">
<p className="text-xs text-gray-400">
{calType === 'gregorian' ? 'Gregorian Calendar' : 'Ethiopian Calendar (ኢትዮጵያ)'}
</p>
</div>
)}
{manualMode ? (
<div className="px-5 py-5 space-y-4">
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Day</label>
<input type="number" min={1} max={31} value={manDay} onChange={(e) => setManDay(e.target.value)} placeholder="DD" className="input-field text-center text-lg font-semibold" />
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Month</label>
<input type="number" min={1} max={12} value={manMonth} onChange={(e) => setManMonth(e.target.value)} placeholder="MM" className="input-field text-center text-lg font-semibold" />
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Year</label>
<input type="number" min={currentYear - 110} max={currentYear} value={manYear} onChange={(e) => setManYear(e.target.value)} placeholder="YYYY" className="input-field text-center text-lg font-semibold" />
</div>
</div>
{manDay && manMonth && manYear && !manualValid && (
<p className="text-red-500 text-xs">Please enter a valid Gregorian date</p>
)}
</div>
) : (
<>
<div className="flex px-5 pt-2 pb-1">
{['Day', 'Month', 'Year'].map(l => (
<div key={l} className="flex-1 text-center text-xs font-semibold text-gray-400 uppercase tracking-wide">{l}</div>
))}
</div>
<div className="relative px-5 pb-2" style={{ height: ITEM_H * 5 }}>
<div
className="absolute left-5 right-5 pointer-events-none rounded-xl border border-primary/30 bg-primary/5 dark:bg-primary/10"
style={{ top: ITEM_H * 2, height: ITEM_H }}
/>
<div className="flex gap-2 h-full">
{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)}
</>
)}
</div>
</div>
</>
)}
<div className="px-5 pb-8 pt-3 flex justify-end">
<button
type="button"
onClick={manualMode ? confirmManual : confirm}
disabled={manualMode && !manualValid}
className="px-6 py-2.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow disabled:opacity-40 disabled:cursor-not-allowed"
>
{confirmLabel}
</button>
</div>
</div>
<style>{`@keyframes dob-slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}`}</style>
</>
)}
</div>
);
}
const passengerSchema = z.object({ 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'), 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'), nationality: z.string().min(1, 'Nationality is required'),
phone: z.string().optional(), phone: z.string().min(1, 'Phone number is required'),
email: z.string().email('Invalid email').optional().or(z.literal('')), email: z.string().optional(),
nationalId: z.string().optional(), nationalId: z.string().optional(),
passportNumber: z.string().optional(), passportNumber: z.string().optional(),
passportCountry: z.string().optional(), passportCountry: z.string().optional(),
@@ -26,15 +348,25 @@ const passengerSchema = z.object({
faydaVerified: z.boolean().optional(), faydaVerified: z.boolean().optional(),
faydaSub: z.string().optional(), faydaSub: z.string().optional(),
formExpanded: z.boolean().optional(), formExpanded: z.boolean().optional(),
}).refine((data) => { }).superRefine((data, ctx) => {
if (data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian') { if (data.gender !== 'Male' && data.gender !== 'Female') {
return data.passportNumber && data.passportNumber.length > 0 && ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Gender is required', path: ['gender'] });
data.passportCountry && data.passportCountry.length > 0; }
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({ const formSchema = z.object({
@@ -57,7 +389,8 @@ export default function PassengersPage() {
const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0); const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0);
const { register, control, handleSubmit, setValue, watch, formState: { errors } } = useForm<FormData>({ const { register, control, handleSubmit, setValue, watch, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema as any),
mode: 'onChange',
defaultValues: { defaultValues: {
passengers: Array.from({ length: totalPassengers }, () => ({ passengers: Array.from({ length: totalPassengers }, () => ({
name: '', name: '',
@@ -418,82 +751,80 @@ export default function PassengersPage() {
)} )}
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
{/* Full Name */}
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
<input <input
{...register(`passengers.${index}.name`)} {...register(`passengers.${index}.name`)}
className="input-field" className={`input-field ${errors.passengers?.[index]?.name ? 'border-red-500' : ''}`}
placeholder="Full name as per ID" placeholder="Full name as per ID"
value={passengers[index]?.name || ''}
onChange={(e) => setValue(`passengers.${index}.name`, e.target.value)}
/> />
{errors.passengers?.[index]?.name && ( {errors.passengers?.[index]?.name && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.name?.message}</p> <p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.name?.message}</p>
)} )}
</div> </div>
{/* Date of Birth */}
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
<input <DobPickerModal
type="date"
{...register(`passengers.${index}.dateOfBirth`)}
className="input-field"
value={passengers[index]?.dateOfBirth || ''} value={passengers[index]?.dateOfBirth || ''}
onChange={(e) => 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 && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.dateOfBirth?.message}</p>
)}
</div> </div>
{/* Gender */}
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
<select <select
{...register(`passengers.${index}.gender`)} {...register(`passengers.${index}.gender`)}
className="input-field" className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`}
value={passengers[index]?.gender || ''}
onChange={(e) => setValue(`passengers.${index}.gender`, e.target.value as any)}
> >
<option value="">Select gender</option> <option value="">Select gender</option>
<option value="Male">Male</option> <option value="Male">Male</option>
<option value="Female">Female</option> <option value="Female">Female</option>
</select> </select>
{errors.passengers?.[index]?.gender && (
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.gender?.message}</p>
)}
</div> </div>
{/* Nationality (read-only) */}
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality *</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality</label>
<input <input
{...register(`passengers.${index}.nationality`)} {...register(`passengers.${index}.nationality`)}
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed" className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
readOnly readOnly
disabled disabled
value={passengers[index]?.nationality || ''}
/> />
</div> </div>
{/* Phone */}
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
<input <input
{...register(`passengers.${index}.phone`)} {...register(`passengers.${index}.phone`)}
className="input-field" className={`input-field ${errors.passengers?.[index]?.phone ? 'border-red-500' : ''}`}
placeholder="+251911234567" placeholder="+251911234567"
value={passengers[index]?.phone || ''}
onChange={(e) => setValue(`passengers.${index}.phone`, e.target.value)}
/> />
{errors.passengers?.[index]?.phone && (
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.phone?.message}</p>
)}
</div> </div>
{/* Email */}
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
<input <input
type="email" type="email"
{...register(`passengers.${index}.email`)} {...register(`passengers.${index}.email`)}
className="input-field" className={`input-field ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
placeholder="email@example.com" placeholder="email@example.com"
value={passengers[index]?.email || ''}
onChange={(e) => setValue(`passengers.${index}.email`, e.target.value)}
/> />
{errors.passengers?.[index]?.email && ( {errors.passengers?.[index]?.email && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.email?.message}</p> <p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.email?.message}</p>
)} )}
</div> </div>
</div> </div>
@@ -501,113 +832,109 @@ export default function PassengersPage() {
) : ( ) : (
<> <>
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
{/* Full Name */}
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
<input <input
{...register(`passengers.${index}.name`)} {...register(`passengers.${index}.name`)}
className="input-field" className={`input-field ${errors.passengers?.[index]?.name ? 'border-red-500' : ''}`}
placeholder="Full name as per passport" placeholder="Full name as per passport"
value={passengers[index]?.name || ''}
onChange={(e) => setValue(`passengers.${index}.name`, e.target.value)}
/> />
{errors.passengers?.[index]?.name && ( {errors.passengers?.[index]?.name && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.name?.message}</p> <p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.name?.message}</p>
)} )}
</div> </div>
{/* Date of Birth */}
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
<input <DobPickerModal
type="date"
{...register(`passengers.${index}.dateOfBirth`)}
className="input-field"
value={passengers[index]?.dateOfBirth || ''} value={passengers[index]?.dateOfBirth || ''}
onChange={(e) => 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 && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.dateOfBirth?.message}</p>
)}
</div> </div>
{/* Gender */}
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
<select <select
{...register(`passengers.${index}.gender`)} {...register(`passengers.${index}.gender`)}
className="input-field" className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`}
value={passengers[index]?.gender || ''}
onChange={(e) => setValue(`passengers.${index}.gender`, e.target.value as any)}
> >
<option value="">Select gender</option> <option value="">Select gender</option>
<option value="Male">Male</option> <option value="Male">Male</option>
<option value="Female">Female</option> <option value="Female">Female</option>
</select> </select>
{errors.passengers?.[index]?.gender && (
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.gender?.message}</p>
)}
</div> </div>
{/* Nationality (read-only) */}
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality *</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality</label>
<input <input
{...register(`passengers.${index}.nationality`)} {...register(`passengers.${index}.nationality`)}
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed" className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
readOnly readOnly
disabled disabled
value={passengers[index]?.nationality || ''}
/> />
</div> </div>
{/* Phone */}
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
<input <input
{...register(`passengers.${index}.phone`)} {...register(`passengers.${index}.phone`)}
className="input-field" className={`input-field ${errors.passengers?.[index]?.phone ? 'border-red-500' : ''}`}
placeholder="+254712345678" placeholder="+254712345678"
value={passengers[index]?.phone || ''}
onChange={(e) => setValue(`passengers.${index}.phone`, e.target.value)}
/> />
{errors.passengers?.[index]?.phone && (
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.phone?.message}</p>
)}
</div> </div>
{/* Email */}
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
<input <input
type="email" type="email"
{...register(`passengers.${index}.email`)} {...register(`passengers.${index}.email`)}
className="input-field" className={`input-field ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
placeholder="email@example.com" placeholder="email@example.com"
value={passengers[index]?.email || ''}
onChange={(e) => setValue(`passengers.${index}.email`, e.target.value)}
/> />
{errors.passengers?.[index]?.email && ( {errors.passengers?.[index]?.email && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.email?.message}</p> <p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.email?.message}</p>
)} )}
</div> </div>
</div> </div>
{/* Passport fields */}
<div className="border-t dark:border-gray-700 pt-4 mt-4"> <div className="border-t dark:border-gray-700 pt-4 mt-4">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Passport Details</h4>
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Passport Number *</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Passport Number *</label>
<input <input
{...register(`passengers.${index}.passportNumber`)} {...register(`passengers.${index}.passportNumber`)}
className="input-field" className={`input-field ${errors.passengers?.[index]?.passportNumber ? 'border-red-500' : ''}`}
placeholder="P1234567" placeholder="P1234567"
value={passengers[index]?.passportNumber || ''}
onChange={(e) => setValue(`passengers.${index}.passportNumber`, e.target.value)}
/> />
{errors.passengers?.[index]?.passportNumber && ( {errors.passengers?.[index]?.passportNumber && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.passportNumber?.message}</p> <p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.passportNumber?.message}</p>
)} )}
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Issuing Country / Authority *</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Issuing Country *</label>
<input <input
{...register(`passengers.${index}.passportCountry`)} {...register(`passengers.${index}.passportCountry`)}
className="input-field" className={`input-field ${errors.passengers?.[index]?.passportCountry ? 'border-red-500' : ''}`}
placeholder="e.g., Djibouti / Government of Djibouti" placeholder="e.g., Djibouti"
value={passengers[index]?.passportCountry || ''}
onChange={(e) => setValue(`passengers.${index}.passportCountry`, e.target.value)}
/> />
{errors.passengers?.[index]?.passportCountry && ( {errors.passengers?.[index]?.passportCountry && (
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.passportCountry?.message}</p> <p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.passportCountry?.message}</p>
)} )}
</div> </div>
@@ -617,8 +944,6 @@ export default function PassengersPage() {
type="date" type="date"
{...register(`passengers.${index}.passportIssueDate`)} {...register(`passengers.${index}.passportIssueDate`)}
className="input-field" className="input-field"
value={passengers[index]?.passportIssueDate || ''}
onChange={(e) => setValue(`passengers.${index}.passportIssueDate`, e.target.value)}
/> />
</div> </div>
@@ -628,8 +953,6 @@ export default function PassengersPage() {
type="date" type="date"
{...register(`passengers.${index}.passportExpiryDate`)} {...register(`passengers.${index}.passportExpiryDate`)}
className="input-field" className="input-field"
value={passengers[index]?.passportExpiryDate || ''}
onChange={(e) => setValue(`passengers.${index}.passportExpiryDate`, e.target.value)}
/> />
</div> </div>
</div> </div>
@@ -652,7 +975,18 @@ export default function PassengersPage() {
)} )}
<div className="flex gap-4"> <div className="flex gap-4">
<button type="button" onClick={() => router.back()} className="btn-secondary flex-1" disabled={saving}> <button type="button" onClick={() => {
const params = new URLSearchParams({
origin: searchCriteria.originStationId,
destination: searchCriteria.destinationStationId,
date: searchCriteria.departureDate,
adults: String(searchCriteria.adultCount),
children: String(searchCriteria.childCount),
nationality: searchCriteria.nationality,
...(searchCriteria.promoCode && { promoCode: searchCriteria.promoCode }),
});
router.push(`/booking/results?${params}`);
}} className="btn-secondary flex-1" disabled={saving}>
Back Back
</button> </button>
<button type="submit" className="btn-primary flex-1" disabled={saving}> <button type="submit" className="btn-primary flex-1" disabled={saving}>

View File

@@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { useBookingStore } from '@/lib/booking-store'; import { useBookingStore } from '@/lib/booking-store';
import { Schedule } from '@/types'; import { Schedule } from '@/types';
import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Loader2, Check, ChevronDown, ChevronUp, MapPin, Gift } from 'lucide-react'; import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Loader2, Check, X, MapPin, Gift, Train } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
@@ -14,19 +14,39 @@ export default function ResultsPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule); const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule);
const [selectedClasses, setSelectedClasses] = useState<Record<string, string>>({}); const [selectedClasses, setSelectedClasses] = useState<Record<string, string>>({});
const [expandedSchedules, setExpandedSchedules] = useState<Record<string, boolean>>({}); const [classModal, setClassModal] = useState<Schedule | null>(null);
const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null); const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null);
const searchCriteria = useBookingStore((s) => s.searchCriteria);
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
// Prefer URL params; fall back to persisted store values
const searchData = { const searchData = {
originStationId: searchParams.get('origin') || '', originStationId: searchParams.get('origin') || searchCriteria?.originStationId || '',
destinationStationId: searchParams.get('destination') || '', destinationStationId: searchParams.get('destination') || searchCriteria?.destinationStationId || '',
date: searchParams.get('date') || '', date: searchParams.get('date') || searchCriteria?.departureDate || '',
adultCount: parseInt(searchParams.get('adults') || '1'), adultCount: parseInt(searchParams.get('adults') || '') || searchCriteria?.adultCount || 1,
childCount: parseInt(searchParams.get('children') || '0'), childCount: parseInt(searchParams.get('children') || '') || searchCriteria?.childCount || 0,
nationality: searchParams.get('nationality') || 'ETHIOPIAN', nationality: searchParams.get('nationality') || searchCriteria?.nationality || 'ETHIOPIAN',
promoCode: searchParams.get('promoCode') || '', promoCode: searchParams.get('promoCode') || searchCriteria?.promoCode || '',
}; };
// Sync URL params back into store whenever they are present in the URL
useEffect(() => {
if (searchParams.get('origin')) {
setSearchCriteria({
originStationId: searchParams.get('origin')!,
destinationStationId: searchParams.get('destination')!,
departureDate: searchParams.get('date')!,
adultCount: parseInt(searchParams.get('adults') || '1'),
childCount: parseInt(searchParams.get('children') || '0'),
nationality: (searchParams.get('nationality') || 'ETHIOPIAN') as 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER',
promoCode: searchParams.get('promoCode') || '',
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { useEffect(() => {
if (searchData.promoCode) { if (searchData.promoCode) {
apiClient apiClient
@@ -54,6 +74,7 @@ export default function ResultsPage() {
adults: searchData.adultCount.toString(), adults: searchData.adultCount.toString(),
children: searchData.childCount.toString(), children: searchData.childCount.toString(),
nationality: searchData.nationality, nationality: searchData.nationality,
...(searchData.promoCode && { promoCode: searchData.promoCode }),
}); });
return `/booking/search?${params}`; return `/booking/search?${params}`;
}; };
@@ -73,18 +94,8 @@ export default function ResultsPage() {
enabled: !!searchData.originStationId && !!searchData.destinationStationId, enabled: !!searchData.originStationId && !!searchData.destinationStationId,
}); });
const toggleExpanded = (scheduleId: string) => {
setExpandedSchedules(prev => ({
...prev,
[scheduleId]: !prev[scheduleId]
}));
};
const handleSelectClass = (scheduleId: string, seatClass: string) => { const handleSelectClass = (scheduleId: string, seatClass: string) => {
setSelectedClasses(prev => ({ setSelectedClasses(prev => ({ ...prev, [scheduleId]: seatClass }));
...prev,
[scheduleId]: seatClass
}));
}; };
const handleSelect = (schedule: Schedule) => { const handleSelect = (schedule: Schedule) => {
@@ -180,31 +191,127 @@ export default function ResultsPage() {
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
{/* Class selection modal */}
{classModal && (() => {
const scheduleId = classModal.scheduleId || classModal.id || '';
const selectedClass = selectedClasses[scheduleId];
return (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-[99] bg-black/50 backdrop-blur-sm" onClick={() => setClassModal(null)} />
{/* Drawer */}
<div className="fixed inset-y-0 right-0 z-[100] w-full sm:w-[640px] bg-white dark:bg-gray-900 shadow-2xl flex flex-col"
style={{ animation: 'drawer-slide-in 0.25s cubic-bezier(0.32,0.72,0,1)' }}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
<div>
<h2 className="text-base font-bold text-gray-900 dark:text-white">Select Class</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center gap-1">
<Train className="w-3 h-3" />
{classModal.trainNumber} · {classModal.origin?.name} {classModal.destination?.name}
</p>
</div>
<button
type="button"
onClick={() => setClassModal(null)}
className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* Class grid */}
<div className="flex-1 overflow-y-auto p-4">
{classModal.faresByClass && Array.isArray(classModal.faresByClass) && classModal.faresByClass.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{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 (
<button
key={fareClass.seatClassName}
onClick={() => isAvailable && handleSelectClass(scheduleId, fareClass.seatClassName)}
disabled={!isAvailable}
className={`relative w-full p-4 rounded-xl border-2 text-left transition-all ${
isSelected
? 'border-primary bg-primary/5 dark:bg-primary/10 shadow-sm'
: isAvailable
? 'border-gray-200 dark:border-gray-700 hover:border-primary/50 hover:shadow-sm'
: 'border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800 opacity-50 cursor-not-allowed'
}`}
>
{isSelected && (
<div className="absolute top-3 right-3 w-6 h-6 bg-primary rounded-full flex items-center justify-center">
<Check className="w-3.5 h-3.5 text-white" />
</div>
)}
<p className="font-semibold text-gray-900 dark:text-white pr-8">
{fareClass.seatClassName.replace(/_/g, ' ')}
</p>
<p className="text-xl font-bold text-primary dark:text-white mt-2">
ETB {((fareClass.baseFareMinor || 0) / 100).toFixed(2)}
</p>
<p className="text-xs text-gray-400 mt-0.5">per adult</p>
<p className={`text-xs mt-2 font-medium ${
isAvailable ? 'text-green-600 dark:text-green-400' : 'text-red-500'
}`}>
{isAvailable
? `${availableSeats} ${isBedClass ? 'bed' : 'seat'}${availableSeats !== 1 ? 's' : ''} available`
: 'Sold out'}
</p>
</button>
);
})}
</div>
) : (
<p className="text-center py-8 text-gray-400 text-sm">No seat classes available</p>
)}
</div>
{/* Footer */}
<div className="px-5 py-4 border-t border-gray-100 dark:border-gray-800 flex-shrink-0">
<button
onClick={() => { if (selectedClass) { handleSelect(classModal); setClassModal(null); } }}
disabled={!selectedClass}
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 disabled:opacity-40 disabled:cursor-not-allowed shadow-lg"
>
<span>Continue</span>
<ArrowRight className="w-4 h-4" />
</button>
{!selectedClass && (
<p className="text-center text-xs text-gray-400 mt-2">Please select a class to continue</p>
)}
</div>
</div>
<style>{`@keyframes drawer-slide-in{from{transform:translateX(100%)}to{transform:translateX(0)}}`}</style>
</>
);
})()}
{/* Promo Notification */} {/* Promo Notification */}
{promoData && ( {promoData && (
<div className="mb-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 flex items-start gap-3"> <div className="mb-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5"> <Check className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
<Check className="w-5 h-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1"> <div className="flex-1">
<h3 className="font-semibold text-green-900 dark:text-green-200">Promo code applied!</h3> <h3 className="font-semibold text-green-900 dark:text-green-200">Promo code applied!</h3>
<p className="text-sm text-green-800 dark:text-green-300 mt-1"> <p className="text-sm text-green-800 dark:text-green-300 mt-1">
<span className="font-mono font-bold">{promoData.code}</span> - {promoData.message} <span className="font-mono font-bold">{promoData.code}</span> {promoData.message}
</p> </p>
</div> </div>
</div> </div>
)} )}
<div className="mb-8"> <div className="mb-8">
<button <button onClick={() => router.push(buildSearchUrl())} className="btn-ghost px-0 py-4 flex items-center gap-2">
onClick={() => router.push(buildSearchUrl())}
className="btn-ghost px-0 py-4 flex items-center gap-2"
>
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
Modify search Modify search
</button> </button>
<h1 className="section-title">Available trains</h1> <h1 className="section-title">Available schedules</h1>
<div className="flex flex-wrap items-center gap-4 text-gray-600 dark:text-gray-400 mt-3"> <div className="hidden sm:flex flex-wrap items-center gap-4 text-gray-600 dark:text-gray-400 mt-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Calendar className="w-4 h-4" /> <Calendar className="w-4 h-4" />
<span>{searchData.date ? format(new Date(searchData.date), 'EEEE, MMMM d, yyyy') : 'Date not specified'}</span> <span>{searchData.date ? format(new Date(searchData.date), 'EEEE, MMMM d, yyyy') : 'Date not specified'}</span>
@@ -225,26 +332,21 @@ export default function ResultsPage() {
<div className="space-y-4"> <div className="space-y-4">
{results.map((schedule) => { {results.map((schedule) => {
const scheduleId = schedule.scheduleId || schedule.id || ''; const scheduleId = schedule.scheduleId || schedule.id || '';
const isExpanded = expandedSchedules[scheduleId];
const selectedClass = selectedClasses[scheduleId]; const selectedClass = selectedClasses[scheduleId];
const lowestFare = schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0 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)) ? Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0))
: null; : null;
const hours = Math.floor((schedule.durationMinutes || 0) / 60); const hours = Math.floor((schedule.durationMinutes || 0) / 60);
const minutes = (schedule.durationMinutes || 0) % 60; const minutes = (schedule.durationMinutes || 0) % 60;
const durationStr = `${hours}h ${minutes}m`; const durationStr = `${hours}h ${minutes}m`;
const departureDate = schedule.departureAt ? new Date(schedule.departureAt) : null; const departureDate = schedule.departureAt ? new Date(schedule.departureAt) : null;
const arrivalDate = schedule.arrivalAt ? new Date(schedule.arrivalAt) : null; const arrivalDate = schedule.arrivalAt ? new Date(schedule.arrivalAt) : null;
const isNextDay = departureDate && arrivalDate && const isNextDay = departureDate && arrivalDate && departureDate.toDateString() !== arrivalDate.toDateString();
departureDate.toDateString() !== arrivalDate.toDateString();
return ( return (
<div key={scheduleId} className="card group"> <div key={scheduleId} className="card">
<div className="flex flex-col gap-6">
<div className="flex flex-col lg:flex-row lg:items-center gap-6"> <div className="flex flex-col lg:flex-row lg:items-center gap-6">
{/* Train info */}
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center flex-shrink-0"> <div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
@@ -255,7 +357,7 @@ export default function ResultsPage() {
<div className="text-sm text-gray-600 dark:text-gray-400">{schedule.trainName || 'Express Service'}</div> <div className="text-sm text-gray-600 dark:text-gray-400">{schedule.trainName || 'Express Service'}</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="text-center"> <div className="text-center">
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100"> <div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
@@ -266,136 +368,62 @@ export default function ResultsPage() {
</div> </div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.origin?.name || 'Origin'}</div> <div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.origin?.name || 'Origin'}</div>
</div> </div>
<div className="flex-1 flex flex-col items-center"> <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"> <div className="flex items-center gap-2 mb-2 text-sm text-gray-600 dark:text-gray-400">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
<span>{durationStr}</span> <span>{durationStr}</span>
</div> </div>
<div className="w-full h-0.5 bg-gray-200 dark:bg-gray-700 relative"> <div className="w-full h-0.5 bg-gray-200 dark:bg-gray-700 relative">
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full"></div> <div className="absolute left-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full" />
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full"></div> <div className="absolute right-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full" />
</div>
<div className="flex items-center gap-2 mt-2 text-sm text-gray-600 dark:text-gray-400">
{schedule.stops && schedule.stops.length > 0 && (
<>
<MapPin className="w-4 h-4" />
<span>{schedule.stops.length - 2} stops</span>
</>
)}
</div> </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" />
<span>{schedule.stops.length - 2} stops</span>
</div>
)}
</div> </div>
<div className="text-center"> <div className="text-center">
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100"> <div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'HH:mm') : '--:--'} {schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'HH:mm') : '--:--'}
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center justify-center gap-1"> <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center justify-center gap-1">
<span>{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'MMM d') : ''}</span> <span>{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'MMM d') : ''}</span>
{isNextDay && ( {isNextDay && <span className="text-orange-500 font-medium">(+1)</span>}
<span className="text-orange-600 dark:text-orange-400 font-medium">(+1)</span>
)}
</div> </div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.destination?.name || 'Destination'}</div> <div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.destination?.name || 'Destination'}</div>
</div> </div>
</div> </div>
</div> </div>
{/* Fare + action */}
<div className="lg:border-l dark:border-gray-700 lg:pl-6 lg:min-w-[220px]"> <div className="lg:border-l dark:border-gray-700 lg:pl-6 lg:min-w-[220px]">
<div className="text-center lg:text-right"> <div className="text-center lg:text-right">
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Starting from</div> <div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Starting from</div>
<div className="text-3xl font-bold text-primary dark:text-white"> <div className="text-3xl font-bold text-primary">
{lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'} {lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">per adult</div> <div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">per adult</div>
{selectedClass && (
<p className="text-xs text-primary font-semibold mb-2">
{selectedClass.replace(/_/g, ' ')} selected
</p>
)}
<button <button
onClick={() => toggleExpanded(scheduleId)} onClick={() => setClassModal(schedule)}
className="btn-secondary w-full flex items-center justify-center gap-2" className="btn-secondary w-full flex items-center justify-center gap-2"
> >
<span>Select class</span> {selectedClass ? 'Change class' : 'Select class'}
{isExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{isExpanded && (
<div className="mt-6 pt-6 border-t dark:border-gray-700">
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-3">Select Class</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{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 (
<button
key={fareClass.seatClassName}
onClick={() => isAvailable && handleSelectClass(scheduleId, fareClass.seatClassName)}
disabled={!isAvailable}
className={`relative p-4 rounded-lg border-2 text-left transition-all ${
isSelected
? 'border-primary bg-blue-50 dark:bg-blue-900/20 shadow-md'
: isAvailable
? 'border-gray-200 dark:border-gray-700 hover:border-blue-300 hover:shadow-sm'
: 'border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800 opacity-60 cursor-not-allowed'
}`}
>
{isSelected && (
<div className="absolute top-2 right-2 w-6 h-6 bg-primary rounded-full flex items-center justify-center">
<Check className="w-4 h-4 text-white" />
</div>
)}
<div className="font-semibold text-gray-900 dark:text-gray-100 mb-1">
{fareClass.seatClassName.replace(/_/g, ' ')}
</div>
<div className="text-2xl font-bold text-primary dark:text-white mb-1">
ETB {((fareClass.baseFareMinor || 0) / 100).toFixed(2)}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">
{isAvailable ? (
<span className="text-green-600 dark:text-green-400 font-medium">
{availableSeats} {isBedClass ? 'bed' : 'seat'}{availableSeats !== 1 ? 's' : ''} available
</span>
) : (
<span className="text-red-600 dark:text-red-400 font-medium">Sold out</span>
)}
</div>
</button>
);
})
) : (
<div className="col-span-3 text-center py-4 text-gray-500 dark:text-gray-400">
No seat classes available
</div>
)}
</div>
<div className="mt-4 flex justify-end">
<button
onClick={() => handleSelect(schedule)}
disabled={!selectedClass}
className={`btn-primary flex items-center justify-center gap-2 ${
!selectedClass
? 'opacity-50 cursor-not-allowed'
: 'group-hover:shadow-xl'
}`}
>
<span>Continue</span>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</button>
</div>
</div>
)}
</div> </div>
</div> );
)})} })}
</div> </div>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -224,6 +224,7 @@ export default function SeatsPage() {
if (bookingId && selectedSeats.length > 0) { if (bookingId && selectedSeats.length > 0) {
bookSeatsMutation.mutate(selectedSeats); bookSeatsMutation.mutate(selectedSeats);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookingId]); }, [bookingId]);
const parseSeatArrangement = (arrangement: string | null): number[] => { const parseSeatArrangement = (arrangement: string | null): number[] => {
@@ -394,6 +395,72 @@ export default function SeatsPage() {
if (!selectedSchedule || !passengers.length) return null; 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 = () => (
<>
<div className="flex items-center justify-between mb-1">
<h3 className="font-bold text-gray-900 dark:text-white">Selection Summary</h3>
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
allSelected ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
}`}>
{selectedSeats.length}/{passengers.length} selected
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
{allSelected ? 'All seats selected — ready to continue' : `Select ${passengers.length - selectedSeats.length} more seat(s)`}
</p>
{/* Progress bar */}
<div className="h-1.5 bg-gray-100 dark:bg-gray-700 rounded-full mb-4 overflow-hidden">
<div
className="h-full bg-[rgb(20,113,76)] rounded-full transition-all duration-300"
style={{ width: `${(selectedSeats.length / passengers.length) * 100}%` }}
/>
</div>
<div className="space-y-2 mb-5">
{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 (
<div key={i} className="flex items-center justify-between py-2 border-b border-gray-100 dark:border-gray-800 last:border-0">
<div className="flex items-center gap-2">
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
assignedSeat ? 'bg-[rgb(20,113,76)] text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'
}`}>{i + 1}</div>
<span className="text-sm text-gray-700 dark:text-gray-300 truncate max-w-[120px]">{p.name}</span>
</div>
<span className={`text-sm font-semibold ${
assignedSeat ? 'text-[rgb(20,113,76)]' : 'text-gray-400'
}`}>
{assignedSeat ? `${seatLabel}${bedLabel}` : 'Not selected'}
</span>
</div>
);
})}
</div>
<button
onClick={handleContinue}
disabled={selectedSeats.length === 0 || holdMutation.isPending}
className="w-full py-3 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold text-sm rounded-xl transition-all shadow-lg"
>
{holdMutation.isPending ? 'Holding seats...' : allSelected ? 'Continue' : 'Continue with partial selection'}
</button>
<button
onClick={handleAutoAssign}
disabled={holdMutation.isPending}
className="w-full mt-2 py-2.5 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 text-gray-700 dark:text-gray-300 font-semibold text-sm rounded-xl transition-all"
>
Auto-assign seats
</button>
</>
);
return ( return (
<> <>
<CustomModal <CustomModal
@@ -403,166 +470,143 @@ export default function SeatsPage() {
message={modalState.message} message={modalState.message}
type={modalState.type} type={modalState.type}
/> />
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4"> {/* Mobile summary bottom-sheet */}
<div className="max-w-6xl mx-auto"> {selectedSeats.length > 0 && (
<div className="flex items-center gap-3 mb-6"> <>
<div className="fixed inset-x-0 bottom-0 z-50 lg:hidden bg-white dark:bg-gray-900 rounded-t-2xl shadow-2xl p-5"
style={{ animation: 'seats-slide-up 0.25s cubic-bezier(0.32,0.72,0,1)' }}
>
<div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full mx-auto mb-4" />
<SummaryContent />
</div>
<style>{`@keyframes seats-slide-up{from{transform:translateY(100%)}to{transform:translateY(0)}}`}</style>
</>
)}
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<div className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 sticky top-0 z-30">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto flex items-center justify-between h-14">
<button <button
onClick={handleBackToPassengers} onClick={handleBackToPassengers}
className="flex items-center gap-2 text-[rgb(20_113_76)] hover:text-[rgb(10_80_50)] font-semibold transition-colors" className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-primary transition-colors font-medium"
> >
<ChevronLeft className="w-5 h-5" /> <ChevronLeft className="w-4 h-4" />
Back to passenger details Back
</button> </button>
<h1 className="text-base font-bold text-gray-900 dark:text-white">Select Seats</h1>
<div className="text-sm font-semibold text-[rgb(20,113,76)]">
{selectedSeats.length}/{passengers.length}
</div>
</div> </div>
</div>
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Select seats</h1> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="container mx-auto px-4 py-5">
<div className="lg:col-span-2"> <div className="max-w-6xl mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
{/* ── Seat map panel ── */}
<div className="lg:col-span-2 space-y-4">
{isLoading ? ( {isLoading ? (
<div className="bg-white dark:bg-gray-800 rounded-lg p-4"> <div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">
<div className="text-center py-8 text-gray-500 dark:text-gray-400"> <div className="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-3" />
<p>Loading seats...</p> <p className="text-sm text-gray-500 dark:text-gray-400">Loading seat map...</p>
</div>
</div> </div>
) : error ? ( ) : error ? (
<div className="bg-white dark:bg-gray-800 rounded-lg p-4"> <div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">
<div className="text-center py-8 text-red-500 dark:text-red-400"> <p className="text-red-500 font-medium">Error loading seats</p>
<p>Error loading seats</p> <p className="text-sm text-gray-400 mt-1">{(error as any)?.message || 'Please try again'}</p>
<p className="text-sm mt-2">{error?.message || 'Please try again'}</p>
</div>
</div> </div>
) : filteredCoaches.length === 0 ? ( ) : filteredCoaches.length === 0 ? (
<div className="bg-white dark:bg-gray-800 rounded-lg p-4"> <div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">
<div className="text-center py-8 text-gray-500 dark:text-gray-400"> <p className="text-gray-500 dark:text-gray-400">No coaches available for {selectedSchedule?.selectedSeatClass}</p>
<p>No coaches available for {selectedSchedule?.selectedSeatClass}</p>
<p className="text-sm mt-2">Please select a different seat class</p>
</div>
</div> </div>
) : ( ) : (
<div className="space-y-6"> <>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4"> {/* Coach selector */}
<h3 className="font-semibold mb-3 text-gray-900 dark:text-gray-100">Select coach</h3> <div className="bg-white dark:bg-gray-800 rounded-2xl p-4 border border-gray-100 dark:border-gray-700">
<div className="flex flex-row gap-2"> <h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">Coach</h3>
{filteredCoaches?.map((coach: any) => { <div className="flex flex-wrap gap-2">
{filteredCoaches.map((coach: any) => {
const coachSeats = coach.seats?.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')) || []; const coachSeats = coach.seats?.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')) || [];
const isBedCoach = coach.seatClass?.toLowerCase().includes('bed') || coach.mode?.toLowerCase().includes('bed'); const isBed = coach.seatClass?.toLowerCase().includes('bed') || coach.mode?.toLowerCase().includes('bed');
let filteredSeats = coachSeats; let fSeats = coachSeats;
if (isBedCoach && selectedSchedule?.selectedSeatClass) { if (isBed && selectedSchedule?.selectedSeatClass) {
const bedPos = getBedPosition(selectedSchedule.selectedSeatClass); const bedPos = getBedPosition(selectedSchedule.selectedSeatClass);
if (bedPos) { if (bedPos) fSeats = coachSeats.filter((s: any) => s.bedPosition === bedPos);
filteredSeats = coachSeats.filter((s: any) => s.bedPosition === bedPos);
}
} }
const availableCount = filteredSeats.filter((s: any) => s.status === 'AVAILABLE').length || 0; const available = fSeats.filter((s: any) => s.status === 'AVAILABLE').length;
const seatClassName = selectedSchedule?.selectedSeatClass || (typeof coach.seatClass === 'string' ? coach.seatClass : (coach.seatClass?.name || coach.coachClass || '')); const isActive = selectedCoach === coach.id;
return ( return (
<button <button
key={coach.id} key={coach.id}
onClick={() => setSelectedCoach(coach.id)} onClick={() => setSelectedCoach(coach.id)}
className={`px-4 py-2 rounded transition-all text-left ${ className={`px-4 py-2.5 rounded-xl text-sm font-semibold transition-all ${
selectedCoach === coach.id isActive
? 'bg-[rgb(20_113_76)] text-white shadow-lg' ? 'bg-[rgb(20,113,76)] text-white shadow-md'
: 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`} }`}
> >
<div className="font-semibold">{coach.label || coach.name || coach.coachNumber}</div> <div>{coach.label || coach.name || coach.coachNumber}</div>
<div className="text-xs opacity-75">{seatClassName}</div> <div className={`text-xs mt-0.5 ${isActive ? 'text-white/70' : 'text-gray-400'}`}>{available} free</div>
<div className="text-xs opacity-75">{availableCount} available</div>
</button> </button>
); );
})} })}
</div> </div>
</div> </div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4"> {/* Seat map */}
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100"> <div className="bg-white dark:bg-gray-800 rounded-2xl p-4 border border-gray-100 dark:border-gray-700">
Seat map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber} <div className="flex items-center justify-between mb-3">
</h3> <h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{selectedCoachData && ( {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4"> </h3>
Arrangement: {selectedCoachData.seatArrangement} Total: {selectedCoachData.totalSeats} seats <span className="text-xs text-gray-400">{selectedCoachData?.seatArrangement}</span>
</p>
)}
<div className="flex flex-wrap gap-4 mb-6 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-green-500 rounded"></div>
<span className="text-gray-700 dark:text-gray-300">Available</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-[rgb(20_113_76)] rounded"></div>
<span className="text-gray-700 dark:text-gray-300">Selected</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-yellow-500 rounded"></div>
<span className="text-gray-700 dark:text-gray-300">Held</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-gray-500 rounded"></div>
<span className="text-gray-700 dark:text-gray-300">Booked</span>
</div>
</div> </div>
<div className="bg-gray-50 dark:bg-gray-700/30 p-6 rounded-lg overflow-x-auto border border-gray-200 dark:border-gray-700 w-fit"> {/* Legend */}
{validSeats.length === 0 ? ( <div className="flex flex-wrap gap-3 mb-4">
<div className="text-center py-8 text-gray-500 dark:text-gray-400"> {[
<p>No seats available in this coach</p> { color: 'bg-green-500', label: 'Available' },
<p className="text-sm mt-2">Please select a different coach</p> { color: 'bg-[rgb(20,113,76)]', label: 'Selected' },
{ color: 'bg-yellow-500', label: 'Held' },
{ color: 'bg-gray-400', label: 'Booked' },
].map(({ color, label }) => (
<div key={label} className="flex items-center gap-1.5">
<div className={`w-3 h-3 ${color} rounded-sm`} />
<span className="text-xs text-gray-500 dark:text-gray-400">{label}</span>
</div> </div>
) : ( ))}
renderCoachSeats(selectedCoachData, (selectedCoachData.seatClass?.toLowerCase().includes('bed') || selectedCoachData.mode?.toLowerCase().includes('bed'))) </div>
)}
<div className="overflow-x-auto">
<div className="inline-block bg-gray-50 dark:bg-gray-700/30 rounded-xl p-4 border border-gray-200 dark:border-gray-700">
{validSeats.length === 0 ? (
<p className="text-sm text-gray-400 py-4">No seats in this coach</p>
) : (
renderCoachSeats(selectedCoachData, isBedCoach)
)}
</div>
</div> </div>
</div> </div>
</div> </>
)} )}
</div> </div>
<div className="lg:col-span-1"> {/* ── Sidebar summary (desktop only) ── */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 sticky top-6"> <div className="hidden lg:block lg:col-span-1">
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Selection summary</h3> <div className="bg-white dark:bg-gray-800 rounded-2xl p-5 border border-gray-100 dark:border-gray-700 sticky top-20">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4"> <SummaryContent />
Select {passengers.length} seat(s) for your passengers
</p>
<p className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">
{selectedSeats.length} / {passengers.length} selected
</p>
<div className="space-y-2 mb-6 max-h-48 overflow-y-auto">
{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 (
<div key={i} className="flex justify-between text-sm text-gray-700 dark:text-gray-300">
<span>{p.name}</span>
<span className="font-medium text-[rgb(20_113_76)]">
{seatLabel}{bedLabel}
</span>
</div>
);
})}
</div>
<button
onClick={handleContinue}
disabled={selectedSeats.length === 0}
className="w-full bg-[rgb(20_113_76)] hover:bg-[rgb(10_80_50)] disabled:bg-gray-400 text-white font-semibold py-2 rounded mb-2 transition-colors"
>
Continue with selected seats
</button>
<button
onClick={handleAutoAssign}
disabled={holdMutation.isPending}
className="w-full bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 text-white font-semibold py-2 rounded transition-colors"
>
{holdMutation.isPending ? 'Assigning...' : 'Auto-assign seats'}
</button>
</div> </div>
</div> </div>
</div> </div>
{/* Mobile spacer so bottom-sheet doesn't cover last seat */}
<div className="h-48 lg:hidden" />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -157,4 +157,22 @@
.animate-slide-in-right { .animate-slide-in-right {
animation: slide-in-right 0.5s ease-out; 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;
}
} }

View File

@@ -23,7 +23,7 @@ function LoginContent() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({ const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
resolver: zodResolver(loginSchema), resolver: zodResolver(loginSchema as any),
}); });
const onSubmit = async (data: LoginForm) => { const onSubmit = async (data: LoginForm) => {

View File

@@ -1,6 +1,6 @@
'use client'; '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 Link from 'next/link';
import { useLanguage, getTranslation, Language } from '@/lib/i18n'; import { useLanguage, getTranslation, Language } from '@/lib/i18n';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -102,41 +102,17 @@ export function Footer() {
<div> <div>
<h4 className="font-semibold mb-4">{t('footer.follow')}</h4> <h4 className="font-semibold mb-4">{t('footer.follow')}</h4>
<div className="flex gap-3"> <div className="flex gap-3">
<a <a href="https://web.facebook.com/ethiodjiboutirailwaysc" target="_blank" rel="noopener noreferrer" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="Facebook">
href="https://web.facebook.com/ethiodjiboutirailwaysc" <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"/></svg>
target="_blank"
rel="noopener noreferrer"
className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110"
aria-label="Facebook"
>
<Facebook className="w-5 h-5" />
</a> </a>
<a <a href="https://twitter.com/edr" target="_blank" rel="noopener noreferrer" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="Twitter">
href="https://twitter.com/edr" <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
target="_blank"
rel="noopener noreferrer"
className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110"
aria-label="Twitter"
>
<Twitter className="w-5 h-5" />
</a> </a>
<a <a href="https://instagram.com/edr" target="_blank" rel="noopener noreferrer" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="Instagram">
href="https://instagram.com/edr" <svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="20" rx="5" ry="5"/><circle cx="12" cy="12" r="4"/><circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none"/></svg>
target="_blank"
rel="noopener noreferrer"
className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110"
aria-label="Instagram"
>
<Instagram className="w-5 h-5" />
</a> </a>
<a <a href="https://linkedin.com/company/edr" target="_blank" rel="noopener noreferrer" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="LinkedIn">
href="https://linkedin.com/company/edr" <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6zM2 9h4v12H2z"/><circle cx="4" cy="4" r="2"/></svg>
target="_blank"
rel="noopener noreferrer"
className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110"
aria-label="LinkedIn"
>
<Linkedin className="w-5 h-5" />
</a> </a>
</div> </div>
</div> </div>

View File

@@ -28,16 +28,23 @@ export default function ModernDatePicker({
placeholder = 'Select date', placeholder = 'Select date',
}: ModernDatePickerProps) { }: ModernDatePickerProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isMobileView, setIsMobileView] = useState(false);
const [calendarType, setCalendarType] = useState<'gregorian' | 'ethiopian'>('gregorian'); const [calendarType, setCalendarType] = useState<'gregorian' | 'ethiopian'>('gregorian');
const [viewMonth, setViewMonth] = useState(value?.getMonth() || new Date().getMonth()); const [viewMonth, setViewMonth] = useState(value?.getMonth() ?? new Date().getMonth());
const [viewYear, setViewYear] = useState(value?.getFullYear() || new Date().getFullYear()); const [viewYear, setViewYear] = useState(value?.getFullYear() ?? new Date().getFullYear());
// Initialize Ethiopian calendar with current date
const initialEthDate = gregorianToEthiopian(value || new Date()); const initialEthDate = gregorianToEthiopian(value || new Date());
const [ethViewMonth, setEthViewMonth] = useState(initialEthDate.month); const [ethViewMonth, setEthViewMonth] = useState(initialEthDate.month);
const [ethViewYear, setEthViewYear] = useState(initialEthDate.year); const [ethViewYear, setEthViewYear] = useState(initialEthDate.year);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const check = () => setIsMobileView(window.innerWidth < 768);
check();
window.addEventListener('resize', check);
return () => window.removeEventListener('resize', check);
}, []);
useEffect(() => { useEffect(() => {
if (value) { if (value) {
setViewMonth(value.getMonth()); setViewMonth(value.getMonth());
@@ -48,39 +55,27 @@ export default function ModernDatePicker({
} }
}, [value]); }, [value]);
// Lock body scroll when modal is open (both mobile and desktop modal)
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) { if (isOpen) {
document.addEventListener('mousedown', handleClickOutside); document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
} }
return () => { document.body.style.overflow = ''; };
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]); }, [isOpen]);
const toggleCalendarType = () => { const toggleCalendarType = () => {
const newType = calendarType === 'gregorian' ? 'ethiopian' : 'gregorian'; const newType = calendarType === 'gregorian' ? 'ethiopian' : 'gregorian';
const ref = value || new Date();
if (newType === 'ethiopian') { if (newType === 'ethiopian') {
// When switching to Ethiopian, show the Ethiopian equivalent of current Gregorian view const ethDate = gregorianToEthiopian(ref);
// Use today's date if no value is selected, otherwise use the selected value
const referenceDate = value || new Date();
const ethDate = gregorianToEthiopian(referenceDate);
setEthViewMonth(ethDate.month); setEthViewMonth(ethDate.month);
setEthViewYear(ethDate.year); setEthViewYear(ethDate.year);
} else { } else {
// When switching to Gregorian, show the Gregorian equivalent of current Ethiopian view setViewMonth(ref.getMonth());
const referenceDate = value || new Date(); setViewYear(ref.getFullYear());
setViewMonth(referenceDate.getMonth());
setViewYear(referenceDate.getFullYear());
} }
setCalendarType(newType); setCalendarType(newType);
}; };
@@ -90,102 +85,46 @@ export default function ModernDatePicker({
}; };
const handleEthiopianDateSelect = (ethDate: EthiopianDate) => { const handleEthiopianDateSelect = (ethDate: EthiopianDate) => {
const gregDate = ethiopianToGregorian(ethDate); handleDateSelect(ethiopianToGregorian(ethDate));
handleDateSelect(gregDate);
}; };
const renderGregorianCalendar = () => { const renderGregorianCalendar = () => {
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const firstDayOfMonth = new Date(viewYear, viewMonth, 1).getDay(); const firstDay = new Date(viewYear, viewMonth, 1).getDay();
const days: (number | null)[] = []; const days: (number | null)[] = Array(firstDay).fill(null);
for (let d = 1; d <= daysInMonth; d++) days.push(d);
for (let i = 0; i < firstDayOfMonth; i++) { const monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December'];
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'];
return ( return (
<div className="p-4"> <div className="p-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<button <button type="button" onClick={() => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1); } else setViewMonth(m => m - 1); }} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
type="button"
onClick={() => {
if (viewMonth === 0) {
setViewMonth(11);
setViewYear(viewYear - 1);
} else {
setViewMonth(viewMonth - 1);
}
}}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<ChevronLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" /> <ChevronLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</button> </button>
<div className="font-semibold text-base text-gray-900 dark:text-gray-100"> <span className="font-semibold text-base text-gray-900 dark:text-gray-100">{monthNames[viewMonth]} {viewYear}</span>
{monthNames[viewMonth]} {viewYear} <button type="button" onClick={() => { if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1); } else setViewMonth(m => m + 1); }} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
</div>
<button
type="button"
onClick={() => {
if (viewMonth === 11) {
setViewMonth(0);
setViewYear(viewYear + 1);
} else {
setViewMonth(viewMonth + 1);
}
}}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<ChevronRight className="w-5 h-5 text-gray-600 dark:text-gray-400" /> <ChevronRight className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</button> </button>
</div> </div>
<div className="grid grid-cols-7 gap-1 mb-2"> <div className="grid grid-cols-7 gap-1 mb-2">
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(day => ( {['Su','Mo','Tu','We','Th','Fr','Sa'].map(d => (
<div key={day} className="text-center text-xs font-semibold text-gray-500 dark:text-gray-400 py-2"> <div key={d} className="text-center text-xs font-semibold text-gray-400 py-2">{d}</div>
{day}
</div>
))} ))}
</div> </div>
<div className="grid grid-cols-7 gap-1"> <div className="grid grid-cols-7 gap-1">
{days.map((day, index) => { {days.map((day, i) => {
if (day === null) { if (day === null) return <div key={`e-${i}`} />;
return <div key={`empty-${index}`} />;
}
const date = new Date(viewYear, viewMonth, day); const date = new Date(viewYear, viewMonth, day);
const isSelected = value && const isSelected = value && date.getDate() === value.getDate() && date.getMonth() === value.getMonth() && date.getFullYear() === value.getFullYear();
date.getDate() === value.getDate() && const isToday = date.toDateString() === new Date().toDateString();
date.getMonth() === value.getMonth() && const isDisabled = (minDate && date < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) || (maxDate && date > new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate()));
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()));
return ( return (
<button <button key={day} type="button" onClick={() => !isDisabled && handleDateSelect(date)} disabled={!!isDisabled}
key={day} className={`aspect-square flex items-center justify-center text-sm rounded-lg transition-all
type="button"
onClick={() => !isDisabled && handleDateSelect(date)}
disabled={isDisabled}
className={`
aspect-square flex items-center justify-center text-sm rounded-lg transition-all
${isSelected ? 'bg-primary text-white font-semibold shadow-md scale-105' : ''} ${isSelected ? 'bg-primary text-white font-semibold shadow-md scale-105' : ''}
${isToday && !isSelected ? 'border-2 border-primary text-primary font-semibold' : ''} ${isToday && !isSelected ? 'border-2 border-primary text-primary font-semibold' : ''}
${!isSelected && !isToday && !isDisabled ? 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300' : ''} ${!isSelected && !isToday && !isDisabled ? 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300' : ''}
${isDisabled ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed' : ''} ${isDisabled ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed' : ''}`}>
`}
>
{day} {day}
</button> </button>
); );
@@ -197,133 +136,54 @@ export default function ModernDatePicker({
const renderEthiopianCalendar = () => { const renderEthiopianCalendar = () => {
const daysInMonth = getDaysInEthiopianMonth(ethViewYear, ethViewMonth); 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 firstDate = ethiopianToGregorian({ year: ethViewYear, month: ethViewMonth, day: 1 });
const firstDayOfWeek = firstDate.getDay(); const firstDayOfWeek = firstDate.getDay();
const daysWithOffset: (number | null)[] = Array(firstDayOfWeek).fill(null);
const daysWithOffset: (number | null)[] = []; for (let d = 1; d <= daysInMonth; d++) daysWithOffset.push(d);
for (let i = 0; i < firstDayOfWeek; i++) {
daysWithOffset.push(null);
}
daysWithOffset.push(...days);
// Get month name safely
const monthName = ETHIOPIAN_MONTHS[ethViewMonth - 1] || `Month ${ethViewMonth}`; const monthName = ETHIOPIAN_MONTHS[ethViewMonth - 1] || `Month ${ethViewMonth}`;
return ( return (
<div className="p-4"> <div className="p-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<button <button type="button" onClick={() => { if (ethViewMonth === 1) { setEthViewMonth(13); setEthViewYear(y => y - 1); } else setEthViewMonth(m => m - 1); }} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
type="button"
onClick={() => {
if (ethViewMonth === 1) {
setEthViewMonth(13);
setEthViewYear(ethViewYear - 1);
} else {
setEthViewMonth(ethViewMonth - 1);
}
}}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<ChevronLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" /> <ChevronLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</button> </button>
<div className="font-semibold text-base text-gray-900 dark:text-gray-100"> <span className="font-semibold text-base text-gray-900 dark:text-gray-100">{monthName} {ethViewYear}</span>
{monthName} {ethViewYear} <button type="button" onClick={() => { if (ethViewMonth === 13) { setEthViewMonth(1); setEthViewYear(y => y + 1); } else setEthViewMonth(m => m + 1); }} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
</div>
<button
type="button"
onClick={() => {
if (ethViewMonth === 13) {
setEthViewMonth(1);
setEthViewYear(ethViewYear + 1);
} else {
setEthViewMonth(ethViewMonth + 1);
}
}}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<ChevronRight className="w-5 h-5 text-gray-600 dark:text-gray-400" /> <ChevronRight className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</button> </button>
</div> </div>
<div className="grid grid-cols-7 gap-1 mb-2"> <div className="grid grid-cols-7 gap-1 mb-2">
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(day => ( {['Su','Mo','Tu','We','Th','Fr','Sa'].map(d => (
<div key={day} className="text-center text-xs font-semibold text-gray-500 dark:text-gray-400 py-2"> <div key={d} className="text-center text-xs font-semibold text-gray-400 py-2">{d}</div>
{day}
</div>
))} ))}
</div> </div>
<div className="grid grid-cols-7 gap-1"> <div className="grid grid-cols-7 gap-1">
{daysWithOffset.map((day, index) => { {daysWithOffset.map((day, i) => {
if (day === null) { if (day === null) return <div key={`e-${i}`} />;
return <div key={`empty-${index}`} />;
}
const ethDate: EthiopianDate = { year: ethViewYear, month: ethViewMonth, day }; const ethDate: EthiopianDate = { year: ethViewYear, month: ethViewMonth, day };
const gregDate = ethiopianToGregorian(ethDate); const gregDate = ethiopianToGregorian(ethDate);
const isSelected = value && gregDate.getDate() === value.getDate() && gregDate.getMonth() === value.getMonth() && gregDate.getFullYear() === value.getFullYear();
const isSelected = value && const todayEth = gregorianToEthiopian(new Date());
gregDate.getDate() === value.getDate() && const isToday = ethDate.day === todayEth.day && ethDate.month === todayEth.month && ethDate.year === todayEth.year;
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;
let isDisabled = false; let isDisabled = false;
if (minDate) { if (minDate) {
const minYear = minDate.getFullYear(); const [mY, mM, mD] = [minDate.getFullYear(), minDate.getMonth(), minDate.getDate()];
const minMonth = minDate.getMonth(); const [gY, gM, gD] = [gregDate.getFullYear(), gregDate.getMonth(), gregDate.getDate()];
const minDay = minDate.getDate(); if (gY < mY || (gY === mY && gM < mM) || (gY === mY && gM === mM && gD < mD)) isDisabled = true;
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;
}
} }
if (maxDate && !isDisabled) { if (maxDate && !isDisabled) {
const maxYear = maxDate.getFullYear(); const [mY, mM, mD] = [maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate()];
const maxMonth = maxDate.getMonth(); const [gY, gM, gD] = [gregDate.getFullYear(), gregDate.getMonth(), gregDate.getDate()];
const maxDay = maxDate.getDate(); if (gY > mY || (gY === mY && gM > mM) || (gY === mY && gM === mM && gD > mD)) isDisabled = true;
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;
}
} }
return ( return (
<button <button key={day} type="button" onClick={() => !isDisabled && handleEthiopianDateSelect(ethDate)} disabled={isDisabled}
key={day} className={`aspect-square flex items-center justify-center text-sm rounded-lg transition-all
type="button"
onClick={() => !isDisabled && handleEthiopianDateSelect(ethDate)}
disabled={isDisabled}
className={`
aspect-square flex items-center justify-center text-sm rounded-lg transition-all
${isSelected ? 'bg-primary text-white font-semibold shadow-md scale-105' : ''} ${isSelected ? 'bg-primary text-white font-semibold shadow-md scale-105' : ''}
${isToday && !isSelected ? 'border-2 border-primary text-primary font-semibold' : ''} ${isToday && !isSelected ? 'border-2 border-primary text-primary font-semibold' : ''}
${!isSelected && !isToday && !isDisabled ? 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300' : ''} ${!isSelected && !isToday && !isDisabled ? 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300' : ''}
${isDisabled ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed' : ''} ${isDisabled ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed' : ''}`}>
`}
>
{day} {day}
</button> </button>
); );
@@ -333,62 +193,128 @@ export default function ModernDatePicker({
); );
}; };
const calendarFooter = value && (
<div className="border-t border-gray-200 dark:border-gray-700 p-3 bg-gray-50 dark:bg-gray-800/60 space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500 dark:text-gray-400">Gregorian:</span>
<span className="font-medium text-gray-700 dark:text-gray-300">{format(value, 'MMMM d, yyyy')}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500 dark:text-gray-400">Ethiopian:</span>
<span className="font-medium text-gray-700 dark:text-gray-300">{formatEthiopianDate(gregorianToEthiopian(value))}</span>
</div>
</div>
);
// Shared modal content (used by both mobile and desktop)
const modalContent = (
<div className="flex flex-col h-full">
{/* Header row: title + close */}
<div className="flex items-center justify-between px-4 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4 text-primary" />
<h2 className="text-base font-bold text-gray-900 dark:text-white">Select Date</h2>
</div>
<button
type="button"
onClick={() => setIsOpen(false)}
className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<X className="w-5 h-5 text-gray-500 dark:text-gray-400" />
</button>
</div>
{/* Toggle row: always full-width, clearly visible */}
<div className="px-4 py-2.5 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
<div className="flex rounded-lg overflow-hidden border-2 border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={() => calendarType !== 'gregorian' && toggleCalendarType()}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-semibold transition-colors ${
calendarType === 'gregorian'
? 'bg-primary text-white'
: 'bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<Globe className="w-3.5 h-3.5" />
Gregorian
</button>
<button
type="button"
onClick={() => calendarType !== 'ethiopian' && toggleCalendarType()}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-semibold transition-colors ${
calendarType === 'ethiopian'
? 'bg-primary text-white'
: 'bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<Globe className="w-3.5 h-3.5" />
Ethiopian
</button>
</div>
</div>
{/* Calendar body */}
<div className="flex-1 overflow-y-auto">
{calendarType === 'gregorian' ? renderGregorianCalendar() : renderEthiopianCalendar()}
{calendarFooter}
</div>
</div>
);
return ( return (
<div className="relative" ref={containerRef}> <div className="relative" ref={containerRef}>
{/* Trigger button */}
<button <button
type="button" type="button"
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(true)}
className="w-full px-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-left flex items-center justify-between bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors group" 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"
> >
<span className={value ? 'text-gray-900 dark:text-gray-100' : 'text-gray-500 dark:text-gray-400'}> <span className={`text-sm ${value ? 'text-gray-900 dark:text-gray-100 font-medium' : 'text-gray-400'}`}>
{value ? format(value, 'EEEE, MMMM d, yyyy') : placeholder} {value ? format(value, 'EEE, MMM d, yyyy') : placeholder}
</span> </span>
<CalendarIcon className="w-5 h-5 text-gray-400 dark:text-gray-500 group-hover:text-primary transition-colors" /> <CalendarIcon className="w-4 h-4 text-gray-400 group-hover:text-primary transition-colors flex-shrink-0" />
</button> </button>
{isOpen && ( {isOpen && (
<div className="absolute z-50 mt-2 bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 w-80 animate-in fade-in slide-in-from-top-2 duration-200"> <>
<div className="flex items-center justify-between p-3 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4 text-primary" />
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
{calendarType === 'gregorian' ? 'Gregorian Calendar' : 'Ethiopian Calendar'}
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={toggleCalendarType}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium bg-primary/10 hover:bg-primary/20 dark:bg-primary/20 dark:hover:bg-primary/30 text-primary rounded-lg transition-colors"
>
<Globe className="w-3.5 h-3.5" />
{calendarType === 'gregorian' ? 'ET' : 'GC'}
</button>
<button
type="button"
onClick={() => setIsOpen(false)}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<X className="w-4 h-4 text-gray-500 dark:text-gray-400" />
</button>
</div>
</div>
{calendarType === 'gregorian' ? renderGregorianCalendar() : renderEthiopianCalendar()} {/* Mobile: full-screen takeover */}
{isMobileView ? (
{value && ( <div
<div className="border-t border-gray-200 dark:border-gray-700 p-3 bg-gray-50 dark:bg-gray-900/50 space-y-1.5"> className="fixed inset-0 z-[100] bg-white dark:bg-gray-900 flex flex-col"
<div className="flex items-center justify-between text-xs"> style={{ animation: 'mdp-slide-up 0.25s cubic-bezier(0.32,0.72,0,1)' }}
<span className="text-gray-500 dark:text-gray-400">Gregorian:</span> >
<span className="font-medium text-gray-700 dark:text-gray-300">{format(value, 'MMMM d, yyyy')}</span> {modalContent}
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500 dark:text-gray-400">Ethiopian:</span>
<span className="font-medium text-gray-700 dark:text-gray-300">{formatEthiopianDate(gregorianToEthiopian(value))}</span>
</div>
</div> </div>
) : (
/* Desktop: centred modal with backdrop */
<>
<div className="fixed inset-0 z-[99] bg-black/40 backdrop-blur-sm" onClick={() => setIsOpen(false)} />
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 pointer-events-none"
>
<div
className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-sm pointer-events-auto"
style={{ animation: 'mdp-scale-in 0.2s cubic-bezier(0.34,1.56,0.64,1)' }}
>
{modalContent}
</div>
</div>
</>
)} )}
</div>
<style>{`
@keyframes mdp-slide-up {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes mdp-scale-in {
from { transform: scale(0.92); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
`}</style>
</>
)} )}
</div> </div>
); );

View File

@@ -1,87 +1,93 @@
'use client'; 'use client';
import { Check } from 'lucide-react'; import { Check } from 'lucide-react';
import { useEffect, useRef } from 'react';
interface Step { const steps = [
id: string; { id: 'search', name: 'Search' },
name: string; { id: 'results', name: 'Results' },
href: string; { id: 'passengers', name: 'Passengers' },
} { id: 'seats', name: 'Seats' },
{ id: 'review', name: 'Review' },
const steps: Step[] = [ { id: 'payment', name: 'Payment' },
{ id: 'search', name: 'Search', href: '/booking/search' }, { id: 'confirmation', name: 'Done' },
{ 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' },
]; ];
interface ProgressIndicatorProps { export function ProgressIndicator({ currentStep }: { currentStep: string }) {
currentStep: string;
}
export function ProgressIndicator({ currentStep }: ProgressIndicatorProps) {
const currentIndex = steps.findIndex((s) => s.id === currentStep); const currentIndex = steps.findIndex((s) => s.id === currentStep);
const scrollRef = useRef<HTMLDivElement>(null);
const activeRef = useRef<HTMLLIElement>(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 (
<li
key={step.id}
ref={isCurrent ? activeRef : undefined}
className="flex flex-col items-center flex-shrink-0 w-[4.5rem] md:flex-1"
>
{/* Line + Circle row */}
<div className="flex items-center w-full">
{/* Left connector */}
<div className={`flex-1 h-0.5 ${index === 0 ? 'invisible' : isComplete ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'}`} />
{/* Circle */}
<div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full transition-all duration-300 ${
isComplete ? 'bg-primary shadow-sm' :
isCurrent ? 'border-[3px] border-primary bg-white dark:bg-gray-800 shadow-sm' :
'border-2 border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800'
}`}>
{isComplete
? <Check className="h-4 w-4 text-white" />
: <span className={`text-xs font-bold ${isCurrent ? 'text-primary' : 'text-gray-400 dark:text-gray-500'}`}>{index + 1}</span>
}
</div>
{/* Right connector */}
<div className={`flex-1 h-0.5 ${index === steps.length - 1 ? 'invisible' : isComplete ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'}`} />
</div>
{/* Label */}
<p className={`mt-1.5 text-[10px] md:text-xs font-medium text-center leading-tight whitespace-nowrap ${
isCurrent ? 'text-primary font-bold' :
isComplete ? 'text-gray-600 dark:text-gray-300' :
'text-gray-400 dark:text-gray-500'
}`}>
{step.name}
</p>
</li>
);
};
return ( return (
<nav aria-label="Progress" className="py-6"> <nav aria-label="Progress">
<ol className="flex items-center max-w-6xl mx-auto"> {/* Mobile: full-bleed scrollable */}
{steps.map((step, index) => { <div
const isComplete = index < currentIndex; ref={scrollRef}
const isCurrent = index === currentIndex; className="md:hidden 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>
return ( {/* Desktop: full container width matching header/footer */}
<li key={step.id} className="flex flex-col items-center" style={{ width: `${100 / steps.length}%` }}> <div className="hidden md:block container mx-auto px-4 py-4">
<div className="flex items-center w-full"> <ol className="flex items-start max-w-6xl mx-auto">
<div {steps.map((s, i) => stepItem(s, i))}
className={`flex-1 h-1 transition-all duration-300 ${ </ol>
index === 0 ? 'opacity-0' : isComplete ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700' </div>
}`}
/>
<div
className={`relative flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full transition-all duration-300 ${
isComplete
? 'bg-primary shadow-lg scale-110'
: isCurrent
? 'border-4 border-primary bg-white dark:bg-gray-800 shadow-lg scale-110'
: 'border-2 border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800'
}`}
>
{isComplete ? (
<Check className="h-5 w-5 text-white" />
) : (
<span
className={`text-sm font-bold ${
isCurrent ? 'text-primary' : 'text-gray-400 dark:text-gray-500'
}`}
>
{index + 1}
</span>
)}
</div>
<div
className={`flex-1 h-1 transition-all duration-300 ${
index === steps.length - 1 ? 'opacity-0' : isComplete ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'
}`}
/>
</div>
<div className="flex items-center w-full">
<div className={`flex-1 ${index === 0 ? 'opacity-0' : ''}`} />
<p
className={`mt-3 text-xs md:text-sm font-medium transition-colors flex-shrink-0 ${
isCurrent ? 'text-primary font-bold' : isComplete ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500'
}`}
>
{step.name}
</p>
<div className={`flex-1 ${index === steps.length - 1 ? 'opacity-0' : ''}`} />
</div>
</li>
);
})}
</ol>
</nav> </nav>
); );
} }

View File

@@ -42,7 +42,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
}); });
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<SearchForm>({ const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<SearchForm>({
resolver: zodResolver(searchSchema), resolver: zodResolver(searchSchema as any),
defaultValues: { defaultValues: {
adultCount: 1, adultCount: 1,
childCount: 0, childCount: 0,

View File

@@ -8,6 +8,7 @@ export interface SearchCriteria {
adultCount: number; adultCount: number;
childCount: number; childCount: number;
nationality: 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER'; nationality: 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER';
promoCode?: string;
} }
export interface PassengerDetail { export interface PassengerDetail {

File diff suppressed because one or more lines are too long