From 8f23f85e63c29bcb33ce333cd9ad33b80233e2cc Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Tue, 7 Jul 2026 23:55:35 +0300 Subject: [PATCH] Set nationality required on booking widget --- .../portal/src/app/booking/results/page.tsx | 1057 +++++++++++------ .../portal/src/app/booking/search/page.tsx | 107 +- 2 files changed, 779 insertions(+), 385 deletions(-) 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 77a18380f..66f91dd60 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 @@ -1,57 +1,106 @@ -'use client'; +"use client"; -import { useSearchParams, useRouter } from 'next/navigation'; -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, Check, X, MapPin, Gift, Train, Bed, Armchair, Star } from 'lucide-react'; -import { format } from 'date-fns'; -import { formatTime, getTimePeriod } from '@/utils/format'; -import { useState, useEffect } from 'react'; +import { useSearchParams, useRouter } from "next/navigation"; +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, + Check, + X, + MapPin, + Gift, + Train, + Bed, + Armchair, + Star, +} from "lucide-react"; +import { format } from "date-fns"; +import { formatTime, getTimePeriod } from "@/utils/format"; +import { useState, useEffect } from "react"; export default function ResultsPage() { const router = useRouter(); const searchParams = useSearchParams(); - const { setSelectedSchedule, setOutboundSchedule, setInboundSchedule } = useBookingStore(); - const [selectedCoachTypes, setSelectedCoachTypes] = useState>({}); + const { setSelectedSchedule, setOutboundSchedule, setInboundSchedule } = + useBookingStore(); + const [selectedCoachTypes, setSelectedCoachTypes] = useState< + Record + >({}); const [outboundScheduleData, setOutboundScheduleData] = useState( () => useBookingStore.getState().outboundSchedule, ); const [classModal, setClassModal] = useState(null); - const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null); - const [roundTripStep, setRoundTripStep] = useState<'outbound' | 'inbound'>(() => { - const { outboundSchedule, searchCriteria: sc } = useBookingStore.getState(); - return outboundSchedule && sc?.tripType === 'ROUND_TRIP' ? 'inbound' : 'outbound'; - }); + const [promoData, setPromoData] = useState<{ + code: string; + discount: string; + message: string; + } | null>(null); + const [roundTripStep, setRoundTripStep] = useState<"outbound" | "inbound">( + () => { + const { outboundSchedule, searchCriteria: sc } = + useBookingStore.getState(); + return outboundSchedule && sc?.tripType === "ROUND_TRIP" + ? "inbound" + : "outbound"; + }, + ); const searchCriteria = useBookingStore((s) => s.searchCriteria); const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria); 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') ?? searchCriteria?.tripType ?? 'ONE_WAY') === '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 || '', + 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") ?? + searchCriteria?.tripType ?? + "ONE_WAY") === "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 || "", }; useEffect(() => { - if (searchParams.get('origin')) { + 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') || '', + 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") || "", }); } }, [searchParams, setSearchCriteria]); @@ -59,13 +108,13 @@ export default function ResultsPage() { useEffect(() => { if (searchData.promoCode) { apiClient - .post('/promos/validate', { code: searchData.promoCode }) + .post("/promos/validate", { code: searchData.promoCode }) .then((response: any) => { if (response.applicable || response.valid) { setPromoData({ code: searchData.promoCode, - discount: response.message || 'Discount applied', - message: response.message || 'Promo code applied successfully!', + discount: response.message || "Discount applied", + message: response.message || "Promo code applied successfully!", }); } }) @@ -90,8 +139,12 @@ export default function ResultsPage() { return `/booking/search?${params}`; }; - const { data: results, isLoading, error } = useQuery({ - queryKey: ['search', searchData], + const { + data: results, + isLoading, + error, + } = useQuery({ + queryKey: ["search", searchData], queryFn: async (): Promise => { const payload: any = { originStationId: searchData.originStationId, @@ -102,15 +155,13 @@ export default function ResultsPage() { nationality: searchData.nationality, journeyType: searchData.journeyType, }; - - if (searchData.journeyType === 'ROUND_TRIP' && searchData.returnDate) { + + if (searchData.journeyType === "ROUND_TRIP" && searchData.returnDate) { payload.returnDate = searchData.returnDate; } - - - const response = await apiClient.post('/search', payload) as any; - - + + const response = (await apiClient.post("/search", payload)) as any; + return response; }, enabled: !!searchData.originStationId && !!searchData.destinationStationId, @@ -118,20 +169,20 @@ export default function ResultsPage() { gcTime: 0, }); - const isRoundTrip = searchData.journeyType === 'ROUND_TRIP'; - + const isRoundTrip = searchData.journeyType === "ROUND_TRIP"; + // Handle both response formats: // 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 (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) { + } else if (results.journeyType === "ONE_WAY" && results.outbound) { // One-way response format with outbound array outboundSchedules = results.outbound || []; } else if (Array.isArray(results)) { @@ -142,40 +193,68 @@ export default function ResultsPage() { outboundSchedules = results.data; } } - - // Alternatives are surfaced whenever a leg returns no exact-date results. - const alternativeOutbound: Schedule[] = (!!results && outboundSchedules.length === 0) ? (results?.alternativeOutbound || []) : []; - const alternativeInbound: Schedule[] = (isRoundTrip && !!results && inboundSchedules.length === 0) ? (results?.alternativeInbound || []) : []; - const requestedDate: string = (results && results.requestedDate) || searchData.date; - const requestedReturnDate: string = (results && results.requestedReturnDate) || searchData.returnDate || ''; - const isOneWayNoOutbound = !isRoundTrip && !!results && outboundSchedules.length === 0; + // Alternatives are surfaced whenever a leg returns no exact-date results. + const alternativeOutbound: Schedule[] = + !!results && outboundSchedules.length === 0 + ? results?.alternativeOutbound || [] + : []; + const alternativeInbound: Schedule[] = + isRoundTrip && !!results && inboundSchedules.length === 0 + ? results?.alternativeInbound || [] + : []; + const requestedDate: string = + (results && results.requestedDate) || searchData.date; + const requestedReturnDate: string = + (results && results.requestedReturnDate) || searchData.returnDate || ""; + + const isOneWayNoOutbound = + !isRoundTrip && !!results && outboundSchedules.length === 0; // Round-trip: show results view if either leg has exact results OR alternatives. // One-way: need at least one outbound result. const hasResults = isRoundTrip - ? (outboundSchedules.length > 0 || alternativeOutbound.length > 0) || (inboundSchedules.length > 0 || alternativeInbound.length > 0) + ? outboundSchedules.length > 0 || + alternativeOutbound.length > 0 || + inboundSchedules.length > 0 || + alternativeInbound.length > 0 : outboundSchedules.length > 0; - const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => { - setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } })); + const handleSelectCoachType = ( + scheduleId: string, + coachTypeId: string, + coachTypeCode: string, + coachTypeName: string, + seatClassName: string, + ) => { + setSelectedCoachTypes((prev) => ({ + ...prev, + [scheduleId]: { + id: coachTypeId, + code: coachTypeCode, + name: coachTypeName, + seatClassName, + }, + })); }; const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => { - const scheduleId = schedule.scheduleId || schedule.id || ''; + const scheduleId = schedule.scheduleId || schedule.id || ""; const selectedCoachType = selectedCoachTypes[scheduleId]; - + if (!selectedCoachType) { - alert('Please select a coach type before continuing'); + alert("Please select a coach type before continuing"); return; } // Find the coach type to get pricing info - const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.code); + const coachType = schedule.coachTypes?.find( + (ct) => ct.coachTypeCode === selectedCoachType.code, + ); // Use displayAmountMinor (passenger's currency) so stored fare matches what the card showed. const minFare = coachType?.classes.length - ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) + ? Math.min(...coachType.classes.map((c) => c.baseFareMinor)) : 0; - const fareCurrency = 'ETB'; + const fareCurrency = "ETB"; const hours = Math.floor((schedule.durationMinutes || 0) / 60); const minutes = (schedule.durationMinutes || 0) % 60; @@ -184,12 +263,13 @@ export default function ResultsPage() { const scheduleData = { id: scheduleId, trainNumber: schedule.trainNumber, - origin: schedule.origin?.name || 'Origin', - destination: schedule.destination?.name || 'Destination', - originStationId: schedule.origin?.id || schedule.originStationId || '', - destinationStationId: schedule.destination?.id || schedule.destinationStationId || '', - departureTime: schedule.departureAt || schedule.departureTime || '', - arrivalTime: schedule.arrivalAt || schedule.arrivalTime || '', + origin: schedule.origin?.name || "Origin", + destination: schedule.destination?.name || "Destination", + originStationId: schedule.origin?.id || schedule.originStationId || "", + destinationStationId: + schedule.destination?.id || schedule.destinationStationId || "", + departureTime: schedule.departureAt || schedule.departureTime || "", + arrivalTime: schedule.arrivalAt || schedule.arrivalTime || "", duration: durationStr, baseFareAdult: minFare, baseFareChild: minFare, @@ -199,7 +279,8 @@ export default function ResultsPage() { selectedCoachTypeId: selectedCoachType.id, selectedCoachTypeCode: selectedCoachType.code, selectedCoachTypeName: selectedCoachType.name, - seatClassName: (selectedCoachType as any).seatClassName || selectedCoachType.name, + seatClassName: + (selectedCoachType as any).seatClassName || selectedCoachType.name, // Retained so the seat map's coach preview can price a switch to a different // coach type without needing a fresh API call. coachTypes: schedule.coachTypes || [], @@ -210,8 +291,8 @@ export default function ResultsPage() { setOutboundScheduleData(scheduleData); setOutboundSchedule(scheduleData); setClassModal(null); - setRoundTripStep('inbound'); - window.scrollTo({ top: 0, behavior: 'smooth' }); + setRoundTripStep("inbound"); + window.scrollTo({ top: 0, behavior: "smooth" }); return; } @@ -223,8 +304,8 @@ export default function ResultsPage() { // For one-way setSelectedSchedule(scheduleData); } - - router.push('/booking/auth-check'); + + router.push("/booking/auth-check"); }; // Shared "Choose Your Coach" drawer — used by both the normal results view and the @@ -233,118 +314,164 @@ export default function ResultsPage() { const renderClassModal = () => { if (!classModal) return null; - const scheduleId = classModal.scheduleId || classModal.id || ''; + const scheduleId = classModal.scheduleId || classModal.id || ""; const selectedCoachType = selectedCoachTypes[scheduleId]; const isOutbound = (classModal as any).isOutbound; // Dining coaches aren't bookable seat/bed classes — exclude them from selection. - const coachTypes = (classModal.coachTypes || []).filter((ct: any) => ct.coachTypeCode !== 'DPC'); + const coachTypes = (classModal.coachTypes || []).filter( + (ct: any) => ct.coachTypeCode !== "DPC", + ); const getCoachIcon = (typeName: string) => { const lower = typeName.toLowerCase(); - if (lower.includes('soft') || lower.includes('vip')) return Star; - if (lower.includes('bed')) return Bed; + if (lower.includes("soft") || lower.includes("vip")) return Star; + if (lower.includes("bed")) return Bed; return Armchair; }; return ( <> -
setClassModal(null)} /> -
setClassModal(null)} + /> +
-
-
-

Choose Your Coach

-

- - {classModal.trainNumber} - · - {classModal.origin?.name} → {classModal.destination?.name} -

-
- +
+
+

+ Choose Your Coach +

+

+ + {classModal.trainNumber} + · + + {classModal.origin?.name} → {classModal.destination?.name} + +

+ +
-
- {coachTypes.length > 0 ? ( -
- {coachTypes.map((coachType: any, index: number) => { - const isSelected = selectedCoachType?.id === coachType.coachTypeId; - const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0; - const coachCurrency = 'ETB'; - const CoachIcon = getCoachIcon(coachType.coachTypeName); +
+ {coachTypes.length > 0 ? ( +
+ {coachTypes.map((coachType: any, index: number) => { + const isSelected = + selectedCoachType?.id === coachType.coachTypeId; + const minPrice = coachType.classes.length + ? Math.min( + ...coachType.classes.map((c: any) => c.baseFareMinor), + ) + : 0; + const coachCurrency = "ETB"; + const CoachIcon = getCoachIcon(coachType.coachTypeName); - return ( - - ); - })} -
- ) : ( -
-
- -
-

No coach types available for this journey

+
+ )} +
+ + ); + })} +
+ ) : ( +
+
+
+

+ No coach types available for this journey +

+
+ )} +
+ +
+
+ + {!selectedCoachType && ( +

+ + Select a coach type to continue +

)}
- -
-
- - {!selectedCoachType && ( -

- - Select a coach type to continue -

- )} -
-
+