mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Package passenger numbers and pricing updates
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user