mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 00:08:18 +00:00
feat: (upgrade) implement per-passenger fare class upgrade with configurable policies
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function UpgradePoliciesLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
571
apps/edr-passenger-web/portal/src/app/booking/upgrade/page.tsx
Normal file
571
apps/edr-passenger-web/portal/src/app/booking/upgrade/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user