diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index df4a54742..79b8419c5 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -14,38 +14,40 @@ export default function ResultsPage() { const searchParams = useSearchParams(); const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule); const [selectedClasses, setSelectedClasses] = useState>({}); + const [outboundSelected, setOutboundSelected] = useState(false); const [classModal, setClassModal] = useState(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({ + const { data: results, isLoading, error } = useQuery({ queryKey: ['search', searchData], - queryFn: async (): Promise => { - 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 => { + 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 ( +
+
+
+
+
+ +
+
+
{schedule.trainNumber}
+
{schedule.trainName || 'Express Service'}
+
+
+ +
+
+
+ {schedule.departureAt ? format(new Date(schedule.departureAt), 'HH:mm') : '--:--'} +
+
+ {schedule.departureAt ? format(new Date(schedule.departureAt), 'MMM d') : ''} +
+
{schedule.origin?.name || 'Origin'}
+
+ +
+
+ + {durationStr} +
+
+
+
+
+ {schedule.stops && schedule.stops.length > 0 && ( +
+ + {schedule.stops.length - 2} stops +
+ )} +
+ +
+
+ {schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'HH:mm') : '--:--'} +
+
+ {schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'MMM d') : ''} + {isNextDay && (+1)} +
+
{schedule.destination?.name || 'Destination'}
+
+
+
+ +
+
+
Starting from
+
+ {lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'} +
+
per adult
+ {selectedClass && ( +

+ {selectedClass.replace(/_/g, ' ')} selected +

+ )} + +
+
+
+
+ ); + }; + if (isLoading) { return (
@@ -164,7 +315,7 @@ export default function ResultsPage() { ); } - if (!results || results.length === 0) { + if (!hasResults) { return (
@@ -192,19 +343,16 @@ export default function ResultsPage() {
- {/* Class selection modal */} {classModal && (() => { const scheduleId = classModal.scheduleId || classModal.id || ''; const selectedClass = selectedClasses[scheduleId]; + const isOutbound = (classModal as any).isOutbound; return ( <> - {/* Backdrop */}
setClassModal(null)} /> - {/* Drawer */}
- {/* Header */}

Select Class

@@ -222,7 +370,6 @@ export default function ResultsPage() {
- {/* Class grid */}
{classModal.faresByClass && Array.isArray(classModal.faresByClass) && classModal.faresByClass.length > 0 ? (
@@ -234,7 +381,7 @@ export default function ResultsPage() { return (
- {/* Footer */}
{!selectedClass && ( @@ -292,7 +438,6 @@ export default function ResultsPage() { ); })()} - {/* Promo Notification */} {promoData && (
@@ -329,101 +474,42 @@ export default function ResultsPage() {
-
- {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 ( -
-
- {/* Train info */} -
-
-
- -
-
-
{schedule.trainNumber}
-
{schedule.trainName || 'Express Service'}
-
-
- -
-
-
- {schedule.departureAt ? format(new Date(schedule.departureAt), 'HH:mm') : '--:--'} -
-
- {schedule.departureAt ? format(new Date(schedule.departureAt), 'MMM d') : ''} -
-
{schedule.origin?.name || 'Origin'}
-
- -
-
- - {durationStr} -
-
-
-
-
- {schedule.stops && schedule.stops.length > 0 && ( -
- - {schedule.stops.length - 2} stops -
- )} -
- -
-
- {schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'HH:mm') : '--:--'} -
-
- {schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'MMM d') : ''} - {isNextDay && (+1)} -
-
{schedule.destination?.name || 'Destination'}
-
-
-
- - {/* Fare + action */} -
-
-
Starting from
-
- {lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'} -
-
per adult
- {selectedClass && ( -

- {selectedClass.replace(/_/g, ' ')} selected -

- )} - -
-
+
+ {outboundSchedules.length > 0 && ( +
+ {isRoundTrip && ( +
+

+ + Outbound Journey +

+

+ {searchData.date ? format(new Date(searchData.date), 'EEEE, MMMM d, yyyy') : ''} +

+ )} +
+ {outboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, true))}
- ); - })} +
+ )} + + {inboundSchedules.length > 0 && (!isRoundTrip || outboundSelected) && ( +
+
+

+ + Return Journey +

+

+ {searchData.returnDate ? format(new Date(searchData.returnDate), 'EEEE, MMMM d, yyyy') : ''} +

+
+
+ {inboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule))} +
+
+ )}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index 819b59d6d..8295d3a09 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -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; @@ -423,6 +441,7 @@ export default function SearchPage() { const { handleSubmit, watch, setValue, formState: { errors } } = useForm({ 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() {
+ {/* Trip Type Tabs */} +
+
+ + +
+
+ {/* Mobile: stacked */}
@@ -660,6 +711,20 @@ export default function SearchPage() { />
+ {tripType === 'ROUND_TRIP' && ( +
+ +
+ 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" + /> +
+ {errors.returnDate &&

{errors.returnDate.message}

} +
+ )} {/* Pax + Nationality combined trigger */}
- {/* Desktop: single row โ€” From [swap] To | Date | Pax+Nat | Search */} -
- {/* From */} -
- - { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} /> - {errors.originStationId &&

{errors.originStationId.message}

} -
- {/* Swap */} - - {/* To */} -
- - { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} /> - {errors.destinationStationId &&

{errors.destinationStationId.message}

} -
- {/* Divider */} -
- {/* Date */} -
- -
- setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} - minDate={new Date()} placeholder="Select date" - /> -
- {errors.departureDate &&

{errors.departureDate.message}

} -
- {/* Divider */} -
- {/* Pax + Nationality combined โ€” opens shared modal */} -
- - -
- {/* Search */} - -
- - {/* Promo */} -
- {!promoVisible ? ( - - ) : ( -
-
-
- - { 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 */} +
+ {tripType === 'ONE_WAY' ? ( + // ONE WAY: Single row layout +
+ {/* From */} +
+ + { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} /> + {errors.originStationId &&

{errors.originStationId.message}

} +
+ {/* Swap */} + + {/* To */} +
+ + { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} /> + {errors.destinationStationId &&

{errors.destinationStationId.message}

} +
+ {/* Divider */} +
+ {/* Date */} +
+ +
+ setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} + minDate={new Date()} placeholder="Departure" + />
- -
+ {/* Divider */} +
+ {/* Pax + Nationality */} +
+ +
- {promoValidation && ( -
- {promoValidation.valid && } - {promoValidation.message} + {/* Search */} + +
+ ) : ( + // ROUND TRIP: Two row layout +
+ {/* Row 1: From, Swap, To, Departure Date, Return Date */} +
+ {/* From */} +
+ + { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} /> + {errors.originStationId &&

{errors.originStationId.message}

}
- )} + {/* Swap */} + + {/* To */} +
+ + { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} /> + {errors.destinationStationId &&

{errors.destinationStationId.message}

} +
+ {/* Divider */} +
+ {/* Departure Date */} +
+ +
+ setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} + minDate={new Date()} placeholder="Select date" + /> +
+ {errors.departureDate &&

{errors.departureDate.message}

} +
+ {/* Return 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" + /> +
+ {errors.returnDate &&

{errors.returnDate.message}

} +
+
+ + {/* Row 2: Promo, Passengers, Search */} +
+ {/* Promo Code */} +
+ + {!promoVisible ? ( + + ) : ( +
+
+
+ + { 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 /> +
+ + +
+ {promoValidation && ( +
+ {promoValidation.valid && } + {promoValidation.message} +
+ )} +
+ )} +
+ {/* Divider */} +
+ {/* Pax + Nationality */} +
+ + +
+ {/* Search Button */} +
+ + +
+
)}
+ {/* Promo - Only visible in ONE WAY mode on desktop */} + {tripType === 'ONE_WAY' && ( +
+ {!promoVisible ? ( + + ) : ( +
+
+
+ + { 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 /> +
+ + +
+ {promoValidation && ( +
+ {promoValidation.valid && } + {promoValidation.message} +
+ )} +
+ )} +
+ )} +
diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index fc165c4f4..3cdcba6c1 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -50,6 +50,7 @@ export default function SeatsPage() { const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria, bookingId } = useBookingStore(); const [selectedSeats, setSelectedSeats] = useState([]); const [selectedCoach, setSelectedCoach] = useState(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' + } -

Select Seats

+

+ {isRoundTrip + ? (currentJourneyType === 'outbound' ? 'Select Outbound Seats' : 'Select Return Seats') + : 'Select Seats' + } +

{selectedSeats.length}/{passengers.length}
diff --git a/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx b/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx index 180393aab..35901755d 100644 --- a/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx +++ b/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx @@ -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({ resolver: zodResolver(searchSchema as any), defaultValues: { + tripType: 'ONE_WAY', adultCount: 1, childCount: 0, nationality: 'ETHIOPIAN', diff --git a/apps/edr-passenger-web/portal/src/lib/booking-store.ts b/apps/edr-passenger-web/portal/src/lib/booking-store.ts index 32f836ac3..2be5eccf2 100644 --- a/apps/edr-passenger-web/portal/src/lib/booking-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/booking-store.ts @@ -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';