Package passenger numbers and pricing updates

This commit is contained in:
Stephanos A
2026-07-05 18:14:42 +03:00
parent 9e77ca7865
commit afd30c36a0
20 changed files with 537 additions and 105 deletions

View File

@@ -289,7 +289,7 @@ export default function ClassesPage() {
<h3 className="font-semibold text-foreground mb-4">Pricing Configuration</h3>
<div>
<label className="label">Base Fare (ETB) *</label>
<label className="label">Base Fare *</label>
<input
type="number"
name="baseFareMinor"

View File

@@ -107,7 +107,7 @@ export default function FareManagementPage() {
render: (r: any) => <span className="text-sm">{r.nationality || 'All'}</span>,
},
{
key: 'baseFareMinor', label: 'Base Fare (ETB)',
key: 'baseFareMinor', label: 'Base Fare',
render: (r: any) => <span className="font-mono">{(r.baseFareMinor / 100).toFixed(2)}</span>,
},
{
@@ -214,7 +214,7 @@ export default function FareManagementPage() {
</select>
</div>
<div>
<label className="label">Base Fare (ETB) *</label>
<label className="label">Base Fare *</label>
<input type="number" name="baseFareMinor" className="input" min="0" step="0.01"
defaultValue={editingRule ? (editingRule.baseFareMinor / 100).toFixed(2) : ''} required
placeholder="e.g. 350.00" />

View File

@@ -116,7 +116,7 @@ export default function PricingPage() {
const { data: segmentFares = [], isLoading: segmentFaresLoading, refetch: refetchSegmentFares } = useQuery({
queryKey: ['segment-fares', selectedRoute],
queryFn: () => (selectedRoute ? apiClient.get(`/schedules/routes/${selectedRoute}/segment-fares`) : Promise.resolve([])),
queryFn: () => (selectedRoute ? apiClient.get(`/admin/segment-fares?routeId=${selectedRoute}`) : Promise.resolve([])),
enabled: !!selectedRoute && tab === 'segment',
});
@@ -199,7 +199,7 @@ export default function PricingPage() {
});
const createSegmentFareMutation = useMutation({
mutationFn: (data: any) => apiClient.post(`/schedules/segment-fares`, data),
mutationFn: (data: any) => apiClient.post(`/admin/segment-fares`, data),
onSuccess: () => {
refetchSegmentFares();
resetSegmentForm();
@@ -211,7 +211,7 @@ export default function PricingPage() {
});
const updateSegmentFareMutation = useMutation({
mutationFn: (data: any) => apiClient.patch(`/schedules/segment-fares/${data.id}`, data),
mutationFn: ({ id, ...data }: any) => apiClient.put(`/admin/segment-fares/${id}`, data),
onSuccess: () => {
refetchSegmentFares();
setEditingFare(null);
@@ -224,7 +224,7 @@ export default function PricingPage() {
});
const deleteSegmentFareMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/schedules/segment-fares/${id}`),
mutationFn: (id: string) => apiClient.delete(`/admin/segment-fares/${id}`),
onSuccess: () => {
refetchSegmentFares();
setDeleteConfirm({ isOpen: false, id: null });
@@ -422,7 +422,7 @@ export default function PricingPage() {
},
{
key: 'baseFare',
label: 'Fare (ETB)',
label: 'Fare',
render: (fare: any) => {
const minor = fare.baseFareMinor ?? fare.baseFare;
if (minor == null) return <span className="text-muted-foreground"></span>;
@@ -495,7 +495,7 @@ export default function PricingPage() {
},
{
key: 'baseFare',
label: 'Fare (ETB)',
label: 'Fare',
render: (fare: any) => {
const fareValue = fare.baseFare ?? fare.baseFareMinor;
if (fareValue == null) return <span className="text-muted-foreground"></span>;
@@ -870,7 +870,7 @@ export default function PricingPage() {
</div>
<div>
<label className="label">Fare (ETB) *</label>
<label className="label">Fare *</label>
<input
type="number"
min="0"
@@ -1000,7 +1000,7 @@ export default function PricingPage() {
</div>
<div>
<label className="label">Fare (ETB) *</label>
<label className="label">Fare *</label>
<input
type="number"
min="0"

View File

@@ -458,6 +458,14 @@ export const excessBaggageApi = {
delete: (id: string) => apiClient.delete(`/agents/excess-baggage/${id}`),
};
// Segment Fares API
export const segmentFaresApi = {
getAll: (routeId?: string) => apiClient.get<any>(`/admin/segment-fares${routeId ? `?routeId=${routeId}` : ''}`),
create: (data: any) => apiClient.post<any>('/admin/segment-fares', data),
update: (id: string, data: any) => apiClient.put<any>(`/admin/segment-fares/${id}`, data),
remove: (id: string) => apiClient.delete(`/admin/segment-fares/${id}`),
};
// Route Coach Templates API
export const routeCoachTemplatesApi = {
get: (routeId: string) => apiClient.get<any>(`/routes/${routeId}/coaches`),

View File

@@ -28,7 +28,7 @@ const getIconForMethod = (methodId: string) => {
export default function PaymentPage() {
const router = useRouter();
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName } = useBookingStore();
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName, packageTierPriceMinor } = useBookingStore();
const { setPaymentIntent, updateStatus, setCurrency } = usePaymentStore();
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null);
@@ -36,6 +36,10 @@ export default function PaymentPage() {
const [paymentError, setPaymentError] = useState<string | null>(null);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
const isPackage = !!packageTierPriceMinor;
const pkgRoundTripMultiplier = isPackage && isRoundTrip ? 2 : 1;
const pkgAdultFare = isPackage ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0;
const pkgChildFare = isPackage ? Math.round(pkgAdultFare * 0.1) : 0;
const displayCurrency = 'ETB' as const;
@@ -68,18 +72,20 @@ export default function PaymentPage() {
// Fallback: estimate from local store while API hasn't responded yet.
// Uses the same first-child-free calculation as the review page so the
// breakdown shown here matches what the passenger already saw there.
const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce((sum, _, i) => {
const outboundBaseFare = !isPackage && isRoundTrip && outboundSchedule ? passengers.reduce((sum, _, i) => {
return sum + calculatePassengerFare(passengers, i, outboundSchedule.baseFareAdult || 0);
}, 0) : 0;
const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum, _, i) => {
const inboundBaseFare = !isPackage && isRoundTrip && inboundSchedule ? passengers.reduce((sum, _, i) => {
return sum + calculatePassengerFare(passengers, i, inboundSchedule.baseFareAdult || 0);
}, 0) : 0;
const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, _, i) => {
const farePerPassenger = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0;
return sum + calculatePassengerFare(passengers, i, farePerPassenger);
}, 0);
const baseFare = isPackage
? (searchCriteria?.adultCount ?? 0) * pkgAdultFare + (searchCriteria?.childCount ?? 0) * pkgChildFare
: isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, _, i) => {
const farePerPassenger = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0;
return sum + calculatePassengerFare(passengers, i, farePerPassenger);
}, 0);
// API returns amount in major units (e.g. 11602.5 DJF); convert to minor for display consistency
const totalAmount = bookingAmountData != null
@@ -259,17 +265,22 @@ export default function PaymentPage() {
<div className="pt-2 border-t-2 border-gray-200 dark:border-gray-700 space-y-2">
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">Fare breakdown</h3>
{passengers.map((p, i) => {
const outFare = outboundSchedule?.baseFareAdult || 0;
const inFare = inboundSchedule?.baseFareAdult || 0;
const onewayFare = selectedSchedule?.baseFareAdult || 0;
const outboundFare = calculatePassengerFare(passengers, i, outFare);
const inboundFare = calculatePassengerFare(passengers, i, inFare);
const oneWayFare = calculatePassengerFare(passengers, i, onewayFare);
const passengerTotal = isRoundTrip ? outboundFare + inboundFare : oneWayFare;
const isChildPassenger = isChild(p);
const isFreeChild = isChildPassenger && isFirstChild(passengers, i);
let passengerTotal: number;
let isFreeChild = false;
if (isPackage) {
passengerTotal = isChildPassenger ? pkgChildFare : pkgAdultFare;
} else {
const outFare = outboundSchedule?.baseFareAdult || 0;
const inFare = inboundSchedule?.baseFareAdult || 0;
const onewayFare = selectedSchedule?.baseFareAdult || 0;
const outboundFare = calculatePassengerFare(passengers, i, outFare);
const inboundFare = calculatePassengerFare(passengers, i, inFare);
const oneWayFare = calculatePassengerFare(passengers, i, onewayFare);
passengerTotal = isRoundTrip ? outboundFare + inboundFare : oneWayFare;
isFreeChild = isChildPassenger && isFirstChild(passengers, i);
}
return (
<div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
@@ -278,9 +289,9 @@ export default function PaymentPage() {
{p.name || `Passenger ${i + 1}`}
{isChildPassenger && (
<span className={`text-xs font-semibold ml-1 ${
isFreeChild ? 'text-green-600' : 'text-blue-600'
isPackage ? 'text-blue-600' : isFreeChild ? 'text-green-600' : 'text-blue-600'
}`}>
({isFreeChild ? 'CHILD - FREE' : 'CHILD'})
({isPackage ? 'CHILD - 10%' : isFreeChild ? 'CHILD - FREE' : 'CHILD'})
</span>
)}
</span>
@@ -288,15 +299,15 @@ export default function PaymentPage() {
{formatFare(passengerTotal, displayCurrency)}
</span>
</div>
{isRoundTrip && (
{!isPackage && isRoundTrip && (
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
<div className="flex justify-between">
<span>Outbound {isFreeChild ? '(Free)' : ''}</span>
<span>{formatFare(outboundFare, displayCurrency)}</span>
<span>{formatFare(calculatePassengerFare(passengers, i, outboundSchedule?.baseFareAdult || 0), displayCurrency)}</span>
</div>
<div className="flex justify-between">
<span>Return {isFreeChild ? '(Free)' : ''}</span>
<span>{formatFare(inboundFare, displayCurrency)}</span>
<span>{formatFare(calculatePassengerFare(passengers, i, inboundSchedule?.baseFareAdult || 0), displayCurrency)}</span>
</div>
</div>
)}

View File

@@ -385,6 +385,9 @@ export default function ReviewPage() {
}, [isRoundTrip, selectedSchedule, outboundSchedule, inboundSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]);
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;
try {
const seatClasses: any[] = await apiClient.get('/seat-classes');
const scheduleSeatClassName = isRoundTrip
@@ -415,7 +418,7 @@ export default function ReviewPage() {
setFareBreakdown(result);
} catch (err) {
}
}, [passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]);
}, [packageTierPriceMinor, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]);
useEffect(() => {
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return;
@@ -432,7 +435,18 @@ export default function ReviewPage() {
return null;
}
const total = packageTierPriceMinor ?? fareBreakdown?.totalMinor ?? 0;
const isPackageBooking = packageTierPriceMinor !== null;
// packageTierPriceMinor is the per-adult fare for ONE leg.
// Round-trip packages multiply by 2; children pay 10% of the adult fare.
const pkgRoundTripMultiplier = isPackageBooking && isRoundTrip ? 2 : 1;
const pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0;
const pkgChildFare = isPackageBooking ? Math.round(pkgAdultFare * 0.1) : 0;
const adultPassengerCount = searchCriteria?.adultCount ?? passengers.length;
const childPassengerCount = searchCriteria?.childCount ?? 0;
const total = isPackageBooking
? adultPassengerCount * pkgAdultFare + childPassengerCount * pkgChildFare
: (fareBreakdown?.totalMinor ?? 0);
// Shared fare sidebar — rendered in right column (desktop) and inline (mobile)
const FareSidebar = () => (
@@ -442,9 +456,11 @@ export default function ReviewPage() {
</h2>
{passengers.map((p, i) => {
const line = fareBreakdown?.passengers?.[i];
const passengerTotal = line?.fareMinor ?? 0;
const isChildPassenger = isChild(p);
const isFreeChild = line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i));
const passengerTotal = isPackageBooking
? (isChildPassenger ? pkgChildFare : pkgAdultFare)
: (line?.fareMinor ?? 0);
const isFreeChild = !isPackageBooking && (line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i)));
return (
<div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
@@ -453,9 +469,9 @@ export default function ReviewPage() {
{p.name || `Passenger ${i + 1}`}
{isChildPassenger && (
<span className={`text-xs font-semibold ml-1 ${
isFreeChild ? 'text-green-600' : 'text-blue-600'
isPackageBooking ? 'text-blue-600' : isFreeChild ? 'text-green-600' : 'text-blue-600'
}`}>
({isFreeChild ? 'CHILD - FREE' : 'CHILD'})
({isPackageBooking ? 'CHILD - 10%' : isFreeChild ? 'CHILD - FREE' : 'CHILD'})
</span>
)}
</span>

View File

@@ -287,6 +287,10 @@ function PriceTiersPanel({
// ─── Passenger count picker ──────────────────────────────────────────────────
const PKG_MAX_ADULTS = 5;
const PKG_MAX_CHILDREN = 2;
const PKG_CHILD_FARE_RATIO = 0.1;
function PassengerCountModal({
tier,
onClose,
@@ -304,8 +308,9 @@ function PassengerCountModal({
}) {
const [adultCount, setAdultCount] = useState(1);
const [childCount, setChildCount] = useState(0);
const total = adultCount + childCount;
const remaining = tier.availableSeats - tier.bookedSeats;
const childFareMinor = Math.round(tier.priceMinor * PKG_CHILD_FARE_RATIO);
const totalMinor = (adultCount * tier.priceMinor + childCount * childFareMinor) * priceMultiplier;
return (
<>
@@ -322,13 +327,14 @@ function PassengerCountModal({
<div className="px-6 py-3 bg-primary/5 border-b border-primary/10">
<p className="text-xs text-gray-400 uppercase tracking-wide font-semibold">Selected tier</p>
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">{tier.label.trim()}</p>
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per person</p>
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult</p>
</div>
<div className="px-6 py-5 space-y-4">
{[{ label: "Adults", sub: "Age 5+", value: adultCount, min: 1, set: setAdultCount },
{ label: "Children", sub: "Under 5", value: childCount, min: 0, set: setChildCount }]
.map(({ label, sub, value, min, set }) => (
{[
{ label: "Adults", sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, value: adultCount, min: 1, max: Math.min(PKG_MAX_ADULTS, remaining), set: setAdultCount },
{ label: "Children", sub: `Under 5 · max ${PKG_MAX_CHILDREN} · 10% of adult fare`, value: childCount, min: 0, max: Math.min(PKG_MAX_CHILDREN, remaining - adultCount), set: setChildCount },
].map(({ label, sub, value, min, max, set }) => (
<div key={label} className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-white">{label}</p>
@@ -342,7 +348,7 @@ function PassengerCountModal({
</button>
<span className="w-6 text-center text-base font-bold text-gray-900 dark:text-white">{value}</span>
<button type="button" onClick={() => set(value + 1)}
disabled={total >= remaining}
disabled={value >= max}
className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center text-lg font-bold text-gray-600 dark:text-gray-300 disabled:opacity-30 hover:border-primary hover:text-primary transition-colors">
+
</button>
@@ -351,8 +357,8 @@ function PassengerCountModal({
))}
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800">
<span className="text-sm text-gray-500">Total</span>
<span className="text-base font-extrabold text-primary">{formatPrice(tier.priceMinor * priceMultiplier * total, tier.currency)}</span>
<span className="text-sm text-gray-500">Total{priceMultiplier === 2 ? ' (round-trip)' : ''}</span>
<span className="text-base font-extrabold text-primary">{formatPrice(totalMinor, tier.currency)}</span>
</div>
{error && (
@@ -363,7 +369,7 @@ function PassengerCountModal({
)}
<button type="button" onClick={() => onConfirm(adultCount, childCount)}
disabled={loading || total < 1}
disabled={loading || adultCount + childCount < 1}
className="w-full py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-60 text-white font-bold text-sm rounded-xl transition-all shadow-lg flex items-center justify-center gap-2">
{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> Loading...</> : <>Continue <ArrowRight className="w-4 h-4" /></>}
</button>
@@ -460,7 +466,8 @@ export default function PackageDetailPage() {
})),
);
setPackageContext(id, selectedTier.id, ctx.totalMinor, pkg.name);
// Store per-adult tier price (×1 leg); review page applies round-trip multiplier and child pricing
setPackageContext(id, selectedTier.id, selectedTier.priceMinor, pkg.name);
router.push("/booking/passengers");
} catch (err: any) {