mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 09:42:53 +00:00
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "SeatBlock" ADD COLUMN "scheduleId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SeatBlock_scheduleId_idx" ON "SeatBlock"("scheduleId");
|
||||
@@ -1312,6 +1312,7 @@ model NotificationTemplate {
|
||||
model SeatBlock {
|
||||
id String @id @default(uuid())
|
||||
seatId String
|
||||
scheduleId String?
|
||||
reason String
|
||||
blockedBy String
|
||||
approvedBy String?
|
||||
@@ -1320,6 +1321,7 @@ model SeatBlock {
|
||||
seat Seat @relation(fields: [seatId], references: [id])
|
||||
|
||||
@@index([seatId])
|
||||
@@index([scheduleId])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
|
||||
@@ -432,8 +432,8 @@ export class PaymentsService {
|
||||
expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null,
|
||||
failureCode: snapshot.failureCode ?? null,
|
||||
failureMessage: snapshot.failureMessage ?? null,
|
||||
rawInitiation: snapshot.providerResponse
|
||||
? (snapshot.providerResponse as unknown as Prisma.InputJsonValue)
|
||||
rawInitiation: (snapshot as any).providerResponse
|
||||
? ((snapshot as any).providerResponse as unknown as Prisma.InputJsonValue)
|
||||
: Prisma.DbNull,
|
||||
};
|
||||
return this.prisma.paymentIntent.upsert({
|
||||
|
||||
@@ -211,8 +211,8 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
@ApiOperation({ summary: "Block a seat (e.g., maintenance, damage)" })
|
||||
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
||||
@ApiResponse({ status: 200, description: "Seat blocked" })
|
||||
blockSeat(@Param("seatId") seatId: string, @Body() body: { reason: string }) {
|
||||
return this.service.blockSeat(seatId, body.reason);
|
||||
blockSeat(@Param("seatId") seatId: string, @Body() body: { reason: string; scheduleId?: string }) {
|
||||
return this.service.blockSeat(seatId, body.reason, body.scheduleId);
|
||||
}
|
||||
|
||||
@Delete(":seatId/block")
|
||||
@@ -221,8 +221,8 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
@ApiOperation({ summary: "Unblock a seat" })
|
||||
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
||||
@ApiResponse({ status: 200, description: "Seat unblocked" })
|
||||
unblockSeat(@Param("seatId") seatId: string) {
|
||||
return this.service.unblockSeat(seatId);
|
||||
unblockSeat(@Param("seatId") seatId: string, @Query("scheduleId") scheduleId?: string) {
|
||||
return this.service.unblockSeat(seatId, scheduleId);
|
||||
}
|
||||
|
||||
// ── Maintenance ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -224,7 +224,7 @@ export class SeatsService {
|
||||
}
|
||||
}
|
||||
|
||||
const [availability, persistedSeats] = await Promise.all([
|
||||
const [availability, persistedSeats, scheduleBlocks] = await Promise.all([
|
||||
this.segmentsService.getSeatAvailabilityMap(
|
||||
scheduleId, seatIds, stopTimes, reqFrom, reqTo, journeyDirection || JourneyDirection.ONE_WAY,
|
||||
),
|
||||
@@ -232,16 +232,23 @@ export class SeatsService {
|
||||
where: { id: { in: seatIds } },
|
||||
select: { id: true, status: true },
|
||||
}),
|
||||
this.prisma.seatBlock.findMany({
|
||||
where: { seatId: { in: seatIds }, scheduleId },
|
||||
select: { seatId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const persistedStatus = new Map(persistedSeats.map(s => [s.id, s.status]));
|
||||
const scheduleBlockedIds = new Set(scheduleBlocks.map(b => b.seatId));
|
||||
|
||||
for (const seatId of seatIds) {
|
||||
const persisted = persistedStatus.get(seatId);
|
||||
// BLOCKED and UNDER_MAINTENANCE are cross-schedule flags set by admins —
|
||||
// always honour them regardless of hold/booking state.
|
||||
// Global BLOCKED/UNDER_MAINTENANCE (no scheduleId) — always honour
|
||||
if ((persisted as string) === 'BLOCKED' || (persisted as string) === 'UNDER_MAINTENANCE') {
|
||||
statusMap.set(seatId, persisted!);
|
||||
} else if (scheduleBlockedIds.has(seatId)) {
|
||||
// Schedule-scoped block — only blocked for this schedule
|
||||
statusMap.set(seatId, 'BLOCKED');
|
||||
} else {
|
||||
statusMap.set(seatId, availability.get(seatId) ?? 'AVAILABLE');
|
||||
}
|
||||
@@ -291,15 +298,12 @@ export class SeatsService {
|
||||
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
// Only the raw BLOCKED status (seat pulled out of service — a genuine
|
||||
// cross-schedule flag) is trusted here. BOOKED is intentionally NOT checked
|
||||
// against this raw column: the same physical Seat row is reused across every
|
||||
// recurring date a coach runs, and Seat.status only resets to AVAILABLE via a
|
||||
// trip-completion event that isn't guaranteed to fire, so a stale BOOKED value
|
||||
// here would wrongly block a seat that's actually free for this schedule/leg.
|
||||
// The schedule- and leg-scoped SeatHold/JourneySegment checks below are the
|
||||
// authoritative source for whether a seat is actually taken.
|
||||
const blocked = seats.filter(s => s.status === 'BLOCKED');
|
||||
// Only the raw BLOCKED/UNDER_MAINTENANCE status (seat pulled out of service —
|
||||
// a genuine cross-schedule flag) is checked here. Seat.status is never written
|
||||
// for holds/bookings because coaches are reused across schedules; the
|
||||
// schedule-scoped SeatHold/JourneySegment checks below are the authoritative
|
||||
// source for whether a seat is taken on this specific schedule/leg.
|
||||
const blocked = seats.filter(s => s.status === 'BLOCKED' || (s.status as string) === 'UNDER_MAINTENANCE');
|
||||
if (blocked.length > 0)
|
||||
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`);
|
||||
|
||||
@@ -400,11 +404,6 @@ export class SeatsService {
|
||||
passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })),
|
||||
};
|
||||
|
||||
await tx.seat.updateMany({
|
||||
where: { id: { in: seatIds } },
|
||||
data: { status: 'HELD' },
|
||||
});
|
||||
|
||||
return tx.seatHold.create({
|
||||
data: {
|
||||
scheduleId: dto.scheduleId,
|
||||
@@ -545,13 +544,7 @@ export class SeatsService {
|
||||
async releaseHold(holdId: string) {
|
||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } });
|
||||
if (!hold) throw new NotFoundException('Hold not found');
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.seat.updateMany({
|
||||
where: { id: { in: hold.seatIds as string[] }, status: 'HELD' },
|
||||
data: { status: 'AVAILABLE' },
|
||||
}),
|
||||
this.prisma.seatHold.delete({ where: { id: holdId } }),
|
||||
]);
|
||||
await this.prisma.seatHold.delete({ where: { id: holdId } });
|
||||
return { released: true, holdId };
|
||||
}
|
||||
|
||||
@@ -657,21 +650,41 @@ export class SeatsService {
|
||||
}
|
||||
|
||||
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
select: { originStationId: true, destinationStationId: true },
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const seats = await this.prisma.seat.findMany({
|
||||
where: {
|
||||
coach: { assignments: { some: { scheduleId } } },
|
||||
status: 'AVAILABLE',
|
||||
seatNumber: { not: '' },
|
||||
NOT: { seatNumber: { startsWith: '-' } },
|
||||
NOT: [{ seatNumber: { startsWith: '-' } }, { status: 'BLOCKED' }, { status: 'UNDER_MAINTENANCE' as any }],
|
||||
},
|
||||
orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
|
||||
});
|
||||
|
||||
if (seats.length < count) {
|
||||
throw new ConflictException(`Only ${seats.length} seats available, requested ${count}`);
|
||||
const allSeatIds = seats.map(s => s.id);
|
||||
const stopTimes = await this.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId },
|
||||
select: { stationId: true, sequence: true },
|
||||
});
|
||||
const seqOf = (id: string) => stopTimes.find(s => s.stationId === id)?.sequence;
|
||||
const reqFrom = seqOf(schedule.originStationId) ?? 0;
|
||||
const reqTo = seqOf(schedule.destinationStationId) ?? stopTimes.length;
|
||||
|
||||
const unavailable = await this.segmentsService.getSeatAvailabilityMap(
|
||||
scheduleId, allSeatIds, stopTimes, reqFrom, reqTo,
|
||||
);
|
||||
|
||||
const availableSeats = seats.filter(s => !unavailable.has(s.id));
|
||||
|
||||
if (availableSeats.length < count) {
|
||||
throw new ConflictException(`Only ${availableSeats.length} seats available, requested ${count}`);
|
||||
}
|
||||
|
||||
const assigned = this.findContiguousSeats(seats, count);
|
||||
const assigned = this.findContiguousSeats(availableSeats, count);
|
||||
return assigned.map((s) => s.id);
|
||||
}
|
||||
|
||||
@@ -774,24 +787,34 @@ export class SeatsService {
|
||||
return { imported, errors: errors.slice(0, 10) };
|
||||
}
|
||||
|
||||
async blockSeat(seatId: string, reason: string) {
|
||||
async blockSeat(seatId: string, reason: string, scheduleId?: string) {
|
||||
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||
if (!seat) throw new NotFoundException('Seat not found');
|
||||
|
||||
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } });
|
||||
await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } });
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'BLOCKED', reason } });
|
||||
return { blocked: true, seatId, reason };
|
||||
// Schedule-scoped block: only affects this schedule, not all schedules
|
||||
// Global block (no scheduleId): sets Seat.status = BLOCKED for all schedules
|
||||
if (scheduleId) {
|
||||
await this.prisma.seatBlock.create({ data: { seatId, scheduleId, reason, blockedBy: 'system' } });
|
||||
} else {
|
||||
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } });
|
||||
await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } });
|
||||
}
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'BLOCKED', reason, scheduleId } });
|
||||
return { blocked: true, seatId, reason, scheduleId };
|
||||
}
|
||||
|
||||
async unblockSeat(seatId: string) {
|
||||
async unblockSeat(seatId: string, scheduleId?: string) {
|
||||
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||
if (!seat) throw new NotFoundException('Seat not found');
|
||||
|
||||
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } });
|
||||
await this.prisma.seatBlock.deleteMany({ where: { seatId } });
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'AVAILABLE' } });
|
||||
return { unblocked: true, seatId };
|
||||
if (scheduleId) {
|
||||
await this.prisma.seatBlock.deleteMany({ where: { seatId, scheduleId } });
|
||||
} else {
|
||||
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } });
|
||||
await this.prisma.seatBlock.deleteMany({ where: { seatId, scheduleId: null } });
|
||||
}
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'AVAILABLE', scheduleId } });
|
||||
return { unblocked: true, seatId, scheduleId };
|
||||
}
|
||||
|
||||
async setMaintenance(seatId: string, reason: string) {
|
||||
@@ -929,22 +952,12 @@ export class SeatsService {
|
||||
}
|
||||
}
|
||||
|
||||
if (releasedSeatIds.size > 0) {
|
||||
await this.prisma.seat.updateMany({
|
||||
where: { id: { in: Array.from(releasedSeatIds) }, status: 'HELD' },
|
||||
// heldUntil is cleared alongside status — leaving a stale (past) heldUntil on an
|
||||
// AVAILABLE seat is stale data that any future code reading heldUntil directly
|
||||
// (instead of re-deriving availability live) would misinterpret.
|
||||
data: { status: 'AVAILABLE', heldUntil: null },
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: now } } });
|
||||
|
||||
return {
|
||||
expiredHolds: expired.length,
|
||||
releasedSeatIds: Array.from(releasedSeatIds),
|
||||
skippedSeatIds: Array.from(skippedSeatIds),
|
||||
skippedSeatIds: Array.from(skippedSeatIds), // kept for logging/API compat; no DB writes needed
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,8 @@ export default function SeatsPage() {
|
||||
};
|
||||
|
||||
const blockMutation = useMutation({
|
||||
mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }),
|
||||
mutationFn: ({ seatId, reason }: any) =>
|
||||
seatsApi.block(seatId, { reason, ...(activeTab === 'schedule' && selectedSchedule ? { scheduleId: selectedSchedule } : {}) }),
|
||||
onSuccess: () => {
|
||||
invalidateSeatData();
|
||||
setShowBlockModal(false);
|
||||
@@ -97,7 +98,8 @@ export default function SeatsPage() {
|
||||
});
|
||||
|
||||
const unblockMutation = useMutation({
|
||||
mutationFn: (seatId: string) => seatsApi.unblock(seatId),
|
||||
mutationFn: (seatId: string) =>
|
||||
seatsApi.unblock(seatId, activeTab === 'schedule' ? selectedSchedule : undefined),
|
||||
onSuccess: () => {
|
||||
invalidateSeatData();
|
||||
},
|
||||
@@ -143,7 +145,8 @@ export default function SeatsPage() {
|
||||
mutationFn: async ({ coachId, reason }: any) => {
|
||||
const coachSeats = coaches.find((c: any) => c.id === coachId)?.seats || [];
|
||||
const seatIds = coachSeats.map((s: any) => s.id).filter((id: any) => id);
|
||||
return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason })));
|
||||
const scheduleId = activeTab === 'schedule' ? selectedSchedule : undefined;
|
||||
return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason, ...(scheduleId ? { scheduleId } : {}) })));
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateSeatData();
|
||||
@@ -157,7 +160,8 @@ export default function SeatsPage() {
|
||||
mutationFn: async ({ coachId }: any) => {
|
||||
const coachSeats = coaches.find((c: any) => c.id === coachId)?.seats || [];
|
||||
const seatIds = coachSeats.map((s: any) => s.id).filter((id: any) => id);
|
||||
return Promise.all(seatIds.map((seatId: string) => seatsApi.unblock(seatId)));
|
||||
const scheduleId = activeTab === 'schedule' ? selectedSchedule : undefined;
|
||||
return Promise.all(seatIds.map((seatId: string) => seatsApi.unblock(seatId, scheduleId)));
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateSeatData();
|
||||
|
||||
@@ -154,7 +154,7 @@ export const seatsApi = {
|
||||
hold: (data: any) => apiClient.post<any>('/seats/hold', data),
|
||||
release: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`),
|
||||
block: (seatId: string, data: any) => apiClient.post<any>(`/seats/${seatId}/block`, data),
|
||||
unblock: (seatId: string) => apiClient.delete(`/seats/${seatId}/block`),
|
||||
unblock: (seatId: string, scheduleId?: string) => apiClient.delete(`/seats/${seatId}/block${scheduleId ? `?scheduleId=${scheduleId}` : ''}`),
|
||||
removeSeat: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/remove`, {}),
|
||||
undoRemove: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/undo-remove`, {}),
|
||||
setMaintenance: (seatId: string, reason: string) => apiClient.post<any>(`/seats/${seatId}/maintenance`, { reason }),
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useBookingStore } from "@/lib/booking-store";
|
||||
import { usePaymentStore } from "@/lib/payment-store";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CheckCircle, Clock, Copy, Train, FileText } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { isChild, isFirstChild } from "@/utils/fare-utils";
|
||||
@@ -60,6 +60,27 @@ export default function ConfirmationPage() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
|
||||
|
||||
// Grace period after landing on this page: keep showing the generic "processing"
|
||||
// spinner instead of the "payment pending" screen, and poll the payment intent
|
||||
// frequently — booking.status and paymentIntent.status flip to CONFIRMED/SUCCEEDED
|
||||
// together (see finalizePaymentSuccess in payments.service.ts), so a payment that
|
||||
// already succeeded at the provider often just needs a few more seconds for its
|
||||
// webhook to reach us. Once the grace period elapses, fall back to the normal
|
||||
// pending screen with slower background polling.
|
||||
const CONFIRMATION_GRACE_PERIOD_MS = 10_000;
|
||||
const FAST_POLL_INTERVAL_MS = 2_500;
|
||||
const SLOW_POLL_INTERVAL_MS = 10_000;
|
||||
const mountTimeRef = useRef(Date.now());
|
||||
const [withinGracePeriod, setWithinGracePeriod] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(
|
||||
() => setWithinGracePeriod(false),
|
||||
CONFIRMATION_GRACE_PERIOD_MS,
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Warms the code-split voucher module ahead of the click so the handler's own
|
||||
// `await import(...)` resolves near-instantly — on iOS Safari, a file save triggered
|
||||
// too long after the originating click's synchronous execution window is silently
|
||||
@@ -72,35 +93,42 @@ export default function ConfirmationPage() {
|
||||
data: _booking,
|
||||
refetch: refetchBooking,
|
||||
isLoading: isBookingLoading,
|
||||
isError: isBookingError,
|
||||
} = useQuery<BookingWithTicket>({
|
||||
queryKey: ["booking", bookingId],
|
||||
queryFn: async (): Promise<BookingWithTicket> => {
|
||||
try {
|
||||
return await apiClient.get(`/bookings/${bookingId}`);
|
||||
} catch (error) {
|
||||
return {
|
||||
id: bookingId || "",
|
||||
pnr: pnr || undefined,
|
||||
status: "PENDING_PAYMENT",
|
||||
totalMinor: passengers.reduce(
|
||||
(sum) => sum + (selectedSchedule?.baseFareAdult || 0),
|
||||
0,
|
||||
),
|
||||
};
|
||||
}
|
||||
},
|
||||
// Let a real fetch failure surface as a real error (React Query's global retry:1
|
||||
// default then retries once automatically) instead of silently returning a
|
||||
// fabricated "PENDING_PAYMENT" object — that used to mask genuine failures (a
|
||||
// transient blip right after a cross-domain redirect from the payment gateway is
|
||||
// common) as normal pending state forever, since a caught error that returns data
|
||||
// looks like a success to React Query and never gets retried.
|
||||
queryFn: (): Promise<BookingWithTicket> => apiClient.get(`/bookings/${bookingId}`),
|
||||
// Payment status must never be served from a stale cache — the app-wide default
|
||||
// (providers.tsx) is a 60s staleTime, which would otherwise block React Query's
|
||||
// own refetch-on-window-focus from firing (it only refetches stale data). Without
|
||||
// this override, a tab left open past a payment completing can sit showing
|
||||
// "pending" long after it's actually confirmed, even after being refocused,
|
||||
// until the interval below happens to tick — which browsers throttle heavily in
|
||||
// backgrounded tabs, so that can take a very long time.
|
||||
staleTime: 0,
|
||||
enabled: !!bookingId,
|
||||
});
|
||||
|
||||
// Poll the payment intent every 10 s while the booking is PENDING_PAYMENT.
|
||||
// The backend auto-confirms (and generates tickets) when the payment-api reports
|
||||
// SUCCEEDED, so detecting that here means the booking is now CONFIRMED — refetch
|
||||
// to update the UI without requiring the user to refresh.
|
||||
// Poll the payment intent while the booking is PENDING_PAYMENT — fast during the
|
||||
// grace period (catches a webhook that's just a few seconds behind), then slower
|
||||
// in the background afterward. The backend auto-confirms (and generates tickets)
|
||||
// when the payment-api reports SUCCEEDED, so detecting that here means the
|
||||
// booking is now CONFIRMED — refetch to update the UI without requiring the user
|
||||
// to refresh.
|
||||
const { data: intentStatus } = useQuery<any>({
|
||||
queryKey: ["payment-intent-status", bookingId],
|
||||
queryFn: () => apiClient.get(`/payments/intents/${bookingId}`),
|
||||
enabled: _booking?.status === "PENDING_PAYMENT" && !!bookingId,
|
||||
refetchInterval: 10_000,
|
||||
staleTime: 0,
|
||||
refetchInterval: () =>
|
||||
Date.now() - mountTimeRef.current < CONFIRMATION_GRACE_PERIOD_MS
|
||||
? FAST_POLL_INTERVAL_MS
|
||||
: SLOW_POLL_INTERVAL_MS,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -279,8 +307,11 @@ export default function ConfirmationPage() {
|
||||
// without this, _booking is briefly undefined on first load, isConfirmed reads
|
||||
// as false, and the page flashes "payment pending" before flipping to
|
||||
// "confirmed" once the fetch resolves (common, since the payment webhook has
|
||||
// often already completed by the time the user lands here).
|
||||
if (isBookingLoading) {
|
||||
// often already completed by the time the user lands here). Also keep showing
|
||||
// this same spinner through the grace period above if the booking is still
|
||||
// PENDING_PAYMENT — most of the time the webhook lands within that window, so
|
||||
// the user goes straight to "confirmed" without ever seeing "pending" at all.
|
||||
if (isBookingLoading || (withinGracePeriod && _booking?.status === "PENDING_PAYMENT")) {
|
||||
return (
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
@@ -295,6 +326,30 @@ export default function ConfirmationPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// A real fetch failure (not just "still pending") — surface it honestly instead of
|
||||
// silently pretending the booking is pending, and let the user retry the check
|
||||
// without needing a full page refresh.
|
||||
if (isBookingError) {
|
||||
return (
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto flex flex-col items-center justify-center py-24 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
We're having trouble loading your booking status right
|
||||
now. This is usually temporary — tap below to try again.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetchBooking()}
|
||||
className="btn-primary"
|
||||
>
|
||||
Check again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
|
||||
@@ -55,13 +55,20 @@ function DmoneySuccessContent() {
|
||||
// Manage Booking sessions don't carry a bookingId in the client store — the detail page
|
||||
// it lands on re-fetches the booking's real status itself, so there's nothing to verify
|
||||
// client-side here; just hand off without claiming an outcome we can't confirm.
|
||||
if (!bookingId) {
|
||||
if (!cancelled) {
|
||||
router.push(target);
|
||||
}
|
||||
if (manageBookingRef) {
|
||||
router.push(target);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal booking flow: bookingId comes from a Zustand store persisted to localStorage.
|
||||
// This page is always reached via a real cross-domain redirect from the payment gateway
|
||||
// (a full page load, not an in-app navigation), so that store has to rehydrate from
|
||||
// localStorage asynchronously — bookingId can read as empty on the first render or two.
|
||||
// Wait for it instead of treating an empty first-render value as "nothing to verify",
|
||||
// which would silently skip this page's whole verification step and hand off to
|
||||
// /booking/confirmation without ever having checked payment status here.
|
||||
if (!bookingId) return;
|
||||
|
||||
verifyBookingPaid(bookingId).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result === 'SUCCEEDED') {
|
||||
@@ -82,8 +89,7 @@ function DmoneySuccessContent() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [bookingId, router, updateStatus]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
|
||||
|
||||
@@ -55,13 +55,20 @@ function TelebirrSuccessContent() {
|
||||
// Manage Booking sessions don't carry a bookingId in the client store — the detail page
|
||||
// it lands on re-fetches the booking's real status itself, so there's nothing to verify
|
||||
// client-side here; just hand off without claiming an outcome we can't confirm.
|
||||
if (!bookingId) {
|
||||
if (!cancelled) {
|
||||
router.push(target);
|
||||
}
|
||||
if (manageBookingRef) {
|
||||
router.push(target);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal booking flow: bookingId comes from a Zustand store persisted to localStorage.
|
||||
// This page is always reached via a real cross-domain redirect from the payment gateway
|
||||
// (a full page load, not an in-app navigation), so that store has to rehydrate from
|
||||
// localStorage asynchronously — bookingId can read as empty on the first render or two.
|
||||
// Wait for it instead of treating an empty first-render value as "nothing to verify",
|
||||
// which would silently skip this page's whole verification step and hand off to
|
||||
// /booking/confirmation without ever having checked payment status here.
|
||||
if (!bookingId) return;
|
||||
|
||||
verifyBookingPaid(bookingId).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result === 'SUCCEEDED') {
|
||||
@@ -82,8 +89,7 @@ function TelebirrSuccessContent() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [bookingId, router, updateStatus]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
|
||||
|
||||
@@ -55,13 +55,20 @@ function WaafiSuccessContent() {
|
||||
// Manage Booking sessions don't carry a bookingId in the client store — the detail page
|
||||
// it lands on re-fetches the booking's real status itself, so there's nothing to verify
|
||||
// client-side here; just hand off without claiming an outcome we can't confirm.
|
||||
if (!bookingId) {
|
||||
if (!cancelled) {
|
||||
router.push(target);
|
||||
}
|
||||
if (manageBookingRef) {
|
||||
router.push(target);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal booking flow: bookingId comes from a Zustand store persisted to localStorage.
|
||||
// This page is always reached via a real cross-domain redirect from the payment gateway
|
||||
// (a full page load, not an in-app navigation), so that store has to rehydrate from
|
||||
// localStorage asynchronously — bookingId can read as empty on the first render or two.
|
||||
// Wait for it instead of treating an empty first-render value as "nothing to verify",
|
||||
// which would silently skip this page's whole verification step and hand off to
|
||||
// /booking/confirmation without ever having checked payment status here.
|
||||
if (!bookingId) return;
|
||||
|
||||
verifyBookingPaid(bookingId).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result === 'SUCCEEDED') {
|
||||
@@ -82,8 +89,7 @@ function WaafiSuccessContent() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [bookingId, router, updateStatus]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
|
||||
|
||||
Reference in New Issue
Block a user