Merge pull request #189 from Tria-plc/alpha

Update roundtrip booking result
This commit is contained in:
Eyob T.
2026-06-16 22:33:41 +03:00
committed by GitHub
5 changed files with 569 additions and 224 deletions

View File

@@ -14,38 +14,40 @@ export default function ResultsPage() {
const searchParams = useSearchParams();
const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule);
const [selectedClasses, setSelectedClasses] = useState<Record<string, string>>({});
const [outboundSelected, setOutboundSelected] = useState(false);
const [classModal, setClassModal] = useState<Schedule | null>(null);
const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null);
const searchCriteria = useBookingStore((s) => s.searchCriteria);
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
// Prefer URL params; fall back to persisted store values
const searchData = {
originStationId: searchParams.get('origin') || searchCriteria?.originStationId || '',
destinationStationId: searchParams.get('destination') || searchCriteria?.destinationStationId || '',
date: searchParams.get('date') || searchCriteria?.departureDate || '',
returnDate: searchParams.get('returnDate') || searchCriteria?.returnDate,
journeyType: searchParams.get('tripType') === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY',
adultCount: parseInt(searchParams.get('adults') || '') || searchCriteria?.adultCount || 1,
childCount: parseInt(searchParams.get('children') || '') || searchCriteria?.childCount || 0,
nationality: searchParams.get('nationality') || searchCriteria?.nationality || 'ETHIOPIAN',
promoCode: searchParams.get('promoCode') || searchCriteria?.promoCode || '',
};
// Sync URL params back into store whenever they are present in the URL
useEffect(() => {
if (searchParams.get('origin')) {
setSearchCriteria({
tripType: (searchParams.get('tripType') || 'ONE_WAY') as 'ONE_WAY' | 'ROUND_TRIP',
originStationId: searchParams.get('origin')!,
destinationStationId: searchParams.get('destination')!,
departureDate: searchParams.get('date')!,
returnDate: searchParams.get('returnDate') || undefined,
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
}, []);
}, [searchParams, setSearchCriteria]);
useEffect(() => {
if (searchData.promoCode) {
@@ -60,45 +62,91 @@ export default function ResultsPage() {
});
}
})
.catch((err) => {
console.error('Promo validation failed:', err);
.catch(() => {
// Promo validation failed - silently ignore
});
}
}, [searchData.promoCode]);
const buildSearchUrl = () => {
const params = new URLSearchParams({
tripType: searchData.journeyType,
origin: searchData.originStationId,
destination: searchData.destinationStationId,
date: searchData.date,
adults: searchData.adultCount.toString(),
children: searchData.childCount.toString(),
nationality: searchData.nationality,
...(searchData.returnDate && { returnDate: searchData.returnDate }),
...(searchData.promoCode && { promoCode: searchData.promoCode }),
});
return `/booking/search?${params}`;
};
const { data: results, isLoading, error } = useQuery<Schedule[]>({
const { data: results, isLoading, error } = useQuery<any>({
queryKey: ['search', searchData],
queryFn: async (): Promise<Schedule[]> => {
console.log('Searching with criteria:', searchData);
const response = await apiClient.post('/search', searchData) as Schedule[];
console.log('Search results:', response);
console.log('Number of results:', response?.length || 0);
if (response?.length > 0) {
console.log('First schedule availabilityByClass:', response[0].availabilityByClass);
queryFn: async (): Promise<any> => {
const payload: any = {
originStationId: searchData.originStationId,
destinationStationId: searchData.destinationStationId,
date: searchData.date,
adultCount: searchData.adultCount,
childCount: searchData.childCount,
nationality: searchData.nationality,
journeyType: searchData.journeyType,
};
if (searchData.journeyType === 'ROUND_TRIP' && searchData.returnDate) {
payload.returnDate = searchData.returnDate;
}
console.log('🚂 Search Request:', JSON.stringify(payload, null, 2));
const response = await apiClient.post('/search', payload) as any;
console.log('✅ Search Response:', JSON.stringify(response, null, 2));
return response;
},
enabled: !!searchData.originStationId && !!searchData.destinationStationId,
});
const handleSelectClass = (scheduleId: string, seatClass: string) => {
const isRoundTrip = searchData.journeyType === 'ROUND_TRIP';
// Handle both response formats:
// 1. One-way: response is array of schedules
// 2. Round-trip: response has journeyType, outbound, inbound properties
let outboundSchedules: Schedule[] = [];
let inboundSchedules: Schedule[] = [];
if (results) {
if (isRoundTrip && results.journeyType === 'ROUND_TRIP') {
// Round trip response format
outboundSchedules = results.outbound || [];
inboundSchedules = results.inbound || [];
} else if (Array.isArray(results)) {
// One-way response format (array of schedules)
outboundSchedules = results;
} else if (results.data && Array.isArray(results.data)) {
// Fallback: wrapped in data property
outboundSchedules = results.data;
}
}
// For one-way, check if outbound has results
// For round-trip, check if BOTH outbound and inbound have results
const hasResults = isRoundTrip
? (outboundSchedules.length > 0 && inboundSchedules.length > 0)
: outboundSchedules.length > 0;
const handleSelectClass = (scheduleId: string, seatClass: string, isOutbound: boolean = false) => {
setSelectedClasses(prev => ({ ...prev, [scheduleId]: seatClass }));
if (isOutbound && isRoundTrip) {
setOutboundSelected(true);
}
};
const handleSelect = (schedule: Schedule) => {
const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => {
const scheduleId = schedule.scheduleId || schedule.id || '';
const selectedClass = selectedClasses[scheduleId];
@@ -120,7 +168,7 @@ export default function ResultsPage() {
const minutes = (schedule.durationMinutes || 0) % 60;
const durationStr = `${hours}h ${minutes}m`;
setSelectedSchedule({
const scheduleData = {
id: scheduleId,
trainNumber: schedule.trainNumber,
origin: schedule.origin?.name || 'Origin',
@@ -132,10 +180,113 @@ export default function ResultsPage() {
baseFareChild: selectedClassFare.baseFareMinor,
selectedSeatClass: selectedClass,
selectedSeatClassName: selectedClass,
});
};
// For round trip, store outbound and wait for inbound selection
if (isRoundTrip && isOutbound) {
setOutboundSelected(true);
setClassModal(null);
return;
}
// For round trip inbound or one-way, proceed to next step
setSelectedSchedule(scheduleData);
router.push('/booking/auth-check');
};
const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false) => {
const scheduleId = schedule.scheduleId || schedule.id || '';
const selectedClass = selectedClasses[scheduleId];
const lowestFare = schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0
? Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0))
: null;
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
const minutes = (schedule.durationMinutes || 0) % 60;
const durationStr = `${hours}h ${minutes}m`;
const departureDate = schedule.departureAt ? new Date(schedule.departureAt) : null;
const arrivalDate = schedule.arrivalAt ? new Date(schedule.arrivalAt) : null;
const isNextDay = departureDate && arrivalDate && departureDate.toDateString() !== arrivalDate.toDateString();
return (
<div key={scheduleId} className="card">
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
<div className="flex-1">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
<Clock className="w-5 h-5 text-primary" />
</div>
<div>
<div className="font-bold text-lg text-gray-900 dark:text-gray-100">{schedule.trainNumber}</div>
<div className="text-sm text-gray-600 dark:text-gray-400">{schedule.trainName || 'Express Service'}</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-center">
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
{schedule.departureAt ? format(new Date(schedule.departureAt), 'HH:mm') : '--:--'}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{schedule.departureAt ? format(new Date(schedule.departureAt), 'MMM d') : ''}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.origin?.name || 'Origin'}</div>
</div>
<div className="flex-1 flex flex-col items-center">
<div className="flex items-center gap-2 mb-2 text-sm text-gray-600 dark:text-gray-400">
<Clock className="w-4 h-4" />
<span>{durationStr}</span>
</div>
<div className="w-full h-0.5 bg-gray-200 dark:bg-gray-700 relative">
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full" />
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full" />
</div>
{schedule.stops && schedule.stops.length > 0 && (
<div className="flex items-center gap-1 mt-2 text-sm text-gray-600 dark:text-gray-400">
<MapPin className="w-4 h-4" />
<span>{schedule.stops.length - 2} stops</span>
</div>
)}
</div>
<div className="text-center">
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'HH:mm') : '--:--'}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center justify-center gap-1">
<span>{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'MMM d') : ''}</span>
{isNextDay && <span className="text-orange-500 font-medium">(+1)</span>}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.destination?.name || 'Destination'}</div>
</div>
</div>
</div>
<div className="lg:border-l dark:border-gray-700 lg:pl-6 lg:min-w-[220px]">
<div className="text-center lg:text-right">
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Starting from</div>
<div className="text-3xl font-bold text-primary">
{lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">per adult</div>
{selectedClass && (
<p className="text-xs text-primary font-semibold mb-2">
{selectedClass.replace(/_/g, ' ')} selected
</p>
)}
<button
onClick={() => setClassModal({ ...schedule, isOutbound } as any)}
className="btn-secondary w-full flex items-center justify-center gap-2"
>
{selectedClass ? 'Change class' : 'Select class'}
</button>
</div>
</div>
</div>
</div>
);
};
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
@@ -164,7 +315,7 @@ export default function ResultsPage() {
);
}
if (!results || results.length === 0) {
if (!hasResults) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
@@ -192,19 +343,16 @@ export default function ResultsPage() {
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
{/* Class selection modal */}
{classModal && (() => {
const scheduleId = classModal.scheduleId || classModal.id || '';
const selectedClass = selectedClasses[scheduleId];
const isOutbound = (classModal as any).isOutbound;
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>
@@ -222,7 +370,6 @@ export default function ResultsPage() {
</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">
@@ -234,7 +381,7 @@ export default function ResultsPage() {
return (
<button
key={fareClass.seatClassName}
onClick={() => isAvailable && handleSelectClass(scheduleId, fareClass.seatClassName)}
onClick={() => isAvailable && handleSelectClass(scheduleId, fareClass.seatClassName, isOutbound)}
disabled={!isAvailable}
className={`relative w-full p-4 rounded-xl border-2 text-left transition-all ${
isSelected
@@ -272,14 +419,13 @@ export default function ResultsPage() {
)}
</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); } }}
onClick={() => { if (selectedClass) { handleSelect(classModal, isOutbound); } }}
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>
<span>{isRoundTrip && isOutbound ? 'Continue to Return' : 'Continue'}</span>
<ArrowRight className="w-4 h-4" />
</button>
{!selectedClass && (
@@ -292,7 +438,6 @@ export default function ResultsPage() {
);
})()}
{/* Promo Notification */}
{promoData && (
<div className="mb-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 flex items-start gap-3">
<Check className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
@@ -329,101 +474,42 @@ export default function ResultsPage() {
</div>
</div>
<div className="space-y-4">
{results.map((schedule) => {
const scheduleId = schedule.scheduleId || schedule.id || '';
const selectedClass = selectedClasses[scheduleId];
const lowestFare = schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0
? Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0))
: null;
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
const minutes = (schedule.durationMinutes || 0) % 60;
const durationStr = `${hours}h ${minutes}m`;
const departureDate = schedule.departureAt ? new Date(schedule.departureAt) : null;
const arrivalDate = schedule.arrivalAt ? new Date(schedule.arrivalAt) : null;
const isNextDay = departureDate && arrivalDate && departureDate.toDateString() !== arrivalDate.toDateString();
return (
<div key={scheduleId} className="card">
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
{/* Train info */}
<div className="flex-1">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
<Clock className="w-5 h-5 text-primary" />
</div>
<div>
<div className="font-bold text-lg text-gray-900 dark:text-gray-100">{schedule.trainNumber}</div>
<div className="text-sm text-gray-600 dark:text-gray-400">{schedule.trainName || 'Express Service'}</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-center">
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
{schedule.departureAt ? format(new Date(schedule.departureAt), 'HH:mm') : '--:--'}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{schedule.departureAt ? format(new Date(schedule.departureAt), 'MMM d') : ''}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.origin?.name || 'Origin'}</div>
</div>
<div className="flex-1 flex flex-col items-center">
<div className="flex items-center gap-2 mb-2 text-sm text-gray-600 dark:text-gray-400">
<Clock className="w-4 h-4" />
<span>{durationStr}</span>
</div>
<div className="w-full h-0.5 bg-gray-200 dark:bg-gray-700 relative">
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full" />
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full" />
</div>
{schedule.stops && schedule.stops.length > 0 && (
<div className="flex items-center gap-1 mt-2 text-sm text-gray-600 dark:text-gray-400">
<MapPin className="w-4 h-4" />
<span>{schedule.stops.length - 2} stops</span>
</div>
)}
</div>
<div className="text-center">
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'HH:mm') : '--:--'}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center justify-center gap-1">
<span>{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'MMM d') : ''}</span>
{isNextDay && <span className="text-orange-500 font-medium">(+1)</span>}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.destination?.name || 'Destination'}</div>
</div>
</div>
</div>
{/* Fare + action */}
<div className="lg:border-l dark:border-gray-700 lg:pl-6 lg:min-w-[220px]">
<div className="text-center lg:text-right">
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Starting from</div>
<div className="text-3xl font-bold text-primary">
{lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">per adult</div>
{selectedClass && (
<p className="text-xs text-primary font-semibold mb-2">
{selectedClass.replace(/_/g, ' ')} selected
</p>
)}
<button
onClick={() => setClassModal(schedule)}
className="btn-secondary w-full flex items-center justify-center gap-2"
>
{selectedClass ? 'Change class' : 'Select class'}
</button>
</div>
</div>
<div className="space-y-8">
{outboundSchedules.length > 0 && (
<div>
{isRoundTrip && (
<div className="mb-4">
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<ArrowRight className="w-5 h-5 text-primary" />
Outbound Journey
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{searchData.date ? format(new Date(searchData.date), 'EEEE, MMMM d, yyyy') : ''}
</p>
</div>
)}
<div className="space-y-4">
{outboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, true))}
</div>
);
})}
</div>
)}
{inboundSchedules.length > 0 && (!isRoundTrip || outboundSelected) && (
<div>
<div className="mb-4">
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<ArrowRight className="w-5 h-5 text-primary rotate-180" />
Return Journey
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{searchData.returnDate ? format(new Date(searchData.returnDate), 'EEEE, MMMM d, yyyy') : ''}
</p>
</div>
<div className="space-y-4">
{inboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule))}
</div>
</div>
)}
</div>
</div>
</div>

View File

@@ -17,9 +17,11 @@ import { useEffect, useRef, useState, useCallback } 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 origin station'),
destinationStationId: z.string().min(1, 'Please select destination station'),
departureDate: z.string().min(1, 'Please select departure date'),
returnDate: z.string().optional(),
adultCount: z.number().min(1).max(9),
childCount: z.number().min(0).max(9),
nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']),
@@ -27,6 +29,22 @@ const searchSchema = z.object({
}).refine((d) => d.originStationId !== d.destinationStationId, {
message: 'Origin and destination must be different',
path: ['destinationStationId'],
}).refine((d) => {
if (d.tripType === 'ROUND_TRIP' && !d.returnDate) {
return false;
}
return true;
}, {
message: 'Please select return date',
path: ['returnDate'],
}).refine((d) => {
if (d.tripType === 'ROUND_TRIP' && d.returnDate && d.departureDate) {
return d.returnDate >= d.departureDate;
}
return true;
}, {
message: 'Return date must be after departure date',
path: ['returnDate'],
});
type SearchForm = z.infer<typeof searchSchema>;
@@ -423,6 +441,7 @@ export default function SearchPage() {
const { handleSubmit, watch, setValue, formState: { errors } } = useForm<SearchForm>({
resolver: zodResolver(searchSchema as any),
defaultValues: {
tripType: 'ONE_WAY',
adultCount: 1,
childCount: 0,
nationality: 'ETHIOPIAN',
@@ -468,6 +487,8 @@ export default function SearchPage() {
const adultCount = watch('adultCount');
const childCount = watch('childCount');
const departureDate = watch('departureDate');
const returnDate = watch('returnDate');
const tripType = watch('tripType');
const totalPassengers = (adultCount || 1) + (childCount || 0);
const saveRecent = useCallback((id: string) => {
@@ -510,12 +531,14 @@ export default function SearchPage() {
if (data.originStationId) saveRecent(data.originStationId);
if (data.destinationStationId) saveRecent(data.destinationStationId);
const params = new URLSearchParams({
tripType: data.tripType,
origin: data.originStationId,
destination: data.destinationStationId,
date: data.departureDate,
adults: data.adultCount.toString(),
children: data.childCount.toString(),
nationality: data.nationality,
...(data.tripType === 'ROUND_TRIP' && data.returnDate && { returnDate: data.returnDate }),
...(data.promoCode && { promoCode: data.promoCode }),
});
router.push(`/booking/results?${params}`);
@@ -615,6 +638,34 @@ export default function SearchPage() {
<div className="p-4 md:p-5">
{/* Trip Type Tabs */}
<div className="mb-4">
<div className="inline-flex rounded-xl bg-gray-100 dark:bg-gray-800 p-1 w-full md:w-auto">
<button
type="button"
onClick={() => setValue('tripType', 'ONE_WAY')}
className={`flex-1 md:flex-none px-6 py-2.5 rounded-lg text-sm font-semibold transition-all ${
tripType === 'ONE_WAY'
? 'bg-white dark:bg-gray-900 text-primary shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
One Way
</button>
<button
type="button"
onClick={() => setValue('tripType', 'ROUND_TRIP')}
className={`flex-1 md:flex-none px-6 py-2.5 rounded-lg text-sm font-semibold transition-all ${
tripType === 'ROUND_TRIP'
? 'bg-white dark:bg-gray-900 text-primary shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
Round Trip
</button>
</div>
</div>
{/* Mobile: stacked */}
<div className="flex flex-col gap-3 md:hidden">
<div className="space-y-1.5">
@@ -660,6 +711,20 @@ export default function SearchPage() {
/>
</div>
</div>
{tripType === 'ROUND_TRIP' && (
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Return Date</label>
<div className="relative z-20">
<ModernDatePicker
value={returnDate ? new Date(returnDate + 'T00:00:00') : undefined}
onChange={(date) => setValue('returnDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
minDate={departureDate ? new Date(departureDate + 'T00:00:00') : new Date()}
placeholder="Select return date"
/>
</div>
{errors.returnDate && <p className="text-xs text-red-500">{errors.returnDate.message}</p>}
</div>
)}
{/* Pax + Nationality combined trigger */}
<button type="button" onClick={() => setPassengerModalOpen(true)}
className="w-full flex items-center justify-between px-3.5 py-3 border-2 border-gray-200 rounded-xl bg-white">
@@ -676,102 +741,230 @@ export default function SearchPage() {
</button>
</div>
{/* Desktop: single row — From [swap] To | Date | Pax+Nat | Search */}
<div className="hidden md:flex items-end gap-2">
{/* From */}
<div className="flex-1 min-w-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">From</label>
<StationDropdown stations={stations} value={originId} excludeId={destId} placeholder="Departure station"
recentIds={recentStationIds} onSelect={(s) => { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} />
{errors.originStationId && <p className="text-xs text-red-500">{errors.originStationId.message}</p>}
</div>
{/* Swap */}
<button type="button" onClick={handleSwap} disabled={!originId || !destId}
className={`flex-shrink-0 mb-0.5 w-9 h-9 bg-gray-50 border-2 border-gray-200 rounded-full flex items-center justify-center hover:border-primary hover:bg-primary/5 transition-all disabled:opacity-30 ${swapping ? 'rotate-180' : ''}`}>
<ArrowLeftRight className="w-4 h-4 text-gray-500" />
</button>
{/* To */}
<div className="flex-1 min-w-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">To</label>
<StationDropdown stations={stations} value={destId} excludeId={originId} placeholder="Destination station"
recentIds={recentStationIds} onSelect={(s) => { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} />
{errors.destinationStationId && <p className="text-xs text-red-500">{errors.destinationStationId.message}</p>}
</div>
{/* Divider */}
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
{/* Date */}
<div className="w-44 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Date</label>
<div className="relative z-30">
<ModernDatePicker
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
onChange={(date) => setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
minDate={new Date()} placeholder="Select date"
/>
</div>
{errors.departureDate && <p className="text-xs text-red-500">{errors.departureDate.message}</p>}
</div>
{/* Divider */}
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
{/* Pax + Nationality combined — opens shared modal */}
<div className="w-44 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Passengers</label>
<button type="button" onClick={() => setPassengerModalOpen(true)}
className="w-full flex items-center justify-between px-3 py-3.5 border-2 border-gray-200 rounded-xl bg-white hover:border-gray-300 transition-all">
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 truncate">
<Users className="w-4 h-4 text-primary flex-shrink-0" />
{totalPassengers} Pax · {watch('nationality') === 'ETHIOPIAN' ? '🇪🇹' : watch('nationality') === 'DJIBOUTIAN' ? '🇩🇯' : '🌍'}
</span>
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
</button>
</div>
{/* Search */}
<button type="submit" disabled={isLoading}
className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50">
<Search className="w-5 h-5" />
Search
</button>
</div>
{/* Promo */}
<div className="mt-3">
{!promoVisible ? (
<button type="button" onClick={() => setPromoVisible(true)}
className="flex items-center gap-1.5 text-xs text-primary font-medium hover:underline">
<Gift className="w-3.5 h-3.5" />
Apply Promo Code
</button>
) : (
<div className="flex flex-col gap-1.5">
<div className="flex gap-2">
<div className="flex-1 relative">
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary" />
<input type="text" value={promoCode}
onChange={(e) => { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }}
placeholder="Enter promo code"
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())}
className="w-full pl-9 pr-3 py-2.5 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white placeholder-gray-400"
autoFocus />
{/* Desktop: dynamic layout based on trip type */}
<div className={`hidden md:block`}>
{tripType === 'ONE_WAY' ? (
// ONE WAY: Single row layout
<div className="flex items-end gap-2">
{/* From */}
<div className="flex-1 min-w-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">From</label>
<StationDropdown stations={stations} value={originId} excludeId={destId} placeholder="Departure station"
recentIds={recentStationIds} onSelect={(s) => { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} />
{errors.originStationId && <p className="text-xs text-red-500">{errors.originStationId.message}</p>}
</div>
{/* Swap */}
<button type="button" onClick={handleSwap} disabled={!originId || !destId}
className={`flex-shrink-0 mb-0.5 w-9 h-9 bg-gray-50 border-2 border-gray-200 rounded-full flex items-center justify-center hover:border-primary hover:bg-primary/5 transition-all disabled:opacity-30 ${swapping ? 'rotate-180' : ''}`}>
<ArrowLeftRight className="w-4 h-4 text-gray-500" />
</button>
{/* To */}
<div className="flex-1 min-w-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">To</label>
<StationDropdown stations={stations} value={destId} excludeId={originId} placeholder="Destination station"
recentIds={recentStationIds} onSelect={(s) => { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} />
{errors.destinationStationId && <p className="text-xs text-red-500">{errors.destinationStationId.message}</p>}
</div>
{/* Divider */}
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
{/* Date */}
<div className="w-44 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Date</label>
<div className="relative z-30">
<ModernDatePicker
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
onChange={(date) => setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
minDate={new Date()} placeholder="Departure"
/>
</div>
<button type="button" onClick={handleValidatePromo} disabled={!promoCode || promoLoading}
className="px-4 py-2.5 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 disabled:opacity-40 text-sm font-semibold">
{promoLoading ? '...' : 'Apply'}
</button>
<button type="button" onClick={() => { setPromoVisible(false); setPromoCode(''); setPromoValidation(null); }}
className="p-2.5 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100">
<X className="w-4 h-4" />
{errors.departureDate && <p className="text-xs text-red-500">{errors.departureDate.message}</p>}
</div>
{/* Divider */}
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
{/* Pax + Nationality */}
<div className="w-44 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Passengers</label>
<button type="button" onClick={() => setPassengerModalOpen(true)}
className="w-full flex items-center justify-between px-3 py-3.5 border-2 border-gray-200 rounded-xl bg-white hover:border-gray-300 transition-all">
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 truncate">
<Users className="w-4 h-4 text-primary flex-shrink-0" />
{totalPassengers} Pax · {watch('nationality') === 'ETHIOPIAN' ? '🇪🇹' : watch('nationality') === 'DJIBOUTIAN' ? '🇩🇯' : '🌍'}
</span>
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
</button>
</div>
{promoValidation && (
<div className={`flex items-center gap-1.5 text-xs ${promoValidation.valid ? 'text-green-600' : 'text-red-500'}`}>
{promoValidation.valid && <Check className="w-3.5 h-3.5" />}
{promoValidation.message}
{/* Search */}
<button type="submit" disabled={isLoading}
className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50">
<Search className="w-5 h-5" />
Search
</button>
</div>
) : (
// ROUND TRIP: Two row layout
<div className="space-y-3">
{/* Row 1: From, Swap, To, Departure Date, Return Date */}
<div className="flex items-end gap-2">
{/* From */}
<div className="flex-1 min-w-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">From</label>
<StationDropdown stations={stations} value={originId} excludeId={destId} placeholder="Departure station"
recentIds={recentStationIds} onSelect={(s) => { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} />
{errors.originStationId && <p className="text-xs text-red-500">{errors.originStationId.message}</p>}
</div>
)}
{/* Swap */}
<button type="button" onClick={handleSwap} disabled={!originId || !destId}
className={`flex-shrink-0 mb-0.5 w-9 h-9 bg-gray-50 border-2 border-gray-200 rounded-full flex items-center justify-center hover:border-primary hover:bg-primary/5 transition-all disabled:opacity-30 ${swapping ? 'rotate-180' : ''}`}>
<ArrowLeftRight className="w-4 h-4 text-gray-500" />
</button>
{/* To */}
<div className="flex-1 min-w-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">To</label>
<StationDropdown stations={stations} value={destId} excludeId={originId} placeholder="Destination station"
recentIds={recentStationIds} onSelect={(s) => { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} />
{errors.destinationStationId && <p className="text-xs text-red-500">{errors.destinationStationId.message}</p>}
</div>
{/* Divider */}
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
{/* Departure Date */}
<div className="w-44 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Departure</label>
<div className="relative z-30">
<ModernDatePicker
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
onChange={(date) => setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
minDate={new Date()} placeholder="Select date"
/>
</div>
{errors.departureDate && <p className="text-xs text-red-500">{errors.departureDate.message}</p>}
</div>
{/* Return Date */}
<div className="w-44 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Return</label>
<div className="relative z-20">
<ModernDatePicker
value={returnDate ? new Date(returnDate + 'T00:00:00') : undefined}
onChange={(date) => setValue('returnDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
minDate={departureDate ? new Date(departureDate + 'T00:00:00') : new Date()}
placeholder="Select date"
/>
</div>
{errors.returnDate && <p className="text-xs text-red-500">{errors.returnDate.message}</p>}
</div>
</div>
{/* Row 2: Promo, Passengers, Search */}
<div className="flex items-end gap-2">
{/* Promo Code */}
<div className="flex-1 min-w-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
{!promoVisible ? 'Promo Code (Optional)' : 'Promo Code'}
</label>
{!promoVisible ? (
<button type="button" onClick={() => setPromoVisible(true)}
className="w-full flex items-center gap-1.5 px-3.5 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl hover:border-primary transition-all bg-white dark:bg-gray-800 text-left">
<Gift className="w-4 h-4 text-primary" />
<span className="text-sm text-gray-400">Click to add promo code</span>
</button>
) : (
<div className="space-y-1">
<div className="flex gap-2">
<div className="flex-1 relative">
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary" />
<input type="text" value={promoCode}
onChange={(e) => { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }}
placeholder="Enter promo code"
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())}
className="w-full pl-9 pr-3 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400"
autoFocus />
</div>
<button type="button" onClick={handleValidatePromo} disabled={!promoCode || promoLoading}
className="px-4 py-3.5 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-xl hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 text-sm font-semibold transition-colors">
{promoLoading ? '...' : 'Apply'}
</button>
<button type="button" onClick={() => { setPromoVisible(false); setPromoCode(''); setPromoValidation(null); }}
className="p-3.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{promoValidation && (
<div className={`flex items-center gap-1.5 text-xs ${promoValidation.valid ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`}>
{promoValidation.valid && <Check className="w-3.5 h-3.5" />}
{promoValidation.message}
</div>
)}
</div>
)}
</div>
{/* Divider */}
<div className="w-px h-10 bg-gray-200 dark:bg-gray-700 flex-shrink-0" />
{/* Pax + Nationality */}
<div className="w-44 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Passengers</label>
<button type="button" onClick={() => setPassengerModalOpen(true)}
className="w-full flex items-center justify-between px-3 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all">
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 dark:text-white truncate">
<Users className="w-4 h-4 text-primary flex-shrink-0" />
{totalPassengers} Pax · {watch('nationality') === 'ETHIOPIAN' ? '🇪🇹' : watch('nationality') === 'DJIBOUTIAN' ? '🇩🇯' : '🌍'}
</span>
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
</button>
</div>
{/* Search Button */}
<div className="flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide opacity-0 pointer-events-none">Search</label>
<button type="submit" disabled={isLoading}
className="flex items-center justify-center gap-2 px-6 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50">
<Search className="w-5 h-5" />
Search
</button>
</div>
</div>
</div>
)}
</div>
{/* Promo - Only visible in ONE WAY mode on desktop */}
{tripType === 'ONE_WAY' && (
<div className="mt-3">
{!promoVisible ? (
<button type="button" onClick={() => setPromoVisible(true)}
className="flex items-center gap-1.5 text-xs text-primary font-medium hover:underline">
<Gift className="w-3.5 h-3.5" />
Apply Promo Code
</button>
) : (
<div className="flex flex-col gap-1.5">
<div className="flex gap-2">
<div className="flex-1 relative">
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary" />
<input type="text" value={promoCode}
onChange={(e) => { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }}
placeholder="Enter promo code"
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())}
className="w-full pl-9 pr-3 py-2.5 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white placeholder-gray-400"
autoFocus />
</div>
<button type="button" onClick={handleValidatePromo} disabled={!promoCode || promoLoading}
className="px-4 py-2.5 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 disabled:opacity-40 text-sm font-semibold">
{promoLoading ? '...' : 'Apply'}
</button>
<button type="button" onClick={() => { setPromoVisible(false); setPromoCode(''); setPromoValidation(null); }}
className="p-2.5 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100">
<X className="w-4 h-4" />
</button>
</div>
{promoValidation && (
<div className={`flex items-center gap-1.5 text-xs ${promoValidation.valid ? 'text-green-600' : 'text-red-500'}`}>
{promoValidation.valid && <Check className="w-3.5 h-3.5" />}
{promoValidation.message}
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
</form>

View File

@@ -50,6 +50,7 @@ export default function SeatsPage() {
const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria, bookingId } = useBookingStore();
const [selectedSeats, setSelectedSeats] = useState<string[]>([]);
const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
const [currentJourneyType, setCurrentJourneyType] = useState<'outbound' | 'inbound'>('outbound');
const [modalState, setModalState] = useState({
isOpen: false,
title: '',
@@ -57,6 +58,8 @@ export default function SeatsPage() {
type: 'info' as 'warning' | 'error' | 'success' | 'info',
});
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
const { data: seatMapData, isLoading, error } = useQuery({
queryKey: ['seatmap', selectedSchedule?.id],
queryFn: () => apiClient.get(`/seats/seatmap/${selectedSchedule?.id}`),
@@ -70,10 +73,15 @@ export default function SeatsPage() {
seatId: seatIds[i],
}));
// For round trip inbound, swap origin and destination
const isInbound = isRoundTrip && currentJourneyType === 'inbound';
const originId = isInbound ? searchCriteria?.destinationStationId : searchCriteria?.originStationId;
const destinationId = isInbound ? searchCriteria?.originStationId : searchCriteria?.destinationStationId;
return apiClient.post(`/seats/hold`, {
scheduleId: selectedSchedule?.id,
originStationId: searchCriteria?.originStationId,
destinationStationId: searchCriteria?.destinationStationId,
originStationId: originId,
destinationStationId: destinationId,
passengers: passengersForHold,
});
},
@@ -157,17 +165,58 @@ export default function SeatsPage() {
}, [passengers.length]);
const handleContinue = async () => {
if (isRoundTrip && currentJourneyType === 'outbound') {
// Save outbound seats and show inbound
if (selectedSeats.length > 0) {
try {
await holdMutation.mutateAsync(selectedSeats);
const updatedPassengers = passengers.map((p, i) => {
const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]);
return {
...p,
seatId: selectedSeats[i],
seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
};
});
setPassengers(updatedPassengers);
} catch (error: any) {
setModalState({
isOpen: true,
title: 'Seat Hold Failed',
message: error?.response?.data?.message || 'Failed to hold seats. Please try again.',
type: 'error',
});
return;
}
}
setCurrentJourneyType('inbound');
setSelectedSeats([]);
setSelectedCoach(null);
return;
}
// Final continue (one-way or round-trip inbound)
if (selectedSeats.length > 0) {
await holdMutation.mutateAsync(selectedSeats);
const updatedPassengers = passengers.map((p, i) => {
const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]);
return {
...p,
seatId: selectedSeats[i],
seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
};
});
setPassengers(updatedPassengers);
try {
await holdMutation.mutateAsync(selectedSeats);
const updatedPassengers = passengers.map((p, i) => {
const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]);
return {
...p,
seatId: selectedSeats[i],
seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
};
});
setPassengers(updatedPassengers);
} catch (error: any) {
setModalState({
isOpen: true,
title: 'Seat Hold Failed',
message: error?.response?.data?.message || 'Failed to hold seats. Please try again.',
type: 'error',
});
return;
}
}
router.push('/booking/review');
};
@@ -449,7 +498,14 @@ export default function SeatsPage() {
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'}
{holdMutation.isPending
? 'Holding seats...'
: isRoundTrip && currentJourneyType === 'outbound'
? 'Continue to Return Seats'
: allSelected
? 'Continue'
: 'Continue with partial selection'
}
</button>
<button
onClick={handleAutoAssign}
@@ -496,7 +552,12 @@ export default function SeatsPage() {
<ChevronLeft className="w-4 h-4" />
Back
</button>
<h1 className="text-base font-bold text-gray-900 dark:text-white">Select Seats</h1>
<h1 className="text-base font-bold text-gray-900 dark:text-white">
{isRoundTrip
? (currentJourneyType === 'outbound' ? 'Select Outbound Seats' : 'Select Return Seats')
: 'Select Seats'
}
</h1>
<div className="text-sm font-semibold text-[rgb(20,113,76)]">
{selectedSeats.length}/{passengers.length}
</div>

View File

@@ -13,9 +13,11 @@ import { useState } from 'react';
import ModernDatePicker from '@/components/ModernDatePicker';
const searchSchema = z.object({
tripType: z.enum(['ONE_WAY', 'ROUND_TRIP']),
originStationId: z.string().min(1),
destinationStationId: z.string().min(1),
departureDate: z.string().min(1),
returnDate: z.string().optional(),
adultCount: z.number().min(1).max(9),
childCount: z.number().min(0).max(9),
nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']),
@@ -44,6 +46,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<SearchForm>({
resolver: zodResolver(searchSchema as any),
defaultValues: {
tripType: 'ONE_WAY',
adultCount: 1,
childCount: 0,
nationality: 'ETHIOPIAN',

View File

@@ -5,6 +5,8 @@ export interface SearchCriteria {
originStationId: string;
destinationStationId: string;
departureDate: string;
returnDate?: string;
tripType: 'ONE_WAY' | 'ROUND_TRIP';
adultCount: number;
childCount: number;
nationality: 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER';