diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index e77ab70b4..220ecb1b3 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -102,7 +102,7 @@ export default function ConfirmationPage() { const { packageTierPriceMinor } = useBookingStore.getState(); const isPackageBooking = packageTierPriceMinor != null; const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length; - const pkgMultiplier = isPackageBooking && isRoundTrip ? 2 : 1; + const pkgMultiplier = isPackageBooking ? 2 : 1; const pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgMultiplier : 0; const pkgChildFare = pkgAdultFare; 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 194b47dff..c07a812c4 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 @@ -149,6 +149,31 @@ export default function ReviewPage() { fetchSeatDetails(); }, [selectedSchedule?.id, outboundSchedule?.id, inboundSchedule?.id, passengers, isRoundTrip]); + const isPackageBooking = packageTierPriceMinor !== null || !!packageName; + const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length; + const pkgAdultFare = isPackageBooking && packageTierPriceMinor != null ? packageTierPriceMinor * 2 : 0; + const pkgChildFare = pkgAdultFare; + + const isPackageChild = (index: number) => + isPackageBooking ? index >= adultPassengerCount : isChild(passengers[index]); + + const isPkgFreeChild = (index: number) => { + if (!isPackageBooking) return false; + if (!isPackageChild(index)) return false; + const childIndex = index - adultPassengerCount; + return childIndex < adultPassengerCount; + }; + + const getPassengerSeatFare = (p: any): number | null => { + if (isRoundTrip) { + if (p.outboundSeatFareMinor == null && p.inboundSeatFareMinor == null) return null; + if (isPackageBooking) return (p.outboundSeatFareMinor ?? 0) * 2; + return (p.outboundSeatFareMinor ?? 0) + (p.inboundSeatFareMinor ?? 0); + } + if (p.seatFareMinor == null) return null; + return isPackageBooking ? p.seatFareMinor * 2 : p.seatFareMinor; + }; + const createBookingMutation = useMutation({ mutationFn: (data: any) => { const endpoint = isAuthenticated ? '/bookings' : '/bookings/guest'; @@ -393,8 +418,9 @@ export default function ReviewPage() { ? (isChildPassenger && (i - adultPassengerCount) < adultPassengerCount) : (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i))); const seatFare = getPassengerSeatFare(p); + const pkgFallback = isChildPassenger ? pkgChildFare : pkgAdultFare; const fareMinor = isPackageBooking - ? (isFreeChild ? 0 : (isChildPassenger ? pkgChildFare : pkgAdultFare)) + ? (isFreeChild ? 0 : (seatFare ?? pkgFallback)) : (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0)); return { fareMinor, isFree: isFreeChild }; }); @@ -423,7 +449,7 @@ export default function ReviewPage() { const fetchFareBreakdown = useCallback(async (scheduleId: string, originStationId: string, destinationStationId: string) => { // Package bookings use the stored tier price — no fare calculation needed - if (packageTierPriceMinor !== null) return; + if (isPackageBooking) return; try { const seatClasses: any[] = await apiClient.get('/seat-classes'); @@ -433,9 +459,6 @@ export default function ReviewPage() { const fallbackSeatClassId = seatClasses.find((sc: any) => sc.name === scheduleSeatClassName)?.id || seatClasses[0]?.id; if (!fallbackSeatClassId) return; - // Bed coaches price Upper/Middle/Lower as separate classes, so a passenger's own - // assigned berth (captured on the seats page) must resolve its own seatClassId here - // — a single shared class can't correctly price passengers in different berths. const resolveSeatClassId = (p: any): string => { const bedPosition: string | undefined = isRoundTrip ? (p as any).outboundBedPosition : (p as any).bedPosition; if (!bedPosition) return fallbackSeatClassId; @@ -465,7 +488,7 @@ export default function ReviewPage() { setFareBreakdown(result); } catch (err) { } - }, [packageTierPriceMinor, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]); + }, [isPackageBooking, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]); useEffect(() => { if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return; @@ -474,37 +497,17 @@ export default function ReviewPage() { fetchFareBreakdown(scheduleId, searchCriteria.originStationId, searchCriteria.destinationStationId); }, [fetchFareBreakdown, isRoundTrip, outboundSchedule?.id, selectedSchedule?.id, searchCriteria?.originStationId, searchCriteria?.destinationStationId]); - const isPackageBooking = packageTierPriceMinor !== null; - // packageTierPriceMinor is the per-adult fare for ONE leg. - // Round-trip packages multiply by 2. - // First child per adult travels FREE; additional children pay full adult fare. - const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length; - const childPassengerCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length; - const pkgRoundTripMultiplier = isPackageBooking && isRoundTrip ? 2 : 1; - const pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0; - const pkgPaidChildrenCount = Math.max(0, childPassengerCount - adultPassengerCount); - // Paid children pay full adult fare - const pkgChildFare = pkgAdultFare; // full fare for paid children - // For package bookings, passengers are initialized without dateOfBirth so isChild() is // unreliable. Use the stored adultCount from searchCriteria to determine category by index. - const isPackageChild = (index: number) => - isPackageBooking ? index >= adultPassengerCount : isChild(passengers[index]); - - // Per-seat fare captured on the seats page (bed-position-aware, computed locally from - // the schedule's own coachTypes/classes) is guaranteed correct for berths, unlike the - // backend /search/fare-breakdown call whose seatClassId matching for bed positions can't - // be verified here. Prefer it whenever the passenger actually has an assigned seat. - const getPassengerSeatFare = (p: any): number | null => { - if (isRoundTrip) { - if (p.outboundSeatFareMinor == null && p.inboundSeatFareMinor == null) return null; - return (p.outboundSeatFareMinor ?? 0) + (p.inboundSeatFareMinor ?? 0); - } - return p.seatFareMinor ?? null; - }; - const total = isPackageBooking - ? adultPassengerCount * pkgAdultFare + pkgPaidChildrenCount * pkgChildFare + ? passengers.reduce((sum, p, i) => { + const isChild_ = isPackageChild(i); + const isFreeChild = isChild_ && (i - adultPassengerCount) < adultPassengerCount; + if (isFreeChild) return sum; + const seatFare = getPassengerSeatFare(p); + const pkgFallback = isChild_ ? pkgChildFare : pkgAdultFare; + return sum + (seatFare ?? pkgFallback); + }, 0) : passengers.reduce((sum, p, i) => { const isChildPassenger = isChild(p); const line = fareBreakdown?.passengers?.[i]; @@ -517,16 +520,6 @@ export default function ReviewPage() { // Keep computedTotal in sync so handleConfirm can persist it to the store useEffect(() => { setComputedTotal(total); }, [total]); - // For package bookings, determine if a child is free (first per adult) or paid. - // Children are ordered after adults in the passengers array (set on package detail page). - const isPkgFreeChild = (index: number) => { - if (!isPackageBooking) return false; - if (!isPackageChild(index)) return false; - // childIndex = position among children (0-based) - const childIndex = index - adultPassengerCount; - return childIndex < adultPassengerCount; // first adultCount children are free - }; - // Shared fare sidebar — rendered in right column (desktop) and inline (mobile) const FareSidebar = () => (
@@ -541,7 +534,7 @@ export default function ReviewPage() { : (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i))); const seatFare = getPassengerSeatFare(p); const passengerTotal = isPackageBooking - ? (isFreeChild ? 0 : (isChildPassenger ? pkgChildFare : pkgAdultFare)) + ? (isFreeChild ? 0 : (seatFare ?? (isChildPassenger ? pkgChildFare : pkgAdultFare))) : (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0)); return ( @@ -618,9 +611,6 @@ export default function ReviewPage() {

Outbound Journey

- - {outboundSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'} -
{/* Flight-style timeline */} @@ -695,9 +685,6 @@ export default function ReviewPage() {

Return Journey

- - {inboundSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'} -
{/* Flight-style timeline */} diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index ddf115084..a5c30f323 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -174,6 +174,7 @@ export default function SeatsPage() { packageName, packageId, priceTierId, + packageTierPriceMinor, packageDepartureStationId, packageDepartureStationName, setPackageContext, @@ -369,10 +370,10 @@ export default function SeatsPage() { const getSeatFare = useCallback( (seat: any): number | null => { if (!currentCoachTypeClasses.length) return null; - if (seat?.bedPosition) { + if (seat?.bedPosition) { const match = currentCoachTypeClasses.find((c: any) => c.name?.toLowerCase().includes(seat.bedPosition), - ); + ); if (match) return match.baseFareMinor; } const regular = currentCoachTypeClasses.find((c: any) => /regular/i.test(c.name || "")); @@ -495,7 +496,7 @@ export default function SeatsPage() { setModalState({ isOpen: true, title: "Fare Will Change", - message: `Switching to ${coach.label} (${matchedType.coachTypeName || coach.typeName || ""}) changes the fare to ETB ${(newFare / 100 * 2).toFixed(2)} per adult (currently ETB ${(currentFare / 100 * 2).toFixed(2)}). Continue?`, + message: `Switching to ${coach.label} (${matchedType.coachTypeName || coach.typeName || ""}) changes the fare to ETB ${(newFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)} per adult (currently ETB ${(currentFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)}). Continue?`, type: "warning", showCancel: true, confirmText: "Switch Coach", @@ -510,7 +511,7 @@ export default function SeatsPage() { isOpen: true, title: "Switch Coach Type", message: `Switch to ${coach.label}${coachTypeName ? ` (${coachTypeName})` : ""}?${ - newFare != null ? ` Fare: ETB ${(newFare / 100 * 2).toFixed(2)} per adult.` : " This will have a fare change." + newFare != null ? ` Fare: ETB ${(newFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)} per adult.` : " This will have a fare change." }`, type: "info", showCancel: true, @@ -769,26 +770,32 @@ export default function SeatsPage() { const newSeat = validSeats?.find((s: any) => s.id === seatId); const newFare = newSeat ? getSeatFare(newSeat) : null; - // Package bookings: fare-change warning on individual seat clicks is suppressed. - // The only fare-change confirmation is when switching coach type via Train Coach Preview. - if (newFare != null && !isPackageBooking) { + if (newFare != null) { let referenceFare: number | null = null; let referenceLabel = "the fare you originally selected"; - if (originalFareForCurrentLeg != null && originalFareForCurrentLeg !== newFare) { - referenceFare = originalFareForCurrentLeg; + if (isPackageBooking) { + // For package bookings, compare against the stored tier price (per leg). + const pkgLegFare = packageTierPriceMinor; + if (pkgLegFare != null && newFare !== pkgLegFare) { + referenceFare = pkgLegFare; + } } else { - const differingEntry = Object.entries(passengerSeatMap).find(([idx, sid]) => { - if (Number(idx) === activePassengerIndex) return false; - const otherSeat = validSeats?.find((s: any) => s.id === sid); - const otherFare = otherSeat ? getSeatFare(otherSeat) : null; - return otherFare != null && otherFare !== newFare; - }); + if (originalFareForCurrentLeg != null && originalFareForCurrentLeg !== newFare) { + referenceFare = originalFareForCurrentLeg; + } else { + const differingEntry = Object.entries(passengerSeatMap).find(([idx, sid]) => { + if (Number(idx) === activePassengerIndex) return false; + const otherSeat = validSeats?.find((s: any) => s.id === sid); + const otherFare = otherSeat ? getSeatFare(otherSeat) : null; + return otherFare != null && otherFare !== newFare; + }); - if (differingEntry) { - const otherSeat = validSeats?.find((s: any) => s.id === differingEntry[1]); - referenceFare = otherSeat ? getSeatFare(otherSeat) : null; - referenceLabel = "another already-selected seat"; + if (differingEntry) { + const otherSeat = validSeats?.find((s: any) => s.id === differingEntry[1]); + referenceFare = otherSeat ? getSeatFare(otherSeat) : null; + referenceLabel = "another already-selected seat"; + } } } @@ -797,23 +804,52 @@ export default function SeatsPage() { const positionLabel = newSeat?.bedPosition ? `${newSeat.bedPosition.charAt(0).toUpperCase()}${newSeat.bedPosition.slice(1)} berth` : "This seat"; + const legMultiplier = isRoundTrip ? 2 : 1; setModalState({ isOpen: true, title: "Fare Will Change", - message: `${positionLabel} ${seatLabel} costs ETB ${(newFare / 100 * 2).toFixed(2)}, different from ${referenceLabel}. Continue with this selection?`, + message: `${positionLabel} ${seatLabel} costs ETB ${(newFare / 100 * legMultiplier).toFixed(2)}, different from ${referenceLabel} (ETB ${(referenceFare / 100 * legMultiplier).toFixed(2)}). Continue with this selection?`, type: "warning", showCancel: true, confirmText: "Continue", - onConfirm: () => commitSeatAssignment(seatId), + onConfirm: () => { + commitSeatAssignment(seatId); + // For package bookings, sync the stored tier price to the selected berth fare + // so review/payment/confirmation pages use the correct amount. + if (isPackageBooking && packageId) { + setPackageContext( + packageId, + priceTierId ?? '', + newFare, + packageName ?? undefined, + packageDepartureStationId ?? undefined, + packageDepartureStationName ?? undefined, + ); + } + }, }); return; } + + // No fare change — but for package bookings still sync the tier price to the + // actual berth fare (handles the case where the first seat picked matches the + // stored price but we still want it explicitly confirmed). + if (isPackageBooking && packageId && newFare !== packageTierPriceMinor) { + setPackageContext( + packageId, + priceTierId ?? '', + newFare, + packageName ?? undefined, + packageDepartureStationId ?? undefined, + packageDepartureStationName ?? undefined, + ); + } } commitSeatAssignment(seatId); }, - [passengerSeatMap, activePassengerIndex, validSeats, getSeatFare, originalFareForCurrentLeg, commitSeatAssignment], + [passengerSeatMap, activePassengerIndex, validSeats, getSeatFare, originalFareForCurrentLeg, commitSeatAssignment, isPackageBooking, packageTierPriceMinor, packageId, priceTierId, packageName, packageDepartureStationId, packageDepartureStationName, setPackageContext, isRoundTrip], ); const allSeatsAssigned = @@ -1034,6 +1070,26 @@ export default function SeatsPage() { }; }); setPassengers(updatedPassengers); + + // For one-way package bookings, sync the stored tier price with the actual berth + // fare so review/payment/confirmation pages reflect the correct amount. + if (!isRoundTrip && isPackageBooking && packageId) { + const firstEligibleIdx = seatEligibleIndices[0]; + const firstSeatData = firstEligibleIdx != null + ? validSeats?.find((s: any) => s.id === seatIds[firstEligibleIdx]) + : null; + const berthFare = firstSeatData ? getSeatFare(firstSeatData) : null; + if (berthFare != null) { + setPackageContext( + packageId, + priceTierId ?? '', + berthFare, + packageName ?? undefined, + packageDepartureStationId ?? undefined, + packageDepartureStationName ?? undefined, + ); + } + } } catch (error: any) { setModalState({ isOpen: true, @@ -1678,6 +1734,9 @@ export default function SeatsPage() { ? validSeats?.find((s: any) => s.id === assignedSeatId) : null; const seatLabel = assignedSeat ? buildSeatLabel(assignedSeat) : ""; + const seatFare = assignedSeat + ? (getSeatFare(assignedSeat) ?? (isPackageBooking ? packageTierPriceMinor ?? null : null)) + : isPackageBooking && assignedSeatId ? (packageTierPriceMinor ?? null) : null; const isActive = i === activePassengerIndex; const isClickable = i <= maxSelectableIndex; return ( @@ -1719,13 +1778,20 @@ export default function SeatsPage() { )}
- - {assignedSeat ? `Seat ${seatLabel}` : "Not Assigned"} - +
+ + {assignedSeat ? `Seat ${seatLabel}` : "Not Assigned"} + + {assignedSeat && seatFare != null && ( + + ETB {(seatFare / 100 * (isPackageBooking ? 2 : 1)).toFixed(2)} + + )} +
); })} diff --git a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx index c7ef3fe52..32e3f1178 100644 --- a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx @@ -566,7 +566,7 @@ export default function PackageDetailPage() { coachTypeId: g.coachTypeId, coachTypeName: g.coachTypeName, coachTypeCode: g.coachTypeCode, - classes: [{ name: g.coachTypeName, baseFareMinor: g.minPrice }], + classes: g.tiers.map((t) => ({ name: t.label, baseFareMinor: t.priceMinor })), })), });