feat: (upgrade) implement per-passenger fare class upgrade with configurable policies

This commit is contained in:
Abubeker Yasin
2026-09-02 16:18:36 +03:00
parent 31121db07b
commit e914aaeb53
34 changed files with 2705 additions and 34 deletions

View File

@@ -9,6 +9,9 @@ type Tab = 'general' | 'payment' | 'integrations' | 'configurations';
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<Tab>('general');
const [seatHoldMinutes, setSeatHoldMinutes] = useState('5');
const [bookingPayWindow, setBookingPayWindow] = useState('120');
const [reschedulePayWindow, setReschedulePayWindow] = useState('120');
const [upgradePayWindow, setUpgradePayWindow] = useState('120');
const [holdCutoffHours, setHoldCutoffHours] = useState('2');
const [boardingWindowHours, setBoardingWindowHours] = useState('4');
const [throttleAuthLimit, setThrottleAuthLimit] = useState('5');
@@ -24,6 +27,9 @@ export default function SettingsPage() {
systemConfigApi.getAll()
.then((data) => {
if (data?.seat_hold_duration_minutes) setSeatHoldMinutes(data.seat_hold_duration_minutes);
if (data?.booking_payment_window_minutes) setBookingPayWindow(data.booking_payment_window_minutes);
if (data?.reschedule_payment_window_minutes) setReschedulePayWindow(data.reschedule_payment_window_minutes);
if (data?.upgrade_payment_window_minutes) setUpgradePayWindow(data.upgrade_payment_window_minutes);
if (data?.hold_cutoff_hours_before_departure) setHoldCutoffHours(data.hold_cutoff_hours_before_departure);
if (data?.boarding_window_hours_before_departure) setBoardingWindowHours(data.boarding_window_hours_before_departure);
if (data?.throttle_auth_limit) setThrottleAuthLimit(data.throttle_auth_limit);
@@ -40,6 +46,9 @@ export default function SettingsPage() {
try {
await systemConfigApi.update({
seat_hold_duration_minutes: seatHoldMinutes,
booking_payment_window_minutes: bookingPayWindow,
reschedule_payment_window_minutes: reschedulePayWindow,
upgrade_payment_window_minutes: upgradePayWindow,
hold_cutoff_hours_before_departure: holdCutoffHours,
boarding_window_hours_before_departure: boardingWindowHours,
throttle_auth_limit: throttleAuthLimit,
@@ -149,6 +158,44 @@ export default function SettingsPage() {
</div>
</div>
)}
<h3 className="text-lg font-semibold text-foreground">Payment Windows (minutes)</h3>
<p className="text-xs text-muted-foreground -mt-4">
How long a payer has before the request expires and the held seat is released. The
check-in cutoff is still the hard limit a longer window can never allow payment after
boarding closes.
</p>
<div className="max-w-sm space-y-4">
<div className="space-y-2">
<label className="label" htmlFor="booking-pay-window">New booking</label>
<input
id="booking-pay-window"
type="number" min="1" className="input"
value={bookingPayWindow}
onChange={(e) => setBookingPayWindow(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Time to pay for a new booking before it is auto-cancelled. Default: 120.</p>
</div>
<div className="space-y-2">
<label className="label" htmlFor="reschedule-pay-window">Reschedule</label>
<input
id="reschedule-pay-window"
type="number" min="1" className="input"
value={reschedulePayWindow}
onChange={(e) => setReschedulePayWindow(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Time to pay a reschedule charge. Default: 120.</p>
</div>
<div className="space-y-2">
<label className="label" htmlFor="upgrade-pay-window">Fare upgrade</label>
<input
id="upgrade-pay-window"
type="number" min="1" className="input"
value={upgradePayWindow}
onChange={(e) => setUpgradePayWindow(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Time to pay a fare-class upgrade. Default: 120.</p>
</div>
</div>
<h3 className="text-lg font-semibold text-foreground">Seat Booking</h3>
{configLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function UpgradePoliciesLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,26 @@
'use client';
import UpgradePolicyManager from '@/components/upgrade/UpgradePolicyManager';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
/**
* Master Data → Upgrade Policies. One policy per fare class (coach type); a class with no policy
* can be neither upgraded from nor to. Gated on bookings:view because that is what
* `GET /upgrade/policies` requires; creating, editing and deleting are admin-only server-side.
*/
export default function UpgradePoliciesPage() {
return (
<PermissionGuard permission={PERMS.bookings.view}>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Upgrade Policies</h1>
<p className="text-muted-foreground mt-1">
Which fare classes a passenger may move up to before check-in, and what the change costs
</p>
</div>
<UpgradePolicyManager />
</div>
</PermissionGuard>
);
}

View File

@@ -29,6 +29,7 @@ import {
Briefcase,
Calendar,
CalendarClock,
ArrowUpNarrowWide,
Utensils,
Package,
Moon,
@@ -92,6 +93,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{ name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view },
{ name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view },
{ name: 'Reschedule Policies', href: '/reschedule-policies', icon: CalendarClock, permission: PERMS.bookings.view },
{ name: 'Upgrade Policies', href: '/upgrade-policies', icon: ArrowUpNarrowWide, permission: PERMS.bookings.view },
]
},
{

View File

@@ -0,0 +1,363 @@
'use client';
import { useEffect, useState } from 'react';
import { Edit, Plus, Save, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import {
upgradePolicyApi,
type UpgradePolicyCoachType,
type UpgradePolicyRow,
type UpgradePolicyValues,
} from '@/lib/api';
const EMPTY_POLICY: UpgradePolicyValues = {
rank: 0,
feePercent: 0,
feeMinMinor: 0,
feeWaived: false,
isUpgradable: true,
isTargetable: true,
isActive: true,
};
// Money is entered in ETB and stored in minor units.
const etb = (minor: number) => String(minor / 100);
const toMinor = (value: string) => Math.round(Number(value || 0) * 100);
const feeLabel = (p: UpgradePolicyRow) =>
p.feeWaived
? 'Waived'
: p.feePercent > 0 || p.feeMinMinor > 0
? `${p.feePercent}% · min ETB ${etb(p.feeMinMinor)}`
: 'Free';
/**
* Policy US-17 — one upgrade policy per fare class (coach type), listed as a table and edited in
* a dialog, the same shape as Reschedule Policies and Coach Management.
*/
export default function UpgradePolicyManager() {
const [rows, setRows] = useState<UpgradePolicyRow[]>([]);
const [available, setAvailable] = useState<UpgradePolicyCoachType[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState('');
const [showModal, setShowModal] = useState(false);
const [editing, setEditing] = useState<UpgradePolicyRow | null>(null);
const [coachTypeId, setCoachTypeId] = useState('');
const [form, setForm] = useState<UpgradePolicyValues>(EMPTY_POLICY);
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState('');
const [deleting, setDeleting] = useState<UpgradePolicyRow | null>(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const load = async () => {
setLoading(true);
try {
const [policies, coachTypes] = await Promise.all([
upgradePolicyApi.list(),
upgradePolicyApi.availableCoachTypes(),
]);
setRows(Array.isArray(policies) ? policies : []);
setAvailable(Array.isArray(coachTypes) ? coachTypes : []);
} catch {
setMessage('Failed to load upgrade policies.');
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, []);
const openCreate = () => {
setEditing(null);
setCoachTypeId('');
// Suggest the next free rung rather than 0, which would clash with an existing policy.
setForm({ ...EMPTY_POLICY, rank: Math.max(0, ...rows.map((r) => r.rank)) + 1 });
setFormError('');
setShowModal(true);
};
const openEdit = (row: UpgradePolicyRow) => {
setEditing(row);
setCoachTypeId(row.coachTypeId);
setForm({
rank: row.rank,
feePercent: row.feePercent,
feeMinMinor: row.feeMinMinor,
feeWaived: row.feeWaived,
isUpgradable: row.isUpgradable,
isTargetable: row.isTargetable,
isActive: row.isActive,
});
setFormError('');
setShowModal(true);
};
const setField = (patch: Partial<UpgradePolicyValues>) => setForm((f) => ({ ...f, ...patch }));
const submit = async () => {
if (!editing && !coachTypeId) {
setFormError('Pick a fare class.');
return;
}
setSaving(true);
setFormError('');
try {
if (editing) await upgradePolicyApi.update(editing.coachTypeId, form);
else await upgradePolicyApi.create({ coachTypeId, ...form });
setShowModal(false);
setMessage(editing ? 'Policy updated.' : 'Policy created.');
await load();
} catch (err: any) {
setFormError(err?.response?.data?.message || err?.message || 'Failed to save the policy.');
} finally {
setSaving(false);
}
};
const confirmDelete = async () => {
if (!deleting) return;
setDeleteBusy(true);
try {
await upgradePolicyApi.remove(deleting.coachTypeId);
setDeleting(null);
setMessage('Policy deleted.');
await load();
} catch {
setMessage('Failed to delete the policy.');
} finally {
setDeleteBusy(false);
}
};
const columns = [
{
key: 'coachType',
label: 'Fare class',
render: (row: UpgradePolicyRow) => (
<div>
<span className="font-semibold text-foreground">{row.coachType?.code}</span>
<span className="text-muted-foreground"> {row.coachType?.name}</span>
</div>
),
},
{
key: 'rank',
label: 'Rank',
render: (row: UpgradePolicyRow) => <span className="font-mono text-sm">{row.rank}</span>,
},
{
key: 'fee',
label: 'Change fee',
render: (row: UpgradePolicyRow) => <span className="text-sm">{feeLabel(row)}</span>,
},
{
key: 'isUpgradable',
label: 'Upgrade from',
render: (row: UpgradePolicyRow) => (
<span className={`edr-badge ${row.isUpgradable ? 'edr-badge-success' : 'edr-badge-warning'}`}>
{row.isUpgradable ? 'Allowed' : 'No'}
</span>
),
},
{
key: 'isTargetable',
label: 'Upgrade to',
render: (row: UpgradePolicyRow) => (
<span className={`edr-badge ${row.isTargetable ? 'edr-badge-success' : 'edr-badge-warning'}`}>
{row.isTargetable ? 'Allowed' : 'No'}
</span>
),
},
{
key: 'isActive',
label: 'Status',
render: (row: UpgradePolicyRow) => (
<span className={`edr-badge ${row.isActive ? 'edr-badge-success' : 'edr-badge-warning'}`}>
{row.isActive ? 'Active' : 'Disabled'}
</span>
),
},
];
const actions = [
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit },
{
label: 'Delete',
onClick: (row: UpgradePolicyRow) => setDeleting(row),
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<p className="text-xs text-muted-foreground max-w-3xl">
Rank orders the ladder a passenger may only move to a class with a higher rank, on the same train.
Fee = max(fee % × the passenger&apos;s original fare, minimum), read from the class being upgraded
<em> to</em> and charged per upgraded passenger. A fare class with no policy here can be neither
upgraded from nor to.
</p>
<ActionButton icon={Plus} onClick={openCreate} disabled={available.length === 0}>
Add Upgrade Policy
</ActionButton>
</div>
{!loading && available.length === 0 && (
<p className="text-xs text-muted-foreground">Every fare class already has a policy.</p>
)}
{message && <p className="text-sm text-muted-foreground">{message}</p>}
<DataTable
data={rows}
columns={columns}
actions={actions}
loading={loading}
emptyMessage="No upgrade policies yet — add one to allow fare-class upgrades."
/>
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
title={editing ? `Edit Upgrade Policy — ${editing.coachType?.code}` : 'Add Upgrade Policy'}
size="lg"
>
<div className="space-y-4">
<div className="space-y-1">
<label className="label">Fare class</label>
{editing ? (
<>
<input
className="input"
value={`${editing.coachType?.code}${editing.coachType?.name}`}
disabled
/>
<p className="text-xs text-muted-foreground">A policy stays attached to its fare class.</p>
</>
) : (
<select className="input" value={coachTypeId} onChange={(e) => setCoachTypeId(e.target.value)}>
<option value="">Select a fare class...</option>
{available.map((ct) => (
<option key={ct.id} value={ct.id}>
{ct.code} {ct.name}
</option>
))}
</select>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="label">Rank</label>
<input
type="number"
min="0"
className="input"
value={form.rank}
onChange={(e) => setField({ rank: Number(e.target.value) })}
/>
<p className="text-xs text-muted-foreground">Higher beats lower. Must be unique among active policies.</p>
</div>
<div className="space-y-1">
<label className="label">Change fee</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.feeWaived}
onChange={(e) => setField({ feeWaived: e.target.checked })}
/>
Waived charge only the fare difference
</label>
</div>
<div className="space-y-1">
<label className="label">Fee (% of original fare)</label>
<input
type="number"
min="0"
max="100"
className="input"
disabled={form.feeWaived}
value={form.feePercent}
onChange={(e) => setField({ feePercent: Number(e.target.value) })}
/>
</div>
<div className="space-y-1">
<label className="label">Minimum fee (ETB)</label>
<input
type="number"
min="0"
step="0.01"
className="input"
disabled={form.feeWaived}
value={etb(form.feeMinMinor)}
onChange={(e) => setField({ feeMinMinor: toMinor(e.target.value) })}
/>
</div>
<div className="space-y-1">
<label className="label">Upgrade from this class</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.isUpgradable}
onChange={(e) => setField({ isUpgradable: e.target.checked })}
/>
Allowed
</label>
</div>
<div className="space-y-1">
<label className="label">Upgrade to this class</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.isTargetable}
onChange={(e) => setField({ isTargetable: e.target.checked })}
/>
Allowed
</label>
</div>
<div className="space-y-1">
<label className="label">Status</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.isActive}
onChange={(e) => setField({ isActive: e.target.checked })}
/>
Active
</label>
</div>
</div>
{formError && <p className="text-sm text-red-600 dark:text-red-400">{formError}</p>}
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => setShowModal(false)}>
Cancel
</ActionButton>
<ActionButton icon={Save} onClick={submit} loading={saving}>
{editing ? 'Update Policy' : 'Create Policy'}
</ActionButton>
</div>
</div>
</Modal>
<ConfirmDialog
isOpen={!!deleting}
onClose={() => setDeleting(null)}
onConfirm={confirmDelete}
title="Delete upgrade policy"
message={`Delete the upgrade policy for ${deleting?.coachType?.code ?? ''}?`}
warning="Passengers will no longer be able to upgrade out of or into this fare class. Upgrades already applied are unaffected."
confirmText="Delete"
isDanger
isLoading={deleteBusy}
/>
</div>
);
}

View File

@@ -565,6 +565,40 @@ export interface ReschedulePolicyRow extends ReschedulePolicyValues {
coachTypeId: string;
coachType: ReschedulePolicyCoachType;
}
// Fare-class upgrade policy API — one policy per coach type. `rank` orders the ladder; an
// upgrade requires a strictly higher rank. A coach type with no policy can be neither left nor
// entered.
export interface UpgradePolicyValues {
rank: number;
feePercent: number;
feeMinMinor: number;
feeWaived: boolean;
isUpgradable: boolean;
isTargetable: boolean;
isActive: boolean;
}
export interface UpgradePolicyCoachType {
id: string;
code: string;
name: string;
type: string;
}
export interface UpgradePolicyRow extends UpgradePolicyValues {
id: string;
coachTypeId: string;
coachType: UpgradePolicyCoachType;
}
export const upgradePolicyApi = {
list: () => apiClient.get<UpgradePolicyRow[]>('/upgrade/policies'),
availableCoachTypes: () =>
apiClient.get<UpgradePolicyCoachType[]>('/upgrade/policies/available-coach-types'),
create: (data: UpgradePolicyValues & { coachTypeId: string }) =>
apiClient.post<UpgradePolicyRow>('/upgrade/policies', data),
update: (coachTypeId: string, data: Partial<UpgradePolicyValues>) =>
apiClient.patch<UpgradePolicyRow>(`/upgrade/policies/${coachTypeId}`, data),
remove: (coachTypeId: string) => apiClient.delete<any>(`/upgrade/policies/${coachTypeId}`),
};
export const reschedulePolicyApi = {
list: () => apiClient.get<ReschedulePolicyRow[]>('/reschedule/policies'),
availableCoachTypes: () =>

View File

@@ -10,6 +10,7 @@ import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect";
import { useEffect, useState } from "react";
import {
Clock,
ArrowUpCircle,
Users,
CheckCircle2,
AlertCircle,
@@ -351,6 +352,7 @@ function BookingDetailContent() {
const canReschedule = isAuthenticated && (isBooker || !booking.contactPhone);
const reschedulePath = `/booking/reschedule?ref=${booking.bookingRef}`;
const upgradePath = `/booking/upgrade?ref=${booking.bookingRef}`;
const StatusBadge = () => {
const statusConfig = {
@@ -1070,6 +1072,31 @@ function BookingDetailContent() {
{isAuthInitialized && !isAuthenticated ? "Sign in to reschedule" : "Reschedule"}
</button>
)}
{/* Same gating as Reschedule: hidden from a signed-in viewer who did not book
the trip, because the API refuses them; a guest still gets the sign-in
prompt, since signing in as the booker is what unblocks them. Whether any
higher class actually exists on this train is the upgrade page's call. */}
{bookingSupportsReschedule && (!isAuthInitialized || !isAuthenticated || canReschedule) && (
<button
disabled={!isAuthInitialized}
onClick={() =>
router.push(
isAuthenticated
? upgradePath
: `/login?redirect=${encodeURIComponent(upgradePath)}`,
)
}
title={
isAuthenticated
? "Move to a higher fare class on the same train"
: "Upgrading needs an account — sign in to continue"
}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<ArrowUpCircle className="w-4 h-4" />
{isAuthInitialized && !isAuthenticated ? "Sign in to upgrade" : "Upgrade class"}
</button>
)}
</>
)}
</div>

View File

@@ -0,0 +1,571 @@
"use client";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query";
import { format } from "date-fns";
import { AlertCircle, ArrowUpCircle, CheckCircle2, ChevronLeft, Loader2 } from "lucide-react";
import { apiClient } from "@/lib/api-client";
import { useAuthStore } from "@/lib/auth-store";
import SeatMap, { buildSeatLabel, getValidSeatsForCoach } from "@/components/SeatMap";
type Target = {
coachTypeId: string;
code: string;
name: string;
rank: number;
feePercent: number;
feeMinMinor: number;
feeWaived: boolean;
};
type PassengerOption = {
bookingSeatId: string;
passengerName: string;
passengerCategory: string;
seatId: string;
seatLabel: string | null;
coachTypeId: string;
currentFareMinor: number;
targets: Target[];
};
type LegOption = {
leg: number;
scheduleId: string;
originStationId: string | null;
destinationStationId: string | null;
departureAt: string;
checkinCutoffAt: string | null;
checkinMinutes: number | null;
canUpgrade: boolean;
blockers: string[];
passengers: PassengerOption[];
};
type Options = {
bookingRef: string;
bookingType: string;
legs: LegOption[];
pending: { id: string; amountDueMinor: number; paymentToken: string | null; expiresAt: string | null } | null;
};
type QuoteItem = {
bookingSeatId: string;
passengerName: string;
oldSeatLabel: string | null;
newSeatLabel: string | null;
oldFareMinor: number;
newFareMinor: number;
feeMinor: number;
fareDifferenceMinor: number;
};
type Quote = {
allowed: boolean;
blockers: string[];
newCoachTypeCode: string;
items: QuoteItem[];
oldFareMinor: number;
newFareMinor: number;
fareDifferenceMinor: number;
feeMinor: number;
amountDueMinor: number;
};
const etb = (minor: number) => `ETB ${(minor / 100).toFixed(2)}`;
function UpgradePageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const ref = searchParams.get("ref") || "";
// Every endpoint here is behind JwtGuard, so a guest deep-linking would otherwise watch the
// options request 401 and land on a message blaming the booking. Send them to sign in and
// bring them back. Waits for isInitialized: the store starts logged-out.
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const isAuthInitialized = useAuthStore((s) => s.isInitialized);
const needsLogin = isAuthInitialized && !isAuthenticated;
useEffect(() => {
if (!needsLogin) return;
const back = ref ? `/booking/upgrade?ref=${ref}` : "/booking/lookup";
router.replace(`/login?redirect=${encodeURIComponent(back)}`);
}, [needsLogin, ref, router]);
const [legNo, setLegNo] = useState(1);
const [targetCoachTypeId, setTargetCoachTypeId] = useState("");
/** bookingSeatId → chosen seat. Only the passengers in here are upgrading. */
const [picks, setPicks] = useState<Record<string, string>>({});
const [activeBookingSeatId, setActiveBookingSeatId] = useState<string | null>(null);
const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
const [done, setDone] = useState<{ status: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery<Options>({
queryKey: ["upgrade-options", ref],
queryFn: () => apiClient.get<Options>(`/bookings/${ref}/upgrade`),
enabled: !!ref && isAuthInitialized && isAuthenticated,
retry: false,
});
const leg = useMemo(
() => options?.legs.find((l) => l.leg === legNo) ?? options?.legs[0],
[options, legNo],
);
// Every class anyone on this leg could move up to, de-duplicated for the chooser.
const targets = useMemo(() => {
const byId = new Map<string, Target>();
for (const p of leg?.passengers ?? []) for (const t of p.targets) byId.set(t.coachTypeId, t);
return [...byId.values()].sort((a, b) => a.rank - b.rank);
}, [leg]);
const target = targets.find((t) => t.coachTypeId === targetCoachTypeId) ?? null;
const resetSelection = () => {
setPicks({});
setActiveBookingSeatId(null);
setSelectedCoach(null);
setError(null);
};
// Switching leg or target invalidates every seat already picked — they belong to a coach that
// is no longer being shown.
useEffect(() => {
resetSelection();
}, [legNo, targetCoachTypeId]);
const { data: seatMap, isLoading: loadingSeats } = useQuery<any>({
queryKey: ["upgrade-seatmap", leg?.scheduleId, targetCoachTypeId, leg?.originStationId, leg?.destinationStationId],
queryFn: async () => {
const res: any = await apiClient.get(
`/seats/seatmap/${leg!.scheduleId}?coachTypeId=${targetCoachTypeId}` +
`&journeyDirection=${legNo === 2 ? "RETURN" : "ONE_WAY"}` +
`&originStationId=${leg!.originStationId}&destinationStationId=${leg!.destinationStationId}`,
);
return res?.data || res;
},
enabled: !!leg?.scheduleId && !!targetCoachTypeId,
});
const coaches: any[] = useMemo(() => seatMap?.coaches ?? [], [seatMap]);
const autoExpandedFor = useRef<string | null>(null);
useEffect(() => {
if (!targetCoachTypeId || coaches.length === 0) return;
if (autoExpandedFor.current === targetCoachTypeId) return;
autoExpandedFor.current = targetCoachTypeId;
setSelectedCoach(coaches[0].id);
}, [targetCoachTypeId, coaches]);
/** Passengers eligible for the chosen target, in the API's own order. */
const eligible = useMemo(
() => (leg?.passengers ?? []).filter((p) => p.targets.some((t) => t.coachTypeId === targetCoachTypeId)),
[leg, targetCoachTypeId],
);
// Someone must be "active" for a seat click to mean anything. Without this the seat map looks
// fully interactive but every click is a silent no-op until a passenger row is clicked first —
// and on a single-passenger booking there is nothing obvious to click.
useEffect(() => {
if (!targetCoachTypeId || eligible.length === 0) return;
setActiveBookingSeatId((current) => {
if (current && eligible.some((p) => p.bookingSeatId === current)) return current;
return eligible[0].bookingSeatId;
});
}, [targetCoachTypeId, eligible]);
const items = useMemo(
() =>
eligible
.filter((p) => picks[p.bookingSeatId])
.map((p) => ({ bookingSeatId: p.bookingSeatId, newSeatId: picks[p.bookingSeatId] })),
[eligible, picks],
);
const quoteBody = leg && targetCoachTypeId && items.length > 0
? { leg: leg.leg, newCoachTypeId: targetCoachTypeId, items }
: null;
const { data: quote, isFetching: quoting } = useQuery<Quote>({
queryKey: ["upgrade-quote", ref, quoteBody],
queryFn: () => apiClient.post<Quote>(`/bookings/${ref}/upgrade/quote`, quoteBody),
enabled: !!quoteBody,
});
const confirm = useMutation({
mutationFn: async () => {
// Goes through the upgrade module rather than /seats/hold directly: an upgrade holds on
// the SAME schedule the booking already occupies, so a retry collides with the caller's
// own abandoned attempt. The server clears those first, and derives the schedule and
// stations from the booking instead of trusting us.
const hold: any = await apiClient.post(`/bookings/${ref}/upgrade/hold`, {
leg: leg!.leg,
seatIds: items.map((it) => it.newSeatId),
});
return apiClient.post<any>(`/bookings/${ref}/upgrade`, { ...quoteBody, holdId: hold.holdId || hold.id });
},
onSuccess: (res) => {
if (res.paymentToken) router.push(`/pay-balance/${res.paymentToken}`);
else setDone({ status: res.status });
},
onError: (e: any) => setError(e?.response?.data?.message || e?.message || "Could not upgrade"),
});
const seatOwner = (seatId: string) =>
Object.entries(picks).find(([, sid]) => sid === seatId)?.[0] ?? null;
const handleSeatToggle = (seatId: string) => {
const owner = seatOwner(seatId);
if (owner && owner !== activeBookingSeatId) return; // already another passenger's pick
// Fall back to the first passenger still without a seat, so a click is never swallowed.
const forPassenger =
activeBookingSeatId ?? eligible.find((p) => !picks[p.bookingSeatId])?.bookingSeatId;
if (!forPassenger) return;
setPicks((prev) => {
const next = { ...prev };
if (next[forPassenger] === seatId) {
delete next[forPassenger];
return next;
}
next[forPassenger] = seatId;
// Move to the next passenger still without a seat, so a multi-passenger upgrade can be
// filled by clicking straight down the coach — same behaviour as /booking/seats.
const nextUnassigned = eligible.find((p) => p.bookingSeatId !== forPassenger && !next[p.bookingSeatId]);
if (nextUnassigned) setActiveBookingSeatId(nextUnassigned.bookingSeatId);
return next;
});
};
const labelForSeat = (seatId: string) => {
const seat = coaches.flatMap((c: any) => getValidSeatsForCoach(c)).find((s: any) => s.id === seatId);
return seat ? buildSeatLabel(seat) : "";
};
if (!ref) return <Shell><p className="text-gray-600">Missing booking reference.</p></Shell>;
if (!isAuthInitialized || needsLogin) {
return <Shell><Loader2 className="w-6 h-6 animate-spin text-primary" /></Shell>;
}
if (loadingOptions) return <Shell><Loader2 className="w-6 h-6 animate-spin text-primary" /></Shell>;
if (optionsError || !options || !leg) {
return (
<Shell>
<p className="text-red-600">
{(optionsError as any)?.response?.data?.message || "This booking cannot be upgraded."}
</p>
</Shell>
);
}
if (done) {
return (
<Shell>
<div className="text-center space-y-4">
<CheckCircle2 className="w-14 h-14 text-green-600 mx-auto" />
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Upgrade confirmed</h1>
<p className="text-gray-600 dark:text-gray-400">New tickets have been issued for booking {ref}.</p>
<button className="btn-primary" onClick={() => router.push(`/booking/detail?ref=${ref}`)}>
View booking
</button>
</div>
</Shell>
);
}
if (options.pending) {
return (
<Shell>
<div className="space-y-4">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Upgrade awaiting payment</h1>
<p className="text-gray-600 dark:text-gray-400">
An upgrade of {etb(options.pending.amountDueMinor)} is waiting to be paid
{options.pending.expiresAt ? ` before ${format(new Date(options.pending.expiresAt), "dd MMM HH:mm")}` : ""}.
Your new seats are held until then.
</p>
{options.pending.paymentToken && (
<button className="btn-primary" onClick={() => router.push(`/pay-balance/${options.pending!.paymentToken}`)}>
Pay now
</button>
)}
</div>
</Shell>
);
}
const Summary = () => (
<div className="card space-y-3">
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100 pb-2 border-b border-gray-100 dark:border-gray-800">
Upgrade summary
</h2>
<div>
<div className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 mb-1">Journey</div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{format(new Date(leg.departureAt), "EEE dd MMM, HH:mm")}
</div>
{leg.checkinCutoffAt && (
<div className="text-xs text-gray-500 dark:text-gray-400">
Upgrades close {format(new Date(leg.checkinCutoffAt), "dd MMM HH:mm")}
</div>
)}
</div>
{target && (
<div className="pt-3 border-t border-gray-100 dark:border-gray-800">
<div className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 mb-1">Upgrading to</div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{target.code} {target.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Change fee: {target.feeWaived || (target.feePercent === 0 && target.feeMinMinor === 0)
? "none"
: `${target.feePercent}% (min ${etb(target.feeMinMinor)})`}
</div>
</div>
)}
<div className="pt-3 border-t border-gray-100 dark:border-gray-800 space-y-1">
{eligible.map((p) => (
<div key={p.bookingSeatId} className="flex justify-between text-sm">
<span className="text-gray-700 dark:text-gray-300 truncate max-w-[55%]">{p.passengerName}</span>
<span className={picks[p.bookingSeatId] ? "font-semibold text-gray-900 dark:text-gray-100" : "text-gray-400"}>
{picks[p.bookingSeatId]
? `${p.seatLabel ?? "seat"}${labelForSeat(picks[p.bookingSeatId])}`
: "Not upgrading"}
</span>
</div>
))}
</div>
{!quoteBody ? (
<p className="text-xs text-gray-500 dark:text-gray-400 pt-3 border-t border-gray-100 dark:border-gray-800">
Choose a class and a seat for each passenger you want to upgrade.
</p>
) : quoting ? (
<div className="pt-3 border-t border-gray-100 dark:border-gray-800">
<Loader2 className="w-5 h-5 animate-spin text-primary" />
</div>
) : quote ? (
<div className="pt-3 border-t border-gray-100 dark:border-gray-800 space-y-2 text-sm">
<Row label="Current fare" value={etb(quote.oldFareMinor)} />
<Row label="New fare" value={etb(quote.newFareMinor)} />
<Row label="Fare difference" value={etb(Math.max(0, quote.fareDifferenceMinor))} />
<Row label="Change fee" value={etb(quote.feeMinor)} />
<div className="flex justify-between items-center pt-2 border-t border-gray-200 dark:border-gray-700">
<span className="font-bold text-gray-900 dark:text-gray-100">Total due now</span>
<span className="text-xl font-bold text-primary">{etb(quote.amountDueMinor)}</span>
</div>
{quote.blockers.length > 0 && (
<div className="text-red-600 flex gap-2 text-xs">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<div>{quote.blockers.map((b) => <div key={b}>{b}</div>)}</div>
</div>
)}
{error && <div className="text-red-600 text-xs">{error}</div>}
<button
className="btn-primary w-full mt-1"
disabled={!quote.allowed || confirm.isPending}
onClick={() => { setError(null); confirm.mutate(); }}
>
{confirm.isPending ? "Processing..." : `Continue to payment · ${etb(quote.amountDueMinor)}`}
</button>
</div>
) : null}
</div>
);
return (
<Shell wide>
<button
onClick={() => router.push(`/booking/detail?ref=${ref}`)}
className="flex items-center gap-1 text-sm text-gray-500 mb-4"
>
<ChevronLeft className="w-4 h-4" /> Back to booking
</button>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-1">Upgrade {ref}</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
Move to a higher fare class on the same train. Each passenger can be upgraded on their own.
</p>
{options.legs.length > 1 && (
<div className="flex gap-2 mb-6">
{options.legs.map((l) => (
<button
key={l.leg}
onClick={() => setLegNo(l.leg)}
className={`px-4 py-2 rounded-lg border text-sm ${
legNo === l.leg ? "border-primary text-primary" : "border-gray-200 text-gray-600"
}`}
>
{l.leg === 1 ? "Outbound" : "Return"} · {format(new Date(l.departureAt), "dd MMM")}
</button>
))}
</div>
)}
{!leg.canUpgrade && (
<div className="rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 p-4 text-sm text-red-700 mb-6 flex gap-2">
<AlertCircle className="w-5 h-5 shrink-0" />
<div>
{leg.blockers.length
? leg.blockers.map((b) => <div key={b}>{b}</div>)
: <div>No higher fare class is available on this train.</div>}
</div>
</div>
)}
{leg.canUpgrade && (
<div className="lg:grid lg:grid-cols-3 lg:gap-6">
<div className="lg:col-span-2 space-y-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-100 dark:border-gray-700 shadow-sm">
{/* Step 1 — class */}
<h2 className="font-semibold text-gray-900 dark:text-white mb-3">Choose a class</h2>
<div className="grid sm:grid-cols-2 gap-3 mb-6">
{targets.map((t) => (
<button
key={t.coachTypeId}
onClick={() => setTargetCoachTypeId(t.coachTypeId)}
className={`rounded-xl border p-4 text-left ${
targetCoachTypeId === t.coachTypeId
? "border-primary bg-primary/5"
: "border-gray-200 dark:border-gray-700 hover:border-gray-300"
}`}
>
<div className="font-semibold text-gray-900 dark:text-white">{t.name}</div>
<div className="text-xs text-gray-500">
{t.code} ·{" "}
{t.feeWaived || (t.feePercent === 0 && t.feeMinMinor === 0)
? "no change fee"
: `${t.feePercent}% change fee`}
</div>
</button>
))}
</div>
{/* Step 2 — who, and which seat */}
{targetCoachTypeId && (
<>
<h2 className="font-semibold text-gray-900 dark:text-white mb-1">
Who is upgrading? ({items.length}/{eligible.length})
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Pick a passenger, then choose their new seat below. Leave a passenger unselected to keep
their current seat.
</p>
<div className="mb-4 rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800">
{eligible.map((p) => {
const isActive = activeBookingSeatId === p.bookingSeatId;
const picked = picks[p.bookingSeatId];
return (
<button
key={p.bookingSeatId}
type="button"
// Always selects, never clears: with nobody active every seat click is
// silently ignored, which reads as a broken seat map.
onClick={() => setActiveBookingSeatId(p.bookingSeatId)}
className={`w-full flex items-center justify-between py-2 px-3 text-left transition-all ${
isActive ? "bg-[rgb(20,113,76)]/5 dark:bg-[rgb(20,113,76)]/10" : ""
} hover:bg-gray-50 dark:hover:bg-gray-800/60`}
>
<div className="flex items-center gap-2 min-w-0">
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
picked
? "bg-[rgb(20,113,76)] text-white"
: isActive
? "bg-[rgb(20,113,76)]/20 text-[rgb(20,113,76)] ring-2 ring-[rgb(20,113,76)]"
: "bg-gray-200 dark:bg-gray-700 text-gray-500"
}`}
>
<ArrowUpCircle className="w-3.5 h-3.5" />
</div>
<div className="min-w-0">
<span className="text-sm text-gray-700 dark:text-gray-300 truncate block max-w-[160px]">
{p.passengerName}
</span>
<span className="text-[10px] text-gray-400">
now in seat {p.seatLabel ?? "—"}
</span>
</div>
</div>
<span
className={`text-sm font-semibold flex-shrink-0 ${
picked ? "text-[rgb(20,113,76)]" : "text-gray-400"
}`}
>
{picked ? `Seat ${labelForSeat(picked)}` : isActive ? "Pick a seat" : "Not upgrading"}
</span>
</button>
);
})}
</div>
{loadingSeats ? (
<Loader2 className="w-5 h-5 animate-spin text-primary" />
) : (
<SeatMap
coaches={coaches}
selectedCoachId={selectedCoach}
onSelectCoach={setSelectedCoach}
isSeatSelected={(id) => !!activeBookingSeatId && picks[activeBookingSeatId] === id}
isSeatAssignedToOther={(id) => {
const owner = seatOwner(id);
return !!owner && owner !== activeBookingSeatId;
}}
onSeatToggle={handleSeatToggle}
emptyLabel="No seats of that class on this train."
/>
)}
</>
)}
</div>
<div className="lg:hidden">
<Summary />
</div>
</div>
<div className="hidden lg:block">
<div className="sticky top-6 max-h-[calc(100vh-3rem)] overflow-y-auto">
<Summary />
</div>
</div>
</div>
)}
</Shell>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between text-gray-700 dark:text-gray-300">
<span>{label}</span>
<span>{value}</span>
</div>
);
}
function Shell({ children, wide = false }: { children: React.ReactNode; wide?: boolean }) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6">
<div className="container mx-auto px-4">
{wide ? (
<div className="max-w-6xl mx-auto">{children}</div>
) : (
<div className="max-w-3xl mx-auto bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">
{children}
</div>
)}
</div>
</div>
);
}
export default function UpgradePage() {
return (
<Suspense fallback={<Shell><Loader2 className="w-6 h-6 animate-spin text-primary" /></Shell>}>
<UpgradePageContent />
</Suspense>
);
}

View File

@@ -10,6 +10,7 @@ import {
ChevronLeft,
ChevronRight,
Clock,
ArrowUpCircle,
Eye,
CreditCard,
RefreshCw,
@@ -63,33 +64,52 @@ function describeSeats(seats: MyBookingItem['seats'], leg: number) {
interface RowActions {
canReschedule: boolean;
rescheduleBlocker: string | null;
canUpgrade: boolean;
upgradeBlocker: string | null;
isPendingPayment: boolean;
}
/**
* The coarse reschedule gate, mirroring booking/detail/page.tsx. The per-leg rules
* (fare-class policy, cutoff, seats still free) belong to the reschedule page, which
* names them as blockers — this only avoids sending the customer somewhere that is
* certain to reject them. The phone test matches the API's own ownership check
* (reschedule.service.ts loadOwnedBooking), which is phone-based, not account-based.
* The coarse gate for both change actions, mirroring booking/detail/page.tsx. Reschedule and
* upgrade share it because the booking-shape rules and the ownership check are identical — only
* the wording differs, hence the verb.
*
* The per-leg rules (fare-class policy, cutoffs, whether a higher class even runs on this train,
* seats still free) belong to the reschedule and upgrade pages, which name them as blockers. This
* only avoids sending the customer somewhere certain to reject them. The phone test matches the
* API's own ownership check (loadOwnedBooking), which is phone-based, not account-based.
*/
function resolveActions(b: MyBookingItem, userPhone?: string): RowActions {
const isPendingPayment = b.status === 'PENDING_PAYMENT' || b.status === 'DRAFT';
let rescheduleBlocker: string | null = null;
if (b.status !== 'CONFIRMED') rescheduleBlocker = 'Only a confirmed booking can be rescheduled';
else if (b.isPackageBooking) rescheduleBlocker = 'Package bookings cannot be rescheduled online';
else if (!['ONE_WAY', 'ROUND_TRIP'].includes(b.bookingType))
rescheduleBlocker = 'Transit bookings cannot be rescheduled online';
else if (b.outboundBoardedAt) rescheduleBlocker = 'This trip has already been boarded';
// The API applies policy.cutoffMinutes to the old leg's departure, so a departed trip
// is always rejected. Say so here instead of sending them to a page that refuses.
else if (new Date(b.schedule.departureAt).getTime() <= Date.now())
rescheduleBlocker = 'This trip has already departed';
else if (b.contactPhone && !samePhone(userPhone, b.contactPhone))
rescheduleBlocker = 'Only the person who made this booking can reschedule it';
// Both forms are needed: "can be rescheduled" but "can reschedule it".
type Verbs = { past: string; base: string };
const RESCHEDULE: Verbs = { past: 'rescheduled', base: 'reschedule' };
const UPGRADE: Verbs = { past: 'upgraded', base: 'upgrade' };
return { canReschedule: rescheduleBlocker === null, rescheduleBlocker, isPendingPayment };
let reason: ((v: Verbs) => string) | null = null;
if (b.status !== 'CONFIRMED') reason = (v) => `Only a confirmed booking can be ${v.past}`;
else if (b.isPackageBooking) reason = (v) => `Package bookings cannot be ${v.past} online`;
else if (!['ONE_WAY', 'ROUND_TRIP'].includes(b.bookingType))
reason = (v) => `Transit bookings cannot be ${v.past} online`;
else if (b.outboundBoardedAt) reason = () => 'This trip has already been boarded';
// Both APIs apply a cutoff measured against departure, so a departed trip is always rejected.
// Say so here instead of sending them to a page that refuses.
else if (new Date(b.schedule.departureAt).getTime() <= Date.now())
reason = () => 'This trip has already departed';
else if (b.contactPhone && !samePhone(userPhone, b.contactPhone))
reason = (v) => `Only the person who made this booking can ${v.base} it`;
const rescheduleBlocker = reason ? reason(RESCHEDULE) : null;
const upgradeBlocker = reason ? reason(UPGRADE) : null;
return {
canReschedule: rescheduleBlocker === null,
rescheduleBlocker,
canUpgrade: upgradeBlocker === null,
upgradeBlocker,
isPendingPayment,
};
}
/**
@@ -122,6 +142,7 @@ export default function MyBookingsTable() {
const openDetail = (b: MyBookingItem) => router.push(`/booking/detail?ref=${b.bookingRef}`);
const openReschedule = (b: MyBookingItem) => router.push(`/booking/reschedule?ref=${b.bookingRef}`);
const openUpgrade = (b: MyBookingItem) => router.push(`/booking/upgrade?ref=${b.bookingRef}`);
const cardClass =
'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700';
@@ -268,6 +289,20 @@ export default function MyBookingsTable() {
Reschedule
</button>
)}
{!actions.isPendingPayment && (
<button
onClick={() => openUpgrade(b)}
disabled={!actions.canUpgrade}
title={
actions.upgradeBlocker ??
'Move to a higher fare class on the same train'
}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed text-xs font-medium transition-colors whitespace-nowrap"
>
<ArrowUpCircle className="w-3.5 h-3.5" />
Upgrade
</button>
)}
</div>
</td>
</tr>
@@ -344,6 +379,20 @@ export default function MyBookingsTable() {
Reschedule
</button>
)}
{!actions.isPendingPayment && (
<button
onClick={() => openUpgrade(b)}
disabled={!actions.canUpgrade}
title={
actions.upgradeBlocker ??
'Move to a higher fare class on the same train'
}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed text-xs font-medium"
>
<ArrowUpCircle className="w-3.5 h-3.5" />
Upgrade
</button>
)}
</div>
</div>
);