Seat price related updates

This commit is contained in:
Stephanos A
2026-07-07 19:35:19 +03:00
parent 222687f4ac
commit 9126091fbd
4 changed files with 135 additions and 82 deletions

View File

@@ -102,7 +102,7 @@ export default function ConfirmationPage() {
const { packageTierPriceMinor } = useBookingStore.getState(); const { packageTierPriceMinor } = useBookingStore.getState();
const isPackageBooking = packageTierPriceMinor != null; const isPackageBooking = packageTierPriceMinor != null;
const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length; 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 pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgMultiplier : 0;
const pkgChildFare = pkgAdultFare; const pkgChildFare = pkgAdultFare;

View File

@@ -149,6 +149,31 @@ export default function ReviewPage() {
fetchSeatDetails(); fetchSeatDetails();
}, [selectedSchedule?.id, outboundSchedule?.id, inboundSchedule?.id, passengers, isRoundTrip]); }, [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({ const createBookingMutation = useMutation({
mutationFn: (data: any) => { mutationFn: (data: any) => {
const endpoint = isAuthenticated ? '/bookings' : '/bookings/guest'; const endpoint = isAuthenticated ? '/bookings' : '/bookings/guest';
@@ -393,8 +418,9 @@ export default function ReviewPage() {
? (isChildPassenger && (i - adultPassengerCount) < adultPassengerCount) ? (isChildPassenger && (i - adultPassengerCount) < adultPassengerCount)
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i))); : (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
const seatFare = getPassengerSeatFare(p); const seatFare = getPassengerSeatFare(p);
const pkgFallback = isChildPassenger ? pkgChildFare : pkgAdultFare;
const fareMinor = isPackageBooking const fareMinor = isPackageBooking
? (isFreeChild ? 0 : (isChildPassenger ? pkgChildFare : pkgAdultFare)) ? (isFreeChild ? 0 : (seatFare ?? pkgFallback))
: (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0)); : (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0));
return { fareMinor, isFree: isFreeChild }; return { fareMinor, isFree: isFreeChild };
}); });
@@ -423,7 +449,7 @@ export default function ReviewPage() {
const fetchFareBreakdown = useCallback(async (scheduleId: string, originStationId: string, destinationStationId: string) => { const fetchFareBreakdown = useCallback(async (scheduleId: string, originStationId: string, destinationStationId: string) => {
// Package bookings use the stored tier price — no fare calculation needed // Package bookings use the stored tier price — no fare calculation needed
if (packageTierPriceMinor !== null) return; if (isPackageBooking) return;
try { try {
const seatClasses: any[] = await apiClient.get('/seat-classes'); 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; const fallbackSeatClassId = seatClasses.find((sc: any) => sc.name === scheduleSeatClassName)?.id || seatClasses[0]?.id;
if (!fallbackSeatClassId) return; 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 resolveSeatClassId = (p: any): string => {
const bedPosition: string | undefined = isRoundTrip ? (p as any).outboundBedPosition : (p as any).bedPosition; const bedPosition: string | undefined = isRoundTrip ? (p as any).outboundBedPosition : (p as any).bedPosition;
if (!bedPosition) return fallbackSeatClassId; if (!bedPosition) return fallbackSeatClassId;
@@ -465,7 +488,7 @@ export default function ReviewPage() {
setFareBreakdown(result); setFareBreakdown(result);
} catch (err) { } catch (err) {
} }
}, [packageTierPriceMinor, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]); }, [isPackageBooking, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]);
useEffect(() => { useEffect(() => {
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return; if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return;
@@ -474,37 +497,17 @@ export default function ReviewPage() {
fetchFareBreakdown(scheduleId, searchCriteria.originStationId, searchCriteria.destinationStationId); fetchFareBreakdown(scheduleId, searchCriteria.originStationId, searchCriteria.destinationStationId);
}, [fetchFareBreakdown, isRoundTrip, outboundSchedule?.id, selectedSchedule?.id, 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 // For package bookings, passengers are initialized without dateOfBirth so isChild() is
// unreliable. Use the stored adultCount from searchCriteria to determine category by index. // 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 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) => { : passengers.reduce((sum, p, i) => {
const isChildPassenger = isChild(p); const isChildPassenger = isChild(p);
const line = fareBreakdown?.passengers?.[i]; 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 // Keep computedTotal in sync so handleConfirm can persist it to the store
useEffect(() => { setComputedTotal(total); }, [total]); 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) // Shared fare sidebar — rendered in right column (desktop) and inline (mobile)
const FareSidebar = () => ( const FareSidebar = () => (
<div className="card space-y-3"> <div className="card space-y-3">
@@ -541,7 +534,7 @@ export default function ReviewPage() {
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i))); : (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
const seatFare = getPassengerSeatFare(p); const seatFare = getPassengerSeatFare(p);
const passengerTotal = isPackageBooking const passengerTotal = isPackageBooking
? (isFreeChild ? 0 : (isChildPassenger ? pkgChildFare : pkgAdultFare)) ? (isFreeChild ? 0 : (seatFare ?? (isChildPassenger ? pkgChildFare : pkgAdultFare)))
: (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0)); : (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0));
return ( return (
@@ -618,9 +611,6 @@ export default function ReviewPage() {
<div className="flex items-center gap-2 mb-6 pb-4 border-b border-gray-100 dark:border-gray-800"> <div className="flex items-center gap-2 mb-6 pb-4 border-b border-gray-100 dark:border-gray-800">
<div className="w-2 h-2 bg-primary rounded-full" /> <div className="w-2 h-2 bg-primary rounded-full" />
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">Outbound Journey</h2> <h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">Outbound Journey</h2>
<span className="ml-auto text-xs px-2.5 py-1 bg-primary/10 text-primary rounded-full font-semibold">
{outboundSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'}
</span>
</div> </div>
{/* Flight-style timeline */} {/* Flight-style timeline */}
@@ -695,9 +685,6 @@ export default function ReviewPage() {
<div className="flex items-center gap-2 mb-6 pb-4 border-b border-gray-100 dark:border-gray-800"> <div className="flex items-center gap-2 mb-6 pb-4 border-b border-gray-100 dark:border-gray-800">
<div className="w-2 h-2 bg-blue-500 rounded-full" /> <div className="w-2 h-2 bg-blue-500 rounded-full" />
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">Return Journey</h2> <h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">Return Journey</h2>
<span className="ml-auto text-xs px-2.5 py-1 bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full font-semibold">
{inboundSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'}
</span>
</div> </div>
{/* Flight-style timeline */} {/* Flight-style timeline */}

View File

@@ -174,6 +174,7 @@ export default function SeatsPage() {
packageName, packageName,
packageId, packageId,
priceTierId, priceTierId,
packageTierPriceMinor,
packageDepartureStationId, packageDepartureStationId,
packageDepartureStationName, packageDepartureStationName,
setPackageContext, setPackageContext,
@@ -495,7 +496,7 @@ export default function SeatsPage() {
setModalState({ setModalState({
isOpen: true, isOpen: true,
title: "Fare Will Change", 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", type: "warning",
showCancel: true, showCancel: true,
confirmText: "Switch Coach", confirmText: "Switch Coach",
@@ -510,7 +511,7 @@ export default function SeatsPage() {
isOpen: true, isOpen: true,
title: "Switch Coach Type", title: "Switch Coach Type",
message: `Switch to ${coach.label}${coachTypeName ? ` (${coachTypeName})` : ""}?${ 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", type: "info",
showCancel: true, showCancel: true,
@@ -769,26 +770,32 @@ export default function SeatsPage() {
const newSeat = validSeats?.find((s: any) => s.id === seatId); const newSeat = validSeats?.find((s: any) => s.id === seatId);
const newFare = newSeat ? getSeatFare(newSeat) : null; const newFare = newSeat ? getSeatFare(newSeat) : null;
// Package bookings: fare-change warning on individual seat clicks is suppressed. if (newFare != null) {
// The only fare-change confirmation is when switching coach type via Train Coach Preview.
if (newFare != null && !isPackageBooking) {
let referenceFare: number | null = null; let referenceFare: number | null = null;
let referenceLabel = "the fare you originally selected"; let referenceLabel = "the fare you originally selected";
if (originalFareForCurrentLeg != null && originalFareForCurrentLeg !== newFare) { if (isPackageBooking) {
referenceFare = originalFareForCurrentLeg; // For package bookings, compare against the stored tier price (per leg).
const pkgLegFare = packageTierPriceMinor;
if (pkgLegFare != null && newFare !== pkgLegFare) {
referenceFare = pkgLegFare;
}
} else { } else {
const differingEntry = Object.entries(passengerSeatMap).find(([idx, sid]) => { if (originalFareForCurrentLeg != null && originalFareForCurrentLeg !== newFare) {
if (Number(idx) === activePassengerIndex) return false; referenceFare = originalFareForCurrentLeg;
const otherSeat = validSeats?.find((s: any) => s.id === sid); } else {
const otherFare = otherSeat ? getSeatFare(otherSeat) : null; const differingEntry = Object.entries(passengerSeatMap).find(([idx, sid]) => {
return otherFare != null && otherFare !== newFare; 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) { if (differingEntry) {
const otherSeat = validSeats?.find((s: any) => s.id === differingEntry[1]); const otherSeat = validSeats?.find((s: any) => s.id === differingEntry[1]);
referenceFare = otherSeat ? getSeatFare(otherSeat) : null; referenceFare = otherSeat ? getSeatFare(otherSeat) : null;
referenceLabel = "another already-selected seat"; referenceLabel = "another already-selected seat";
}
} }
} }
@@ -797,23 +804,52 @@ export default function SeatsPage() {
const positionLabel = newSeat?.bedPosition const positionLabel = newSeat?.bedPosition
? `${newSeat.bedPosition.charAt(0).toUpperCase()}${newSeat.bedPosition.slice(1)} berth` ? `${newSeat.bedPosition.charAt(0).toUpperCase()}${newSeat.bedPosition.slice(1)} berth`
: "This seat"; : "This seat";
const legMultiplier = isRoundTrip ? 2 : 1;
setModalState({ setModalState({
isOpen: true, isOpen: true,
title: "Fare Will Change", 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", type: "warning",
showCancel: true, showCancel: true,
confirmText: "Continue", 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; 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); 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 = const allSeatsAssigned =
@@ -1034,6 +1070,26 @@ export default function SeatsPage() {
}; };
}); });
setPassengers(updatedPassengers); 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) { } catch (error: any) {
setModalState({ setModalState({
isOpen: true, isOpen: true,
@@ -1678,6 +1734,9 @@ export default function SeatsPage() {
? validSeats?.find((s: any) => s.id === assignedSeatId) ? validSeats?.find((s: any) => s.id === assignedSeatId)
: null; : null;
const seatLabel = assignedSeat ? buildSeatLabel(assignedSeat) : ""; const seatLabel = assignedSeat ? buildSeatLabel(assignedSeat) : "";
const seatFare = assignedSeat
? (getSeatFare(assignedSeat) ?? (isPackageBooking ? packageTierPriceMinor ?? null : null))
: isPackageBooking && assignedSeatId ? (packageTierPriceMinor ?? null) : null;
const isActive = i === activePassengerIndex; const isActive = i === activePassengerIndex;
const isClickable = i <= maxSelectableIndex; const isClickable = i <= maxSelectableIndex;
return ( return (
@@ -1719,13 +1778,20 @@ export default function SeatsPage() {
)} )}
</div> </div>
</div> </div>
<span <div className="flex flex-col items-end flex-shrink-0">
className={`text-sm font-semibold flex-shrink-0 ${ <span
assignedSeat ? "text-[rgb(20,113,76)]" : "text-gray-400" className={`text-sm font-semibold ${
}`} assignedSeat ? "text-[rgb(20,113,76)]" : "text-gray-400"
> }`}
{assignedSeat ? `Seat ${seatLabel}` : "Not Assigned"} >
</span> {assignedSeat ? `Seat ${seatLabel}` : "Not Assigned"}
</span>
{assignedSeat && seatFare != null && (
<span className="text-[11px] text-gray-500 dark:text-gray-400">
ETB {(seatFare / 100 * (isPackageBooking ? 2 : 1)).toFixed(2)}
</span>
)}
</div>
</button> </button>
); );
})} })}

View File

@@ -566,7 +566,7 @@ export default function PackageDetailPage() {
coachTypeId: g.coachTypeId, coachTypeId: g.coachTypeId,
coachTypeName: g.coachTypeName, coachTypeName: g.coachTypeName,
coachTypeCode: g.coachTypeCode, coachTypeCode: g.coachTypeCode,
classes: [{ name: g.coachTypeName, baseFareMinor: g.minPrice }], classes: g.tiers.map((t) => ({ name: t.label, baseFareMinor: t.priceMinor })),
})), })),
}); });