feat: ( bookings ) add authenticated My Bookings history to the passenger portal

This commit is contained in:
Abubeker Yasin
2026-08-29 12:28:42 +03:00
parent 1026c3b273
commit 6551660e5f
10 changed files with 645 additions and 130 deletions

View File

@@ -22,7 +22,7 @@ import {
ApiBody,
} from "@nestjs/swagger";
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
import { BookingsService } from "./bookings.service";
import { BookingsService, BookingScope } from "./bookings.service";
import { GuestBookingService } from "./guest-booking.service";
import {
CreateBookingDto,
@@ -38,6 +38,8 @@ import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../comm
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
import { SeatsService } from "../seats/seats.service";
const BOOKING_SCOPES: BookingScope[] = ["upcoming", "past", "cancelled", "all"];
@ApiTags("Booking")
@Controller("bookings")
// @Throttle({ strict: { limit: 20, ttl: 60_000 } })
@@ -66,6 +68,13 @@ export class BookingsController {
required: false,
description: "Filter by booking status",
})
@ApiQuery({
name: "scope",
required: false,
enum: ["upcoming", "past", "cancelled", "all"],
description:
"Which slice of the history to return. 'upcoming' and 'past' split on the schedule's departure and exclude cancelled/refunded bookings; 'cancelled' returns only those. Defaults to 'all'.",
})
@ApiQuery({ name: "page", required: false, description: "Page number" })
@ApiQuery({
name: "pageSize",
@@ -80,6 +89,7 @@ export class BookingsController {
@Req() req: any,
@Query("search") search?: string,
@Query("status") status?: string,
@Query("scope") scope?: BookingScope,
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
) {
@@ -88,6 +98,7 @@ export class BookingsController {
return this.service.findByIamUserId(iamUserId, {
search,
status,
scope: BOOKING_SCOPES.includes(scope as BookingScope) ? scope : "all",
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20,
});

View File

@@ -15,7 +15,7 @@ import { FareEngineService } from '../fare-engine/fare-engine.service';
import { PaymentsService } from '../payments/payments.service';
import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils';
import { normalizePhoneVariants } from '../../common/utils/phone.utils';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
import { BookingStatus, Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
import { JourneyDirection } from '../seats/seats.dto';
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
@@ -67,8 +67,15 @@ interface BookingFilters {
dateTo?: string;
page?: number;
pageSize?: number;
/** Portal "My bookings" tabs. Only honoured by findByPassengerId. */
scope?: BookingScope;
}
export type BookingScope = 'upcoming' | 'past' | 'cancelled' | 'all';
/** Statuses that mean the reservation is off — used by the `cancelled` scope. */
const CLOSED_BOOKING_STATUSES: BookingStatus[] = ['CANCELLED', 'REFUNDED'];
@Injectable()
export class BookingsService {
private readonly logger = new Logger(BookingsService.name);
@@ -87,16 +94,34 @@ export class BookingsService {
) {}
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
const passenger = await this.prisma.passenger.findUniqueOrThrow({ where: { iamUserId }, select: { id: true } });
// An IAM user with no Passenger row is normal, not an error: a freshly registered
// account that has never booked, or a staff account. findUniqueOrThrow raised P2025
// here, which surfaced as a 500 on the portal's "My bookings" page. Empty page instead.
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } });
if (!passenger) {
const page = filters.page ?? 1;
const pageSize = filters.pageSize ?? 20;
return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } };
}
return this.findByPassengerId(passenger.id, filters);
}
/**
* The portal's authenticated "My bookings" history (GET /bookings/my).
*
* `scope` drives the Upcoming / Past / Cancelled tabs server-side so each tab paginates
* correctly, rather than the client filtering one page at a time. Note it filters on
* `schedule.departureAt` — the schedule's own origin departure — while each item's
* displayed `departureAt` comes from resolveBookingSegment, i.e. the passenger's own
* boarding stop. They differ by the run time to that stop; that is close enough for a
* tab filter and avoids a correlated stopTimes query per row.
*/
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
const { search, status, page = 1, pageSize = 20 } = filters;
const { search, status, scope = 'all', page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = { passengerId };
if (search) {
where.OR = [
{ bookingRef: { contains: search, mode: 'insensitive' } },
@@ -104,22 +129,41 @@ export class BookingsService {
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
];
}
if (status) {
// `status` used to be forwarded raw, so an unrecognised value threw a Prisma
// validation error (a 500) rather than being ignored. Only accept real enum members.
if (status && (Object.values(BookingStatus) as string[]).includes(status)) {
where.status = status;
}
const now = new Date();
let orderBy: any = { createdAt: 'desc' };
if (scope === 'cancelled') {
where.status = { in: CLOSED_BOOKING_STATUSES };
} else if (scope === 'upcoming' || scope === 'past') {
// Don't clobber an explicit `status` filter — intersect with it.
if (!where.status) where.status = { notIn: CLOSED_BOOKING_STATUSES };
where.schedule = {
...(where.schedule ?? {}),
departureAt: scope === 'upcoming' ? { gte: now } : { lt: now },
};
orderBy = { schedule: { departureAt: scope === 'upcoming' ? 'asc' : 'desc' } };
}
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
orderBy,
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
paymentIntent: true,
seats: { include: { seat: true } },
seats: { include: { seat: { include: { coach: { select: { number: true } } } } } },
priceTier: { select: { priceMinor: true } },
// A rescheduled booking stays CONFIRMED — there is no RESCHEDULED status — so the
// portal needs this to show a "Rescheduled" chip alongside the real status.
reschedules: { where: { status: 'APPLIED' }, select: { id: true } },
},
}),
this.prisma.booking.count({ where }),
@@ -150,6 +194,21 @@ export class BookingsService {
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
// Seat/coach per passenger, so the history table can show a Seat / Coach column
// without a round trip to GET /bookings/:ref for every row. `leg` disambiguates
// outbound (1) from return (2) on a round trip.
seats: booking.seats.map((bs: any) => ({
leg: bs.leg ?? 1,
passengerName: bs.passengerName,
seatNumber: bs.seat?.seatNumber ?? null,
coachNumber: bs.seat?.coach?.number ?? null,
})),
rescheduled: ((booking as any).reschedules?.length ?? 0) > 0,
// These three let the portal apply the same coarse reschedule gate the booking
// detail page uses, without fetching each booking in full.
outboundBoardedAt: (booking as any).outboundBoardedAt ?? null,
isPackageBooking: !!(booking as any).packageId,
contactPhone: (booking as any).contactPhone ?? null,
};
}),
meta: {

View File

@@ -6,6 +6,7 @@ import { useState } from "react";
import { apiClient } from "@/lib/api-client";
import { format } from "date-fns";
import { toZonedDate } from "@/utils/format";
import { STATUS_LABELS } from "@/lib/api/bookings";
type SearchMode = "pnr" | "phone";
@@ -30,15 +31,6 @@ interface BookingListItem {
seatCount: number;
}
const STATUS_LABELS: Record<string, { label: string; className: string }> = {
CONFIRMED: { label: "Confirmed", className: "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300" },
PENDING_PAYMENT: { label: "Pending Payment", className: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" },
CANCELLED: { label: "Cancelled", className: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300" },
BOARDED: { label: "Boarded", className: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300" },
NO_SHOW: { label: "No Show", className: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300" },
REFUNDED: { label: "Refunded", className: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300" },
};
export default function BookingLookupPage() {
const router = useRouter();
const [mode, setMode] = useState<SearchMode>("pnr");

View File

@@ -0,0 +1,62 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Loader2, Search } from 'lucide-react';
import { useAuthStore } from '@/lib/auth-store';
import MyBookingsTable from '@/components/MyBookingsTable';
/**
* "My Bookings" for a signed-in customer — every booking on the account, without the
* BRN / phone lookup a guest has to go through at /booking/lookup.
*
* The portal's middleware does no auth gating (it only sets the CSP nonce), so pages
* self-check. Same shape as /profile and /booking/reschedule.
*/
export default function MyBookingsPage() {
const router = useRouter();
const { isAuthenticated, isInitialized, initialize } = useAuthStore();
useEffect(() => {
initialize();
}, [initialize]);
useEffect(() => {
if (isInitialized && !isAuthenticated) {
router.push('/login?redirect=/bookings');
}
}, [isInitialized, isAuthenticated, router]);
if (!isInitialized || !isAuthenticated) {
return (
<div className="min-h-[60vh] flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-[rgb(20,113,76)]" />
</div>
);
}
return (
<div className="max-w-6xl mx-auto px-4 py-8">
<div className="flex flex-wrap items-start justify-between gap-3 mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">My Bookings</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Every trip booked on this account.
</p>
</div>
{/* A customer can still hold a booking made under a different phone number as a
guest — that one is only reachable by reference, so keep the door open. */}
<Link
href="/booking/lookup"
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<Search className="w-4 h-4" />
Look up another booking
</Link>
</div>
<MyBookingsTable />
</div>
);
}

View File

@@ -4,32 +4,19 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { useTheme } from '@/components/ThemeProvider';
import {
User, Settings, Ticket, Calendar, MapPin,
Download, Trash2, Lock, Bell, CreditCard,
import {
User, Settings, Ticket,
Download, Trash2, Lock, Bell,
MapPinned, Palette, CheckCircle,
Eye, Edit, LogOut, X
Edit, LogOut, X
} from 'lucide-react';
import { apiClient } from '@/lib/api-client';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import CustomModal from '@/components/CustomModal';
import MyBookingsTable from '@/components/MyBookingsTable';
type Tab = 'bookings' | 'profile' | 'settings';
interface Booking {
id: string;
pnr: string;
status: string;
totalMinor: number;
createdAt: string;
trip?: {
trainNumber: string;
departureAt: string;
origin?: { name: string };
destination?: { name: string };
};
}
export default function ProfilePage() {
const router = useRouter();
const { user, isAuthenticated, logout, initialize, updateUser, isInitialized, fetchProfile } = useAuthStore();
@@ -82,18 +69,6 @@ export default function ProfilePage() {
}
}, [isInitialized, isAuthenticated, user, router, fetchProfile]);
const { data: bookings, isLoading: loadingBookings } = useQuery({
queryKey: ['user-bookings'],
queryFn: async () => {
try {
return await apiClient.get('/bookings/my-bookings');
} catch {
return [];
}
},
enabled: isAuthenticated && activeTab === 'bookings',
});
const updateProfileMutation = useMutation({
mutationFn: (data: any) => apiClient.patch('/auth/profile', data),
onSuccess: (response) => {
@@ -229,16 +204,6 @@ export default function ProfilePage() {
setShowModal(true);
};
const getStatusBadge = (status: string) => {
const styles = {
CONFIRMED: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300',
PENDING: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300',
CANCELLED: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300',
COMPLETED: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300',
};
return styles[status as keyof typeof styles] || styles.PENDING;
};
if (!isInitialized || !user) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
@@ -322,71 +287,11 @@ export default function ProfilePage() {
{activeTab === 'bookings' && (
<div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Bookings</h2>
{loadingBookings ? (
<div className="card text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[rgb(20_113_76)] mx-auto"></div>
<p className="text-gray-600 dark:text-gray-400 mt-4">Loading bookings...</p>
</div>
) : bookings && Array.isArray(bookings) && bookings.length > 0 ? (
bookings.map((booking: Booking) => (
<div key={booking.id} className="card hover:shadow-lg transition-shadow">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<span className={`badge ${getStatusBadge(booking.status)}`}>
{booking.status}
</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
PNR: {booking.pnr}
</span>
</div>
<div className="grid md:grid-cols-3 gap-4 text-sm">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-gray-400" />
<span className="text-gray-600 dark:text-gray-400">
{booking.trip?.departureAt
? new Date(booking.trip.departureAt).toLocaleDateString('en-US', { timeZone: 'Africa/Addis_Ababa' })
: 'N/A'}
</span>
</div>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
<span className="text-gray-600 dark:text-gray-400">
{booking.trip?.origin?.name} {booking.trip?.destination?.name}
</span>
</div>
<div className="flex items-center gap-2">
<CreditCard className="w-4 h-4 text-gray-400" />
<span className="text-gray-900 dark:text-gray-100 font-semibold">
ETB {((booking.totalMinor || 0) / 100).toFixed(2)}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<button
onClick={() => router.push(`/booking/confirmation?id=${booking.id}`)}
className="btn-secondary text-sm flex items-center gap-2"
>
<Eye className="w-4 h-4" />
View
</button>
</div>
</div>
</div>
))
) : (
<div className="card text-center py-12">
<Ticket className="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<p className="text-gray-600 dark:text-gray-400 mb-4">No bookings yet</p>
<button onClick={() => router.push('/booking/search')} className="btn-primary">
Book Your First Trip
</button>
</div>
)}
{/* Same component as /bookings, so the two never drift. It replaces a card
list that called GET /bookings/my-bookings — a route that does not exist
(the real one is GET /bookings/my), whose 404 was swallowed, so this tab
always read "No bookings yet". */}
<MyBookingsTable />
</div>
)}

View File

@@ -39,9 +39,12 @@ const BOOKING_STEP_MAP: Record<string, string> = {
'/booking/confirmation': 'confirmation',
};
const NAV_LINKS = [
// "My Bookings" resolves differently by session: a signed-in customer gets their own
// account history at /bookings, a guest gets the BRN / phone lookup form. Same label
// either way, because it is the same intent.
const navLinks = (isAuthenticated: boolean) => [
{ href: '/', label: 'Home', icon: Home },
{ href: '/booking/lookup', label: 'My Bookings', icon: Ticket },
{ href: isAuthenticated ? '/bookings' : '/booking/lookup', label: 'My Bookings', icon: Ticket },
{ href: '/contact', label: 'Contact', icon: Phone },
{ href: '/help', label: 'Help', icon: HelpCircle },
];
@@ -83,7 +86,7 @@ export default function AppSidebar() {
</Link>
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-1">
{NAV_LINKS.map(({ href, label, icon: Icon }) => {
{navLinks(isAuthenticated).map(({ href, label, icon: Icon }) => {
const isActive = href === '/' ? pathname === '/' : pathname?.startsWith(href);
return (
<Link

View File

@@ -28,7 +28,14 @@ export default function BottomTabBar() {
const tabs = [
{ href: '/', label: 'Home', icon: Home, match: (p: string) => p === '/' },
{ href: '/booking/lookup', label: 'Bookings', icon: Ticket, match: (p: string) => p.startsWith('/booking/lookup') || p.startsWith('/booking/detail') },
// Signed in: straight to the account's own history. Signed out: the guest lookup form.
{
href: isAuthenticated ? '/bookings' : '/booking/lookup',
label: 'Bookings',
icon: Ticket,
match: (p: string) =>
p.startsWith('/bookings') || p.startsWith('/booking/lookup') || p.startsWith('/booking/detail'),
},
{ href: '/contact', label: 'Contact', icon: Phone, match: (p: string) => p.startsWith('/contact') },
{
href: isAuthenticated ? '/profile' : '/login',

View File

@@ -0,0 +1,379 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { format } from 'date-fns';
import {
Ticket,
Loader2,
ChevronLeft,
ChevronRight,
Clock,
Eye,
CreditCard,
RefreshCw,
AlertCircle,
} from 'lucide-react';
import { toZonedDate } from '@/utils/format';
import { samePhone } from '@/utils/phone';
import { useAuthStore } from '@/lib/auth-store';
import {
fetchMyBookings,
statusBadge,
type BookingScope,
type MyBookingItem,
} from '@/lib/api/bookings';
const PAGE_SIZE = 10;
const SCOPES: { id: BookingScope; label: string }[] = [
{ id: 'upcoming', label: 'Upcoming' },
{ id: 'past', label: 'Past' },
{ id: 'cancelled', label: 'Cancelled' },
];
const EMPTY_COPY: Record<BookingScope, string> = {
upcoming: 'No upcoming trips.',
past: 'No past trips yet.',
cancelled: 'No cancelled bookings.',
all: 'No bookings yet.',
};
function formatTravelDate(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
return format(toZonedDate(d), 'dd MMM yyyy, HH:mm');
}
/**
* "CRS-0002 · 12, 14" for one leg. Round trips carry leg-2 seats on the same booking.
* Coach.number is already a full code on this data (e.g. "CRS-0002"), so it is printed
* as-is rather than prefixed.
*/
function describeSeats(seats: MyBookingItem['seats'], leg: number) {
const forLeg = (seats ?? []).filter((s) => (s.leg ?? 1) === leg);
const numbers = forLeg.map((s) => s.seatNumber).filter(Boolean);
if (numbers.length === 0) return null;
const coaches = Array.from(new Set(forLeg.map((s) => s.coachNumber).filter(Boolean)));
const coachLabel = coaches.length > 0 ? `${coaches.join(' / ')} · ` : '';
return `${coachLabel}${numbers.join(', ')}`;
}
interface RowActions {
canReschedule: boolean;
rescheduleBlocker: 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.
*/
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';
else if (b.contactPhone && !samePhone(userPhone, b.contactPhone))
rescheduleBlocker = 'Only the person who made this booking can reschedule it';
return { canReschedule: rescheduleBlocker === null, rescheduleBlocker, isPendingPayment };
}
/**
* The signed-in customer's own booking history, from GET /bookings/my — no BRN or
* phone lookup. Rendered by /bookings and by the /profile Bookings tab, so both stay
* one implementation.
*/
export default function MyBookingsTable() {
const router = useRouter();
const userPhone = useAuthStore((s) => s.user?.phone);
const [scope, setScope] = useState<BookingScope>('upcoming');
const [page, setPage] = useState(1);
const { data, isLoading, isError, refetch, isFetching } = useQuery({
queryKey: ['my-bookings', scope, page],
queryFn: () => fetchMyBookings({ scope, page, pageSize: PAGE_SIZE }),
// Keeps the current rows on screen while a tab or page change is in flight instead
// of collapsing to the spinner and jumping the scroll position.
placeholderData: keepPreviousData,
});
const items = data?.items ?? [];
const meta = data?.meta;
const totalPages = meta?.totalPages ?? 0;
const switchScope = (next: BookingScope) => {
setScope(next);
setPage(1);
};
const openDetail = (b: MyBookingItem) => router.push(`/booking/detail?ref=${b.bookingRef}`);
const openReschedule = (b: MyBookingItem) => router.push(`/booking/reschedule?ref=${b.bookingRef}`);
const cardClass =
'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700';
return (
<div>
{/* Scope tabs */}
<div className="flex flex-wrap items-center gap-2 mb-5">
{SCOPES.map(({ id, label }) => (
<button
key={id}
onClick={() => switchScope(id)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
scope === id
? 'bg-[rgb(20,113,76)] text-white'
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
{label}
</button>
))}
{isFetching && !isLoading && (
<Loader2 className="w-4 h-4 animate-spin text-gray-400" aria-hidden="true" />
)}
</div>
{isLoading ? (
<div className={`${cardClass} py-16 text-center`}>
<Loader2 className="w-8 h-8 animate-spin text-[rgb(20,113,76)] mx-auto" />
<p className="text-gray-600 dark:text-gray-400 mt-3 text-sm">Loading your bookings</p>
</div>
) : isError ? (
<div className={`${cardClass} py-16 text-center`}>
<AlertCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
<p className="text-gray-700 dark:text-gray-300 mb-4 text-sm">
We could not load your bookings just now.
</p>
<button
onClick={() => refetch()}
className="inline-flex items-center gap-2 px-4 py-2 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 text-sm font-medium"
>
<RefreshCw className="w-4 h-4" />
Try again
</button>
</div>
) : items.length === 0 ? (
<div className={`${cardClass} py-16 text-center`}>
<Ticket className="w-14 h-14 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<p className="text-gray-600 dark:text-gray-400 mb-4">{EMPTY_COPY[scope]}</p>
<button
onClick={() => router.push('/booking/search')}
className="px-4 py-2 rounded-lg bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white text-sm font-medium transition-colors"
>
Book a trip
</button>
</div>
) : (
<>
{/* Desktop: the tabular view */}
<div className={`hidden lg:block ${cardClass} overflow-x-auto`}>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 text-left text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
<th scope="col" className="px-4 py-3 font-semibold">Booking Ref</th>
<th scope="col" className="px-4 py-3 font-semibold">Travel date &amp; time</th>
<th scope="col" className="px-4 py-3 font-semibold">Route</th>
<th scope="col" className="px-4 py-3 font-semibold">Seat / Coach</th>
<th scope="col" className="px-4 py-3 font-semibold">Status</th>
<th scope="col" className="px-4 py-3 font-semibold text-right">Actions</th>
</tr>
</thead>
<tbody>
{items.map((b) => {
const badge = statusBadge(b.status);
const actions = resolveActions(b, userPhone);
const outbound = describeSeats(b.seats, 1);
const inbound = describeSeats(b.seats, 2);
return (
<tr
key={b.id}
className="border-b border-gray-100 dark:border-gray-700/60 last:border-0 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors"
>
<td className="px-4 py-3 font-mono font-semibold tracking-wider text-gray-900 dark:text-white whitespace-nowrap">
{b.bookingRef}
</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">
{formatTravelDate(b.schedule.departureAt)}
</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
{b.schedule.originStation.name} {b.schedule.destinationStation.name}
{b.bookingType === 'ROUND_TRIP' && (
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
(round trip)
</span>
)}
</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">
{outbound ?? <span className="text-gray-400"></span>}
{inbound && (
<span className="block text-xs text-gray-500 dark:text-gray-400">
Return: {inbound}
</span>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${badge.className}`}>
{badge.label}
</span>
{b.rescheduled && (
<span className="ml-1.5 text-xs px-2 py-0.5 rounded-full font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300">
Rescheduled
</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => openDetail(b)}
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 text-xs font-medium transition-colors whitespace-nowrap"
>
{actions.isPendingPayment ? (
<>
<CreditCard className="w-3.5 h-3.5" />
Complete payment
</>
) : (
<>
<Eye className="w-3.5 h-3.5" />
View ticket
</>
)}
</button>
{!actions.isPendingPayment && (
<button
onClick={() => openReschedule(b)}
disabled={!actions.canReschedule}
title={
actions.rescheduleBlocker ??
'Change the date, train or seats on this booking'
}
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"
>
<Clock className="w-3.5 h-3.5" />
Reschedule
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Mobile: the same rows as cards — the portal's established list pattern */}
<div className="lg:hidden space-y-3">
{items.map((b) => {
const badge = statusBadge(b.status);
const actions = resolveActions(b, userPhone);
const outbound = describeSeats(b.seats, 1);
const inbound = describeSeats(b.seats, 2);
return (
<div key={b.id} className={`${cardClass} p-4`}>
<div className="flex items-start justify-between gap-2 mb-2">
<span className="font-mono font-bold tracking-wider text-gray-900 dark:text-white">
{b.bookingRef}
</span>
<div className="flex flex-wrap justify-end gap-1">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${badge.className}`}>
{badge.label}
</span>
{b.rescheduled && (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300">
Rescheduled
</span>
)}
</div>
</div>
<p className="text-sm text-gray-700 dark:text-gray-300">
{b.schedule.originStation.name} {b.schedule.destinationStation.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{formatTravelDate(b.schedule.departureAt)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Seat / Coach: {outbound ?? '—'}
{inbound && ` · Return: ${inbound}`}
</p>
<div className="flex flex-wrap gap-2 mt-3">
<button
onClick={() => openDetail(b)}
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 text-xs font-medium"
>
{actions.isPendingPayment ? (
<>
<CreditCard className="w-3.5 h-3.5" />
Complete payment
</>
) : (
<>
<Eye className="w-3.5 h-3.5" />
View ticket
</>
)}
</button>
{!actions.isPendingPayment && (
<button
onClick={() => openReschedule(b)}
disabled={!actions.canReschedule}
title={
actions.rescheduleBlocker ??
'Change the date, train or seats on this booking'
}
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"
>
<Clock className="w-3.5 h-3.5" />
Reschedule
</button>
)}
</div>
</div>
);
})}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between gap-3 mt-4">
<p className="text-xs text-gray-500 dark:text-gray-400">
Page {meta?.page ?? page} of {totalPages} · {meta?.total ?? items.length} booking
{(meta?.total ?? items.length) !== 1 ? 's' : ''}
</p>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="inline-flex items-center gap-1 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"
>
<ChevronLeft className="w-4 h-4" />
Previous
</button>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="inline-flex items-center gap-1 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"
>
Next
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,94 @@
import { apiClient } from '@/lib/api-client';
/**
* The authenticated booking history: GET /bookings/my (JwtGuard). This is the
* account-linked list — no BRN or phone lookup — as opposed to the public
* /bookings/by-phone and /bookings/:bookingRef used by the guest lookup page.
*/
export type BookingScope = 'upcoming' | 'past' | 'cancelled' | 'all';
export interface MyBookingSeat {
leg: number;
passengerName: string;
seatNumber: string | null;
coachNumber: string | null;
}
export interface MyBookingItem {
id: string;
bookingRef: string;
status: string;
totalMinor: number;
currency?: string | null;
displayCurrency?: string | null;
displayTotalMinor?: number | null;
adultCount: number;
childCount: number;
bookingType: string;
returnLegStatus?: string | null;
createdAt: string;
schedule: {
train?: { number?: string; name?: string } | null;
originStation: { id?: string; name: string; code?: string; city?: string };
destinationStation: { id?: string; name: string; code?: string; city?: string };
departureAt: string;
arrivalAt?: string | null;
};
paymentIntent?: { method?: string; status?: string } | null;
seatCount: number;
seats: MyBookingSeat[];
rescheduled: boolean;
outboundBoardedAt: string | null;
isPackageBooking: boolean;
contactPhone: string | null;
}
export interface MyBookingsResponse {
items: MyBookingItem[];
meta: { page: number; pageSize: number; total: number; totalPages: number };
}
const EMPTY_META = { page: 1, pageSize: 20, total: 0, totalPages: 0 };
export async function fetchMyBookings(params: {
scope?: BookingScope;
page?: number;
pageSize?: number;
search?: string;
}): Promise<MyBookingsResponse> {
const query = new URLSearchParams();
if (params.scope) query.set('scope', params.scope);
if (params.page) query.set('page', String(params.page));
if (params.pageSize) query.set('pageSize', String(params.pageSize));
if (params.search?.trim()) query.set('search', params.search.trim());
// apiClient.get already unwraps `response.data?.data || response.data`, but the API
// has been seen to return both shapes for list endpoints — mirror the defensive read
// the guest lookup page uses for /bookings/by-phone.
const resp: any = await apiClient.get(`/bookings/my?${query.toString()}`);
return {
items: resp?.items ?? resp?.data?.items ?? [],
meta: resp?.meta ?? resp?.data?.meta ?? { ...EMPTY_META, pageSize: params.pageSize ?? 20 },
};
}
/**
* Badge presentation for `BookingStatus`. The enum has no COMPLETED and no
* RESCHEDULED member (schema.prisma) — BOARDED is what "travelled" looks like, and a
* rescheduled booking stays CONFIRMED, so that is shown as a separate chip.
* Shared with the guest lookup page so the two lists cannot drift.
*/
export const STATUS_LABELS: Record<string, { label: string; className: string }> = {
CONFIRMED: { label: 'Confirmed', className: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300' },
PENDING_PAYMENT: { label: 'Pending Payment', className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300' },
DRAFT: { label: 'Draft', className: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' },
CANCELLED: { label: 'Cancelled', className: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300' },
BOARDED: { label: 'Completed', className: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300' },
NO_SHOW: { label: 'No Show', className: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' },
REFUNDED: { label: 'Refunded', className: 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300' },
};
export function statusBadge(status: string) {
return STATUS_LABELS[status] ?? { label: status, className: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' };
}

View File

@@ -3,6 +3,8 @@ import { NextRequest, NextResponse } from 'next/server';
// Routes that should NOT redirect to home on hard refresh
const PRESERVED_ROUTES = [
'/booking/',
// Note the trailing slash above: '/booking/' does not match '/bookings'.
'/bookings',
'/login',
'/register',
'/forgot-password',
@@ -117,6 +119,7 @@ export function middleware(request: NextRequest) {
// Tell crawlers not to index private/transactional routes.
const NOINDEX_PREFIXES = [
'/booking/',
'/bookings',
'/login',
'/register',
'/forgot-password',