mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #133 from Tria-plc/alpha
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -28,9 +28,7 @@ export default function BookingLayout({
|
||||
<div>
|
||||
{showProgress && (
|
||||
<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} />
|
||||
</div>
|
||||
<ProgressIndicator currentStep={currentStep} />
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
|
||||
@@ -7,16 +7,338 @@ import { useRouter } from 'next/navigation';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CheckCircle, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { CheckCircle, ExternalLink, Loader2, CalendarDays, X, Globe } from 'lucide-react';
|
||||
import { gregorianToEthiopian, ethiopianToGregorian, ETHIOPIAN_MONTHS, getDaysInEthiopianMonth } from '@/lib/ethiopian-calendar';
|
||||
|
||||
const GC_MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||
|
||||
function daysInGCMonth(y: number, m: number) {
|
||||
return new Date(y, m, 0).getDate();
|
||||
}
|
||||
|
||||
function DobPickerModal({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (iso: string) => void;
|
||||
error?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [manualMode, setManualMode] = useState(false);
|
||||
const [calType, setCalType] = useState<'gregorian' | 'ethiopian'>('gregorian');
|
||||
const currentYear = new Date().getFullYear();
|
||||
const currentEthYear = gregorianToEthiopian(new Date()).year;
|
||||
|
||||
// Parse stored ISO value (always Gregorian)
|
||||
const parsed = value ? value.split('-') : [];
|
||||
const initGCYear = parsed[0] ? parseInt(parsed[0]) : currentYear - 25;
|
||||
const initGCMonth = parsed[1] ? parseInt(parsed[1]) : 1;
|
||||
const initGCDay = parsed[2] ? parseInt(parsed[2]) : 1;
|
||||
|
||||
// Gregorian drum state
|
||||
const [selGCYear, setSelGCYear] = useState(initGCYear);
|
||||
const [selGCMonth, setSelGCMonth] = useState(initGCMonth);
|
||||
const [selGCDay, setSelGCDay] = useState(initGCDay);
|
||||
|
||||
// Ethiopian drum state — initialise from parsed value if present
|
||||
const initEth = value ? gregorianToEthiopian(new Date(initGCYear, initGCMonth - 1, initGCDay)) : { year: currentEthYear - 25, month: 1, day: 1 };
|
||||
const [selEthYear, setSelEthYear] = useState(initEth.year);
|
||||
const [selEthMonth, setSelEthMonth] = useState(initEth.month);
|
||||
const [selEthDay, setSelEthDay] = useState(initEth.day);
|
||||
|
||||
// Manual inputs
|
||||
const [manDay, setManDay] = useState(parsed[2] ? String(parseInt(parsed[2])) : '');
|
||||
const [manMonth, setManMonth] = useState(parsed[1] ? String(parseInt(parsed[1])) : '');
|
||||
const [manYear, setManYear] = useState(parsed[0] || '');
|
||||
|
||||
// Computed
|
||||
const gcMaxDay = daysInGCMonth(selGCYear, selGCMonth);
|
||||
const ethMaxDay = getDaysInEthiopianMonth(selEthYear, selEthMonth);
|
||||
const gcSafeDay = Math.min(selGCDay, gcMaxDay);
|
||||
const ethSafeDay = Math.min(selEthDay, ethMaxDay);
|
||||
|
||||
const gcYears = Array.from({ length: 100 }, (_, i) => currentYear - i);
|
||||
const ethYears = Array.from({ length: 100 }, (_, i) => currentEthYear - i);
|
||||
const gcMonths = GC_MONTHS.map((m, i) => ({ label: m, value: i + 1 }));
|
||||
const ethMonths = ETHIOPIAN_MONTHS.map((m, i) => ({ label: m, value: i + 1 }));
|
||||
const gcDays = Array.from({ length: gcMaxDay }, (_, i) => i + 1);
|
||||
const ethDays = Array.from({ length: ethMaxDay }, (_, i) => i + 1);
|
||||
|
||||
const dayRef = useRef<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({
|
||||
name: z.string().min(2, 'Name is required'),
|
||||
name: z.string().min(2, 'Full name is required (min 2 characters)'),
|
||||
dateOfBirth: z.string().min(1, 'Date of birth is required'),
|
||||
gender: z.enum(['Male', 'Female']).optional(),
|
||||
gender: z.string().min(1, 'Gender is required'),
|
||||
nationality: z.string().min(1, 'Nationality is required'),
|
||||
phone: z.string().optional(),
|
||||
email: z.string().email('Invalid email').optional().or(z.literal('')),
|
||||
phone: z.string().min(1, 'Phone number is required'),
|
||||
email: z.string().optional(),
|
||||
nationalId: z.string().optional(),
|
||||
passportNumber: z.string().optional(),
|
||||
passportCountry: z.string().optional(),
|
||||
@@ -26,15 +348,25 @@ const passengerSchema = z.object({
|
||||
faydaVerified: z.boolean().optional(),
|
||||
faydaSub: z.string().optional(),
|
||||
formExpanded: z.boolean().optional(),
|
||||
}).refine((data) => {
|
||||
if (data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian') {
|
||||
return data.passportNumber && data.passportNumber.length > 0 &&
|
||||
data.passportCountry && data.passportCountry.length > 0;
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.gender !== 'Male' && data.gender !== 'Female') {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Gender is required', path: ['gender'] });
|
||||
}
|
||||
if (data.email && data.email.trim().length > 0) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(data.email)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['email'] });
|
||||
}
|
||||
}
|
||||
const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian';
|
||||
if (isNonEthiopian) {
|
||||
if (!data.passportNumber || data.passportNumber.trim().length === 0) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number is required', path: ['passportNumber'] });
|
||||
}
|
||||
if (!data.passportCountry || data.passportCountry.trim().length === 0) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Issuing country is required', path: ['passportCountry'] });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}, {
|
||||
message: 'Passport number and country are required for non-Ethiopian passengers',
|
||||
path: ['passportNumber'],
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -57,7 +389,8 @@ export default function PassengersPage() {
|
||||
const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0);
|
||||
|
||||
const { register, control, handleSubmit, setValue, watch, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
resolver: zodResolver(formSchema as any),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
passengers: Array.from({ length: totalPassengers }, () => ({
|
||||
name: '',
|
||||
@@ -418,82 +751,80 @@ export default function PassengersPage() {
|
||||
)}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{/* Full Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.name`)}
|
||||
className="input-field"
|
||||
className={`input-field ${errors.passengers?.[index]?.name ? 'border-red-500' : ''}`}
|
||||
placeholder="Full name as per ID"
|
||||
value={passengers[index]?.name || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.name`, e.target.value)}
|
||||
/>
|
||||
{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>
|
||||
|
||||
{/* Date of Birth */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
|
||||
<input
|
||||
type="date"
|
||||
{...register(`passengers.${index}.dateOfBirth`)}
|
||||
className="input-field"
|
||||
<DobPickerModal
|
||||
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>
|
||||
|
||||
{/* Gender */}
|
||||
<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
|
||||
{...register(`passengers.${index}.gender`)}
|
||||
className="input-field"
|
||||
value={passengers[index]?.gender || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.gender`, e.target.value as any)}
|
||||
className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`}
|
||||
>
|
||||
<option value="">Select gender</option>
|
||||
<option value="Male">Male</option>
|
||||
<option value="Female">Female</option>
|
||||
</select>
|
||||
{errors.passengers?.[index]?.gender && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.gender?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nationality (read-only) */}
|
||||
<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
|
||||
{...register(`passengers.${index}.nationality`)}
|
||||
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
|
||||
readOnly
|
||||
disabled
|
||||
value={passengers[index]?.nationality || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<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
|
||||
{...register(`passengers.${index}.phone`)}
|
||||
className="input-field"
|
||||
className={`input-field ${errors.passengers?.[index]?.phone ? 'border-red-500' : ''}`}
|
||||
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>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
{...register(`passengers.${index}.email`)}
|
||||
className="input-field"
|
||||
className={`input-field ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
|
||||
placeholder="email@example.com"
|
||||
value={passengers[index]?.email || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.email`, e.target.value)}
|
||||
/>
|
||||
{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>
|
||||
@@ -501,113 +832,109 @@ export default function PassengersPage() {
|
||||
) : (
|
||||
<>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{/* Full Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.name`)}
|
||||
className="input-field"
|
||||
className={`input-field ${errors.passengers?.[index]?.name ? 'border-red-500' : ''}`}
|
||||
placeholder="Full name as per passport"
|
||||
value={passengers[index]?.name || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.name`, e.target.value)}
|
||||
/>
|
||||
{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>
|
||||
|
||||
{/* Date of Birth */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
|
||||
<input
|
||||
type="date"
|
||||
{...register(`passengers.${index}.dateOfBirth`)}
|
||||
className="input-field"
|
||||
<DobPickerModal
|
||||
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>
|
||||
|
||||
{/* Gender */}
|
||||
<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
|
||||
{...register(`passengers.${index}.gender`)}
|
||||
className="input-field"
|
||||
value={passengers[index]?.gender || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.gender`, e.target.value as any)}
|
||||
className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`}
|
||||
>
|
||||
<option value="">Select gender</option>
|
||||
<option value="Male">Male</option>
|
||||
<option value="Female">Female</option>
|
||||
</select>
|
||||
{errors.passengers?.[index]?.gender && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.gender?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nationality (read-only) */}
|
||||
<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
|
||||
{...register(`passengers.${index}.nationality`)}
|
||||
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
|
||||
readOnly
|
||||
disabled
|
||||
value={passengers[index]?.nationality || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<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
|
||||
{...register(`passengers.${index}.phone`)}
|
||||
className="input-field"
|
||||
className={`input-field ${errors.passengers?.[index]?.phone ? 'border-red-500' : ''}`}
|
||||
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>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
{...register(`passengers.${index}.email`)}
|
||||
className="input-field"
|
||||
className={`input-field ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
|
||||
placeholder="email@example.com"
|
||||
value={passengers[index]?.email || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.email`, e.target.value)}
|
||||
/>
|
||||
{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>
|
||||
|
||||
{/* Passport fields */}
|
||||
<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>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Passport Number *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.passportNumber`)}
|
||||
className="input-field"
|
||||
className={`input-field ${errors.passengers?.[index]?.passportNumber ? 'border-red-500' : ''}`}
|
||||
placeholder="P1234567"
|
||||
value={passengers[index]?.passportNumber || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.passportNumber`, e.target.value)}
|
||||
/>
|
||||
{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>
|
||||
<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
|
||||
{...register(`passengers.${index}.passportCountry`)}
|
||||
className="input-field"
|
||||
placeholder="e.g., Djibouti / Government of Djibouti"
|
||||
value={passengers[index]?.passportCountry || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.passportCountry`, e.target.value)}
|
||||
className={`input-field ${errors.passengers?.[index]?.passportCountry ? 'border-red-500' : ''}`}
|
||||
placeholder="e.g., Djibouti"
|
||||
/>
|
||||
{errors.passengers?.[index]?.passportCountry && (
|
||||
<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>
|
||||
|
||||
@@ -617,8 +944,6 @@ export default function PassengersPage() {
|
||||
type="date"
|
||||
{...register(`passengers.${index}.passportIssueDate`)}
|
||||
className="input-field"
|
||||
value={passengers[index]?.passportIssueDate || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.passportIssueDate`, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -628,8 +953,6 @@ export default function PassengersPage() {
|
||||
type="date"
|
||||
{...register(`passengers.${index}.passportExpiryDate`)}
|
||||
className="input-field"
|
||||
value={passengers[index]?.passportExpiryDate || ''}
|
||||
onChange={(e) => setValue(`passengers.${index}.passportExpiryDate`, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -652,7 +975,18 @@ export default function PassengersPage() {
|
||||
)}
|
||||
|
||||
<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
|
||||
</button>
|
||||
<button type="submit" className="btn-primary flex-1" disabled={saving}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
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 { useState, useEffect } from 'react';
|
||||
|
||||
@@ -14,19 +14,39 @@ export default function ResultsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule);
|
||||
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 searchCriteria = useBookingStore((s) => s.searchCriteria);
|
||||
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
|
||||
|
||||
// Prefer URL params; fall back to persisted store values
|
||||
const searchData = {
|
||||
originStationId: searchParams.get('origin') || '',
|
||||
destinationStationId: searchParams.get('destination') || '',
|
||||
date: searchParams.get('date') || '',
|
||||
adultCount: parseInt(searchParams.get('adults') || '1'),
|
||||
childCount: parseInt(searchParams.get('children') || '0'),
|
||||
nationality: searchParams.get('nationality') || 'ETHIOPIAN',
|
||||
promoCode: searchParams.get('promoCode') || '',
|
||||
originStationId: searchParams.get('origin') || searchCriteria?.originStationId || '',
|
||||
destinationStationId: searchParams.get('destination') || searchCriteria?.destinationStationId || '',
|
||||
date: searchParams.get('date') || searchCriteria?.departureDate || '',
|
||||
adultCount: parseInt(searchParams.get('adults') || '') || searchCriteria?.adultCount || 1,
|
||||
childCount: parseInt(searchParams.get('children') || '') || searchCriteria?.childCount || 0,
|
||||
nationality: searchParams.get('nationality') || searchCriteria?.nationality || 'ETHIOPIAN',
|
||||
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(() => {
|
||||
if (searchData.promoCode) {
|
||||
apiClient
|
||||
@@ -54,6 +74,7 @@ export default function ResultsPage() {
|
||||
adults: searchData.adultCount.toString(),
|
||||
children: searchData.childCount.toString(),
|
||||
nationality: searchData.nationality,
|
||||
...(searchData.promoCode && { promoCode: searchData.promoCode }),
|
||||
});
|
||||
return `/booking/search?${params}`;
|
||||
};
|
||||
@@ -73,18 +94,8 @@ export default function ResultsPage() {
|
||||
enabled: !!searchData.originStationId && !!searchData.destinationStationId,
|
||||
});
|
||||
|
||||
const toggleExpanded = (scheduleId: string) => {
|
||||
setExpandedSchedules(prev => ({
|
||||
...prev,
|
||||
[scheduleId]: !prev[scheduleId]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSelectClass = (scheduleId: string, seatClass: string) => {
|
||||
setSelectedClasses(prev => ({
|
||||
...prev,
|
||||
[scheduleId]: seatClass
|
||||
}));
|
||||
setSelectedClasses(prev => ({ ...prev, [scheduleId]: seatClass }));
|
||||
};
|
||||
|
||||
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="container mx-auto px-4">
|
||||
<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 */}
|
||||
{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="flex-shrink-0 mt-0.5">
|
||||
<Check className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<Check className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<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">
|
||||
<span className="font-mono font-bold">{promoData.code}</span> - {promoData.message}
|
||||
<span className="font-mono font-bold">{promoData.code}</span> — {promoData.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8">
|
||||
<button
|
||||
onClick={() => router.push(buildSearchUrl())}
|
||||
className="btn-ghost px-0 py-4 flex items-center gap-2"
|
||||
>
|
||||
<button onClick={() => router.push(buildSearchUrl())} className="btn-ghost px-0 py-4 flex items-center gap-2">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Modify search
|
||||
</button>
|
||||
<h1 className="section-title">Available trains</h1>
|
||||
<div className="flex flex-wrap items-center gap-4 text-gray-600 dark:text-gray-400 mt-3">
|
||||
<h1 className="section-title">Available schedules</h1>
|
||||
<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">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<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">
|
||||
{results.map((schedule) => {
|
||||
const scheduleId = schedule.scheduleId || schedule.id || '';
|
||||
const isExpanded = expandedSchedules[scheduleId];
|
||||
const selectedClass = selectedClasses[scheduleId];
|
||||
|
||||
const lowestFare = schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0
|
||||
? Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0))
|
||||
: null;
|
||||
|
||||
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
||||
const minutes = (schedule.durationMinutes || 0) % 60;
|
||||
const durationStr = `${hours}h ${minutes}m`;
|
||||
|
||||
const departureDate = schedule.departureAt ? new Date(schedule.departureAt) : null;
|
||||
const arrivalDate = schedule.arrivalAt ? new Date(schedule.arrivalAt) : null;
|
||||
const isNextDay = departureDate && arrivalDate &&
|
||||
departureDate.toDateString() !== arrivalDate.toDateString();
|
||||
|
||||
const arrivalDate = schedule.arrivalAt ? new Date(schedule.arrivalAt) : null;
|
||||
const isNextDay = departureDate && arrivalDate && departureDate.toDateString() !== arrivalDate.toDateString();
|
||||
|
||||
return (
|
||||
<div key={scheduleId} className="card group">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div key={scheduleId} className="card">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
|
||||
{/* Train info */}
|
||||
<div className="flex-1">
|
||||
<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">
|
||||
@@ -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>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
@@ -266,136 +368,62 @@ export default function ResultsPage() {
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.origin?.name || 'Origin'}</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex-1 flex flex-col items-center">
|
||||
<div className="flex items-center gap-2 mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{durationStr}</span>
|
||||
</div>
|
||||
<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 right-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full"></div>
|
||||
</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 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>
|
||||
{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 className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<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>
|
||||
{isNextDay && (
|
||||
<span className="text-orange-600 dark:text-orange-400 font-medium">(+1)</span>
|
||||
)}
|
||||
{isNextDay && <span className="text-orange-500 font-medium">(+1)</span>}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.destination?.name || 'Destination'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fare + action */}
|
||||
<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-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'}
|
||||
</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
|
||||
onClick={() => toggleExpanded(scheduleId)}
|
||||
onClick={() => setClassModal(schedule)}
|
||||
className="btn-secondary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<span>Select class</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
{selectedClass ? 'Change class' : 'Select class'}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -224,6 +224,7 @@ export default function SeatsPage() {
|
||||
if (bookingId && selectedSeats.length > 0) {
|
||||
bookSeatsMutation.mutate(selectedSeats);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bookingId]);
|
||||
|
||||
const parseSeatArrangement = (arrangement: string | null): number[] => {
|
||||
@@ -394,6 +395,72 @@ export default function SeatsPage() {
|
||||
|
||||
if (!selectedSchedule || !passengers.length) return null;
|
||||
|
||||
const allSelected = selectedSeats.length === passengers.length;
|
||||
const isBedCoach = selectedCoachData?.seatClass?.toLowerCase().includes('bed') || selectedCoachData?.mode?.toLowerCase().includes('bed');
|
||||
|
||||
// Summary card content — shared between sidebar and mobile modal
|
||||
const SummaryContent = () => (
|
||||
<>
|
||||
<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 (
|
||||
<>
|
||||
<CustomModal
|
||||
@@ -403,166 +470,143 @@ export default function SeatsPage() {
|
||||
message={modalState.message}
|
||||
type={modalState.type}
|
||||
/>
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
|
||||
{/* Mobile summary bottom-sheet */}
|
||||
{selectedSeats.length > 0 && (
|
||||
<>
|
||||
<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
|
||||
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" />
|
||||
Back to passenger details
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Back
|
||||
</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>
|
||||
|
||||
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Select seats</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="container mx-auto px-4 py-5">
|
||||
<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 ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<p>Loading seats...</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">
|
||||
<div className="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-3" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Loading seat map...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
||||
<div className="text-center py-8 text-red-500 dark:text-red-400">
|
||||
<p>Error loading seats</p>
|
||||
<p className="text-sm mt-2">{error?.message || 'Please try again'}</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">
|
||||
<p className="text-red-500 font-medium">Error loading seats</p>
|
||||
<p className="text-sm text-gray-400 mt-1">{(error as any)?.message || 'Please try again'}</p>
|
||||
</div>
|
||||
) : filteredCoaches.length === 0 ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<p>No coaches available for {selectedSchedule?.selectedSeatClass}</p>
|
||||
<p className="text-sm mt-2">Please select a different seat class</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">No coaches available for {selectedSchedule?.selectedSeatClass}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
||||
<h3 className="font-semibold mb-3 text-gray-900 dark:text-gray-100">Select coach</h3>
|
||||
<div className="flex flex-row gap-2">
|
||||
{filteredCoaches?.map((coach: any) => {
|
||||
<>
|
||||
{/* Coach selector */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 border border-gray-100 dark:border-gray-700">
|
||||
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">Coach</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filteredCoaches.map((coach: any) => {
|
||||
const coachSeats = coach.seats?.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')) || [];
|
||||
const isBedCoach = coach.seatClass?.toLowerCase().includes('bed') || coach.mode?.toLowerCase().includes('bed');
|
||||
let filteredSeats = coachSeats;
|
||||
if (isBedCoach && selectedSchedule?.selectedSeatClass) {
|
||||
const isBed = coach.seatClass?.toLowerCase().includes('bed') || coach.mode?.toLowerCase().includes('bed');
|
||||
let fSeats = coachSeats;
|
||||
if (isBed && selectedSchedule?.selectedSeatClass) {
|
||||
const bedPos = getBedPosition(selectedSchedule.selectedSeatClass);
|
||||
if (bedPos) {
|
||||
filteredSeats = coachSeats.filter((s: any) => s.bedPosition === bedPos);
|
||||
}
|
||||
if (bedPos) fSeats = coachSeats.filter((s: any) => s.bedPosition === bedPos);
|
||||
}
|
||||
const availableCount = filteredSeats.filter((s: any) => s.status === 'AVAILABLE').length || 0;
|
||||
const seatClassName = selectedSchedule?.selectedSeatClass || (typeof coach.seatClass === 'string' ? coach.seatClass : (coach.seatClass?.name || coach.coachClass || ''));
|
||||
const available = fSeats.filter((s: any) => s.status === 'AVAILABLE').length;
|
||||
const isActive = selectedCoach === coach.id;
|
||||
return (
|
||||
<button
|
||||
key={coach.id}
|
||||
onClick={() => setSelectedCoach(coach.id)}
|
||||
className={`px-4 py-2 rounded transition-all text-left ${
|
||||
selectedCoach === coach.id
|
||||
? 'bg-[rgb(20_113_76)] text-white shadow-lg'
|
||||
: 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100'
|
||||
className={`px-4 py-2.5 rounded-xl text-sm font-semibold transition-all ${
|
||||
isActive
|
||||
? 'bg-[rgb(20,113,76)] text-white shadow-md'
|
||||
: '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 className="text-xs opacity-75">{seatClassName}</div>
|
||||
<div className="text-xs opacity-75">{availableCount} available</div>
|
||||
<div>{coach.label || coach.name || coach.coachNumber}</div>
|
||||
<div className={`text-xs mt-0.5 ${isActive ? 'text-white/70' : 'text-gray-400'}`}>{available} free</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
||||
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
||||
Seat map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}
|
||||
</h3>
|
||||
{selectedCoachData && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
Arrangement: {selectedCoachData.seatArrangement} • Total: {selectedCoachData.totalSeats} seats
|
||||
</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>
|
||||
{/* Seat map */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 border border-gray-100 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400">{selectedCoachData?.seatArrangement}</span>
|
||||
</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">
|
||||
{validSeats.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<p>No seats available in this coach</p>
|
||||
<p className="text-sm mt-2">Please select a different coach</p>
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-3 mb-4">
|
||||
{[
|
||||
{ color: 'bg-green-500', label: 'Available' },
|
||||
{ color: 'bg-[rgb(20,113,76)]', label: 'Selected' },
|
||||
{ color: 'bg-yellow-500', label: 'Held' },
|
||||
{ color: 'bg-gray-400', label: 'Booked' },
|
||||
].map(({ color, label }) => (
|
||||
<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>
|
||||
) : (
|
||||
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 className="lg:col-span-1">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 sticky top-6">
|
||||
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Selection summary</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
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>
|
||||
{/* ── Sidebar summary (desktop only) ── */}
|
||||
<div className="hidden lg:block lg:col-span-1">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-5 border border-gray-100 dark:border-gray-700 sticky top-20">
|
||||
<SummaryContent />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Mobile spacer so bottom-sheet doesn't cover last seat */}
|
||||
<div className="h-48 lg:hidden" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -157,4 +157,22 @@
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { transform: translateY(100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ function LoginContent() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
resolver: zodResolver(loginSchema as any),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: LoginForm) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Facebook, Twitter, Instagram, Linkedin, Mail, Phone, MapPin } from 'lucide-react';
|
||||
import { Mail, Phone, MapPin } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage, getTranslation, Language } from '@/lib/i18n';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -102,41 +102,17 @@ export function Footer() {
|
||||
<div>
|
||||
<h4 className="font-semibold mb-4">{t('footer.follow')}</h4>
|
||||
<div className="flex gap-3">
|
||||
<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"
|
||||
>
|
||||
<Facebook className="w-5 h-5" />
|
||||
<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">
|
||||
<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>
|
||||
</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"
|
||||
>
|
||||
<Twitter className="w-5 h-5" />
|
||||
<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">
|
||||
<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>
|
||||
</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"
|
||||
>
|
||||
<Instagram className="w-5 h-5" />
|
||||
<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">
|
||||
<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>
|
||||
</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"
|
||||
>
|
||||
<Linkedin className="w-5 h-5" />
|
||||
<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">
|
||||
<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>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,16 +28,23 @@ export default function ModernDatePicker({
|
||||
placeholder = 'Select date',
|
||||
}: ModernDatePickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMobileView, setIsMobileView] = useState(false);
|
||||
const [calendarType, setCalendarType] = useState<'gregorian' | 'ethiopian'>('gregorian');
|
||||
const [viewMonth, setViewMonth] = useState(value?.getMonth() || new Date().getMonth());
|
||||
const [viewYear, setViewYear] = useState(value?.getFullYear() || new Date().getFullYear());
|
||||
|
||||
// Initialize Ethiopian calendar with current date
|
||||
const [viewMonth, setViewMonth] = useState(value?.getMonth() ?? new Date().getMonth());
|
||||
const [viewYear, setViewYear] = useState(value?.getFullYear() ?? new Date().getFullYear());
|
||||
|
||||
const initialEthDate = gregorianToEthiopian(value || new Date());
|
||||
const [ethViewMonth, setEthViewMonth] = useState(initialEthDate.month);
|
||||
const [ethViewYear, setEthViewYear] = useState(initialEthDate.year);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobileView(window.innerWidth < 768);
|
||||
check();
|
||||
window.addEventListener('resize', check);
|
||||
return () => window.removeEventListener('resize', check);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setViewMonth(value.getMonth());
|
||||
@@ -48,39 +55,27 @@ export default function ModernDatePicker({
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Lock body scroll when modal is open (both mobile and desktop modal)
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [isOpen]);
|
||||
|
||||
const toggleCalendarType = () => {
|
||||
const newType = calendarType === 'gregorian' ? 'ethiopian' : 'gregorian';
|
||||
|
||||
const ref = value || new Date();
|
||||
if (newType === 'ethiopian') {
|
||||
// When switching to Ethiopian, show the Ethiopian equivalent of current Gregorian view
|
||||
// Use today's date if no value is selected, otherwise use the selected value
|
||||
const referenceDate = value || new Date();
|
||||
const ethDate = gregorianToEthiopian(referenceDate);
|
||||
const ethDate = gregorianToEthiopian(ref);
|
||||
setEthViewMonth(ethDate.month);
|
||||
setEthViewYear(ethDate.year);
|
||||
} else {
|
||||
// When switching to Gregorian, show the Gregorian equivalent of current Ethiopian view
|
||||
const referenceDate = value || new Date();
|
||||
setViewMonth(referenceDate.getMonth());
|
||||
setViewYear(referenceDate.getFullYear());
|
||||
setViewMonth(ref.getMonth());
|
||||
setViewYear(ref.getFullYear());
|
||||
}
|
||||
|
||||
setCalendarType(newType);
|
||||
};
|
||||
|
||||
@@ -90,102 +85,46 @@ export default function ModernDatePicker({
|
||||
};
|
||||
|
||||
const handleEthiopianDateSelect = (ethDate: EthiopianDate) => {
|
||||
const gregDate = ethiopianToGregorian(ethDate);
|
||||
handleDateSelect(gregDate);
|
||||
handleDateSelect(ethiopianToGregorian(ethDate));
|
||||
};
|
||||
|
||||
const renderGregorianCalendar = () => {
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
const firstDayOfMonth = new Date(viewYear, viewMonth, 1).getDay();
|
||||
const days: (number | null)[] = [];
|
||||
|
||||
for (let i = 0; i < firstDayOfMonth; i++) {
|
||||
days.push(null);
|
||||
}
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
days.push(day);
|
||||
}
|
||||
|
||||
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
const firstDay = new Date(viewYear, viewMonth, 1).getDay();
|
||||
const days: (number | null)[] = Array(firstDay).fill(null);
|
||||
for (let d = 1; d <= daysInMonth; d++) days.push(d);
|
||||
const monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<ChevronLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
<div className="font-semibold text-base text-gray-900 dark:text-gray-100">
|
||||
{monthNames[viewMonth]} {viewYear}
|
||||
</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"
|
||||
>
|
||||
<span className="font-semibold text-base text-gray-900 dark:text-gray-100">{monthNames[viewMonth]} {viewYear}</span>
|
||||
<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">
|
||||
<ChevronRight className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(day => (
|
||||
<div key={day} className="text-center text-xs font-semibold text-gray-500 dark:text-gray-400 py-2">
|
||||
{day}
|
||||
</div>
|
||||
{['Su','Mo','Tu','We','Th','Fr','Sa'].map(d => (
|
||||
<div key={d} className="text-center text-xs font-semibold text-gray-400 py-2">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((day, index) => {
|
||||
if (day === null) {
|
||||
return <div key={`empty-${index}`} />;
|
||||
}
|
||||
|
||||
{days.map((day, i) => {
|
||||
if (day === null) return <div key={`e-${i}`} />;
|
||||
const date = new Date(viewYear, viewMonth, day);
|
||||
const isSelected = value &&
|
||||
date.getDate() === value.getDate() &&
|
||||
date.getMonth() === value.getMonth() &&
|
||||
date.getFullYear() === value.getFullYear();
|
||||
const isToday =
|
||||
date.getDate() === new Date().getDate() &&
|
||||
date.getMonth() === new Date().getMonth() &&
|
||||
date.getFullYear() === new Date().getFullYear();
|
||||
const isDisabled =
|
||||
(minDate && date < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) ||
|
||||
(maxDate && date > new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate()));
|
||||
|
||||
const isSelected = value && date.getDate() === value.getDate() && date.getMonth() === value.getMonth() && date.getFullYear() === value.getFullYear();
|
||||
const isToday = date.toDateString() === new Date().toDateString();
|
||||
const isDisabled = (minDate && date < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) || (maxDate && date > new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate()));
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => !isDisabled && handleDateSelect(date)}
|
||||
disabled={isDisabled}
|
||||
className={`
|
||||
aspect-square flex items-center justify-center text-sm rounded-lg transition-all
|
||||
<button key={day} 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' : ''}
|
||||
${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' : ''}
|
||||
${isDisabled ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed' : ''}
|
||||
`}
|
||||
>
|
||||
${isDisabled ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed' : ''}`}>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
@@ -197,133 +136,54 @@ export default function ModernDatePicker({
|
||||
|
||||
const renderEthiopianCalendar = () => {
|
||||
const daysInMonth = getDaysInEthiopianMonth(ethViewYear, ethViewMonth);
|
||||
const days: number[] = [];
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
days.push(day);
|
||||
}
|
||||
|
||||
const firstDate = ethiopianToGregorian({ year: ethViewYear, month: ethViewMonth, day: 1 });
|
||||
const firstDayOfWeek = firstDate.getDay();
|
||||
|
||||
const daysWithOffset: (number | null)[] = [];
|
||||
for (let i = 0; i < firstDayOfWeek; i++) {
|
||||
daysWithOffset.push(null);
|
||||
}
|
||||
daysWithOffset.push(...days);
|
||||
|
||||
// Get month name safely
|
||||
const daysWithOffset: (number | null)[] = Array(firstDayOfWeek).fill(null);
|
||||
for (let d = 1; d <= daysInMonth; d++) daysWithOffset.push(d);
|
||||
const monthName = ETHIOPIAN_MONTHS[ethViewMonth - 1] || `Month ${ethViewMonth}`;
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<ChevronLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
<div className="font-semibold text-base text-gray-900 dark:text-gray-100">
|
||||
{monthName} {ethViewYear}
|
||||
</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"
|
||||
>
|
||||
<span className="font-semibold text-base text-gray-900 dark:text-gray-100">{monthName} {ethViewYear}</span>
|
||||
<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">
|
||||
<ChevronRight className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(day => (
|
||||
<div key={day} className="text-center text-xs font-semibold text-gray-500 dark:text-gray-400 py-2">
|
||||
{day}
|
||||
</div>
|
||||
{['Su','Mo','Tu','We','Th','Fr','Sa'].map(d => (
|
||||
<div key={d} className="text-center text-xs font-semibold text-gray-400 py-2">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{daysWithOffset.map((day, index) => {
|
||||
if (day === null) {
|
||||
return <div key={`empty-${index}`} />;
|
||||
}
|
||||
|
||||
{daysWithOffset.map((day, i) => {
|
||||
if (day === null) return <div key={`e-${i}`} />;
|
||||
const ethDate: EthiopianDate = { year: ethViewYear, month: ethViewMonth, day };
|
||||
const gregDate = ethiopianToGregorian(ethDate);
|
||||
|
||||
const isSelected = value &&
|
||||
gregDate.getDate() === value.getDate() &&
|
||||
gregDate.getMonth() === value.getMonth() &&
|
||||
gregDate.getFullYear() === value.getFullYear();
|
||||
|
||||
const today = new Date();
|
||||
const todayEth = gregorianToEthiopian(today);
|
||||
const isToday =
|
||||
ethDate.day === todayEth.day &&
|
||||
ethDate.month === todayEth.month &&
|
||||
ethDate.year === todayEth.year;
|
||||
|
||||
const isSelected = value && gregDate.getDate() === value.getDate() && gregDate.getMonth() === value.getMonth() && gregDate.getFullYear() === value.getFullYear();
|
||||
const todayEth = gregorianToEthiopian(new Date());
|
||||
const isToday = ethDate.day === todayEth.day && ethDate.month === todayEth.month && ethDate.year === todayEth.year;
|
||||
let isDisabled = false;
|
||||
if (minDate) {
|
||||
const minYear = minDate.getFullYear();
|
||||
const minMonth = minDate.getMonth();
|
||||
const minDay = minDate.getDate();
|
||||
const gregYear = gregDate.getFullYear();
|
||||
const gregMonth = gregDate.getMonth();
|
||||
const gregDay = gregDate.getDate();
|
||||
|
||||
if (gregYear < minYear ||
|
||||
(gregYear === minYear && gregMonth < minMonth) ||
|
||||
(gregYear === minYear && gregMonth === minMonth && gregDay < minDay)) {
|
||||
isDisabled = true;
|
||||
}
|
||||
const [mY, mM, mD] = [minDate.getFullYear(), minDate.getMonth(), minDate.getDate()];
|
||||
const [gY, gM, gD] = [gregDate.getFullYear(), gregDate.getMonth(), gregDate.getDate()];
|
||||
if (gY < mY || (gY === mY && gM < mM) || (gY === mY && gM === mM && gD < mD)) isDisabled = true;
|
||||
}
|
||||
if (maxDate && !isDisabled) {
|
||||
const maxYear = maxDate.getFullYear();
|
||||
const maxMonth = maxDate.getMonth();
|
||||
const maxDay = maxDate.getDate();
|
||||
const gregYear = gregDate.getFullYear();
|
||||
const gregMonth = gregDate.getMonth();
|
||||
const gregDay = gregDate.getDate();
|
||||
|
||||
if (gregYear > maxYear ||
|
||||
(gregYear === maxYear && gregMonth > maxMonth) ||
|
||||
(gregYear === maxYear && gregMonth === maxMonth && gregDay > maxDay)) {
|
||||
isDisabled = true;
|
||||
}
|
||||
const [mY, mM, mD] = [maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate()];
|
||||
const [gY, gM, gD] = [gregDate.getFullYear(), gregDate.getMonth(), gregDate.getDate()];
|
||||
if (gY > mY || (gY === mY && gM > mM) || (gY === mY && gM === mM && gD > mD)) isDisabled = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => !isDisabled && handleEthiopianDateSelect(ethDate)}
|
||||
disabled={isDisabled}
|
||||
className={`
|
||||
aspect-square flex items-center justify-center text-sm rounded-lg transition-all
|
||||
<button key={day} 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' : ''}
|
||||
${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' : ''}
|
||||
${isDisabled ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed' : ''}
|
||||
`}
|
||||
>
|
||||
${isDisabled ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed' : ''}`}>
|
||||
{day}
|
||||
</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 (
|
||||
<div className="relative" ref={containerRef}>
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
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"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="w-full px-3.5 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary text-left flex items-center justify-between bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all group"
|
||||
>
|
||||
<span className={value ? 'text-gray-900 dark:text-gray-100' : 'text-gray-500 dark:text-gray-400'}>
|
||||
{value ? format(value, 'EEEE, MMMM d, yyyy') : placeholder}
|
||||
<span className={`text-sm ${value ? 'text-gray-900 dark:text-gray-100 font-medium' : 'text-gray-400'}`}>
|
||||
{value ? format(value, 'EEE, MMM d, yyyy') : placeholder}
|
||||
</span>
|
||||
<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>
|
||||
|
||||
{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()}
|
||||
|
||||
{value && (
|
||||
<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">
|
||||
<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>
|
||||
{/* Mobile: full-screen takeover */}
|
||||
{isMobileView ? (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] bg-white dark:bg-gray-900 flex flex-col"
|
||||
style={{ animation: 'mdp-slide-up 0.25s cubic-bezier(0.32,0.72,0,1)' }}
|
||||
>
|
||||
{modalContent}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -1,87 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { Check } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface Step {
|
||||
id: string;
|
||||
name: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const steps: Step[] = [
|
||||
{ id: 'search', name: 'Search', href: '/booking/search' },
|
||||
{ id: 'results', name: 'Results', href: '/booking/results' },
|
||||
{ id: 'passengers', name: 'Passengers', href: '/booking/passengers' },
|
||||
{ id: 'seats', name: 'Seats', href: '/booking/seats' },
|
||||
{ id: 'review', name: 'Review', href: '/booking/review' },
|
||||
{ id: 'payment', name: 'Payment', href: '/booking/payment' },
|
||||
{ id: 'confirmation', name: 'Confirmation', href: '/booking/confirmation' },
|
||||
const steps = [
|
||||
{ id: 'search', name: 'Search' },
|
||||
{ id: 'results', name: 'Results' },
|
||||
{ id: 'passengers', name: 'Passengers' },
|
||||
{ id: 'seats', name: 'Seats' },
|
||||
{ id: 'review', name: 'Review' },
|
||||
{ id: 'payment', name: 'Payment' },
|
||||
{ id: 'confirmation', name: 'Done' },
|
||||
];
|
||||
|
||||
interface ProgressIndicatorProps {
|
||||
currentStep: string;
|
||||
}
|
||||
|
||||
export function ProgressIndicator({ currentStep }: ProgressIndicatorProps) {
|
||||
export function ProgressIndicator({ currentStep }: { currentStep: string }) {
|
||||
const currentIndex = steps.findIndex((s) => s.id === currentStep);
|
||||
const scrollRef = useRef<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 (
|
||||
<nav aria-label="Progress" className="py-6">
|
||||
<ol className="flex items-center max-w-6xl mx-auto">
|
||||
{steps.map((step, index) => {
|
||||
const isComplete = index < currentIndex;
|
||||
const isCurrent = index === currentIndex;
|
||||
<nav aria-label="Progress">
|
||||
{/* Mobile: full-bleed scrollable */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
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 (
|
||||
<li key={step.id} className="flex flex-col items-center" style={{ width: `${100 / steps.length}%` }}>
|
||||
<div className="flex items-center w-full">
|
||||
<div
|
||||
className={`flex-1 h-1 transition-all duration-300 ${
|
||||
index === 0 ? 'opacity-0' : isComplete ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
/>
|
||||
<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>
|
||||
{/* Desktop: full container width matching header/footer */}
|
||||
<div className="hidden md:block container mx-auto px-4 py-4">
|
||||
<ol className="flex items-start max-w-6xl mx-auto">
|
||||
{steps.map((s, i) => stepItem(s, i))}
|
||||
</ol>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
});
|
||||
|
||||
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<SearchForm>({
|
||||
resolver: zodResolver(searchSchema),
|
||||
resolver: zodResolver(searchSchema as any),
|
||||
defaultValues: {
|
||||
adultCount: 1,
|
||||
childCount: 0,
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface SearchCriteria {
|
||||
adultCount: number;
|
||||
childCount: number;
|
||||
nationality: 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER';
|
||||
promoCode?: string;
|
||||
}
|
||||
|
||||
export interface PassengerDetail {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user