mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
feat: ( bookings ) add authenticated My Bookings history to the passenger portal
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
|
||||
62
apps/edr-passenger-web/portal/src/app/bookings/page.tsx
Normal file
62
apps/edr-passenger-web/portal/src/app/bookings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
379
apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx
Normal file
379
apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
94
apps/edr-passenger-web/portal/src/lib/api/bookings.ts
Normal file
94
apps/edr-passenger-web/portal/src/lib/api/bookings.ts
Normal 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' };
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user