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>
|
<div>
|
||||||
{showProgress && (
|
{showProgress && (
|
||||||
<div className="bg-white dark:bg-gray-800 border-b dark:border-gray-700">
|
<div className="bg-white dark:bg-gray-800 border-b dark:border-gray-700">
|
||||||
<div className="container mx-auto px-4 py-4">
|
<ProgressIndicator currentStep={currentStep} />
|
||||||
<ProgressIndicator currentStep={currentStep} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -7,16 +7,338 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { useBookingStore } from '@/lib/booking-store';
|
import { useBookingStore } from '@/lib/booking-store';
|
||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { CheckCircle, ExternalLink, Loader2 } from 'lucide-react';
|
import { CheckCircle, ExternalLink, Loader2, CalendarDays, X, Globe } from 'lucide-react';
|
||||||
|
import { gregorianToEthiopian, ethiopianToGregorian, ETHIOPIAN_MONTHS, getDaysInEthiopianMonth } from '@/lib/ethiopian-calendar';
|
||||||
|
|
||||||
|
const GC_MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||||
|
|
||||||
|
function daysInGCMonth(y: number, m: number) {
|
||||||
|
return new Date(y, m, 0).getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function DobPickerModal({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (iso: string) => void;
|
||||||
|
error?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [manualMode, setManualMode] = useState(false);
|
||||||
|
const [calType, setCalType] = useState<'gregorian' | 'ethiopian'>('gregorian');
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const currentEthYear = gregorianToEthiopian(new Date()).year;
|
||||||
|
|
||||||
|
// Parse stored ISO value (always Gregorian)
|
||||||
|
const parsed = value ? value.split('-') : [];
|
||||||
|
const initGCYear = parsed[0] ? parseInt(parsed[0]) : currentYear - 25;
|
||||||
|
const initGCMonth = parsed[1] ? parseInt(parsed[1]) : 1;
|
||||||
|
const initGCDay = parsed[2] ? parseInt(parsed[2]) : 1;
|
||||||
|
|
||||||
|
// Gregorian drum state
|
||||||
|
const [selGCYear, setSelGCYear] = useState(initGCYear);
|
||||||
|
const [selGCMonth, setSelGCMonth] = useState(initGCMonth);
|
||||||
|
const [selGCDay, setSelGCDay] = useState(initGCDay);
|
||||||
|
|
||||||
|
// Ethiopian drum state — initialise from parsed value if present
|
||||||
|
const initEth = value ? gregorianToEthiopian(new Date(initGCYear, initGCMonth - 1, initGCDay)) : { year: currentEthYear - 25, month: 1, day: 1 };
|
||||||
|
const [selEthYear, setSelEthYear] = useState(initEth.year);
|
||||||
|
const [selEthMonth, setSelEthMonth] = useState(initEth.month);
|
||||||
|
const [selEthDay, setSelEthDay] = useState(initEth.day);
|
||||||
|
|
||||||
|
// Manual inputs
|
||||||
|
const [manDay, setManDay] = useState(parsed[2] ? String(parseInt(parsed[2])) : '');
|
||||||
|
const [manMonth, setManMonth] = useState(parsed[1] ? String(parseInt(parsed[1])) : '');
|
||||||
|
const [manYear, setManYear] = useState(parsed[0] || '');
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const gcMaxDay = daysInGCMonth(selGCYear, selGCMonth);
|
||||||
|
const ethMaxDay = getDaysInEthiopianMonth(selEthYear, selEthMonth);
|
||||||
|
const gcSafeDay = Math.min(selGCDay, gcMaxDay);
|
||||||
|
const ethSafeDay = Math.min(selEthDay, ethMaxDay);
|
||||||
|
|
||||||
|
const gcYears = Array.from({ length: 100 }, (_, i) => currentYear - i);
|
||||||
|
const ethYears = Array.from({ length: 100 }, (_, i) => currentEthYear - i);
|
||||||
|
const gcMonths = GC_MONTHS.map((m, i) => ({ label: m, value: i + 1 }));
|
||||||
|
const ethMonths = ETHIOPIAN_MONTHS.map((m, i) => ({ label: m, value: i + 1 }));
|
||||||
|
const gcDays = Array.from({ length: gcMaxDay }, (_, i) => i + 1);
|
||||||
|
const ethDays = Array.from({ length: ethMaxDay }, (_, i) => i + 1);
|
||||||
|
|
||||||
|
const dayRef = useRef<HTMLDivElement>(null);
|
||||||
|
const monthRef = useRef<HTMLDivElement>(null);
|
||||||
|
const yearRef = useRef<HTMLDivElement>(null);
|
||||||
|
const ITEM_H = 48;
|
||||||
|
|
||||||
|
const scrollTo = (ref: React.RefObject<HTMLDivElement>, idx: number) => {
|
||||||
|
ref.current?.scrollTo({ top: Math.max(0, idx) * ITEM_H, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scroll drums to current selection when opened or calType changed
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || manualMode) return;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (calType === 'gregorian') {
|
||||||
|
scrollTo(dayRef, gcSafeDay - 1);
|
||||||
|
scrollTo(monthRef, selGCMonth - 1);
|
||||||
|
scrollTo(yearRef, gcYears.indexOf(selGCYear));
|
||||||
|
} else {
|
||||||
|
scrollTo(dayRef, ethSafeDay - 1);
|
||||||
|
scrollTo(monthRef, selEthMonth - 1);
|
||||||
|
scrollTo(yearRef, ethYears.indexOf(selEthYear));
|
||||||
|
}
|
||||||
|
}, 60);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, manualMode, calType]);
|
||||||
|
|
||||||
|
const makeScrollHandler = (
|
||||||
|
ref: React.RefObject<HTMLDivElement>,
|
||||||
|
setter: (v: number) => void,
|
||||||
|
getList: () => number[],
|
||||||
|
) => {
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const list = getList();
|
||||||
|
const idx = Math.round(el.scrollTop / ITEM_H);
|
||||||
|
const clamped = Math.max(0, Math.min(idx, list.length - 1));
|
||||||
|
setter(list[clamped]);
|
||||||
|
el.scrollTo({ top: clamped * ITEM_H, behavior: 'smooth' });
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const gcDayHandler = useRef(makeScrollHandler(dayRef, setSelGCDay, () => Array.from({ length: daysInGCMonth(selGCYear, selGCMonth) }, (_, i) => i + 1))).current;
|
||||||
|
const gcMonthHandler = useRef(makeScrollHandler(monthRef, setSelGCMonth, () => GC_MONTHS.map((_, i) => i + 1))).current;
|
||||||
|
const gcYearHandler = useRef(makeScrollHandler(yearRef, setSelGCYear, () => Array.from({ length: 100 }, (_, i) => new Date().getFullYear() - i))).current;
|
||||||
|
|
||||||
|
const ethDayHandler = useRef(makeScrollHandler(dayRef, setSelEthDay, () => Array.from({ length: getDaysInEthiopianMonth(selEthYear, selEthMonth) }, (_, i) => i + 1))).current;
|
||||||
|
const ethMonthHandler = useRef(makeScrollHandler(monthRef, setSelEthMonth, () => ETHIOPIAN_MONTHS.map((_, i) => i + 1))).current;
|
||||||
|
const ethYearHandler = useRef(makeScrollHandler(yearRef, setSelEthYear, () => Array.from({ length: 100 }, (_, i) => gregorianToEthiopian(new Date()).year - i))).current;
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
let gregDate: Date;
|
||||||
|
if (calType === 'gregorian') {
|
||||||
|
const d = Math.min(selGCDay, daysInGCMonth(selGCYear, selGCMonth));
|
||||||
|
gregDate = new Date(selGCYear, selGCMonth - 1, d);
|
||||||
|
} else {
|
||||||
|
const d = Math.min(selEthDay, getDaysInEthiopianMonth(selEthYear, selEthMonth));
|
||||||
|
gregDate = ethiopianToGregorian({ year: selEthYear, month: selEthMonth, day: d });
|
||||||
|
}
|
||||||
|
const iso = `${gregDate.getFullYear()}-${String(gregDate.getMonth() + 1).padStart(2,'0')}-${String(gregDate.getDate()).padStart(2,'0')}`;
|
||||||
|
onChange(iso);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmManual = () => {
|
||||||
|
const d = parseInt(manDay), m = parseInt(manMonth), y = parseInt(manYear);
|
||||||
|
if (!d || !m || !y || m < 1 || m > 12 || d < 1 || d > daysInGCMonth(y, m) || y < currentYear - 110 || y > currentYear) return;
|
||||||
|
onChange(`${y}-${String(m).padStart(2,'0')}-${String(d).padStart(2,'0')}`);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const manualValid = (() => {
|
||||||
|
const d = parseInt(manDay), m = parseInt(manMonth), y = parseInt(manYear);
|
||||||
|
return d >= 1 && m >= 1 && m <= 12 && y >= currentYear - 110 && y <= currentYear && d <= daysInGCMonth(y, m);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Display: always show Gregorian ISO as human-readable, with calendar label
|
||||||
|
const displayValue = (() => {
|
||||||
|
if (!value) return '';
|
||||||
|
const [y, mo, d] = value.split('-').map(Number);
|
||||||
|
const gcStr = `${GC_MONTHS[mo - 1]} ${d}, ${y}`;
|
||||||
|
if (calType === 'ethiopian') {
|
||||||
|
const eth = gregorianToEthiopian(new Date(y, mo - 1, d));
|
||||||
|
return `${ETHIOPIAN_MONTHS[eth.month - 1]} ${eth.day}, ${eth.year} (${gcStr})`;
|
||||||
|
}
|
||||||
|
return gcStr;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Confirm button label
|
||||||
|
const confirmLabel = (() => {
|
||||||
|
if (manualMode) return manualValid ? `Confirm — ${manDay}/${manMonth}/${manYear}` : 'Confirm';
|
||||||
|
if (calType === 'gregorian') {
|
||||||
|
const d = Math.min(selGCDay, gcMaxDay);
|
||||||
|
return `Confirm — ${GC_MONTHS[selGCMonth - 1]} ${d}, ${selGCYear}`;
|
||||||
|
} else {
|
||||||
|
const d = Math.min(selEthDay, ethMaxDay);
|
||||||
|
const gcDate = ethiopianToGregorian({ year: selEthYear, month: selEthMonth, day: d });
|
||||||
|
return `Confirm — ${ETHIOPIAN_MONTHS[selEthMonth - 1]} ${d}, ${selEthYear} (GC: ${GC_MONTHS[gcDate.getMonth()]} ${gcDate.getDate()}, ${gcDate.getFullYear()})`;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const col = (
|
||||||
|
ref: React.RefObject<HTMLDivElement>,
|
||||||
|
list: Array<{ label: string; value: number }>,
|
||||||
|
selected: number,
|
||||||
|
setter: (v: number) => void,
|
||||||
|
scrollHandler: () => void,
|
||||||
|
) => (
|
||||||
|
<div className="flex-1 flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
onScroll={scrollHandler}
|
||||||
|
className="h-full overflow-y-auto scrollbar-hide"
|
||||||
|
style={{ scrollSnapType: 'y mandatory' }}
|
||||||
|
>
|
||||||
|
<div style={{ height: ITEM_H * 2 }} />
|
||||||
|
{list.map((item, idx) => (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
style={{ height: ITEM_H, scrollSnapAlign: 'center', cursor: 'pointer' }}
|
||||||
|
onClick={() => { setter(item.value); ref.current?.scrollTo({ top: idx * ITEM_H, behavior: 'smooth' }); }}
|
||||||
|
className={`flex items-center justify-center text-sm font-medium transition-all select-none ${
|
||||||
|
item.value === selected ? 'text-primary font-bold text-base' : 'text-gray-400 dark:text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ height: ITEM_H * 2 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className={`input-field w-full text-left flex items-center justify-between ${
|
||||||
|
error ? 'border-red-500' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={displayValue ? 'text-gray-900 dark:text-white text-sm' : 'text-gray-400 text-sm'}>
|
||||||
|
{displayValue || 'Select date of birth'}
|
||||||
|
</span>
|
||||||
|
<CalendarDays className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
|
</button>
|
||||||
|
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-[99] bg-black/50" onClick={() => setOpen(false)} />
|
||||||
|
<div
|
||||||
|
className="fixed inset-x-0 bottom-0 z-[100] bg-white dark:bg-gray-900 rounded-t-2xl shadow-2xl"
|
||||||
|
style={{ animation: 'dob-slide-up 0.25s cubic-bezier(0.32,0.72,0,1)' }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-base font-bold text-gray-900 dark:text-white">Date of Birth</h2>
|
||||||
|
{!manualMode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCalType(c => c === 'gregorian' ? 'ethiopian' : 'gregorian')}
|
||||||
|
className="flex items-center gap-1 text-xs font-semibold px-2.5 py-1 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
||||||
|
>
|
||||||
|
<Globe className="w-3 h-3" />
|
||||||
|
{calType === 'gregorian' ? 'ET' : 'GC'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setManualMode(!manualMode)}
|
||||||
|
className="text-xs text-gray-500 font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{manualMode ? 'Use scroll' : 'Enter manually'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => setOpen(false)} className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||||
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar type label */}
|
||||||
|
{!manualMode && (
|
||||||
|
<div className="px-5 pt-2 pb-0">
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{calType === 'gregorian' ? 'Gregorian Calendar' : 'Ethiopian Calendar (ኢትዮጵያ)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{manualMode ? (
|
||||||
|
<div className="px-5 py-5 space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Day</label>
|
||||||
|
<input type="number" min={1} max={31} value={manDay} onChange={(e) => setManDay(e.target.value)} placeholder="DD" className="input-field text-center text-lg font-semibold" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Month</label>
|
||||||
|
<input type="number" min={1} max={12} value={manMonth} onChange={(e) => setManMonth(e.target.value)} placeholder="MM" className="input-field text-center text-lg font-semibold" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Year</label>
|
||||||
|
<input type="number" min={currentYear - 110} max={currentYear} value={manYear} onChange={(e) => setManYear(e.target.value)} placeholder="YYYY" className="input-field text-center text-lg font-semibold" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{manDay && manMonth && manYear && !manualValid && (
|
||||||
|
<p className="text-red-500 text-xs">Please enter a valid Gregorian date</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex px-5 pt-2 pb-1">
|
||||||
|
{['Day', 'Month', 'Year'].map(l => (
|
||||||
|
<div key={l} className="flex-1 text-center text-xs font-semibold text-gray-400 uppercase tracking-wide">{l}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="relative px-5 pb-2" style={{ height: ITEM_H * 5 }}>
|
||||||
|
<div
|
||||||
|
className="absolute left-5 right-5 pointer-events-none rounded-xl border border-primary/30 bg-primary/5 dark:bg-primary/10"
|
||||||
|
style={{ top: ITEM_H * 2, height: ITEM_H }}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 h-full">
|
||||||
|
{calType === 'gregorian' ? (
|
||||||
|
<>
|
||||||
|
{col(dayRef, gcDays.map(d => ({ label: String(d), value: d })), gcSafeDay, setSelGCDay, gcDayHandler)}
|
||||||
|
{col(monthRef, gcMonths, selGCMonth, setSelGCMonth, gcMonthHandler)}
|
||||||
|
{col(yearRef, gcYears.map(y => ({ label: String(y), value: y })), selGCYear, setSelGCYear, gcYearHandler)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{col(dayRef, ethDays.map(d => ({ label: String(d), value: d })), ethSafeDay, setSelEthDay, ethDayHandler)}
|
||||||
|
{col(monthRef, ethMonths, selEthMonth, setSelEthMonth, ethMonthHandler)}
|
||||||
|
{col(yearRef, ethYears.map(y => ({ label: String(y), value: y })), selEthYear, setSelEthYear, ethYearHandler)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="px-5 pb-8 pt-3 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={manualMode ? confirmManual : confirm}
|
||||||
|
disabled={manualMode && !manualValid}
|
||||||
|
className="px-6 py-2.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>{`@keyframes dob-slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}`}</style>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const passengerSchema = z.object({
|
const passengerSchema = z.object({
|
||||||
name: z.string().min(2, 'Name is required'),
|
name: z.string().min(2, 'Full name is required (min 2 characters)'),
|
||||||
dateOfBirth: z.string().min(1, 'Date of birth is required'),
|
dateOfBirth: z.string().min(1, 'Date of birth is required'),
|
||||||
gender: z.enum(['Male', 'Female']).optional(),
|
gender: z.string().min(1, 'Gender is required'),
|
||||||
nationality: z.string().min(1, 'Nationality is required'),
|
nationality: z.string().min(1, 'Nationality is required'),
|
||||||
phone: z.string().optional(),
|
phone: z.string().min(1, 'Phone number is required'),
|
||||||
email: z.string().email('Invalid email').optional().or(z.literal('')),
|
email: z.string().optional(),
|
||||||
nationalId: z.string().optional(),
|
nationalId: z.string().optional(),
|
||||||
passportNumber: z.string().optional(),
|
passportNumber: z.string().optional(),
|
||||||
passportCountry: z.string().optional(),
|
passportCountry: z.string().optional(),
|
||||||
@@ -26,15 +348,25 @@ const passengerSchema = z.object({
|
|||||||
faydaVerified: z.boolean().optional(),
|
faydaVerified: z.boolean().optional(),
|
||||||
faydaSub: z.string().optional(),
|
faydaSub: z.string().optional(),
|
||||||
formExpanded: z.boolean().optional(),
|
formExpanded: z.boolean().optional(),
|
||||||
}).refine((data) => {
|
}).superRefine((data, ctx) => {
|
||||||
if (data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian') {
|
if (data.gender !== 'Male' && data.gender !== 'Female') {
|
||||||
return data.passportNumber && data.passportNumber.length > 0 &&
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Gender is required', path: ['gender'] });
|
||||||
data.passportCountry && data.passportCountry.length > 0;
|
}
|
||||||
|
if (data.email && data.email.trim().length > 0) {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(data.email)) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['email'] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian';
|
||||||
|
if (isNonEthiopian) {
|
||||||
|
if (!data.passportNumber || data.passportNumber.trim().length === 0) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number is required', path: ['passportNumber'] });
|
||||||
|
}
|
||||||
|
if (!data.passportCountry || data.passportCountry.trim().length === 0) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Issuing country is required', path: ['passportCountry'] });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}, {
|
|
||||||
message: 'Passport number and country are required for non-Ethiopian passengers',
|
|
||||||
path: ['passportNumber'],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
@@ -57,7 +389,8 @@ export default function PassengersPage() {
|
|||||||
const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0);
|
const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0);
|
||||||
|
|
||||||
const { register, control, handleSubmit, setValue, watch, formState: { errors } } = useForm<FormData>({
|
const { register, control, handleSubmit, setValue, watch, formState: { errors } } = useForm<FormData>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema as any),
|
||||||
|
mode: 'onChange',
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
passengers: Array.from({ length: totalPassengers }, () => ({
|
passengers: Array.from({ length: totalPassengers }, () => ({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -418,82 +751,80 @@ export default function PassengersPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
{/* Full Name */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
|
||||||
<input
|
<input
|
||||||
{...register(`passengers.${index}.name`)}
|
{...register(`passengers.${index}.name`)}
|
||||||
className="input-field"
|
className={`input-field ${errors.passengers?.[index]?.name ? 'border-red-500' : ''}`}
|
||||||
placeholder="Full name as per ID"
|
placeholder="Full name as per ID"
|
||||||
value={passengers[index]?.name || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.name`, e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
{errors.passengers?.[index]?.name && (
|
{errors.passengers?.[index]?.name && (
|
||||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.name?.message}</p>
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.name?.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Date of Birth */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
|
||||||
<input
|
<DobPickerModal
|
||||||
type="date"
|
|
||||||
{...register(`passengers.${index}.dateOfBirth`)}
|
|
||||||
className="input-field"
|
|
||||||
value={passengers[index]?.dateOfBirth || ''}
|
value={passengers[index]?.dateOfBirth || ''}
|
||||||
onChange={(e) => setValue(`passengers.${index}.dateOfBirth`, e.target.value)}
|
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
|
||||||
|
error={errors.passengers?.[index]?.dateOfBirth?.message}
|
||||||
/>
|
/>
|
||||||
{errors.passengers?.[index]?.dateOfBirth && (
|
|
||||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.dateOfBirth?.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Gender */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
|
||||||
<select
|
<select
|
||||||
{...register(`passengers.${index}.gender`)}
|
{...register(`passengers.${index}.gender`)}
|
||||||
className="input-field"
|
className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`}
|
||||||
value={passengers[index]?.gender || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.gender`, e.target.value as any)}
|
|
||||||
>
|
>
|
||||||
<option value="">Select gender</option>
|
<option value="">Select gender</option>
|
||||||
<option value="Male">Male</option>
|
<option value="Male">Male</option>
|
||||||
<option value="Female">Female</option>
|
<option value="Female">Female</option>
|
||||||
</select>
|
</select>
|
||||||
|
{errors.passengers?.[index]?.gender && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.gender?.message}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Nationality (read-only) */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality *</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality</label>
|
||||||
<input
|
<input
|
||||||
{...register(`passengers.${index}.nationality`)}
|
{...register(`passengers.${index}.nationality`)}
|
||||||
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
|
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
|
||||||
readOnly
|
readOnly
|
||||||
disabled
|
disabled
|
||||||
value={passengers[index]?.nationality || ''}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
|
||||||
<input
|
<input
|
||||||
{...register(`passengers.${index}.phone`)}
|
{...register(`passengers.${index}.phone`)}
|
||||||
className="input-field"
|
className={`input-field ${errors.passengers?.[index]?.phone ? 'border-red-500' : ''}`}
|
||||||
placeholder="+251911234567"
|
placeholder="+251911234567"
|
||||||
value={passengers[index]?.phone || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.phone`, e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
|
{errors.passengers?.[index]?.phone && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.phone?.message}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
{...register(`passengers.${index}.email`)}
|
{...register(`passengers.${index}.email`)}
|
||||||
className="input-field"
|
className={`input-field ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
|
||||||
placeholder="email@example.com"
|
placeholder="email@example.com"
|
||||||
value={passengers[index]?.email || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.email`, e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
{errors.passengers?.[index]?.email && (
|
{errors.passengers?.[index]?.email && (
|
||||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.email?.message}</p>
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.email?.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -501,113 +832,109 @@ export default function PassengersPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
{/* Full Name */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
|
||||||
<input
|
<input
|
||||||
{...register(`passengers.${index}.name`)}
|
{...register(`passengers.${index}.name`)}
|
||||||
className="input-field"
|
className={`input-field ${errors.passengers?.[index]?.name ? 'border-red-500' : ''}`}
|
||||||
placeholder="Full name as per passport"
|
placeholder="Full name as per passport"
|
||||||
value={passengers[index]?.name || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.name`, e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
{errors.passengers?.[index]?.name && (
|
{errors.passengers?.[index]?.name && (
|
||||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.name?.message}</p>
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.name?.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Date of Birth */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
|
||||||
<input
|
<DobPickerModal
|
||||||
type="date"
|
|
||||||
{...register(`passengers.${index}.dateOfBirth`)}
|
|
||||||
className="input-field"
|
|
||||||
value={passengers[index]?.dateOfBirth || ''}
|
value={passengers[index]?.dateOfBirth || ''}
|
||||||
onChange={(e) => setValue(`passengers.${index}.dateOfBirth`, e.target.value)}
|
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
|
||||||
|
error={errors.passengers?.[index]?.dateOfBirth?.message}
|
||||||
/>
|
/>
|
||||||
{errors.passengers?.[index]?.dateOfBirth && (
|
|
||||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.dateOfBirth?.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Gender */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
|
||||||
<select
|
<select
|
||||||
{...register(`passengers.${index}.gender`)}
|
{...register(`passengers.${index}.gender`)}
|
||||||
className="input-field"
|
className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`}
|
||||||
value={passengers[index]?.gender || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.gender`, e.target.value as any)}
|
|
||||||
>
|
>
|
||||||
<option value="">Select gender</option>
|
<option value="">Select gender</option>
|
||||||
<option value="Male">Male</option>
|
<option value="Male">Male</option>
|
||||||
<option value="Female">Female</option>
|
<option value="Female">Female</option>
|
||||||
</select>
|
</select>
|
||||||
|
{errors.passengers?.[index]?.gender && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.gender?.message}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Nationality (read-only) */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality *</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality</label>
|
||||||
<input
|
<input
|
||||||
{...register(`passengers.${index}.nationality`)}
|
{...register(`passengers.${index}.nationality`)}
|
||||||
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
|
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
|
||||||
readOnly
|
readOnly
|
||||||
disabled
|
disabled
|
||||||
value={passengers[index]?.nationality || ''}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
|
||||||
<input
|
<input
|
||||||
{...register(`passengers.${index}.phone`)}
|
{...register(`passengers.${index}.phone`)}
|
||||||
className="input-field"
|
className={`input-field ${errors.passengers?.[index]?.phone ? 'border-red-500' : ''}`}
|
||||||
placeholder="+254712345678"
|
placeholder="+254712345678"
|
||||||
value={passengers[index]?.phone || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.phone`, e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
|
{errors.passengers?.[index]?.phone && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.phone?.message}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
{...register(`passengers.${index}.email`)}
|
{...register(`passengers.${index}.email`)}
|
||||||
className="input-field"
|
className={`input-field ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
|
||||||
placeholder="email@example.com"
|
placeholder="email@example.com"
|
||||||
value={passengers[index]?.email || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.email`, e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
{errors.passengers?.[index]?.email && (
|
{errors.passengers?.[index]?.email && (
|
||||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.email?.message}</p>
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.email?.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Passport fields */}
|
||||||
<div className="border-t dark:border-gray-700 pt-4 mt-4">
|
<div className="border-t dark:border-gray-700 pt-4 mt-4">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Passport Details</h4>
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Passport Number *</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Passport Number *</label>
|
||||||
<input
|
<input
|
||||||
{...register(`passengers.${index}.passportNumber`)}
|
{...register(`passengers.${index}.passportNumber`)}
|
||||||
className="input-field"
|
className={`input-field ${errors.passengers?.[index]?.passportNumber ? 'border-red-500' : ''}`}
|
||||||
placeholder="P1234567"
|
placeholder="P1234567"
|
||||||
value={passengers[index]?.passportNumber || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.passportNumber`, e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
{errors.passengers?.[index]?.passportNumber && (
|
{errors.passengers?.[index]?.passportNumber && (
|
||||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.passportNumber?.message}</p>
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.passportNumber?.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Issuing Country / Authority *</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Issuing Country *</label>
|
||||||
<input
|
<input
|
||||||
{...register(`passengers.${index}.passportCountry`)}
|
{...register(`passengers.${index}.passportCountry`)}
|
||||||
className="input-field"
|
className={`input-field ${errors.passengers?.[index]?.passportCountry ? 'border-red-500' : ''}`}
|
||||||
placeholder="e.g., Djibouti / Government of Djibouti"
|
placeholder="e.g., Djibouti"
|
||||||
value={passengers[index]?.passportCountry || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.passportCountry`, e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
{errors.passengers?.[index]?.passportCountry && (
|
{errors.passengers?.[index]?.passportCountry && (
|
||||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.passportCountry?.message}</p>
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.passportCountry?.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -617,8 +944,6 @@ export default function PassengersPage() {
|
|||||||
type="date"
|
type="date"
|
||||||
{...register(`passengers.${index}.passportIssueDate`)}
|
{...register(`passengers.${index}.passportIssueDate`)}
|
||||||
className="input-field"
|
className="input-field"
|
||||||
value={passengers[index]?.passportIssueDate || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.passportIssueDate`, e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -628,8 +953,6 @@ export default function PassengersPage() {
|
|||||||
type="date"
|
type="date"
|
||||||
{...register(`passengers.${index}.passportExpiryDate`)}
|
{...register(`passengers.${index}.passportExpiryDate`)}
|
||||||
className="input-field"
|
className="input-field"
|
||||||
value={passengers[index]?.passportExpiryDate || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.passportExpiryDate`, e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -652,7 +975,18 @@ export default function PassengersPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<button type="button" onClick={() => router.back()} className="btn-secondary flex-1" disabled={saving}>
|
<button type="button" onClick={() => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
origin: searchCriteria.originStationId,
|
||||||
|
destination: searchCriteria.destinationStationId,
|
||||||
|
date: searchCriteria.departureDate,
|
||||||
|
adults: String(searchCriteria.adultCount),
|
||||||
|
children: String(searchCriteria.childCount),
|
||||||
|
nationality: searchCriteria.nationality,
|
||||||
|
...(searchCriteria.promoCode && { promoCode: searchCriteria.promoCode }),
|
||||||
|
});
|
||||||
|
router.push(`/booking/results?${params}`);
|
||||||
|
}} className="btn-secondary flex-1" disabled={saving}>
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn-primary flex-1" disabled={saving}>
|
<button type="submit" className="btn-primary flex-1" disabled={saving}>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useBookingStore } from '@/lib/booking-store';
|
import { useBookingStore } from '@/lib/booking-store';
|
||||||
import { Schedule } from '@/types';
|
import { Schedule } from '@/types';
|
||||||
import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Loader2, Check, ChevronDown, ChevronUp, MapPin, Gift } from 'lucide-react';
|
import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Loader2, Check, X, MapPin, Gift, Train } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
@@ -14,19 +14,39 @@ export default function ResultsPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule);
|
const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule);
|
||||||
const [selectedClasses, setSelectedClasses] = useState<Record<string, string>>({});
|
const [selectedClasses, setSelectedClasses] = useState<Record<string, string>>({});
|
||||||
const [expandedSchedules, setExpandedSchedules] = useState<Record<string, boolean>>({});
|
const [classModal, setClassModal] = useState<Schedule | null>(null);
|
||||||
const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null);
|
const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null);
|
||||||
|
|
||||||
|
const searchCriteria = useBookingStore((s) => s.searchCriteria);
|
||||||
|
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
|
||||||
|
|
||||||
|
// Prefer URL params; fall back to persisted store values
|
||||||
const searchData = {
|
const searchData = {
|
||||||
originStationId: searchParams.get('origin') || '',
|
originStationId: searchParams.get('origin') || searchCriteria?.originStationId || '',
|
||||||
destinationStationId: searchParams.get('destination') || '',
|
destinationStationId: searchParams.get('destination') || searchCriteria?.destinationStationId || '',
|
||||||
date: searchParams.get('date') || '',
|
date: searchParams.get('date') || searchCriteria?.departureDate || '',
|
||||||
adultCount: parseInt(searchParams.get('adults') || '1'),
|
adultCount: parseInt(searchParams.get('adults') || '') || searchCriteria?.adultCount || 1,
|
||||||
childCount: parseInt(searchParams.get('children') || '0'),
|
childCount: parseInt(searchParams.get('children') || '') || searchCriteria?.childCount || 0,
|
||||||
nationality: searchParams.get('nationality') || 'ETHIOPIAN',
|
nationality: searchParams.get('nationality') || searchCriteria?.nationality || 'ETHIOPIAN',
|
||||||
promoCode: searchParams.get('promoCode') || '',
|
promoCode: searchParams.get('promoCode') || searchCriteria?.promoCode || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sync URL params back into store whenever they are present in the URL
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('origin')) {
|
||||||
|
setSearchCriteria({
|
||||||
|
originStationId: searchParams.get('origin')!,
|
||||||
|
destinationStationId: searchParams.get('destination')!,
|
||||||
|
departureDate: searchParams.get('date')!,
|
||||||
|
adultCount: parseInt(searchParams.get('adults') || '1'),
|
||||||
|
childCount: parseInt(searchParams.get('children') || '0'),
|
||||||
|
nationality: (searchParams.get('nationality') || 'ETHIOPIAN') as 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER',
|
||||||
|
promoCode: searchParams.get('promoCode') || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchData.promoCode) {
|
if (searchData.promoCode) {
|
||||||
apiClient
|
apiClient
|
||||||
@@ -54,6 +74,7 @@ export default function ResultsPage() {
|
|||||||
adults: searchData.adultCount.toString(),
|
adults: searchData.adultCount.toString(),
|
||||||
children: searchData.childCount.toString(),
|
children: searchData.childCount.toString(),
|
||||||
nationality: searchData.nationality,
|
nationality: searchData.nationality,
|
||||||
|
...(searchData.promoCode && { promoCode: searchData.promoCode }),
|
||||||
});
|
});
|
||||||
return `/booking/search?${params}`;
|
return `/booking/search?${params}`;
|
||||||
};
|
};
|
||||||
@@ -73,18 +94,8 @@ export default function ResultsPage() {
|
|||||||
enabled: !!searchData.originStationId && !!searchData.destinationStationId,
|
enabled: !!searchData.originStationId && !!searchData.destinationStationId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleExpanded = (scheduleId: string) => {
|
|
||||||
setExpandedSchedules(prev => ({
|
|
||||||
...prev,
|
|
||||||
[scheduleId]: !prev[scheduleId]
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectClass = (scheduleId: string, seatClass: string) => {
|
const handleSelectClass = (scheduleId: string, seatClass: string) => {
|
||||||
setSelectedClasses(prev => ({
|
setSelectedClasses(prev => ({ ...prev, [scheduleId]: seatClass }));
|
||||||
...prev,
|
|
||||||
[scheduleId]: seatClass
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelect = (schedule: Schedule) => {
|
const handleSelect = (schedule: Schedule) => {
|
||||||
@@ -180,31 +191,127 @@ export default function ResultsPage() {
|
|||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
|
|
||||||
|
{/* Class selection modal */}
|
||||||
|
{classModal && (() => {
|
||||||
|
const scheduleId = classModal.scheduleId || classModal.id || '';
|
||||||
|
const selectedClass = selectedClasses[scheduleId];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="fixed inset-0 z-[99] bg-black/50 backdrop-blur-sm" onClick={() => setClassModal(null)} />
|
||||||
|
{/* Drawer */}
|
||||||
|
<div className="fixed inset-y-0 right-0 z-[100] w-full sm:w-[640px] bg-white dark:bg-gray-900 shadow-2xl flex flex-col"
|
||||||
|
style={{ animation: 'drawer-slide-in 0.25s cubic-bezier(0.32,0.72,0,1)' }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-gray-900 dark:text-white">Select Class</h2>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center gap-1">
|
||||||
|
<Train className="w-3 h-3" />
|
||||||
|
{classModal.trainNumber} · {classModal.origin?.name} → {classModal.destination?.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setClassModal(null)}
|
||||||
|
className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Class grid */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{classModal.faresByClass && Array.isArray(classModal.faresByClass) && classModal.faresByClass.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{classModal.faresByClass.map((fareClass: any) => {
|
||||||
|
const isSelected = selectedClass === fareClass.seatClassName;
|
||||||
|
const availableSeats = classModal.availabilityByClass?.[fareClass.seatClassName] || 0;
|
||||||
|
const isAvailable = availableSeats > 0;
|
||||||
|
const isBedClass = fareClass.seatClassName.toLowerCase().includes('bed');
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={fareClass.seatClassName}
|
||||||
|
onClick={() => isAvailable && handleSelectClass(scheduleId, fareClass.seatClassName)}
|
||||||
|
disabled={!isAvailable}
|
||||||
|
className={`relative w-full p-4 rounded-xl border-2 text-left transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-primary bg-primary/5 dark:bg-primary/10 shadow-sm'
|
||||||
|
: isAvailable
|
||||||
|
? 'border-gray-200 dark:border-gray-700 hover:border-primary/50 hover:shadow-sm'
|
||||||
|
: 'border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800 opacity-50 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute top-3 right-3 w-6 h-6 bg-primary rounded-full flex items-center justify-center">
|
||||||
|
<Check className="w-3.5 h-3.5 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-white pr-8">
|
||||||
|
{fareClass.seatClassName.replace(/_/g, ' ')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-bold text-primary dark:text-white mt-2">
|
||||||
|
ETB {((fareClass.baseFareMinor || 0) / 100).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">per adult</p>
|
||||||
|
<p className={`text-xs mt-2 font-medium ${
|
||||||
|
isAvailable ? 'text-green-600 dark:text-green-400' : 'text-red-500'
|
||||||
|
}`}>
|
||||||
|
{isAvailable
|
||||||
|
? `${availableSeats} ${isBedClass ? 'bed' : 'seat'}${availableSeats !== 1 ? 's' : ''} available`
|
||||||
|
: 'Sold out'}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-center py-8 text-gray-400 text-sm">No seat classes available</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-5 py-4 border-t border-gray-100 dark:border-gray-800 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => { if (selectedClass) { handleSelect(classModal); setClassModal(null); } }}
|
||||||
|
disabled={!selectedClass}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all disabled:opacity-40 disabled:cursor-not-allowed shadow-lg"
|
||||||
|
>
|
||||||
|
<span>Continue</span>
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{!selectedClass && (
|
||||||
|
<p className="text-center text-xs text-gray-400 mt-2">Please select a class to continue</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>{`@keyframes drawer-slide-in{from{transform:translateX(100%)}to{transform:translateX(0)}}`}</style>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Promo Notification */}
|
{/* Promo Notification */}
|
||||||
{promoData && (
|
{promoData && (
|
||||||
<div className="mb-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 flex items-start gap-3">
|
<div className="mb-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 flex items-start gap-3">
|
||||||
<div className="flex-shrink-0 mt-0.5">
|
<Check className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
|
||||||
<Check className="w-5 h-5 text-green-600 dark:text-green-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="font-semibold text-green-900 dark:text-green-200">Promo code applied!</h3>
|
<h3 className="font-semibold text-green-900 dark:text-green-200">Promo code applied!</h3>
|
||||||
<p className="text-sm text-green-800 dark:text-green-300 mt-1">
|
<p className="text-sm text-green-800 dark:text-green-300 mt-1">
|
||||||
<span className="font-mono font-bold">{promoData.code}</span> - {promoData.message}
|
<span className="font-mono font-bold">{promoData.code}</span> — {promoData.message}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<button
|
<button onClick={() => router.push(buildSearchUrl())} className="btn-ghost px-0 py-4 flex items-center gap-2">
|
||||||
onClick={() => router.push(buildSearchUrl())}
|
|
||||||
className="btn-ghost px-0 py-4 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
Modify search
|
Modify search
|
||||||
</button>
|
</button>
|
||||||
<h1 className="section-title">Available trains</h1>
|
<h1 className="section-title">Available schedules</h1>
|
||||||
<div className="flex flex-wrap items-center gap-4 text-gray-600 dark:text-gray-400 mt-3">
|
<div className="hidden sm:flex flex-wrap items-center gap-4 text-gray-600 dark:text-gray-400 mt-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Calendar className="w-4 h-4" />
|
<Calendar className="w-4 h-4" />
|
||||||
<span>{searchData.date ? format(new Date(searchData.date), 'EEEE, MMMM d, yyyy') : 'Date not specified'}</span>
|
<span>{searchData.date ? format(new Date(searchData.date), 'EEEE, MMMM d, yyyy') : 'Date not specified'}</span>
|
||||||
@@ -225,26 +332,21 @@ export default function ResultsPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{results.map((schedule) => {
|
{results.map((schedule) => {
|
||||||
const scheduleId = schedule.scheduleId || schedule.id || '';
|
const scheduleId = schedule.scheduleId || schedule.id || '';
|
||||||
const isExpanded = expandedSchedules[scheduleId];
|
|
||||||
const selectedClass = selectedClasses[scheduleId];
|
const selectedClass = selectedClasses[scheduleId];
|
||||||
|
|
||||||
const lowestFare = schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0
|
const lowestFare = schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0
|
||||||
? Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0))
|
? Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
||||||
const minutes = (schedule.durationMinutes || 0) % 60;
|
const minutes = (schedule.durationMinutes || 0) % 60;
|
||||||
const durationStr = `${hours}h ${minutes}m`;
|
const durationStr = `${hours}h ${minutes}m`;
|
||||||
|
|
||||||
const departureDate = schedule.departureAt ? new Date(schedule.departureAt) : null;
|
const departureDate = schedule.departureAt ? new Date(schedule.departureAt) : null;
|
||||||
const arrivalDate = schedule.arrivalAt ? new Date(schedule.arrivalAt) : null;
|
const arrivalDate = schedule.arrivalAt ? new Date(schedule.arrivalAt) : null;
|
||||||
const isNextDay = departureDate && arrivalDate &&
|
const isNextDay = departureDate && arrivalDate && departureDate.toDateString() !== arrivalDate.toDateString();
|
||||||
departureDate.toDateString() !== arrivalDate.toDateString();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={scheduleId} className="card group">
|
<div key={scheduleId} className="card">
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
|
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
|
||||||
|
{/* Train info */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
|
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
@@ -255,7 +357,7 @@ export default function ResultsPage() {
|
|||||||
<div className="text-sm text-gray-600 dark:text-gray-400">{schedule.trainName || 'Express Service'}</div>
|
<div className="text-sm text-gray-600 dark:text-gray-400">{schedule.trainName || 'Express Service'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
@@ -266,136 +368,62 @@ export default function ResultsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.origin?.name || 'Origin'}</div>
|
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.origin?.name || 'Origin'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col items-center">
|
<div className="flex-1 flex flex-col items-center">
|
||||||
<div className="flex items-center gap-2 mb-2 text-sm text-gray-600 dark:text-gray-400">
|
<div className="flex items-center gap-2 mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
<span>{durationStr}</span>
|
<span>{durationStr}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full h-0.5 bg-gray-200 dark:bg-gray-700 relative">
|
<div className="w-full h-0.5 bg-gray-200 dark:bg-gray-700 relative">
|
||||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full"></div>
|
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full" />
|
||||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full"></div>
|
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full" />
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 mt-2 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{schedule.stops && schedule.stops.length > 0 && (
|
|
||||||
<>
|
|
||||||
<MapPin className="w-4 h-4" />
|
|
||||||
<span>{schedule.stops.length - 2} stops</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{schedule.stops && schedule.stops.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
<span>{schedule.stops.length - 2} stops</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'HH:mm') : '--:--'}
|
{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'HH:mm') : '--:--'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center justify-center gap-1">
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center justify-center gap-1">
|
||||||
<span>{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'MMM d') : ''}</span>
|
<span>{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'MMM d') : ''}</span>
|
||||||
{isNextDay && (
|
{isNextDay && <span className="text-orange-500 font-medium">(+1)</span>}
|
||||||
<span className="text-orange-600 dark:text-orange-400 font-medium">(+1)</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.destination?.name || 'Destination'}</div>
|
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.destination?.name || 'Destination'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Fare + action */}
|
||||||
<div className="lg:border-l dark:border-gray-700 lg:pl-6 lg:min-w-[220px]">
|
<div className="lg:border-l dark:border-gray-700 lg:pl-6 lg:min-w-[220px]">
|
||||||
<div className="text-center lg:text-right">
|
<div className="text-center lg:text-right">
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Starting from</div>
|
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Starting from</div>
|
||||||
<div className="text-3xl font-bold text-primary dark:text-white">
|
<div className="text-3xl font-bold text-primary">
|
||||||
{lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
|
{lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">per adult</div>
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">per adult</div>
|
||||||
|
{selectedClass && (
|
||||||
|
<p className="text-xs text-primary font-semibold mb-2">
|
||||||
|
{selectedClass.replace(/_/g, ' ')} selected
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleExpanded(scheduleId)}
|
onClick={() => setClassModal(schedule)}
|
||||||
className="btn-secondary w-full flex items-center justify-center gap-2"
|
className="btn-secondary w-full flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<span>Select class</span>
|
{selectedClass ? 'Change class' : 'Select class'}
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronUp className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="mt-6 pt-6 border-t dark:border-gray-700">
|
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-3">Select Class</h4>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
||||||
{schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0 ? (
|
|
||||||
schedule.faresByClass.map((fareClass: any) => {
|
|
||||||
const isSelected = selectedClass === fareClass.seatClassName;
|
|
||||||
const availableSeats = schedule.availabilityByClass?.[fareClass.seatClassName] || 0;
|
|
||||||
const isAvailable = availableSeats > 0;
|
|
||||||
const isBedClass = fareClass.seatClassName.toLowerCase().includes('bed');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={fareClass.seatClassName}
|
|
||||||
onClick={() => isAvailable && handleSelectClass(scheduleId, fareClass.seatClassName)}
|
|
||||||
disabled={!isAvailable}
|
|
||||||
className={`relative p-4 rounded-lg border-2 text-left transition-all ${
|
|
||||||
isSelected
|
|
||||||
? 'border-primary bg-blue-50 dark:bg-blue-900/20 shadow-md'
|
|
||||||
: isAvailable
|
|
||||||
? 'border-gray-200 dark:border-gray-700 hover:border-blue-300 hover:shadow-sm'
|
|
||||||
: 'border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800 opacity-60 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isSelected && (
|
|
||||||
<div className="absolute top-2 right-2 w-6 h-6 bg-primary rounded-full flex items-center justify-center">
|
|
||||||
<Check className="w-4 h-4 text-white" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
|
||||||
{fareClass.seatClassName.replace(/_/g, ' ')}
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold text-primary dark:text-white mb-1">
|
|
||||||
ETB {((fareClass.baseFareMinor || 0) / 100).toFixed(2)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
{isAvailable ? (
|
|
||||||
<span className="text-green-600 dark:text-green-400 font-medium">
|
|
||||||
{availableSeats} {isBedClass ? 'bed' : 'seat'}{availableSeats !== 1 ? 's' : ''} available
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-red-600 dark:text-red-400 font-medium">Sold out</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<div className="col-span-3 text-center py-4 text-gray-500 dark:text-gray-400">
|
|
||||||
No seat classes available
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex justify-end">
|
|
||||||
<button
|
|
||||||
onClick={() => handleSelect(schedule)}
|
|
||||||
disabled={!selectedClass}
|
|
||||||
className={`btn-primary flex items-center justify-center gap-2 ${
|
|
||||||
!selectedClass
|
|
||||||
? 'opacity-50 cursor-not-allowed'
|
|
||||||
: 'group-hover:shadow-xl'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span>Continue</span>
|
|
||||||
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
)})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -224,6 +224,7 @@ export default function SeatsPage() {
|
|||||||
if (bookingId && selectedSeats.length > 0) {
|
if (bookingId && selectedSeats.length > 0) {
|
||||||
bookSeatsMutation.mutate(selectedSeats);
|
bookSeatsMutation.mutate(selectedSeats);
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [bookingId]);
|
}, [bookingId]);
|
||||||
|
|
||||||
const parseSeatArrangement = (arrangement: string | null): number[] => {
|
const parseSeatArrangement = (arrangement: string | null): number[] => {
|
||||||
@@ -394,6 +395,72 @@ export default function SeatsPage() {
|
|||||||
|
|
||||||
if (!selectedSchedule || !passengers.length) return null;
|
if (!selectedSchedule || !passengers.length) return null;
|
||||||
|
|
||||||
|
const allSelected = selectedSeats.length === passengers.length;
|
||||||
|
const isBedCoach = selectedCoachData?.seatClass?.toLowerCase().includes('bed') || selectedCoachData?.mode?.toLowerCase().includes('bed');
|
||||||
|
|
||||||
|
// Summary card content — shared between sidebar and mobile modal
|
||||||
|
const SummaryContent = () => (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<h3 className="font-bold text-gray-900 dark:text-white">Selection Summary</h3>
|
||||||
|
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
||||||
|
allSelected ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
|
||||||
|
}`}>
|
||||||
|
{selectedSeats.length}/{passengers.length} selected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
{allSelected ? 'All seats selected — ready to continue' : `Select ${passengers.length - selectedSeats.length} more seat(s)`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="h-1.5 bg-gray-100 dark:bg-gray-700 rounded-full mb-4 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-[rgb(20,113,76)] rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${(selectedSeats.length / passengers.length) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-5">
|
||||||
|
{passengers.map((p, i) => {
|
||||||
|
const assignedSeat = selectedSeats[i] ? validSeats?.find((s: any) => s.id === selectedSeats[i]) : null;
|
||||||
|
const seatLabel = assignedSeat ? (assignedSeat.number || assignedSeat.label || assignedSeat.seatNumber || '—') : '—';
|
||||||
|
const bedLabel = assignedSeat ? getBedLabel(assignedSeat.bedPosition) : '';
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center justify-between py-2 border-b border-gray-100 dark:border-gray-800 last:border-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
|
||||||
|
assignedSeat ? 'bg-[rgb(20,113,76)] text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'
|
||||||
|
}`}>{i + 1}</div>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300 truncate max-w-[120px]">{p.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className={`text-sm font-semibold ${
|
||||||
|
assignedSeat ? 'text-[rgb(20,113,76)]' : 'text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{assignedSeat ? `${seatLabel}${bedLabel}` : 'Not selected'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleContinue}
|
||||||
|
disabled={selectedSeats.length === 0 || holdMutation.isPending}
|
||||||
|
className="w-full py-3 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold text-sm rounded-xl transition-all shadow-lg"
|
||||||
|
>
|
||||||
|
{holdMutation.isPending ? 'Holding seats...' : allSelected ? 'Continue' : 'Continue with partial selection'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAutoAssign}
|
||||||
|
disabled={holdMutation.isPending}
|
||||||
|
className="w-full mt-2 py-2.5 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 text-gray-700 dark:text-gray-300 font-semibold text-sm rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
Auto-assign seats
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CustomModal
|
<CustomModal
|
||||||
@@ -403,166 +470,143 @@ export default function SeatsPage() {
|
|||||||
message={modalState.message}
|
message={modalState.message}
|
||||||
type={modalState.type}
|
type={modalState.type}
|
||||||
/>
|
/>
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
|
||||||
<div className="container mx-auto px-4">
|
{/* Mobile summary bottom-sheet */}
|
||||||
<div className="max-w-6xl mx-auto">
|
{selectedSeats.length > 0 && (
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<>
|
||||||
|
<div className="fixed inset-x-0 bottom-0 z-50 lg:hidden bg-white dark:bg-gray-900 rounded-t-2xl shadow-2xl p-5"
|
||||||
|
style={{ animation: 'seats-slide-up 0.25s cubic-bezier(0.32,0.72,0,1)' }}
|
||||||
|
>
|
||||||
|
<div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full mx-auto mb-4" />
|
||||||
|
<SummaryContent />
|
||||||
|
</div>
|
||||||
|
<style>{`@keyframes seats-slide-up{from{transform:translateY(100%)}to{transform:translateY(0)}}`}</style>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 sticky top-0 z-30">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="max-w-6xl mx-auto flex items-center justify-between h-14">
|
||||||
<button
|
<button
|
||||||
onClick={handleBackToPassengers}
|
onClick={handleBackToPassengers}
|
||||||
className="flex items-center gap-2 text-[rgb(20_113_76)] hover:text-[rgb(10_80_50)] font-semibold transition-colors"
|
className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-primary transition-colors font-medium"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-5 h-5" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
Back to passenger details
|
Back
|
||||||
</button>
|
</button>
|
||||||
|
<h1 className="text-base font-bold text-gray-900 dark:text-white">Select Seats</h1>
|
||||||
|
<div className="text-sm font-semibold text-[rgb(20,113,76)]">
|
||||||
|
{selectedSeats.length}/{passengers.length}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Select seats</h1>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="container mx-auto px-4 py-5">
|
||||||
<div className="lg:col-span-2">
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||||
|
|
||||||
|
{/* ── Seat map panel ── */}
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
<div className="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-3" />
|
||||||
<p>Loading seats...</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">Loading seat map...</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">
|
||||||
<div className="text-center py-8 text-red-500 dark:text-red-400">
|
<p className="text-red-500 font-medium">Error loading seats</p>
|
||||||
<p>Error loading seats</p>
|
<p className="text-sm text-gray-400 mt-1">{(error as any)?.message || 'Please try again'}</p>
|
||||||
<p className="text-sm mt-2">{error?.message || 'Please try again'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : filteredCoaches.length === 0 ? (
|
) : filteredCoaches.length === 0 ? (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
<p className="text-gray-500 dark:text-gray-400">No coaches available for {selectedSchedule?.selectedSeatClass}</p>
|
||||||
<p>No coaches available for {selectedSchedule?.selectedSeatClass}</p>
|
|
||||||
<p className="text-sm mt-2">Please select a different seat class</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<>
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
{/* Coach selector */}
|
||||||
<h3 className="font-semibold mb-3 text-gray-900 dark:text-gray-100">Select coach</h3>
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 border border-gray-100 dark:border-gray-700">
|
||||||
<div className="flex flex-row gap-2">
|
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">Coach</h3>
|
||||||
{filteredCoaches?.map((coach: any) => {
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{filteredCoaches.map((coach: any) => {
|
||||||
const coachSeats = coach.seats?.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')) || [];
|
const coachSeats = coach.seats?.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')) || [];
|
||||||
const isBedCoach = coach.seatClass?.toLowerCase().includes('bed') || coach.mode?.toLowerCase().includes('bed');
|
const isBed = coach.seatClass?.toLowerCase().includes('bed') || coach.mode?.toLowerCase().includes('bed');
|
||||||
let filteredSeats = coachSeats;
|
let fSeats = coachSeats;
|
||||||
if (isBedCoach && selectedSchedule?.selectedSeatClass) {
|
if (isBed && selectedSchedule?.selectedSeatClass) {
|
||||||
const bedPos = getBedPosition(selectedSchedule.selectedSeatClass);
|
const bedPos = getBedPosition(selectedSchedule.selectedSeatClass);
|
||||||
if (bedPos) {
|
if (bedPos) fSeats = coachSeats.filter((s: any) => s.bedPosition === bedPos);
|
||||||
filteredSeats = coachSeats.filter((s: any) => s.bedPosition === bedPos);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const availableCount = filteredSeats.filter((s: any) => s.status === 'AVAILABLE').length || 0;
|
const available = fSeats.filter((s: any) => s.status === 'AVAILABLE').length;
|
||||||
const seatClassName = selectedSchedule?.selectedSeatClass || (typeof coach.seatClass === 'string' ? coach.seatClass : (coach.seatClass?.name || coach.coachClass || ''));
|
const isActive = selectedCoach === coach.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={coach.id}
|
key={coach.id}
|
||||||
onClick={() => setSelectedCoach(coach.id)}
|
onClick={() => setSelectedCoach(coach.id)}
|
||||||
className={`px-4 py-2 rounded transition-all text-left ${
|
className={`px-4 py-2.5 rounded-xl text-sm font-semibold transition-all ${
|
||||||
selectedCoach === coach.id
|
isActive
|
||||||
? 'bg-[rgb(20_113_76)] text-white shadow-lg'
|
? 'bg-[rgb(20,113,76)] text-white shadow-md'
|
||||||
: 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100'
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="font-semibold">{coach.label || coach.name || coach.coachNumber}</div>
|
<div>{coach.label || coach.name || coach.coachNumber}</div>
|
||||||
<div className="text-xs opacity-75">{seatClassName}</div>
|
<div className={`text-xs mt-0.5 ${isActive ? 'text-white/70' : 'text-gray-400'}`}>{available} free</div>
|
||||||
<div className="text-xs opacity-75">{availableCount} available</div>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
{/* Seat map */}
|
||||||
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 border border-gray-100 dark:border-gray-700">
|
||||||
Seat map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}
|
<div className="flex items-center justify-between mb-3">
|
||||||
</h3>
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
{selectedCoachData && (
|
{selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
</h3>
|
||||||
Arrangement: {selectedCoachData.seatArrangement} • Total: {selectedCoachData.totalSeats} seats
|
<span className="text-xs text-gray-400">{selectedCoachData?.seatArrangement}</span>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 mb-6 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 bg-green-500 rounded"></div>
|
|
||||||
<span className="text-gray-700 dark:text-gray-300">Available</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 bg-[rgb(20_113_76)] rounded"></div>
|
|
||||||
<span className="text-gray-700 dark:text-gray-300">Selected</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 bg-yellow-500 rounded"></div>
|
|
||||||
<span className="text-gray-700 dark:text-gray-300">Held</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 bg-gray-500 rounded"></div>
|
|
||||||
<span className="text-gray-700 dark:text-gray-300">Booked</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-700/30 p-6 rounded-lg overflow-x-auto border border-gray-200 dark:border-gray-700 w-fit">
|
{/* Legend */}
|
||||||
{validSeats.length === 0 ? (
|
<div className="flex flex-wrap gap-3 mb-4">
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
{[
|
||||||
<p>No seats available in this coach</p>
|
{ color: 'bg-green-500', label: 'Available' },
|
||||||
<p className="text-sm mt-2">Please select a different coach</p>
|
{ color: 'bg-[rgb(20,113,76)]', label: 'Selected' },
|
||||||
|
{ color: 'bg-yellow-500', label: 'Held' },
|
||||||
|
{ color: 'bg-gray-400', label: 'Booked' },
|
||||||
|
].map(({ color, label }) => (
|
||||||
|
<div key={label} className="flex items-center gap-1.5">
|
||||||
|
<div className={`w-3 h-3 ${color} rounded-sm`} />
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
))}
|
||||||
renderCoachSeats(selectedCoachData, (selectedCoachData.seatClass?.toLowerCase().includes('bed') || selectedCoachData.mode?.toLowerCase().includes('bed')))
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="inline-block bg-gray-50 dark:bg-gray-700/30 rounded-xl p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
{validSeats.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400 py-4">No seats in this coach</p>
|
||||||
|
) : (
|
||||||
|
renderCoachSeats(selectedCoachData, isBedCoach)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-1">
|
{/* ── Sidebar summary (desktop only) ── */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 sticky top-6">
|
<div className="hidden lg:block lg:col-span-1">
|
||||||
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Selection summary</h3>
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-5 border border-gray-100 dark:border-gray-700 sticky top-20">
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
<SummaryContent />
|
||||||
Select {passengers.length} seat(s) for your passengers
|
|
||||||
</p>
|
|
||||||
<p className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
|
||||||
{selectedSeats.length} / {passengers.length} selected
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-2 mb-6 max-h-48 overflow-y-auto">
|
|
||||||
{passengers.map((p, i) => {
|
|
||||||
const assignedSeat = selectedSeats[i] ? validSeats?.find((s: any) => s.id === selectedSeats[i]) : null;
|
|
||||||
const seatLabel = assignedSeat ? (assignedSeat.number || assignedSeat.label || assignedSeat.seatNumber || '-') : '-';
|
|
||||||
const bedLabel = assignedSeat ? getBedLabel(assignedSeat.bedPosition) : '';
|
|
||||||
return (
|
|
||||||
<div key={i} className="flex justify-between text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
<span>{p.name}</span>
|
|
||||||
<span className="font-medium text-[rgb(20_113_76)]">
|
|
||||||
{seatLabel}{bedLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleContinue}
|
|
||||||
disabled={selectedSeats.length === 0}
|
|
||||||
className="w-full bg-[rgb(20_113_76)] hover:bg-[rgb(10_80_50)] disabled:bg-gray-400 text-white font-semibold py-2 rounded mb-2 transition-colors"
|
|
||||||
>
|
|
||||||
Continue with selected seats
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleAutoAssign}
|
|
||||||
disabled={holdMutation.isPending}
|
|
||||||
className="w-full bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 text-white font-semibold py-2 rounded transition-colors"
|
|
||||||
>
|
|
||||||
{holdMutation.isPending ? 'Assigning...' : 'Auto-assign seats'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile spacer so bottom-sheet doesn't cover last seat */}
|
||||||
|
<div className="h-48 lg:hidden" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -157,4 +157,22 @@
|
|||||||
.animate-slide-in-right {
|
.animate-slide-in-right {
|
||||||
animation: slide-in-right 0.5s ease-out;
|
animation: slide-in-right 0.5s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
from { transform: translateY(100%); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-up {
|
||||||
|
animation: slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function LoginContent() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
|
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
|
||||||
resolver: zodResolver(loginSchema),
|
resolver: zodResolver(loginSchema as any),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (data: LoginForm) => {
|
const onSubmit = async (data: LoginForm) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Facebook, Twitter, Instagram, Linkedin, Mail, Phone, MapPin } from 'lucide-react';
|
import { Mail, Phone, MapPin } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage, getTranslation, Language } from '@/lib/i18n';
|
import { useLanguage, getTranslation, Language } from '@/lib/i18n';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
@@ -102,41 +102,17 @@ export function Footer() {
|
|||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold mb-4">{t('footer.follow')}</h4>
|
<h4 className="font-semibold mb-4">{t('footer.follow')}</h4>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<a
|
<a href="https://web.facebook.com/ethiodjiboutirailwaysc" target="_blank" rel="noopener noreferrer" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="Facebook">
|
||||||
href="https://web.facebook.com/ethiodjiboutirailwaysc"
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"/></svg>
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110"
|
|
||||||
aria-label="Facebook"
|
|
||||||
>
|
|
||||||
<Facebook className="w-5 h-5" />
|
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a href="https://twitter.com/edr" target="_blank" rel="noopener noreferrer" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="Twitter">
|
||||||
href="https://twitter.com/edr"
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110"
|
|
||||||
aria-label="Twitter"
|
|
||||||
>
|
|
||||||
<Twitter className="w-5 h-5" />
|
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a href="https://instagram.com/edr" target="_blank" rel="noopener noreferrer" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="Instagram">
|
||||||
href="https://instagram.com/edr"
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="20" rx="5" ry="5"/><circle cx="12" cy="12" r="4"/><circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none"/></svg>
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110"
|
|
||||||
aria-label="Instagram"
|
|
||||||
>
|
|
||||||
<Instagram className="w-5 h-5" />
|
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a href="https://linkedin.com/company/edr" target="_blank" rel="noopener noreferrer" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="LinkedIn">
|
||||||
href="https://linkedin.com/company/edr"
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6zM2 9h4v12H2z"/><circle cx="4" cy="4" r="2"/></svg>
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110"
|
|
||||||
aria-label="LinkedIn"
|
|
||||||
>
|
|
||||||
<Linkedin className="w-5 h-5" />
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,16 +28,23 @@ export default function ModernDatePicker({
|
|||||||
placeholder = 'Select date',
|
placeholder = 'Select date',
|
||||||
}: ModernDatePickerProps) {
|
}: ModernDatePickerProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isMobileView, setIsMobileView] = useState(false);
|
||||||
const [calendarType, setCalendarType] = useState<'gregorian' | 'ethiopian'>('gregorian');
|
const [calendarType, setCalendarType] = useState<'gregorian' | 'ethiopian'>('gregorian');
|
||||||
const [viewMonth, setViewMonth] = useState(value?.getMonth() || new Date().getMonth());
|
const [viewMonth, setViewMonth] = useState(value?.getMonth() ?? new Date().getMonth());
|
||||||
const [viewYear, setViewYear] = useState(value?.getFullYear() || new Date().getFullYear());
|
const [viewYear, setViewYear] = useState(value?.getFullYear() ?? new Date().getFullYear());
|
||||||
|
|
||||||
// Initialize Ethiopian calendar with current date
|
|
||||||
const initialEthDate = gregorianToEthiopian(value || new Date());
|
const initialEthDate = gregorianToEthiopian(value || new Date());
|
||||||
const [ethViewMonth, setEthViewMonth] = useState(initialEthDate.month);
|
const [ethViewMonth, setEthViewMonth] = useState(initialEthDate.month);
|
||||||
const [ethViewYear, setEthViewYear] = useState(initialEthDate.year);
|
const [ethViewYear, setEthViewYear] = useState(initialEthDate.year);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const check = () => setIsMobileView(window.innerWidth < 768);
|
||||||
|
check();
|
||||||
|
window.addEventListener('resize', check);
|
||||||
|
return () => window.removeEventListener('resize', check);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value) {
|
if (value) {
|
||||||
setViewMonth(value.getMonth());
|
setViewMonth(value.getMonth());
|
||||||
@@ -48,39 +55,27 @@ export default function ModernDatePicker({
|
|||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
|
// Lock body scroll when modal is open (both mobile and desktop modal)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = '';
|
||||||
}
|
}
|
||||||
|
return () => { document.body.style.overflow = ''; };
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const toggleCalendarType = () => {
|
const toggleCalendarType = () => {
|
||||||
const newType = calendarType === 'gregorian' ? 'ethiopian' : 'gregorian';
|
const newType = calendarType === 'gregorian' ? 'ethiopian' : 'gregorian';
|
||||||
|
const ref = value || new Date();
|
||||||
if (newType === 'ethiopian') {
|
if (newType === 'ethiopian') {
|
||||||
// When switching to Ethiopian, show the Ethiopian equivalent of current Gregorian view
|
const ethDate = gregorianToEthiopian(ref);
|
||||||
// Use today's date if no value is selected, otherwise use the selected value
|
|
||||||
const referenceDate = value || new Date();
|
|
||||||
const ethDate = gregorianToEthiopian(referenceDate);
|
|
||||||
setEthViewMonth(ethDate.month);
|
setEthViewMonth(ethDate.month);
|
||||||
setEthViewYear(ethDate.year);
|
setEthViewYear(ethDate.year);
|
||||||
} else {
|
} else {
|
||||||
// When switching to Gregorian, show the Gregorian equivalent of current Ethiopian view
|
setViewMonth(ref.getMonth());
|
||||||
const referenceDate = value || new Date();
|
setViewYear(ref.getFullYear());
|
||||||
setViewMonth(referenceDate.getMonth());
|
|
||||||
setViewYear(referenceDate.getFullYear());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setCalendarType(newType);
|
setCalendarType(newType);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,102 +85,46 @@ export default function ModernDatePicker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEthiopianDateSelect = (ethDate: EthiopianDate) => {
|
const handleEthiopianDateSelect = (ethDate: EthiopianDate) => {
|
||||||
const gregDate = ethiopianToGregorian(ethDate);
|
handleDateSelect(ethiopianToGregorian(ethDate));
|
||||||
handleDateSelect(gregDate);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderGregorianCalendar = () => {
|
const renderGregorianCalendar = () => {
|
||||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||||
const firstDayOfMonth = new Date(viewYear, viewMonth, 1).getDay();
|
const firstDay = new Date(viewYear, viewMonth, 1).getDay();
|
||||||
const days: (number | null)[] = [];
|
const days: (number | null)[] = Array(firstDay).fill(null);
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) days.push(d);
|
||||||
for (let i = 0; i < firstDayOfMonth; i++) {
|
const monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||||
days.push(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let day = 1; day <= daysInMonth; day++) {
|
|
||||||
days.push(day);
|
|
||||||
}
|
|
||||||
|
|
||||||
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<button
|
<button type="button" onClick={() => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1); } else setViewMonth(m => m - 1); }} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (viewMonth === 0) {
|
|
||||||
setViewMonth(11);
|
|
||||||
setViewYear(viewYear - 1);
|
|
||||||
} else {
|
|
||||||
setViewMonth(viewMonth - 1);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
<ChevronLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
<div className="font-semibold text-base text-gray-900 dark:text-gray-100">
|
<span className="font-semibold text-base text-gray-900 dark:text-gray-100">{monthNames[viewMonth]} {viewYear}</span>
|
||||||
{monthNames[viewMonth]} {viewYear}
|
<button type="button" onClick={() => { if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1); } else setViewMonth(m => m + 1); }} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (viewMonth === 11) {
|
|
||||||
setViewMonth(0);
|
|
||||||
setViewYear(viewYear + 1);
|
|
||||||
} else {
|
|
||||||
setViewMonth(viewMonth + 1);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
<ChevronRight className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||||
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(day => (
|
{['Su','Mo','Tu','We','Th','Fr','Sa'].map(d => (
|
||||||
<div key={day} className="text-center text-xs font-semibold text-gray-500 dark:text-gray-400 py-2">
|
<div key={d} className="text-center text-xs font-semibold text-gray-400 py-2">{d}</div>
|
||||||
{day}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-7 gap-1">
|
<div className="grid grid-cols-7 gap-1">
|
||||||
{days.map((day, index) => {
|
{days.map((day, i) => {
|
||||||
if (day === null) {
|
if (day === null) return <div key={`e-${i}`} />;
|
||||||
return <div key={`empty-${index}`} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(viewYear, viewMonth, day);
|
const date = new Date(viewYear, viewMonth, day);
|
||||||
const isSelected = value &&
|
const isSelected = value && date.getDate() === value.getDate() && date.getMonth() === value.getMonth() && date.getFullYear() === value.getFullYear();
|
||||||
date.getDate() === value.getDate() &&
|
const isToday = date.toDateString() === new Date().toDateString();
|
||||||
date.getMonth() === value.getMonth() &&
|
const isDisabled = (minDate && date < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) || (maxDate && date > new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate()));
|
||||||
date.getFullYear() === value.getFullYear();
|
|
||||||
const isToday =
|
|
||||||
date.getDate() === new Date().getDate() &&
|
|
||||||
date.getMonth() === new Date().getMonth() &&
|
|
||||||
date.getFullYear() === new Date().getFullYear();
|
|
||||||
const isDisabled =
|
|
||||||
(minDate && date < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) ||
|
|
||||||
(maxDate && date > new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate()));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button key={day} type="button" onClick={() => !isDisabled && handleDateSelect(date)} disabled={!!isDisabled}
|
||||||
key={day}
|
className={`aspect-square flex items-center justify-center text-sm rounded-lg transition-all
|
||||||
type="button"
|
|
||||||
onClick={() => !isDisabled && handleDateSelect(date)}
|
|
||||||
disabled={isDisabled}
|
|
||||||
className={`
|
|
||||||
aspect-square flex items-center justify-center text-sm rounded-lg transition-all
|
|
||||||
${isSelected ? 'bg-primary text-white font-semibold shadow-md scale-105' : ''}
|
${isSelected ? 'bg-primary text-white font-semibold shadow-md scale-105' : ''}
|
||||||
${isToday && !isSelected ? 'border-2 border-primary text-primary font-semibold' : ''}
|
${isToday && !isSelected ? 'border-2 border-primary text-primary font-semibold' : ''}
|
||||||
${!isSelected && !isToday && !isDisabled ? 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300' : ''}
|
${!isSelected && !isToday && !isDisabled ? 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300' : ''}
|
||||||
${isDisabled ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed' : ''}
|
${isDisabled ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed' : ''}`}>
|
||||||
`}
|
|
||||||
>
|
|
||||||
{day}
|
{day}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -197,133 +136,54 @@ export default function ModernDatePicker({
|
|||||||
|
|
||||||
const renderEthiopianCalendar = () => {
|
const renderEthiopianCalendar = () => {
|
||||||
const daysInMonth = getDaysInEthiopianMonth(ethViewYear, ethViewMonth);
|
const daysInMonth = getDaysInEthiopianMonth(ethViewYear, ethViewMonth);
|
||||||
const days: number[] = [];
|
|
||||||
|
|
||||||
for (let day = 1; day <= daysInMonth; day++) {
|
|
||||||
days.push(day);
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstDate = ethiopianToGregorian({ year: ethViewYear, month: ethViewMonth, day: 1 });
|
const firstDate = ethiopianToGregorian({ year: ethViewYear, month: ethViewMonth, day: 1 });
|
||||||
const firstDayOfWeek = firstDate.getDay();
|
const firstDayOfWeek = firstDate.getDay();
|
||||||
|
const daysWithOffset: (number | null)[] = Array(firstDayOfWeek).fill(null);
|
||||||
const daysWithOffset: (number | null)[] = [];
|
for (let d = 1; d <= daysInMonth; d++) daysWithOffset.push(d);
|
||||||
for (let i = 0; i < firstDayOfWeek; i++) {
|
|
||||||
daysWithOffset.push(null);
|
|
||||||
}
|
|
||||||
daysWithOffset.push(...days);
|
|
||||||
|
|
||||||
// Get month name safely
|
|
||||||
const monthName = ETHIOPIAN_MONTHS[ethViewMonth - 1] || `Month ${ethViewMonth}`;
|
const monthName = ETHIOPIAN_MONTHS[ethViewMonth - 1] || `Month ${ethViewMonth}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<button
|
<button type="button" onClick={() => { if (ethViewMonth === 1) { setEthViewMonth(13); setEthViewYear(y => y - 1); } else setEthViewMonth(m => m - 1); }} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (ethViewMonth === 1) {
|
|
||||||
setEthViewMonth(13);
|
|
||||||
setEthViewYear(ethViewYear - 1);
|
|
||||||
} else {
|
|
||||||
setEthViewMonth(ethViewMonth - 1);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
<ChevronLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
<div className="font-semibold text-base text-gray-900 dark:text-gray-100">
|
<span className="font-semibold text-base text-gray-900 dark:text-gray-100">{monthName} {ethViewYear}</span>
|
||||||
{monthName} {ethViewYear}
|
<button type="button" onClick={() => { if (ethViewMonth === 13) { setEthViewMonth(1); setEthViewYear(y => y + 1); } else setEthViewMonth(m => m + 1); }} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (ethViewMonth === 13) {
|
|
||||||
setEthViewMonth(1);
|
|
||||||
setEthViewYear(ethViewYear + 1);
|
|
||||||
} else {
|
|
||||||
setEthViewMonth(ethViewMonth + 1);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
<ChevronRight className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||||
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(day => (
|
{['Su','Mo','Tu','We','Th','Fr','Sa'].map(d => (
|
||||||
<div key={day} className="text-center text-xs font-semibold text-gray-500 dark:text-gray-400 py-2">
|
<div key={d} className="text-center text-xs font-semibold text-gray-400 py-2">{d}</div>
|
||||||
{day}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-7 gap-1">
|
<div className="grid grid-cols-7 gap-1">
|
||||||
{daysWithOffset.map((day, index) => {
|
{daysWithOffset.map((day, i) => {
|
||||||
if (day === null) {
|
if (day === null) return <div key={`e-${i}`} />;
|
||||||
return <div key={`empty-${index}`} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ethDate: EthiopianDate = { year: ethViewYear, month: ethViewMonth, day };
|
const ethDate: EthiopianDate = { year: ethViewYear, month: ethViewMonth, day };
|
||||||
const gregDate = ethiopianToGregorian(ethDate);
|
const gregDate = ethiopianToGregorian(ethDate);
|
||||||
|
const isSelected = value && gregDate.getDate() === value.getDate() && gregDate.getMonth() === value.getMonth() && gregDate.getFullYear() === value.getFullYear();
|
||||||
const isSelected = value &&
|
const todayEth = gregorianToEthiopian(new Date());
|
||||||
gregDate.getDate() === value.getDate() &&
|
const isToday = ethDate.day === todayEth.day && ethDate.month === todayEth.month && ethDate.year === todayEth.year;
|
||||||
gregDate.getMonth() === value.getMonth() &&
|
|
||||||
gregDate.getFullYear() === value.getFullYear();
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
const todayEth = gregorianToEthiopian(today);
|
|
||||||
const isToday =
|
|
||||||
ethDate.day === todayEth.day &&
|
|
||||||
ethDate.month === todayEth.month &&
|
|
||||||
ethDate.year === todayEth.year;
|
|
||||||
|
|
||||||
let isDisabled = false;
|
let isDisabled = false;
|
||||||
if (minDate) {
|
if (minDate) {
|
||||||
const minYear = minDate.getFullYear();
|
const [mY, mM, mD] = [minDate.getFullYear(), minDate.getMonth(), minDate.getDate()];
|
||||||
const minMonth = minDate.getMonth();
|
const [gY, gM, gD] = [gregDate.getFullYear(), gregDate.getMonth(), gregDate.getDate()];
|
||||||
const minDay = minDate.getDate();
|
if (gY < mY || (gY === mY && gM < mM) || (gY === mY && gM === mM && gD < mD)) isDisabled = true;
|
||||||
const gregYear = gregDate.getFullYear();
|
|
||||||
const gregMonth = gregDate.getMonth();
|
|
||||||
const gregDay = gregDate.getDate();
|
|
||||||
|
|
||||||
if (gregYear < minYear ||
|
|
||||||
(gregYear === minYear && gregMonth < minMonth) ||
|
|
||||||
(gregYear === minYear && gregMonth === minMonth && gregDay < minDay)) {
|
|
||||||
isDisabled = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (maxDate && !isDisabled) {
|
if (maxDate && !isDisabled) {
|
||||||
const maxYear = maxDate.getFullYear();
|
const [mY, mM, mD] = [maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate()];
|
||||||
const maxMonth = maxDate.getMonth();
|
const [gY, gM, gD] = [gregDate.getFullYear(), gregDate.getMonth(), gregDate.getDate()];
|
||||||
const maxDay = maxDate.getDate();
|
if (gY > mY || (gY === mY && gM > mM) || (gY === mY && gM === mM && gD > mD)) isDisabled = true;
|
||||||
const gregYear = gregDate.getFullYear();
|
|
||||||
const gregMonth = gregDate.getMonth();
|
|
||||||
const gregDay = gregDate.getDate();
|
|
||||||
|
|
||||||
if (gregYear > maxYear ||
|
|
||||||
(gregYear === maxYear && gregMonth > maxMonth) ||
|
|
||||||
(gregYear === maxYear && gregMonth === maxMonth && gregDay > maxDay)) {
|
|
||||||
isDisabled = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button key={day} type="button" onClick={() => !isDisabled && handleEthiopianDateSelect(ethDate)} disabled={isDisabled}
|
||||||
key={day}
|
className={`aspect-square flex items-center justify-center text-sm rounded-lg transition-all
|
||||||
type="button"
|
|
||||||
onClick={() => !isDisabled && handleEthiopianDateSelect(ethDate)}
|
|
||||||
disabled={isDisabled}
|
|
||||||
className={`
|
|
||||||
aspect-square flex items-center justify-center text-sm rounded-lg transition-all
|
|
||||||
${isSelected ? 'bg-primary text-white font-semibold shadow-md scale-105' : ''}
|
${isSelected ? 'bg-primary text-white font-semibold shadow-md scale-105' : ''}
|
||||||
${isToday && !isSelected ? 'border-2 border-primary text-primary font-semibold' : ''}
|
${isToday && !isSelected ? 'border-2 border-primary text-primary font-semibold' : ''}
|
||||||
${!isSelected && !isToday && !isDisabled ? 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300' : ''}
|
${!isSelected && !isToday && !isDisabled ? 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300' : ''}
|
||||||
${isDisabled ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed' : ''}
|
${isDisabled ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed' : ''}`}>
|
||||||
`}
|
|
||||||
>
|
|
||||||
{day}
|
{day}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -333,62 +193,128 @@ export default function ModernDatePicker({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const calendarFooter = value && (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 p-3 bg-gray-50 dark:bg-gray-800/60 space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Gregorian:</span>
|
||||||
|
<span className="font-medium text-gray-700 dark:text-gray-300">{format(value, 'MMMM d, yyyy')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Ethiopian:</span>
|
||||||
|
<span className="font-medium text-gray-700 dark:text-gray-300">{formatEthiopianDate(gregorianToEthiopian(value))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Shared modal content (used by both mobile and desktop)
|
||||||
|
const modalContent = (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Header row: title + close */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarIcon className="w-4 h-4 text-primary" />
|
||||||
|
<h2 className="text-base font-bold text-gray-900 dark:text-white">Select Date</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle row: always full-width, clearly visible */}
|
||||||
|
<div className="px-4 py-2.5 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
|
||||||
|
<div className="flex rounded-lg overflow-hidden border-2 border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => calendarType !== 'gregorian' && toggleCalendarType()}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-semibold transition-colors ${
|
||||||
|
calendarType === 'gregorian'
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Globe className="w-3.5 h-3.5" />
|
||||||
|
Gregorian
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => calendarType !== 'ethiopian' && toggleCalendarType()}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-semibold transition-colors ${
|
||||||
|
calendarType === 'ethiopian'
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Globe className="w-3.5 h-3.5" />
|
||||||
|
Ethiopian
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar body */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{calendarType === 'gregorian' ? renderGregorianCalendar() : renderEthiopianCalendar()}
|
||||||
|
{calendarFooter}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={containerRef}>
|
<div className="relative" ref={containerRef}>
|
||||||
|
{/* Trigger button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(true)}
|
||||||
className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-left flex items-center justify-between bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors group"
|
className="w-full px-3.5 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary text-left flex items-center justify-between bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all group"
|
||||||
>
|
>
|
||||||
<span className={value ? 'text-gray-900 dark:text-gray-100' : 'text-gray-500 dark:text-gray-400'}>
|
<span className={`text-sm ${value ? 'text-gray-900 dark:text-gray-100 font-medium' : 'text-gray-400'}`}>
|
||||||
{value ? format(value, 'EEEE, MMMM d, yyyy') : placeholder}
|
{value ? format(value, 'EEE, MMM d, yyyy') : placeholder}
|
||||||
</span>
|
</span>
|
||||||
<CalendarIcon className="w-5 h-5 text-gray-400 dark:text-gray-500 group-hover:text-primary transition-colors" />
|
<CalendarIcon className="w-4 h-4 text-gray-400 group-hover:text-primary transition-colors flex-shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="absolute z-50 mt-2 bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 w-80 animate-in fade-in slide-in-from-top-2 duration-200">
|
<>
|
||||||
<div className="flex items-center justify-between p-3 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CalendarIcon className="w-4 h-4 text-primary" />
|
|
||||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
|
||||||
{calendarType === 'gregorian' ? 'Gregorian Calendar' : 'Ethiopian Calendar'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleCalendarType}
|
|
||||||
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium bg-primary/10 hover:bg-primary/20 dark:bg-primary/20 dark:hover:bg-primary/30 text-primary rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Globe className="w-3.5 h-3.5" />
|
|
||||||
{calendarType === 'gregorian' ? 'ET' : 'GC'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{calendarType === 'gregorian' ? renderGregorianCalendar() : renderEthiopianCalendar()}
|
{/* Mobile: full-screen takeover */}
|
||||||
|
{isMobileView ? (
|
||||||
{value && (
|
<div
|
||||||
<div className="border-t border-gray-200 dark:border-gray-700 p-3 bg-gray-50 dark:bg-gray-900/50 space-y-1.5">
|
className="fixed inset-0 z-[100] bg-white dark:bg-gray-900 flex flex-col"
|
||||||
<div className="flex items-center justify-between text-xs">
|
style={{ animation: 'mdp-slide-up 0.25s cubic-bezier(0.32,0.72,0,1)' }}
|
||||||
<span className="text-gray-500 dark:text-gray-400">Gregorian:</span>
|
>
|
||||||
<span className="font-medium text-gray-700 dark:text-gray-300">{format(value, 'MMMM d, yyyy')}</span>
|
{modalContent}
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-xs">
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">Ethiopian:</span>
|
|
||||||
<span className="font-medium text-gray-700 dark:text-gray-300">{formatEthiopianDate(gregorianToEthiopian(value))}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Desktop: centred modal with backdrop */
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-[99] bg-black/40 backdrop-blur-sm" onClick={() => setIsOpen(false)} />
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100] flex items-center justify-center p-4 pointer-events-none"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-sm pointer-events-auto"
|
||||||
|
style={{ animation: 'mdp-scale-in 0.2s cubic-bezier(0.34,1.56,0.64,1)' }}
|
||||||
|
>
|
||||||
|
{modalContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes mdp-slide-up {
|
||||||
|
from { transform: translateY(100%); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes mdp-scale-in {
|
||||||
|
from { transform: scale(0.92); opacity: 0; }
|
||||||
|
to { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,87 +1,93 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Check } from 'lucide-react';
|
import { Check } from 'lucide-react';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
interface Step {
|
const steps = [
|
||||||
id: string;
|
{ id: 'search', name: 'Search' },
|
||||||
name: string;
|
{ id: 'results', name: 'Results' },
|
||||||
href: string;
|
{ id: 'passengers', name: 'Passengers' },
|
||||||
}
|
{ id: 'seats', name: 'Seats' },
|
||||||
|
{ id: 'review', name: 'Review' },
|
||||||
const steps: Step[] = [
|
{ id: 'payment', name: 'Payment' },
|
||||||
{ id: 'search', name: 'Search', href: '/booking/search' },
|
{ id: 'confirmation', name: 'Done' },
|
||||||
{ id: 'results', name: 'Results', href: '/booking/results' },
|
|
||||||
{ id: 'passengers', name: 'Passengers', href: '/booking/passengers' },
|
|
||||||
{ id: 'seats', name: 'Seats', href: '/booking/seats' },
|
|
||||||
{ id: 'review', name: 'Review', href: '/booking/review' },
|
|
||||||
{ id: 'payment', name: 'Payment', href: '/booking/payment' },
|
|
||||||
{ id: 'confirmation', name: 'Confirmation', href: '/booking/confirmation' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
interface ProgressIndicatorProps {
|
export function ProgressIndicator({ currentStep }: { currentStep: string }) {
|
||||||
currentStep: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProgressIndicator({ currentStep }: ProgressIndicatorProps) {
|
|
||||||
const currentIndex = steps.findIndex((s) => s.id === currentStep);
|
const currentIndex = steps.findIndex((s) => s.id === currentStep);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const activeRef = useRef<HTMLLIElement>(null);
|
||||||
|
|
||||||
|
// Auto-scroll active step to centre on mobile
|
||||||
|
useEffect(() => {
|
||||||
|
const container = scrollRef.current;
|
||||||
|
const active = activeRef.current;
|
||||||
|
if (!container || !active) return;
|
||||||
|
const offset = active.offsetLeft + active.offsetWidth / 2 - container.clientWidth / 2;
|
||||||
|
container.scrollTo({ left: offset, behavior: 'smooth' });
|
||||||
|
}, [currentIndex]);
|
||||||
|
|
||||||
|
const stepItem = (step: typeof steps[0], index: number) => {
|
||||||
|
const isComplete = index < currentIndex;
|
||||||
|
const isCurrent = index === currentIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={step.id}
|
||||||
|
ref={isCurrent ? activeRef : undefined}
|
||||||
|
className="flex flex-col items-center flex-shrink-0 w-[4.5rem] md:flex-1"
|
||||||
|
>
|
||||||
|
{/* Line + Circle row */}
|
||||||
|
<div className="flex items-center w-full">
|
||||||
|
{/* Left connector */}
|
||||||
|
<div className={`flex-1 h-0.5 ${index === 0 ? 'invisible' : isComplete ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'}`} />
|
||||||
|
|
||||||
|
{/* Circle */}
|
||||||
|
<div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full transition-all duration-300 ${
|
||||||
|
isComplete ? 'bg-primary shadow-sm' :
|
||||||
|
isCurrent ? 'border-[3px] border-primary bg-white dark:bg-gray-800 shadow-sm' :
|
||||||
|
'border-2 border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800'
|
||||||
|
}`}>
|
||||||
|
{isComplete
|
||||||
|
? <Check className="h-4 w-4 text-white" />
|
||||||
|
: <span className={`text-xs font-bold ${isCurrent ? 'text-primary' : 'text-gray-400 dark:text-gray-500'}`}>{index + 1}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right connector */}
|
||||||
|
<div className={`flex-1 h-0.5 ${index === steps.length - 1 ? 'invisible' : isComplete ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<p className={`mt-1.5 text-[10px] md:text-xs font-medium text-center leading-tight whitespace-nowrap ${
|
||||||
|
isCurrent ? 'text-primary font-bold' :
|
||||||
|
isComplete ? 'text-gray-600 dark:text-gray-300' :
|
||||||
|
'text-gray-400 dark:text-gray-500'
|
||||||
|
}`}>
|
||||||
|
{step.name}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav aria-label="Progress" className="py-6">
|
<nav aria-label="Progress">
|
||||||
<ol className="flex items-center max-w-6xl mx-auto">
|
{/* Mobile: full-bleed scrollable */}
|
||||||
{steps.map((step, index) => {
|
<div
|
||||||
const isComplete = index < currentIndex;
|
ref={scrollRef}
|
||||||
const isCurrent = index === currentIndex;
|
className="md:hidden overflow-x-auto scrollbar-hide px-4 py-3"
|
||||||
|
>
|
||||||
|
<ol className="flex items-start min-w-full">
|
||||||
|
{steps.map((s, i) => stepItem(s, i))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
{/* Desktop: full container width matching header/footer */}
|
||||||
<li key={step.id} className="flex flex-col items-center" style={{ width: `${100 / steps.length}%` }}>
|
<div className="hidden md:block container mx-auto px-4 py-4">
|
||||||
<div className="flex items-center w-full">
|
<ol className="flex items-start max-w-6xl mx-auto">
|
||||||
<div
|
{steps.map((s, i) => stepItem(s, i))}
|
||||||
className={`flex-1 h-1 transition-all duration-300 ${
|
</ol>
|
||||||
index === 0 ? 'opacity-0' : isComplete ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'
|
</div>
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`relative flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full transition-all duration-300 ${
|
|
||||||
isComplete
|
|
||||||
? 'bg-primary shadow-lg scale-110'
|
|
||||||
: isCurrent
|
|
||||||
? 'border-4 border-primary bg-white dark:bg-gray-800 shadow-lg scale-110'
|
|
||||||
: 'border-2 border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isComplete ? (
|
|
||||||
<Check className="h-5 w-5 text-white" />
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className={`text-sm font-bold ${
|
|
||||||
isCurrent ? 'text-primary' : 'text-gray-400 dark:text-gray-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`flex-1 h-1 transition-all duration-300 ${
|
|
||||||
index === steps.length - 1 ? 'opacity-0' : isComplete ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center w-full">
|
|
||||||
<div className={`flex-1 ${index === 0 ? 'opacity-0' : ''}`} />
|
|
||||||
<p
|
|
||||||
className={`mt-3 text-xs md:text-sm font-medium transition-colors flex-shrink-0 ${
|
|
||||||
isCurrent ? 'text-primary font-bold' : isComplete ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{step.name}
|
|
||||||
</p>
|
|
||||||
<div className={`flex-1 ${index === steps.length - 1 ? 'opacity-0' : ''}`} />
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<SearchForm>({
|
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<SearchForm>({
|
||||||
resolver: zodResolver(searchSchema),
|
resolver: zodResolver(searchSchema as any),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
adultCount: 1,
|
adultCount: 1,
|
||||||
childCount: 0,
|
childCount: 0,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface SearchCriteria {
|
|||||||
adultCount: number;
|
adultCount: number;
|
||||||
childCount: number;
|
childCount: number;
|
||||||
nationality: 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER';
|
nationality: 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER';
|
||||||
|
promoCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PassengerDetail {
|
export interface PassengerDetail {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user