mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 10:45:44 +00:00
325 lines
15 KiB
TypeScript
325 lines
15 KiB
TypeScript
'use client';
|
|
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { z } from 'zod';
|
|
import { useRouter } from 'next/navigation';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { apiClient } from '@/lib/api-client';
|
|
import { useBookingStore } from '@/lib/booking-store';
|
|
import { Station } from '@/types';
|
|
import { MapPin, Users, Search, Plus, Minus, ChevronDown, Globe } from 'lucide-react';
|
|
import { useState, useRef, useEffect } from 'react';
|
|
import ModernDatePicker from '@/components/ModernDatePicker';
|
|
|
|
const searchSchema = z.object({
|
|
tripType: z.enum(['ONE_WAY', 'ROUND_TRIP']),
|
|
originStationId: z.string().min(1, 'Please select a departure station'),
|
|
destinationStationId: z.string().min(1, 'Please select an arrival station'),
|
|
departureDate: z.string().min(1, 'Please select a departure date'),
|
|
returnDate: z.string().optional(),
|
|
adultCount: z.number().min(1, 'At least 1 adult is required').max(9, 'Maximum 9 adults allowed'),
|
|
childCount: z.number().min(0, 'Cannot be negative').max(9, 'Maximum 9 children allowed'),
|
|
nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']),
|
|
}).refine((data) => {
|
|
if (!data.originStationId || !data.destinationStationId) return true;
|
|
return data.originStationId !== data.destinationStationId;
|
|
}, {
|
|
message: 'Origin and destination must be different',
|
|
path: ['destinationStationId'],
|
|
});
|
|
|
|
type SearchForm = z.infer<typeof searchSchema>;
|
|
|
|
interface SearchWidgetProps {
|
|
fullWidth?: boolean;
|
|
onSearch?: () => void;
|
|
}
|
|
|
|
const NATIONALITIES = [
|
|
{ value: 'ETHIOPIAN', label: 'Ethiopian' },
|
|
{ value: 'DJIBOUTIAN', label: 'Djiboutian' },
|
|
{ value: 'OTHER', label: 'Other' },
|
|
] as const;
|
|
|
|
// Reusable custom dropdown
|
|
function CustomSelect({
|
|
value,
|
|
onChange,
|
|
options,
|
|
placeholder,
|
|
icon,
|
|
error,
|
|
disabled,
|
|
}: {
|
|
value: string;
|
|
onChange: (val: string) => void;
|
|
options: { value: string; label: string; disabled?: boolean }[];
|
|
placeholder: string;
|
|
icon?: React.ReactNode;
|
|
error?: boolean;
|
|
disabled?: boolean;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const selected = options.find((o) => o.value === value);
|
|
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, []);
|
|
|
|
return (
|
|
<div ref={ref} className="relative">
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => !disabled && setOpen((o) => !o)}
|
|
className={[
|
|
'w-full pr-10 py-3.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base text-left flex items-center gap-2 transition-colors',
|
|
'bg-white dark:bg-gray-800 text-gray-900 dark:text-white',
|
|
'disabled:opacity-60 disabled:cursor-not-allowed',
|
|
icon ? 'pl-11' : 'pl-4',
|
|
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600',
|
|
].join(' ')}
|
|
>
|
|
{icon && <span className="absolute left-3 top-1/2 -translate-y-1/2">{icon}</span>}
|
|
<span className={selected ? 'text-gray-900 dark:text-white' : 'text-gray-400 dark:text-gray-500'}>
|
|
{selected ? selected.label : placeholder}
|
|
</span>
|
|
<ChevronDown className={`absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
|
|
{open && (
|
|
<>
|
|
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
|
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl z-50 max-h-80 overflow-y-auto text-gray-900 dark:text-white">
|
|
{options.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
disabled={opt.disabled}
|
|
onClick={() => { onChange(opt.value); setOpen(false); }}
|
|
className={[
|
|
'w-full text-left px-4 py-3 text-sm transition-colors',
|
|
opt.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer',
|
|
opt.value === value ? 'text-primary font-semibold bg-primary/5 dark:bg-primary/10' : 'text-gray-900 dark:text-white',
|
|
].join(' ')}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps) {
|
|
const router = useRouter();
|
|
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
|
|
const [isPassengerOpen, setIsPassengerOpen] = useState(false);
|
|
|
|
const { data: stations, isLoading } = useQuery<Station[]>({
|
|
queryKey: ['stations'],
|
|
queryFn: async () => await apiClient.get('/stations') as Station[],
|
|
});
|
|
|
|
const { handleSubmit, watch, setValue, clearErrors, formState: { errors } } = useForm<SearchForm>({
|
|
// @ts-ignore
|
|
resolver: zodResolver(searchSchema),
|
|
mode: 'onSubmit',
|
|
reValidateMode: 'onChange',
|
|
defaultValues: {
|
|
tripType: 'ONE_WAY',
|
|
originStationId: '',
|
|
destinationStationId: '',
|
|
adultCount: 1,
|
|
childCount: 0,
|
|
nationality: 'ETHIOPIAN',
|
|
departureDate: new Date().toISOString().split('T')[0],
|
|
},
|
|
});
|
|
|
|
const originId = watch('originStationId');
|
|
const destinationId = watch('destinationStationId');
|
|
const nationality = watch('nationality');
|
|
const adultCount = watch('adultCount');
|
|
const childCount = watch('childCount');
|
|
|
|
const stationOptions = (stations ?? []).map((s) => ({ value: s.id, label: s.name }));
|
|
const destinationOptions = stationOptions.map((s) => ({ ...s, disabled: s.value === originId }));
|
|
|
|
const onSubmit = (data: SearchForm) => {
|
|
setSearchCriteria({ ...data });
|
|
const params = new URLSearchParams({
|
|
origin: data.originStationId,
|
|
destination: data.destinationStationId,
|
|
date: data.departureDate,
|
|
adults: data.adultCount.toString(),
|
|
children: data.childCount.toString(),
|
|
nationality: data.nationality,
|
|
});
|
|
if (onSearch) onSearch();
|
|
router.push(`/booking/results?${params}`);
|
|
};
|
|
|
|
return (
|
|
<div className={fullWidth ? 'w-full' : 'w-full max-w-6xl mx-auto'}>
|
|
<form onSubmit={handleSubmit(onSubmit)} className="bg-white/95 dark:bg-gray-800/95 rounded-2xl shadow-lg border border-gray-200/20 dark:border-gray-700/20 overflow-visible backdrop-blur-sm">
|
|
<div className="p-6 md:p-8 overflow-visible">
|
|
|
|
{/* Row 1: From, To, Date */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end mb-4">
|
|
{/* From */}
|
|
<div className="space-y-2">
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">From</label>
|
|
<CustomSelect
|
|
value={originId}
|
|
onChange={(val) => { setValue('originStationId', val); clearErrors('originStationId'); }}
|
|
options={stationOptions}
|
|
placeholder="Select departure"
|
|
icon={<MapPin className="w-5 h-5 text-primary" />}
|
|
error={!!errors.originStationId}
|
|
disabled={isLoading}
|
|
/>
|
|
{errors.originStationId && (
|
|
<p className="text-red-600 dark:text-red-400 text-sm">{errors.originStationId.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* To */}
|
|
<div className="space-y-2">
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">To</label>
|
|
<CustomSelect
|
|
value={destinationId}
|
|
onChange={(val) => { setValue('destinationStationId', val); clearErrors('destinationStationId'); }}
|
|
options={destinationOptions}
|
|
placeholder="Select arrival"
|
|
icon={<MapPin className="w-5 h-5 text-primary" />}
|
|
error={!!errors.destinationStationId}
|
|
disabled={isLoading}
|
|
/>
|
|
{errors.destinationStationId && (
|
|
<p className="text-red-600 dark:text-red-400 text-sm">{errors.destinationStationId.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Date */}
|
|
<div className="space-y-2">
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Date</label>
|
|
<ModernDatePicker
|
|
value={watch('departureDate') ? new Date(watch('departureDate') + 'T00:00:00') : undefined}
|
|
onChange={(date) => {
|
|
const y = date.getFullYear();
|
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
const d = String(date.getDate()).padStart(2, '0');
|
|
setValue('departureDate', `${y}-${m}-${d}`);
|
|
clearErrors('departureDate');
|
|
}}
|
|
minDate={new Date()}
|
|
placeholder="Select date"
|
|
/>
|
|
{errors.departureDate && (
|
|
<p className="text-red-600 dark:text-red-400 text-sm">{errors.departureDate.message}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 2: Passengers, Nationality, Promo Code */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end mb-4">
|
|
{/* Passengers */}
|
|
<div className="space-y-2 relative z-20">
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Passengers</label>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsPassengerOpen(!isPassengerOpen)}
|
|
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 text-base bg-white dark:bg-gray-800 text-gray-900 dark:text-white flex items-center justify-between hover:border-primary transition-colors dark:[color-scheme:dark]"
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<Users className="w-4 h-4 text-primary" />
|
|
{(adultCount || 1) + (childCount || 0)} Passenger{((adultCount || 1) + (childCount || 0)) !== 1 ? 's' : ''}
|
|
</span>
|
|
<ChevronDown className={`w-4 h-4 text-gray-400 transition-transform ${isPassengerOpen ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
|
|
{isPassengerOpen && (
|
|
<>
|
|
<div className="fixed inset-0 z-40" onClick={() => setIsPassengerOpen(false)} />
|
|
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl z-50 p-4 space-y-4 text-gray-900 dark:text-white">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-sm font-medium text-gray-900 dark:text-white">Adults</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">≥5 years</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button type="button" onClick={() => { const c = adultCount || 1; if (c > 1) setValue('adultCount', c - 1); }} disabled={(adultCount || 1) <= 1} className="w-7 h-7 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40">
|
|
<Minus className="w-3.5 h-3.5 text-primary" />
|
|
</button>
|
|
<span className="w-5 text-center font-semibold text-gray-900 dark:text-white">{adultCount || 1}</span>
|
|
<button type="button" onClick={() => { const c = adultCount || 1; if (c < 9) setValue('adultCount', c + 1); }} disabled={(adultCount || 1) >= 9} className="w-7 h-7 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40">
|
|
<Plus className="w-3.5 h-3.5 text-primary" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="border-t border-gray-100 dark:border-gray-700 pt-4 flex items-center justify-between">
|
|
<div>
|
|
<div className="text-sm font-medium text-gray-900 dark:text-white">Children</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400"><5 years · First free</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button type="button" onClick={() => { const c = childCount || 0; if (c > 0) setValue('childCount', c - 1); }} disabled={(childCount || 0) <= 0} className="w-7 h-7 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40">
|
|
<Minus className="w-3.5 h-3.5 text-primary" />
|
|
</button>
|
|
<span className="w-5 text-center font-semibold text-gray-900 dark:text-white">{childCount || 0}</span>
|
|
<button type="button" onClick={() => { const c = childCount || 0; if (c < 9) setValue('childCount', c + 1); }} disabled={(childCount || 0) >= 9} className="w-7 h-7 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40">
|
|
<Plus className="w-3.5 h-3.5 text-primary" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Nationality */}
|
|
<div className="space-y-2">
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Nationality</label>
|
|
<CustomSelect
|
|
value={nationality}
|
|
onChange={(val) => setValue('nationality', val as SearchForm['nationality'])}
|
|
options={NATIONALITIES.map((n) => ({ value: n.value, label: n.label }))}
|
|
placeholder="Select nationality"
|
|
icon={<Globe className="w-5 h-5 text-primary" />}
|
|
/>
|
|
</div>
|
|
|
|
{/* Promo Code */}
|
|
<div className="space-y-2">
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Promo Code (Optional)</label>
|
|
<input
|
|
type="text"
|
|
placeholder="Enter promo code"
|
|
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 text-base bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search Button */}
|
|
<button
|
|
type="submit"
|
|
className="w-full bg-primary hover:bg-primary/90 text-white font-semibold py-3.5 px-6 rounded-lg transition-all duration-200 flex items-center justify-center gap-2 shadow-lg hover:shadow-xl"
|
|
>
|
|
<Search className="w-5 h-5" />
|
|
<span>Search Train</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|