mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 10:52:53 +00:00
@@ -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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -412,8 +437,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 };
|
||||
});
|
||||
@@ -442,7 +468,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');
|
||||
@@ -452,9 +478,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;
|
||||
@@ -484,7 +507,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;
|
||||
@@ -493,37 +516,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];
|
||||
@@ -536,16 +539,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 = () => (
|
||||
<div className="card space-y-3">
|
||||
@@ -560,7 +553,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 (
|
||||
@@ -637,9 +630,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="w-2 h-2 bg-primary rounded-full" />
|
||||
<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>
|
||||
|
||||
{/* Flight-style timeline */}
|
||||
@@ -714,9 +704,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="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>
|
||||
<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>
|
||||
|
||||
{/* Flight-style timeline */}
|
||||
|
||||
@@ -176,6 +176,7 @@ export default function SeatsPage() {
|
||||
packageName,
|
||||
packageId,
|
||||
priceTierId,
|
||||
packageTierPriceMinor,
|
||||
packageDepartureStationId,
|
||||
packageDepartureStationName,
|
||||
setPackageContext,
|
||||
@@ -418,10 +419,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 || ""));
|
||||
@@ -544,7 +545,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",
|
||||
@@ -559,7 +560,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,
|
||||
@@ -818,26 +819,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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -846,23 +853,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 =
|
||||
@@ -1111,6 +1147,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,
|
||||
@@ -1755,6 +1811,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 (
|
||||
@@ -1796,13 +1855,20 @@ export default function SeatsPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-semibold flex-shrink-0 ${
|
||||
assignedSeat ? "text-[rgb(20,113,76)]" : "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{assignedSeat ? `Seat ${seatLabel}` : "Not Assigned"}
|
||||
</span>
|
||||
<div className="flex flex-col items-end flex-shrink-0">
|
||||
<span
|
||||
className={`text-sm font-semibold ${
|
||||
assignedSeat ? "text-[rgb(20,113,76)]" : "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 })),
|
||||
})),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user