diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 37c110329..d9253a93e 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -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 @@ -92,15 +113,20 @@ export default function ConfirmationPage() { 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({ queryKey: ["payment-intent-status", bookingId], queryFn: () => apiClient.get(`/payments/intents/${bookingId}`), enabled: _booking?.status === "PENDING_PAYMENT" && !!bookingId, - refetchInterval: 10_000, + refetchInterval: () => + Date.now() - mountTimeRef.current < CONFIRMATION_GRACE_PERIOD_MS + ? FAST_POLL_INTERVAL_MS + : SLOW_POLL_INTERVAL_MS, }); useEffect(() => { @@ -279,8 +305,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 (