diff --git a/apps/edr-passenger-web/portal/public/edr-banner.jpg b/apps/edr-passenger-web/portal/public/edr-banner.jpg new file mode 100644 index 000000000..81b8ddca3 Binary files /dev/null and b/apps/edr-passenger-web/portal/public/edr-banner.jpg differ diff --git a/apps/edr-passenger-web/portal/public/edr-logo.png b/apps/edr-passenger-web/portal/public/edr-logo.png new file mode 100644 index 000000000..3966c9e80 Binary files /dev/null and b/apps/edr-passenger-web/portal/public/edr-logo.png differ diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 2efe9b5d4..389123510 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -7,6 +7,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; import { useState, useEffect } from "react"; import { PaymentMethod } from "@/types"; +import { format } from "date-fns"; import { CreditCard, Smartphone, @@ -23,12 +24,14 @@ const getIconForMethod = (methodId: string) => { export default function PaymentPage() { const router = useRouter(); - const { bookingId, pnr, selectedSchedule, passengers } = useBookingStore(); + const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria } = useBookingStore(); const { selectedCurrency, setPaymentIntent, updateStatus } = usePaymentStore(); const [selectedMethod, setSelectedMethod] = useState(null); const [isProcessing, setIsProcessing] = useState(false); + const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; + const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery({ queryKey: ['paymentMethods'], queryFn: async () => { @@ -38,10 +41,21 @@ export default function PaymentPage() { }); // Calculate total amount - const baseFare = passengers.reduce( + const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce( + (sum) => sum + (outboundSchedule.baseFareAdult || 0), + 0, + ) : 0; + + const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce( + (sum) => sum + (inboundSchedule.baseFareAdult || 0), + 0, + ) : 0; + + const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce( (sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0, ); + const totalAmount = baseFare; const paymentMutation = useMutation({ @@ -207,47 +221,231 @@ export default function PaymentPage() { {/* Order Summary */}
-

+

Order summary

-
-
- Route - - {selectedSchedule?.origin} → {selectedSchedule?.destination} - -
-
- Train - - {selectedSchedule?.trainNumber} - -
- {selectedSchedule?.selectedSeatClassName && ( -
+
+ {isRoundTrip ? ( + <> + {/* Outbound Journey */} +
+
+
+ Outbound Journey + + {outboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'} + +
+ + {/* Flight-style timeline */} +
+
+ + {/* Origin */} +
+
+
+ {outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'HH:mm') : '--:--'} +
+
+ {outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'} +
+
+ {outboundSchedule?.origin} +
+
+ + {/* Journey Info */} +
+
+
+ + + + {outboundSchedule?.duration} +
+
+ + + + Train {outboundSchedule?.trainNumber} +
+
+
+ + {/* Destination */} +
+
+
+ {outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'HH:mm') : '--:--'} +
+
+ {outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} +
+
+ {outboundSchedule?.destination} +
+
+
+ +
+
+ Outbound fare + ETB {(outboundBaseFare / 100).toFixed(2)} +
+
+
+ + {/* Return Journey */} +
+
+
+ Return Journey + + {inboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'} + +
+ + {/* Flight-style timeline */} +
+
+ + {/* Origin */} +
+
+
+ {inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'HH:mm') : '--:--'} +
+
+ {inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'} +
+
+ {inboundSchedule?.origin} +
+
+ + {/* Journey Info */} +
+
+
+ + + + {inboundSchedule?.duration} +
+
+ + + + Train {inboundSchedule?.trainNumber} +
+
+
+ + {/* Destination */} +
+
+
+ {inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'HH:mm') : '--:--'} +
+
+ {inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} +
+
+ {inboundSchedule?.destination} +
+
+
+ +
+
+ Return fare + ETB {(inboundBaseFare / 100).toFixed(2)} +
+
+
+ + ) : ( + <> + {/* One-Way Journey */} +
+
+
+ Your Journey + + {selectedSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'} + +
+ + {/* Flight-style timeline */} +
+
+ + {/* Origin */} +
+
+
+ {selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'HH:mm') : '--:--'} +
+
+ {selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'EEE, MMM d') : 'N/A'} +
+
+ {selectedSchedule?.origin} +
+
+ + {/* Journey Info */} +
+
+
+ + + + {selectedSchedule?.duration} +
+
+ + + + Train {selectedSchedule?.trainNumber} +
+
+
+ + {/* Destination */} +
+
+
+ {selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'HH:mm') : '--:--'} +
+
+ {selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} +
+
+ {selectedSchedule?.destination} +
+
+
+
+ + )} + + {/* Passengers and Total */} +
+
- Class + Passengers - {selectedSchedule.selectedSeatClassName.replace(/_/g, " ")} + {passengers.length} passenger{passengers.length !== 1 ? "s" : ""}
- )} -
- - Passengers - - - {passengers.length} passenger - {passengers.length !== 1 ? "s" : ""} - -
-
-
- +
+ Total amount - + ETB {(totalAmount / 100).toFixed(2)}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx index 0943db60e..9b190fbf2 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx @@ -16,7 +16,7 @@ function TelebirrSuccessContent() { const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing'); // Telebirr callback query params - const merchantOrderId = searchParams.get('merchantOrderId') || ''; + const orderid = searchParams.get('orderid') || ''; const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || ''; const bookingIdQp = searchParams.get('bookingId') || bookingId || ''; @@ -25,7 +25,7 @@ function TelebirrSuccessContent() { try { if (bookingIdQp) { await apiClient.patch(`/bookings/${bookingIdQp}/confirm`, { - paymentReference: merchantOrderId || trxRef, + paymentReference: orderid || trxRef, paymentMethod: 'TELEBIRR', }); } @@ -59,7 +59,7 @@ function TelebirrSuccessContent() {

Payment Successful!

Your Telebirr payment was received.

- {merchantOrderId &&

Order ID: {merchantOrderId}

} + {orderid &&

Order ID: {orderid}

} {trxRef &&

Transaction Ref: {trxRef}

}

Redirecting to your booking confirmation…

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 79b8419c5..2e8d9e950 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 @@ -5,16 +5,16 @@ import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { useBookingStore } from '@/lib/booking-store'; import { Schedule } from '@/types'; -import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Loader2, Check, X, MapPin, Gift, Train } from 'lucide-react'; +import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Check, X, MapPin, Gift, Train } from 'lucide-react'; import { format } from 'date-fns'; import { useState, useEffect } from 'react'; export default function ResultsPage() { const router = useRouter(); const searchParams = useSearchParams(); - const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule); + const { setSelectedSchedule, setOutboundSchedule, setInboundSchedule } = useBookingStore(); const [selectedClasses, setSelectedClasses] = useState>({}); - const [outboundSelected, setOutboundSelected] = useState(false); + const [outboundScheduleData, setOutboundScheduleData] = useState(null); const [classModal, setClassModal] = useState(null); const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null); @@ -114,18 +114,21 @@ export default function ResultsPage() { const isRoundTrip = searchData.journeyType === 'ROUND_TRIP'; // Handle both response formats: - // 1. One-way: response is array of schedules + // 1. One-way: response can be array of schedules OR object with journeyType and outbound // 2. Round-trip: response has journeyType, outbound, inbound properties let outboundSchedules: Schedule[] = []; let inboundSchedules: Schedule[] = []; if (results) { - if (isRoundTrip && results.journeyType === 'ROUND_TRIP') { + if (results.journeyType === 'ROUND_TRIP') { // Round trip response format outboundSchedules = results.outbound || []; inboundSchedules = results.inbound || []; + } else if (results.journeyType === 'ONE_WAY' && results.outbound) { + // One-way response format with outbound array + outboundSchedules = results.outbound || []; } else if (Array.isArray(results)) { - // One-way response format (array of schedules) + // One-way response format (direct array of schedules) outboundSchedules = results; } else if (results.data && Array.isArray(results.data)) { // Fallback: wrapped in data property @@ -139,11 +142,8 @@ export default function ResultsPage() { ? (outboundSchedules.length > 0 && inboundSchedules.length > 0) : outboundSchedules.length > 0; - const handleSelectClass = (scheduleId: string, seatClass: string, isOutbound: boolean = false) => { + const handleSelectClass = (scheduleId: string, seatClass: string) => { setSelectedClasses(prev => ({ ...prev, [scheduleId]: seatClass })); - if (isOutbound && isRoundTrip) { - setOutboundSelected(true); - } }; const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => { @@ -184,13 +184,28 @@ export default function ResultsPage() { // For round trip, store outbound and wait for inbound selection if (isRoundTrip && isOutbound) { - setOutboundSelected(true); + setOutboundScheduleData(scheduleData); + setOutboundSchedule(scheduleData); setClassModal(null); + // Scroll to inbound section + setTimeout(() => { + const inboundSection = document.getElementById('inbound-section'); + if (inboundSection) { + inboundSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, 100); return; } - // For round trip inbound or one-way, proceed to next step - setSelectedSchedule(scheduleData); + // For round trip inbound, proceed with both schedules + if (isRoundTrip && !isOutbound) { + setInboundSchedule(scheduleData); + setSelectedSchedule(outboundScheduleData); // Set primary as outbound + } else { + // For one-way + setSelectedSchedule(scheduleData); + } + router.push('/booking/auth-check'); }; @@ -289,10 +304,138 @@ export default function ResultsPage() { if (isLoading) { return ( -
-
- -

Searching for trains...

+
+
+
+ {/* Progress Header */} +
+
+
+
+
+
+
+

+ Searching for trains... +

+

+ Finding the best options for your journey +

+
+
+ {/* Progress bar */} +
+
+
+
+
+ + {/* Skeleton Cards */} +
+ {[1, 2, 3].map((i) => ( +
+
+
+ {/* Train info skeleton */} +
+
+
+
+
+
+
+ + {/* Time and route skeleton */} +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+ ))} +
+
); @@ -381,7 +524,7 @@ export default function ResultsPage() { return ( {!selectedClass && ( @@ -494,8 +637,8 @@ export default function ResultsPage() {
)} - {inboundSchedules.length > 0 && (!isRoundTrip || outboundSelected) && ( -
+ {isRoundTrip && inboundSchedules.length > 0 && outboundScheduleData && ( +

diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 6f9c2a265..d3e75d7d9 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -51,11 +51,13 @@ function getPassengerIdFromToken(token: string): string | null { export default function ReviewPage() { const router = useRouter(); - const { selectedSchedule, passengers, seatHold, setBookingId, setPNR, createAccount, passengerId: storedPassengerId } = useBookingStore(); + const { selectedSchedule, outboundSchedule, inboundSchedule, passengers, seatHold, setBookingId, setPNR, createAccount, passengerId: storedPassengerId, searchCriteria } = useBookingStore(); const { user, isAuthenticated } = useAuthStore(); const [timeLeft, setTimeLeft] = useState(''); const [seatDetails, setSeatDetails] = useState>({}); + const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; + useEffect(() => { if (!seatHold?.expiresAt) return; @@ -79,22 +81,57 @@ export default function ReviewPage() { useEffect(() => { const fetchSeatDetails = async () => { - if (!selectedSchedule?.id) return; - try { - const seatMapData: any = await apiClient.get(`/seats/seatmap/${selectedSchedule.id}`); - const coaches = seatMapData?.coaches || []; - const allSeats = coaches.flatMap((coach: any) => coach.seats || []); - const details: Record = {}; - passengers.forEach(p => { - if (p.seatId) { - const seat = allSeats.find((s: any) => s.id === p.seatId); - if (seat) { - details[p.seatId] = seat.number || seat.label || seat.seatNumber || 'N/A'; + + // Fetch outbound seat details + if (isRoundTrip && outboundSchedule?.id) { + const outboundSeatMap: any = await apiClient.get(`/seats/seatmap/${outboundSchedule.id}`); + const outboundCoaches = outboundSeatMap?.coaches || []; + const outboundSeats = outboundCoaches.flatMap((coach: any) => coach.seats || []); + + passengers.forEach(p => { + if ((p as any).outboundSeatId) { + const seat = outboundSeats.find((s: any) => s.id === (p as any).outboundSeatId); + if (seat) { + details[`outbound-${(p as any).outboundSeatId}`] = seat.number || seat.label || seat.seatNumber || 'N/A'; + } } - } - }); + }); + } + + // Fetch inbound seat details + if (isRoundTrip && inboundSchedule?.id) { + const inboundSeatMap: any = await apiClient.get(`/seats/seatmap/${inboundSchedule.id}`); + const inboundCoaches = inboundSeatMap?.coaches || []; + const inboundSeats = inboundCoaches.flatMap((coach: any) => coach.seats || []); + + passengers.forEach(p => { + if ((p as any).inboundSeatId) { + const seat = inboundSeats.find((s: any) => s.id === (p as any).inboundSeatId); + if (seat) { + details[`inbound-${(p as any).inboundSeatId}`] = seat.number || seat.label || seat.seatNumber || 'N/A'; + } + } + }); + } + + // Fetch one-way seat details + if (!isRoundTrip && selectedSchedule?.id) { + const seatMapData: any = await apiClient.get(`/seats/seatmap/${selectedSchedule.id}`); + const coaches = seatMapData?.coaches || []; + const allSeats = coaches.flatMap((coach: any) => coach.seats || []); + + passengers.forEach(p => { + if (p.seatId) { + const seat = allSeats.find((s: any) => s.id === p.seatId); + if (seat) { + details[p.seatId] = seat.number || seat.label || seat.seatNumber || 'N/A'; + } + } + }); + } + setSeatDetails(details); } catch (error) { console.error('Failed to fetch seat details:', error); @@ -102,7 +139,7 @@ export default function ReviewPage() { }; fetchSeatDetails(); - }, [selectedSchedule?.id, passengers]); + }, [selectedSchedule?.id, outboundSchedule?.id, inboundSchedule?.id, passengers, isRoundTrip]); const createBookingMutation = useMutation({ mutationFn: (data: any) => { @@ -155,6 +192,8 @@ export default function ReviewPage() { console.log('Search criteria:', searchCriteria); console.log('Seat hold:', seatHold); console.log('Selected schedule:', selectedSchedule); + console.log('Outbound schedule:', outboundSchedule); + console.log('Inbound schedule:', inboundSchedule); console.log('Passengers:', passengers); if (!seatHold?.holdId) { @@ -171,12 +210,15 @@ export default function ReviewPage() { return; } + // Get seat class ID let seatClassId = 'default-seat-class-id'; + let returnSeatClassId = 'default-seat-class-id'; try { const seatClasses: any = await apiClient.get('/seat-classes'); console.log('Seat classes:', seatClasses); if (seatClasses && seatClasses.length > 0) { seatClassId = seatClasses[0].id; + returnSeatClassId = seatClasses[0].id; } } catch (err) { console.error('Failed to fetch seat classes:', err); @@ -229,18 +271,20 @@ export default function ReviewPage() { throw new Error('Passenger ID not found in authentication token. Please log in again.'); } + // Build booking request for authenticated users bookingData = { - scheduleId: selectedSchedule?.id || '', + passengerId: passengerId, + scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id, holdId: seatHold.holdId, originStationId: searchCriteria.originStationId, destinationStationId: searchCriteria.destinationStationId, seatClassId: seatClassId, + bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', displayCurrency: 'ETB', - passengerId: passengerId, passengers: passengers.map((p) => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; return { - seatId: p.seatId || '', + seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''), passengerName: p.name, dateOfBirth: p.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', @@ -251,19 +295,34 @@ export default function ReviewPage() { }; }), }; + + // Add round trip specific fields + if (isRoundTrip && inboundSchedule) { + bookingData.returnScheduleId = inboundSchedule.id; + bookingData.returnOriginStationId = searchCriteria.destinationStationId; + bookingData.returnDestinationStationId = searchCriteria.originStationId; + bookingData.returnHoldId = seatHold.holdId; // Assuming same hold ID, adjust if needed + bookingData.returnSeatClassId = returnSeatClassId; + } + + // Add promo code if exists + if (searchCriteria.promoCode) { + bookingData.promoCode = searchCriteria.promoCode; + } } else { // For guests: send full passenger details array bookingData = { - scheduleId: selectedSchedule?.id || '', + scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id, holdId: seatHold.holdId, originStationId: searchCriteria.originStationId, destinationStationId: searchCriteria.destinationStationId, seatClassId: seatClassId, + bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', displayCurrency: 'ETB', passengers: passengers.map(p => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; return { - seatId: p.seatId || '', + seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''), passengerName: p.name, dateOfBirth: p.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', @@ -279,6 +338,20 @@ export default function ReviewPage() { savePassengerDetails: true, deviceId: typeof window !== 'undefined' ? (localStorage.getItem('deviceId') || `device-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`) : undefined, }; + + // Add round trip specific fields + if (isRoundTrip && inboundSchedule) { + bookingData.returnScheduleId = inboundSchedule.id; + bookingData.returnOriginStationId = searchCriteria.destinationStationId; + bookingData.returnDestinationStationId = searchCriteria.originStationId; + bookingData.returnHoldId = seatHold.holdId; // Assuming same hold ID, adjust if needed + bookingData.returnSeatClassId = returnSeatClassId; + } + + // Add promo code if exists + if (searchCriteria.promoCode) { + bookingData.promoCode = searchCriteria.promoCode; + } } if (typeof window !== 'undefined' && !isAuthenticated && bookingData.deviceId && !localStorage.getItem('deviceId')) { @@ -294,26 +367,49 @@ export default function ReviewPage() { }; useEffect(() => { - if (!selectedSchedule || !passengers.length) { - if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) { - console.log('Redirecting to search - missing data'); - router.push('/booking/search'); + if (isRoundTrip) { + if (!outboundSchedule || !inboundSchedule || !passengers.length) { + if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) { + console.log('Redirecting to search - missing round trip data'); + router.push('/booking/search'); + } + } + } else { + if (!selectedSchedule || !passengers.length) { + if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) { + console.log('Redirecting to search - missing data'); + router.push('/booking/search'); + } } } - }, [selectedSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]); + }, [isRoundTrip, selectedSchedule, outboundSchedule, inboundSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]); - if (!selectedSchedule || !passengers.length) { + if (isRoundTrip && (!outboundSchedule || !inboundSchedule || !passengers.length)) { return null; } - console.log('Selected schedule:', selectedSchedule); - console.log('Base fare adult:', selectedSchedule.baseFareAdult); + if (!isRoundTrip && (!selectedSchedule || !passengers.length)) { + return null; + } + + const displaySchedule = isRoundTrip ? outboundSchedule : selectedSchedule; + + console.log('Selected schedule:', displaySchedule); + console.log('Base fare adult:', displaySchedule?.baseFareAdult); console.log('Passengers:', passengers); - const baseFare = passengers.reduce((sum, p, i) => { - const farePerPassenger = selectedSchedule.baseFareAdult || - (selectedSchedule as any).fareAdult || - (selectedSchedule as any).price || + const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce((sum) => { + return sum + (outboundSchedule.baseFareAdult || 0); + }, 0) : 0; + + const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum) => { + return sum + (inboundSchedule.baseFareAdult || 0); + }, 0) : 0; + + const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, p, i) => { + const farePerPassenger = selectedSchedule?.baseFareAdult || + (selectedSchedule as any)?.fareAdult || + (selectedSchedule as any)?.price || 0; console.log(`Passenger ${i}: ${p.name}, fare = ${farePerPassenger}`); @@ -340,51 +436,259 @@ export default function ReviewPage() { )}
-
-

Trip details

-
-
- Train - {selectedSchedule.trainNumber} -
-
- Route - {selectedSchedule.origin} → {selectedSchedule.destination} -
-
- Departure - - {selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'PPp') : 'N/A'} + {/* Outbound Trip Details */} + {isRoundTrip && outboundSchedule && ( +
+
+
+

Outbound Journey

+ + {outboundSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'}
-
- Arrival - - {selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'PPp') : 'N/A'} - -
-
- Duration - {selectedSchedule.duration} + + {/* Flight-style timeline */} +
+ {/* Left column: Timeline with dots and line */} +
+ {/* Origin dot */} +
+ {/* Vertical line */} +
+ {/* Destination dot */} +
+
+ + {/* Right column: Content */} +
+ {/* Origin */} +
+
+ {outboundSchedule.departureTime ? format(new Date(outboundSchedule.departureTime), 'HH:mm') : '--:--'} +
+
+ {outboundSchedule.departureTime ? format(new Date(outboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'} +
+
+ {outboundSchedule.origin} +
+
+ + {/* Journey Info */} +
+
+
+ + + + {outboundSchedule.duration} +
+
+ + + + Train {outboundSchedule.trainNumber} +
+
+
+ + {/* Destination */} +
+
+ {outboundSchedule.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'HH:mm') : '--:--'} +
+
+ {outboundSchedule.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} +
+
+ {outboundSchedule.destination} +
+
+
-
+ )} + + {/* Inbound Trip Details */} + {isRoundTrip && inboundSchedule && ( +
+
+
+

Return Journey

+ + {inboundSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'} + +
+ + {/* Flight-style timeline */} +
+ {/* Left column: Timeline with dots and line */} +
+ {/* Origin dot */} +
+ {/* Vertical line */} +
+ {/* Destination dot */} +
+
+ + {/* Right column: Content */} +
+ {/* Origin */} +
+
+ {inboundSchedule.departureTime ? format(new Date(inboundSchedule.departureTime), 'HH:mm') : '--:--'} +
+
+ {inboundSchedule.departureTime ? format(new Date(inboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'} +
+
+ {inboundSchedule.origin} +
+
+ + {/* Journey Info */} +
+
+
+ + + + {inboundSchedule.duration} +
+
+ + + + Train {inboundSchedule.trainNumber} +
+
+
+ + {/* Destination */} +
+
+ {inboundSchedule.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'HH:mm') : '--:--'} +
+
+ {inboundSchedule.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} +
+
+ {inboundSchedule.destination} +
+
+
+
+
+ )} + + {/* One-Way Trip Details */} + {!isRoundTrip && selectedSchedule && ( +
+
+
+

Trip Details

+ + {selectedSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'} + +
+ + {/* Flight-style timeline */} +
+ {/* Left column: Timeline with dots and line */} +
+ {/* Origin dot */} +
+ {/* Vertical line */} +
+ {/* Destination dot */} +
+
+ + {/* Right column: Content */} +
+ {/* Origin */} +
+
+ {selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'HH:mm') : '--:--'} +
+
+ {selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'EEE, MMM d') : 'N/A'} +
+
+ {selectedSchedule.origin} +
+
+ + {/* Journey Info */} +
+
+
+ + + + {selectedSchedule.duration} +
+
+ + + + Train {selectedSchedule.trainNumber} +
+
+
+ + {/* Destination */} +
+
+ {selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'HH:mm') : '--:--'} +
+
+ {selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} +
+
+ {selectedSchedule.destination} +
+
+
+
+
+ )}

Passengers

{passengers.map((p, i) => ( -
-
-

{p.name}

-

- {p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} • {p.nationality} -

-
-
-

Seat

-

{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : 'Auto-assign'}

+
+
+
+

{p.name}

+

+ {p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} • {p.nationality} +

+
+ {isRoundTrip ? ( +
+
+

Outbound Seat

+

+ {(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : 'Auto-assign'} +

+
+
+

Return Seat

+

+ {(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : 'Auto-assign'} +

+
+
+ ) : ( +
+

Seat

+

{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : 'Auto-assign'}

+
+ )}
))}
@@ -393,10 +697,23 @@ export default function ReviewPage() {

Fare breakdown

-
- Base fare - ETB {(baseFare / 100).toFixed(2)} -
+ {isRoundTrip ? ( + <> +
+ Outbound fare + ETB {(outboundBaseFare / 100).toFixed(2)} +
+
+ Return fare + ETB {(inboundBaseFare / 100).toFixed(2)} +
+ + ) : ( +
+ Base fare + ETB {(baseFare / 100).toFixed(2)} +
+ )}
Total ETB {(total / 100).toFixed(2)} 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 8295d3a09..a587bc87c 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 @@ -1,58 +1,78 @@ -'use client'; +"use client"; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { useQuery } from '@tanstack/react-query'; -import { useAuthStore } from '@/lib/auth-store'; -import { apiClient } from '@/lib/api-client'; -import { useBookingStore } from '@/lib/booking-store'; -import { Station } from '@/types'; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { useAuthStore } from "@/lib/auth-store"; +import { apiClient } from "@/lib/api-client"; +import { useBookingStore } from "@/lib/booking-store"; +import { Station } from "@/types"; import { - MapPin, ArrowRight, ArrowLeftRight, Plus, Minus, Search, - Users, ChevronDown, Gift, Check, X, ChevronLeft, Clock, Zap, -} from 'lucide-react'; -import { useEffect, useRef, useState, useCallback } from 'react'; -import ModernDatePicker from '@/components/ModernDatePicker'; + MapPin, + ArrowRight, + ArrowLeftRight, + Plus, + Minus, + Search, + Users, + ChevronDown, + Gift, + Check, + X, + ChevronLeft, + Clock, + Zap, +} from "lucide-react"; +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']), - promoCode: z.string().optional(), -}).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'], -}); +const searchSchema = z + .object({ + tripType: z.enum(["ONE_WAY", "ROUND_TRIP"]), + originStationId: z.string().min(1, "Please select your departure station"), + destinationStationId: z + .string() + .min(1, "Please select your destination station"), + departureDate: z.string().min(1, "Please select your 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"]), + promoCode: z.string().optional(), + }) + .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; const POPULAR_ROUTES = [ - { from: 'Sebeta', to: 'Nagad', duration: '12h', icon: '🌆' }, - { from: 'Sebeta', to: 'Diredawa', duration: '8h', icon: '🏔️' }, - { from: 'Diredawa', to: 'Nagad', duration: '4h', icon: '🌊' }, + { from: "Sebeta", to: "Nagad", duration: "12h", icon: "🌆" }, + { from: "Sebeta", to: "Diredawa", duration: "8h", icon: "🏔️" }, + { from: "Diredawa", to: "Nagad", duration: "4h", icon: "🌊" }, ]; // ─── Station Modal ──────────────────────────────────────────────────────────── @@ -71,7 +91,7 @@ function StationModal({ onClose: () => void; recentIds: string[]; }) { - const [query, setQuery] = useState(''); + const [query, setQuery] = useState(""); const inputRef = useRef(null); useEffect(() => { @@ -83,7 +103,7 @@ function StationModal({ (s) => s.id !== excludeId && (s.name.toLowerCase().includes(query.toLowerCase()) || - s.code?.toLowerCase().includes(query.toLowerCase())) + s.code?.toLowerCase().includes(query.toLowerCase())), ) : stations.filter((s) => s.id !== excludeId); @@ -102,7 +122,9 @@ function StationModal({ > -

{title}

+

+ {title} +

{/* Search input */} @@ -119,7 +141,7 @@ function StationModal({ {query && (
-

{s.name}

+

+ {s.name} +

{s.code &&

{s.code}

}
@@ -153,7 +179,7 @@ function StationModal({ )}

- {query ? 'Results' : 'All Stations'} + {query ? "Results" : "All Stations"}

{filtered.length === 0 ? (
@@ -172,8 +198,14 @@ function StationModal({
-

{s.name}

- {s.code &&

{s.code} • {s.country}

} +

+ {s.name} +

+ {s.code && ( +

+ {s.code} • {s.country} +

+ )}
)) @@ -202,14 +234,28 @@ function PassengerModal({ onClose: () => void; }) { const rows = [ - { label: 'Adults', sub: '≥ 5 years', val: adultCount, min: 1, max: 9, onChange: onChangeAdult }, - { label: 'Children', sub: '< 5 years • First child free', val: childCount, min: 0, max: 9, onChange: onChangeChild }, + { + label: "Adults", + sub: "≥ 5 years", + val: adultCount, + min: 1, + max: 9, + onChange: onChangeAdult, + }, + { + label: "Children", + sub: "< 5 years • First child free", + val: childCount, + min: 0, + max: 9, + onChange: onChangeChild, + }, ]; const natOptions = [ - { value: 'ETHIOPIAN', label: '🇪🇹 Ethiopian' }, - { value: 'DJIBOUTIAN', label: '🇩🇯 Djiboutian' }, - { value: 'OTHER', label: '🌍 Other' }, + { value: "ETHIOPIAN", label: "🇪🇹 Ethiopian" }, + { value: "DJIBOUTIAN", label: "🇩🇯 Djiboutian" }, + { value: "OTHER", label: "🌍 Other" }, ]; return ( @@ -217,7 +263,7 @@ function PassengerModal({
@@ -225,29 +271,49 @@ function PassengerModal({
-

Passengers & Nationality

+

+ Passengers & Nationality +

-
{rows.map(({ label, sub, val, min, max, onChange }, i) => (
- {i > 0 &&
} + {i > 0 && ( +
+ )}
-

{label}

+

+ {label} +

{sub}

- - {val} -
@@ -255,15 +321,21 @@ function PassengerModal({
))}
-

Nationality

+

+ Nationality +

{natOptions.map((opt) => ( - ))} @@ -271,9 +343,13 @@ function PassengerModal({
-
@@ -302,22 +378,23 @@ function StationDropdown({ recentIds: string[]; onOpen?: () => void; }) { - const [query, setQuery] = useState(''); + const [query, setQuery] = useState(""); const [open, setOpen] = useState(false); const ref = useRef(null); const inputRef = useRef(null); const selectedStation = stations.find((s) => s.id === value); useEffect(() => { - if (selectedStation && !open) setQuery(''); + if (selectedStation && !open) setQuery(""); }, [selectedStation, open]); useEffect(() => { const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + if (ref.current && !ref.current.contains(e.target as Node)) + setOpen(false); }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); }, []); const filtered = query.trim() @@ -325,32 +402,46 @@ function StationDropdown({ (s) => s.id !== excludeId && (s.name.toLowerCase().includes(query.toLowerCase()) || - s.code?.toLowerCase().includes(query.toLowerCase())) + s.code?.toLowerCase().includes(query.toLowerCase())), ) : stations.filter((s) => s.id !== excludeId).slice(0, 8); - const displayValue = open ? query : (selectedStation?.name ?? ''); + const displayValue = open ? query : (selectedStation?.name ?? ""); return (
{ setQuery(e.target.value); setOpen(true); }} - onFocus={() => { setQuery(''); setOpen(true); onOpen?.(); }} + onChange={(e) => { + setQuery(e.target.value); + setOpen(true); + }} + onFocus={() => { + setQuery(""); + setOpen(true); + onOpen?.(); + }} placeholder={placeholder} className="w-full pl-10 pr-8 py-3.5 bg-transparent rounded-xl focus:outline-none text-sm text-gray-900 dark:text-white placeholder-gray-400" /> {value && ( ))}
)} {filtered.length === 0 ? ( -

No stations found

+

+ No stations found +

) : ( filtered.map((s) => ( )) @@ -413,13 +524,23 @@ export default function SearchPage() { const [passengerModalOpen, setPassengerModalOpen] = useState(false); const [promoVisible, setPromoVisible] = useState(false); - const [promoCode, setPromoCode] = useState(''); - const [promoValidation, setPromoValidation] = useState<{ valid: boolean; message: string } | null>(null); + const [promoCode, setPromoCode] = useState(""); + const [promoValidation, setPromoValidation] = useState<{ + valid: boolean; + message: string; + } | null>(null); const [promoLoading, setPromoLoading] = useState(false); const [swapping, setSwapping] = useState(false); - const [stationModal, setStationModal] = useState<'origin' | 'destination' | null>(null); + const [stationModal, setStationModal] = useState< + "origin" | "destination" | null + >(null); + const [hasInteracted, setHasInteracted] = useState(false); const [recentStationIds, setRecentStationIds] = useState(() => { - try { return JSON.parse(localStorage.getItem('edr_recent_stations') || '[]'); } catch { return []; } + try { + return JSON.parse(localStorage.getItem("edr_recent_stations") || "[]"); + } catch { + return []; + } }); const passengerRef = useRef(null); const widgetRef = useRef(null); @@ -429,72 +550,100 @@ export default function SearchPage() { if (!el) return; const headerHeight = 64; const marginTop = 24; - const top = el.getBoundingClientRect().top + window.scrollY - headerHeight - marginTop; - window.scrollTo({ top, behavior: 'smooth' }); + const top = + el.getBoundingClientRect().top + + window.scrollY - + headerHeight - + marginTop; + window.scrollTo({ top, behavior: "smooth" }); }; - const { data: stations = [], isLoading, error } = useQuery({ - queryKey: ['stations'], - queryFn: async () => await apiClient.get('/stations') as Station[], + const { + data: stations = [], + isLoading, + error, + } = useQuery({ + queryKey: ["stations"], + queryFn: async () => (await apiClient.get("/stations")) as Station[], }); - const { handleSubmit, watch, setValue, formState: { errors } } = useForm({ + const { + handleSubmit, + watch, + setValue, + trigger, + clearErrors, + formState: { errors }, + } = useForm({ resolver: zodResolver(searchSchema as any), + mode: "onSubmit", + reValidateMode: "onSubmit", defaultValues: { - tripType: 'ONE_WAY', + tripType: "ONE_WAY", adultCount: 1, childCount: 0, - nationality: 'ETHIOPIAN', - departureDate: new Date().toISOString().split('T')[0], - promoCode: '', + nationality: "ETHIOPIAN", + departureDate: new Date().toISOString().split("T")[0], + promoCode: "", }, }); useEffect(() => { if (isAuthenticated && user?.nationality) { const n = user.nationality.toUpperCase().trim(); - setValue('nationality', n.includes('DJIBOUTIAN') ? 'DJIBOUTIAN' : n.includes('ETHIOPIAN') ? 'ETHIOPIAN' : 'OTHER'); + setValue( + "nationality", + n.includes("DJIBOUTIAN") + ? "DJIBOUTIAN" + : n.includes("ETHIOPIAN") + ? "ETHIOPIAN" + : "OTHER", + ); } }, [isAuthenticated, user?.nationality, setValue]); useEffect(() => { - const o = searchParams.get('origin'); - const d = searchParams.get('destination'); - const date = searchParams.get('date'); - const adults = searchParams.get('adults'); - const children = searchParams.get('children'); - const nat = searchParams.get('nationality'); - if (o) setValue('originStationId', o); - if (d) setValue('destinationStationId', d); - if (date) setValue('departureDate', date); - if (adults) setValue('adultCount', parseInt(adults)); - if (children) setValue('childCount', parseInt(children)); - if (nat) setValue('nationality', nat as 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER'); + const o = searchParams.get("origin"); + const d = searchParams.get("destination"); + const date = searchParams.get("date"); + const adults = searchParams.get("adults"); + const children = searchParams.get("children"); + const nat = searchParams.get("nationality"); + if (o) setValue("originStationId", o); + if (d) setValue("destinationStationId", d); + if (date) setValue("departureDate", date); + if (adults) setValue("adultCount", parseInt(adults)); + if (children) setValue("childCount", parseInt(children)); + if (nat) + setValue("nationality", nat as "ETHIOPIAN" | "DJIBOUTIAN" | "OTHER"); }, [searchParams, setValue]); useEffect(() => { const handler = (e: MouseEvent) => { - if (passengerRef.current && !passengerRef.current.contains(e.target as Node)) { + if ( + passengerRef.current && + !passengerRef.current.contains(e.target as Node) + ) { setPassengerModalOpen(false); } }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); }, []); - const originId = watch('originStationId'); - const destId = watch('destinationStationId'); - const adultCount = watch('adultCount'); - const childCount = watch('childCount'); - const departureDate = watch('departureDate'); - const returnDate = watch('returnDate'); - const tripType = watch('tripType'); + const originId = watch("originStationId"); + const destId = watch("destinationStationId"); + 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) => { setRecentStationIds((prev) => { const next = [id, ...prev.filter((x) => x !== id)].slice(0, 5); - localStorage.setItem('edr_recent_stations', JSON.stringify(next)); + localStorage.setItem("edr_recent_stations", JSON.stringify(next)); return next; }); }, []); @@ -503,8 +652,8 @@ export default function SearchPage() { if (!originId || !destId) return; setSwapping(true); setTimeout(() => { - setValue('originStationId', destId); - setValue('destinationStationId', originId); + setValue("originStationId", destId); + setValue("destinationStationId", originId); setSwapping(false); }, 300); }; @@ -513,20 +662,31 @@ export default function SearchPage() { if (!promoCode.trim()) return setPromoValidation(null); setPromoLoading(true); try { - const res = await apiClient.post('/promos/validate', { code: promoCode }) as any; + const res = (await apiClient.post("/promos/validate", { + code: promoCode, + })) as any; const valid = res.applicable || res.valid; - setPromoValidation({ valid, message: res.message || (valid ? 'Promo applied!' : 'Invalid promo code') }); - if (valid) setValue('promoCode', promoCode); - else setPromoCode(''); + setPromoValidation({ + valid, + message: + res.message || (valid ? "Promo applied!" : "Invalid promo code"), + }); + if (valid) setValue("promoCode", promoCode); + else setPromoCode(""); } catch (err: any) { - setPromoValidation({ valid: false, message: err?.response?.data?.message || 'Promo code is invalid or expired' }); - setPromoCode(''); + setPromoValidation({ + valid: false, + message: + err?.response?.data?.message || "Promo code is invalid or expired", + }); + setPromoCode(""); } finally { setPromoLoading(false); } }; const onSubmit = (data: SearchForm) => { + setHasInteracted(true); setSearchCriteria(data); if (data.originStationId) saveRecent(data.originStationId); if (data.destinationStationId) saveRecent(data.destinationStationId); @@ -538,7 +698,8 @@ export default function SearchPage() { adults: data.adultCount.toString(), children: data.childCount.toString(), nationality: data.nationality, - ...(data.tripType === 'ROUND_TRIP' && data.returnDate && { returnDate: data.returnDate }), + ...(data.tripType === "ROUND_TRIP" && + data.returnDate && { returnDate: data.returnDate }), ...(data.promoCode && { promoCode: data.promoCode }), }); router.push(`/booking/results?${params}`); @@ -549,12 +710,16 @@ export default function SearchPage() { const destStation = getStationById(destId); const handlePopularRoute = (fromName: string, toName: string) => { - const origin = stations.find((s) => s.name.toLowerCase().includes(fromName.toLowerCase())); - const dest = stations.find((s) => s.name.toLowerCase().includes(toName.toLowerCase())); + const origin = stations.find((s) => + s.name.toLowerCase().includes(fromName.toLowerCase()), + ); + const dest = stations.find((s) => + s.name.toLowerCase().includes(toName.toLowerCase()), + ); if (origin && dest) { - setValue('originStationId', origin.id); - setValue('destinationStationId', dest.id); - window.scrollTo({ top: 0, behavior: 'smooth' }); + setValue("originStationId", origin.id); + setValue("destinationStationId", dest.id); + window.scrollTo({ top: 0, behavior: "smooth" }); } }; @@ -565,36 +730,45 @@ export default function SearchPage() { setValue('adultCount', n)} - onChangeChild={(n) => setValue('childCount', n)} - onChangeNationality={(v) => setValue('nationality', v as any)} + nationality={watch("nationality")} + onChangeAdult={(n) => setValue("adultCount", n)} + onChangeChild={(n) => setValue("childCount", n)} + onChangeNationality={(v) => setValue("nationality", v as any)} onClose={() => setPassengerModalOpen(false)} /> )} {/* Station modals (mobile) */} - {stationModal === 'origin' && ( + {stationModal === "origin" && ( { - if (s.id) { setValue('originStationId', s.id); saveRecent(s.id); } + if (s.id) { + setValue("originStationId", s.id); + saveRecent(s.id); + clearErrors("originStationId"); + clearErrors("destinationStationId"); + } setStationModal(null); }} onClose={() => setStationModal(null)} /> )} - {stationModal === 'destination' && ( + {stationModal === "destination" && ( { - if (s.id) { setValue('destinationStationId', s.id); saveRecent(s.id); } + if (s.id) { + setValue("destinationStationId", s.id); + saveRecent(s.id); + clearErrors("destinationStationId"); + } setStationModal(null); }} onClose={() => setStationModal(null)} @@ -604,61 +778,76 @@ export default function SearchPage() { {/* ── 90vh hero with banner image ── */}
- {/* Background image */} -
+ {/* Background image with zoom - fully isolated */} +
+
+
{/* Gradient overlay */}
{/* Hero headline — top area */}
-

- Where are you
headed today? +

+ Where are you +
headed today?

-

Book your train journey across East Africa

+

+ Book your train journey across East Africa +

{/* ── Widget — absolutely positioned at bottom with margin ── */} -
+
- {error && (
⚠️ - Unable to load stations. Please check your connection. + + Unable to load stations. Please check your connection. +
)}
- {/* Trip Type Tabs */}
- -
-
- +
setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} - minDate={new Date()} placeholder="Select date" + 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")}`, + ); + trigger("departureDate"); + }} + minDate={new Date()} + placeholder="Select date" />
- {tripType === 'ROUND_TRIP' && ( + {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()} + 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")}`, + ); + trigger("returnDate"); + }} + minDate={ + departureDate + ? new Date(departureDate + "T00:00:00") + : new Date() + } placeholder="Select return date" />
- {errors.returnDate &&

{errors.returnDate.message}

} + {errors.returnDate && ( +

+ {errors.returnDate.message} +

+ )}
)} {/* Pax + Nationality combined trigger */} - - @@ -743,59 +1024,132 @@ export default function SearchPage() { {/* Desktop: dynamic layout based on trip type */}
- {tripType === 'ONE_WAY' ? ( + {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}

} + + { + setHasInteracted(true); + setValue("originStationId", s.id); + if (s.id) saveRecent(s.id); + clearErrors("originStationId"); + clearErrors("destinationStationId"); + }} + error={hasInteracted ? errors.originStationId?.message : undefined} + onOpen={scrollWidgetIntoView} + /> + {hasInteracted && 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}

} + + { + setHasInteracted(true); + setValue("destinationStationId", s.id); + if (s.id) saveRecent(s.id); + clearErrors("destinationStationId"); + }} + error={hasInteracted ? errors.destinationStationId?.message : undefined} + onOpen={scrollWidgetIntoView} + /> + {hasInteracted && 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" + 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")}`, + ); + trigger("departureDate"); + }} + minDate={new Date()} + placeholder="Departure" />
- {errors.departureDate &&

{errors.departureDate.message}

} + {errors.departureDate && ( +

+ {errors.departureDate.message} +

+ )}
{/* Divider */}
{/* Pax + Nationality */}
- -
{/* Search */} - @@ -807,49 +1161,130 @@ export default function SearchPage() {
{/* From */}
- - { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} /> - {errors.originStationId &&

{errors.originStationId.message}

} + + { + setHasInteracted(true); + setValue("originStationId", s.id); + if (s.id) saveRecent(s.id); + clearErrors("originStationId"); + clearErrors("destinationStationId"); + }} + error={hasInteracted ? errors.originStationId?.message : undefined} + onOpen={scrollWidgetIntoView} + /> + {hasInteracted && 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}

} + + { + setHasInteracted(true); + setValue("destinationStationId", s.id); + if (s.id) saveRecent(s.id); + clearErrors("destinationStationId"); + }} + error={hasInteracted ? errors.destinationStationId?.message : undefined} + onOpen={scrollWidgetIntoView} + /> + {hasInteracted && 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()} + 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")}`, + ); + trigger("departureDate"); + trigger("returnDate"); + }} + minDate={new Date()} placeholder="Select date" />
- {errors.returnDate &&

{errors.returnDate.message}

} + {errors.departureDate && ( +

+ {errors.departureDate.message} +

+ )} +
+ {/* Return Date */} +
+ +
+ { + setValue( + "returnDate", + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, + ); + trigger("returnDate"); + }} + minDate={ + departureDate + ? new Date(departureDate + "T00:00:00") + : new Date() + } + placeholder="Select date" + /> +
+ {errors.returnDate && ( +

+ {errors.returnDate.message} +

+ )}
@@ -858,38 +1293,73 @@ export default function SearchPage() { {/* Promo Code */}
{!promoVisible ? ( - ) : (
- { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }} + { + setPromoCode( + e.target.value.toUpperCase(), + ); + if (promoValidation) + setPromoValidation(null); + }} placeholder="Enter promo code" - onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())} + 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 /> + autoFocus + />
- -
{promoValidation && ( -
- {promoValidation.valid && } +
+ {promoValidation.valid && ( + + )} {promoValidation.message}
)} @@ -900,21 +1370,36 @@ export default function SearchPage() {
{/* Pax + Nationality */}
- -
{/* Search Button */}
- - @@ -925,11 +1410,14 @@ export default function SearchPage() {
{/* Promo - Only visible in ONE WAY mode on desktop */} - {tripType === 'ONE_WAY' && ( + {tripType === "ONE_WAY" && (
{!promoVisible ? ( - @@ -938,25 +1426,49 @@ export default function SearchPage() {
- { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }} + { + setPromoCode(e.target.value.toUpperCase()); + if (promoValidation) setPromoValidation(null); + }} placeholder="Enter promo code" - onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())} + 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 /> + autoFocus + />
- -
{promoValidation && ( -
- {promoValidation.valid && } +
+ {promoValidation.valid && ( + + )} {promoValidation.message}
)} @@ -964,7 +1476,6 @@ export default function SearchPage() { )}
)} -
@@ -978,12 +1489,18 @@ export default function SearchPage() {
-

Popular Routes

+

+ Popular Routes +

{POPULAR_ROUTES.map((route, idx) => ( -
@@ -157,7 +165,7 @@ export default function AppHeader() { )} - + data.originStationId !== data.destinationStationId, { +}).refine((data) => { + if (!data.originStationId || !data.destinationStationId) return true; + return data.originStationId !== data.destinationStationId; +}, { message: 'Origin and destination must be different', path: ['destinationStationId'], }); @@ -43,10 +46,15 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps) queryFn: async () => await apiClient.get('/stations') as Station[], }); - const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm({ - resolver: zodResolver(searchSchema as any), + const { register, handleSubmit, watch, setValue, clearErrors, formState: { errors } } = useForm({ + // @ts-ignore - ZodEffects type compatibility issue + resolver: zodResolver(searchSchema), + mode: 'onSubmit', + reValidateMode: 'onChange', defaultValues: { tripType: 'ONE_WAY', + originStationId: '', + destinationStationId: '', adultCount: 1, childCount: 0, nationality: 'ETHIOPIAN', @@ -89,8 +97,14 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
{errors.originStationId && ( -

{errors.originStationId.message}

+

{errors.originStationId.message}

)}
@@ -110,8 +124,14 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
{errors.destinationStationId && ( -

{errors.destinationStationId.message}

+

{errors.destinationStationId.message}

)}
@@ -135,12 +155,13 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps) const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); setValue('departureDate', `${year}-${month}-${day}`); + clearErrors('departureDate'); }} minDate={new Date()} placeholder="Select date" /> {errors.departureDate && ( -

{errors.departureDate.message}

+

{errors.departureDate.message}

)}
@@ -267,6 +288,17 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps) {/* Row 3: Search Button */}
+ {/* Debug info - remove after testing */} + {Object.keys(errors).length > 0 && ( +
+

Validation Errors:

+
    + {Object.entries(errors).map(([key, value]) => ( +
  • {key}: {value?.message}
  • + ))} +
+
+ )}