diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index 70f18564d..515a00655 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -30,13 +30,10 @@ export class FareEngineService { if (!seatClass) throw new NotFoundException('Seat class not found'); if (!seatClass.isActive) throw new BadRequestException('Seat class is not active'); - // Resolve nationality type: Ethiopian and Djiboutian are LOCAL, everyone else INTERNATIONAL const nationalityUpper = (dto.nationality ?? '').toUpperCase(); const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN') ? 'LOCAL' : 'INTERNATIONAL'; - // Find the nationality-specific seat class for the same coach type and bed position. - // Falls back to the requested seatClass if no nationality-specific one exists. const nationalitySeatClass = await this.prisma.seatClass.findFirst({ where: { coachTypeId: seatClass.coachTypeId, @@ -46,13 +43,10 @@ export class FareEngineService { }, }) ?? seatClass; - // Calculate distance: distanceKm represents cumulative distance from route origin - // For a segment, distance = destination.distanceKm - origin.distanceKm const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!; if (totalDistanceKm < 0 || isNaN(totalDistanceKm)) throw new BadRequestException('Invalid distance calculation - check route stop distances'); - // Resolve fare: FareRule (schedule-scoped → route-scoped) takes precedence over distance×rate const now = new Date(); const [originStation, destStation] = await Promise.all([ this.prisma.station.findUnique({ where: { id: dto.originStationId } }), @@ -81,8 +75,12 @@ export class FareEngineService { let baseFarePerPassengerMinor: number; let ratePerKmMinor: number; let fareSource: string; + let insuranceFactor = 1; + let usdToEtbRate = 1; + // When insuranceFeeMinor is used as a multiplier in the formula it must not + // be added again as a flat fee. This flag tracks that. + let insuranceAlreadyInBase = false; - // 1. Segment override: exact origin→destination stop pair on this route const segmentOverride = await this.prisma.segmentFareRule.findFirst({ where: { routeId: route.id, @@ -106,39 +104,46 @@ export class FareEngineService { }); if (segmentOverride) { - // Flat override for this exact segment — baseFareMinor is the total base, not a per-km rate baseFarePerPassengerMinor = segmentOverride.baseFareMinor; ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; fareSource = 'SEGMENT_FARE_RULE'; } else if (fareRule?.tripId) { - // Schedule-scoped flat override baseFarePerPassengerMinor = fareRule.baseFareMinor; ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; fareSource = 'SCHEDULE_FARE_RULE'; } else { - // Default: distance-based using tariff formula: km × rate × 1.02 - // baseFareMinor stores the per-km rate (tariff decimal × 100000) + // Distance-based formula: + // baseFare (minor) = distanceKm × (baseFareMinor / 100) × insuranceFactor × usdToEtbRate + // baseFareMinor stored as integer (e.g. 300 = 3.00 ETB/km), divided by 100 to get ETB/km. + // insuranceFeeMinor stored as integer (e.g. 102 = 1.02 multiplier), divided by 100; defaults to 1 if unset. + // usdToEtbRate fetched live from CurrencyExchangeRate table. + // Insurance is already baked into baseFarePerPassengerMinor — do NOT add it again as a flat fee. + const ratePerKmEtb = nationalitySeatClass.baseFareMinor / 100; + insuranceFactor = nationalitySeatClass.insuranceFeeMinor > 0 + ? nationalitySeatClass.insuranceFeeMinor / 100 + : 1; + usdToEtbRate = await this.currencyService.getExchangeRate(Currency.USD, Currency.ETB); ratePerKmMinor = nationalitySeatClass.baseFareMinor; - baseFarePerPassengerMinor = Math.round(ratePerKmMinor * totalDistanceKm * 1.02); + baseFarePerPassengerMinor = Math.round( + totalDistanceKm * ratePerKmEtb * insuranceFactor * usdToEtbRate, + ); fareSource = 'SEAT_CLASS_BASE_FARE'; + insuranceAlreadyInBase = true; } - // Premium and insurance fees applied per passenger - const premiumPerPassenger = seatClass.premiumMinor ?? 0; - const insurancePerPassenger = seatClass.insuranceFeeMinor ?? 0; - const farePerPassengerMinor = baseFarePerPassengerMinor + premiumPerPassenger + insurancePerPassenger; + const premiumPerPassenger = seatClass.premiumMinor ?? 0; + const insurancePerPassenger = insuranceAlreadyInBase ? 0 : (seatClass.insuranceFeeMinor ?? 0); + const farePerPassengerMinor = baseFarePerPassengerMinor + premiumPerPassenger + insurancePerPassenger; const adultCount = dto.adultCount ?? 1; const childCount = dto.childCount ?? 0; const freeChildrenCount = Math.min(childCount, adultCount); const paidChildrenCount = Math.max(0, childCount - freeChildrenCount); - // Subtotal includes: (distance-based fare + premium + insurance) × passengers - // First child is free, but pays premium and insurance - const adultSubtotal = farePerPassengerMinor * adultCount; + const adultSubtotal = farePerPassengerMinor * adultCount; const freeChildSubtotal = (premiumPerPassenger + insurancePerPassenger) * freeChildrenCount; const paidChildSubtotal = farePerPassengerMinor * paidChildrenCount; - const subtotalMinor = adultSubtotal + freeChildSubtotal + paidChildSubtotal; + const subtotalMinor = adultSubtotal + freeChildSubtotal + paidChildSubtotal; let discountMinor = 0; let promoLabel = 'none'; @@ -152,8 +157,7 @@ export class FareEngineService { } } - const afterDiscountMinor = subtotalMinor - discountMinor; - const totalEtbMinor = afterDiscountMinor; + const totalEtbMinor = subtotalMinor - discountMinor; const billingCurrency = resolveCurrencyFromNationality(dto.nationality); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); @@ -162,8 +166,10 @@ export class FareEngineService { const calculation = [ `Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`, `Nationality: ${dto.nationality ?? 'unspecified'} → ${nationalityType} → ${nationalitySeatClass.name}`, - `Rate per km: ${ratePerKmMinor} ETB minor (${nationalitySeatClass.name})`, - `Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} × 1.02 = ${baseFarePerPassengerMinor} ETB minor`, + `Rate per km: ${nationalitySeatClass.baseFareMinor} minor → ${nationalitySeatClass.baseFareMinor / 100} ETB/km`, + `Insurance: ${nationalitySeatClass.insuranceFeeMinor} minor → factor ${insuranceFactor}${insuranceAlreadyInBase ? ' (baked into base fare)' : ''}`, + `USD→ETB rate: ${usdToEtbRate}`, + `Base fare/pax: ${totalDistanceKm} km × (${nationalitySeatClass.baseFareMinor} / 100) × ${insuranceFactor} × ${usdToEtbRate} = ${baseFarePerPassengerMinor} ETB minor`, `Premium/pax: ${premiumPerPassenger} ETB minor`, `Insurance/pax: ${insurancePerPassenger} ETB minor`, `Total fare/pax: ${farePerPassengerMinor} ETB minor`, @@ -191,6 +197,8 @@ export class FareEngineService { seatClassName: nationalitySeatClass.name, totalDistanceKm, ratePerKmMinor, + insuranceFactor, + usdToEtbRate, baseFarePerPassengerMinor, premiumPerPassenger, insurancePerPassenger, @@ -323,19 +331,16 @@ export class FareEngineService { if (fareRules.length > 0) { const billingCurrency = resolveCurrencyFromNationality(nationality); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); - return fareRules.map(rule => { - const seatClassId = rule.seatClassId; - return { - seatClassId, - seatClassName: 'Unknown', - baseFareMinor: rule.baseFareMinor, - totalMinor: rule.baseFareMinor, - billingCurrency, - totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate), - exchangeRate, - source: 'FARE_RULE', - }; - }); + return fareRules.map(rule => ({ + seatClassId: rule.seatClassId, + seatClassName: 'Unknown', + baseFareMinor: rule.baseFareMinor, + totalMinor: rule.baseFareMinor, + billingCurrency, + totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate), + exchangeRate, + source: 'FARE_RULE', + })); } throw new BadRequestException( diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.spec.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.spec.ts index 7f6ad7f52..96b3e2ef8 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.spec.ts @@ -72,9 +72,8 @@ describe('TicketsService - Offline Validation', () => { const result = await service.validateOfflineBatch(validations); - expect(result.success).toBe(1); + expect(result.successful).toBe(1); expect(result.failed).toBe(0); - expect(result.duplicate).toBe(0); }); it('should detect duplicate validations', async () => { @@ -98,8 +97,8 @@ describe('TicketsService - Offline Validation', () => { const result = await service.validateOfflineBatch(validations); - expect(result.success).toBe(1); - expect(result.duplicate).toBe(1); + expect(result.successful).toBe(1); + expect(result.failed).toBe(1); }); it('should handle already validated tickets', async () => { @@ -119,8 +118,8 @@ describe('TicketsService - Offline Validation', () => { const result = await service.validateOfflineBatch(validations); - expect(result.duplicate).toBe(1); - expect(result.success).toBe(0); + expect(result.successful).toBe(0); + expect(result.failed).toBe(1); }); }); }); diff --git a/apps/edr-passenger-web/backoffice/src/app/support/page.tsx b/apps/edr-passenger-web/backoffice/src/app/support/page.tsx index 8102cc556..246a6f4f9 100644 --- a/apps/edr-passenger-web/backoffice/src/app/support/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/support/page.tsx @@ -42,7 +42,7 @@ export default function SupportPage() { const { data, isLoading } = useConversations( status === 'ALL' ? { search } : { status, search }, ); - const items = data?.items ?? []; + const items = useMemo(() => data?.items ?? [], [data?.items]); useSupportSocket(true); 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 -

- )} -
-
+