mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge pull request #550 from Tria-plc/alpha
Fix no train section alignment
This commit is contained in:
@@ -8,7 +8,7 @@ import { usePaymentStore } from '@/lib/payment-store';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { CheckCircle, Copy, Train, FileText } from 'lucide-react';
|
||||
import { CheckCircle, Clock, Copy, Train, FileText } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { isChild, isFirstChild } from '@/utils/fare-utils';
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function ConfirmationPage() {
|
||||
return {
|
||||
id: bookingId || '',
|
||||
pnr: pnr || undefined,
|
||||
status: 'CONFIRMED',
|
||||
status: 'PENDING_PAYMENT',
|
||||
totalMinor: passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0),
|
||||
};
|
||||
}
|
||||
@@ -53,6 +53,10 @@ export default function ConfirmationPage() {
|
||||
enabled: !!bookingId,
|
||||
});
|
||||
|
||||
// Only trust an actually-confirmed booking to show ticket numbers / a "CONFIRMED" badge —
|
||||
// a gateway redirect back here does not mean payment succeeded (see payment return pages).
|
||||
const isConfirmed = _booking?.status === 'CONFIRMED';
|
||||
|
||||
useEffect(() => {
|
||||
if (bookingId && !confirmAttempted.current) {
|
||||
confirmAttempted.current = true;
|
||||
@@ -190,14 +194,33 @@ export default function ConfirmationPage() {
|
||||
{/* Success Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="w-20 h-20 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center animate-bounce">
|
||||
<CheckCircle className="w-12 h-12 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
{isConfirmed ? (
|
||||
<div className="w-20 h-20 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center animate-bounce">
|
||||
<CheckCircle className="w-12 h-12 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center">
|
||||
<Clock className="w-12 h-12 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-green-600 dark:text-green-400 mb-2">
|
||||
{packageName ? `${packageName} booking confirmed!` : 'Booking confirmed!'}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-lg">Your train tickets are ready</p>
|
||||
{isConfirmed ? (
|
||||
<>
|
||||
<h1 className="text-4xl font-bold text-green-600 dark:text-green-400 mb-2">
|
||||
{packageName ? `${packageName} booking confirmed!` : 'Booking confirmed!'}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-lg">Your train tickets are ready</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-4xl font-bold text-amber-600 dark:text-amber-400 mb-2">
|
||||
Booking received — payment pending
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-lg">
|
||||
We haven't confirmed your payment yet. Your tickets will be issued once payment is completed.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PNR Card */}
|
||||
@@ -340,7 +363,9 @@ export default function ConfirmationPage() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Status</p>
|
||||
<p className="font-semibold text-green-600 dark:text-green-400">{_booking?.status || 'CONFIRMED'}</p>
|
||||
<p className={`font-semibold ${isConfirmed ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'}`}>
|
||||
{_booking?.status || 'PENDING_PAYMENT'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Passengers</p>
|
||||
@@ -366,7 +391,9 @@ export default function ConfirmationPage() {
|
||||
<div className="space-y-4">
|
||||
{passengers.map((passenger, index) => {
|
||||
const backendTicket = _booking?.ticket || null;
|
||||
const ticketNumber = backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`;
|
||||
const ticketNumber = isConfirmed
|
||||
? backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={index} className="card hover:shadow-lg transition-shadow">
|
||||
@@ -377,13 +404,17 @@ export default function ConfirmationPage() {
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-gray-100">{passenger.name}</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Passenger {index + 1}</p>
|
||||
</div>
|
||||
<span className="badge badge-success">CONFIRMED</span>
|
||||
{isConfirmed ? (
|
||||
<span className="badge badge-success">CONFIRMED</span>
|
||||
) : (
|
||||
<span className="badge badge-warning">AWAITING PAYMENT</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Ticket Number</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{ticketNumber}</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{ticketNumber || 'Pending payment'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Date of Birth</p>
|
||||
|
||||
@@ -3,15 +3,42 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { usePaymentStore } from '@/lib/payment-store';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
|
||||
import { CheckCircle, Loader2 } from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { CheckCircle, XCircle, Loader2, ChevronLeft } from 'lucide-react';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
type IntentStatus = 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED';
|
||||
type ViewState = 'checking' | 'succeeded' | 'failed' | 'unknown';
|
||||
|
||||
// D-Money redirects the browser to this ONE url regardless of outcome — a hit here is not
|
||||
// proof of payment. Poll the backend (which reconciles with the provider) before showing
|
||||
// "Payment Successful". Real confirmation still happens via the webhook; this only decides
|
||||
// what the browser shows.
|
||||
async function verifyBookingPaid(bookingId: string): Promise<'SUCCEEDED' | 'FAILED' | 'UNKNOWN'> {
|
||||
const maxAttempts = 5;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
const intent = await apiClient.get<{ status: IntentStatus }>(`/payments/intents/${bookingId}`);
|
||||
if (intent?.status === 'SUCCEEDED') return 'SUCCEEDED';
|
||||
if (intent?.status === 'FAILED') return 'FAILED';
|
||||
} catch {
|
||||
// transient lookup failure — keep retrying until attempts are exhausted
|
||||
}
|
||||
if (attempt < maxAttempts - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
}
|
||||
}
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
function DmoneySuccessContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { updateStatus } = usePaymentStore();
|
||||
const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
|
||||
const { bookingId } = useBookingStore();
|
||||
const [view, setView] = useState<ViewState>('checking');
|
||||
const [returnTarget, setReturnTarget] = useState('/booking/confirmation');
|
||||
|
||||
// D-Money callback query params (mirrors Telebirr)
|
||||
@@ -19,30 +46,56 @@ function DmoneySuccessContent() {
|
||||
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
|
||||
|
||||
useEffect(() => {
|
||||
// Actual booking confirmation happens server-side via the provider webhook — this page
|
||||
// only reflects that back to the user. A Manage Booking payment (paying for an
|
||||
// already-existing booking) has no in-progress booking-store session to show a
|
||||
// confirmation from, so it goes back to that booking's detail view instead.
|
||||
let cancelled = false;
|
||||
|
||||
const manageBookingRef = consumeManageBookingPaymentReturn();
|
||||
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
|
||||
setReturnTarget(target);
|
||||
updateStatus('SUCCEEDED');
|
||||
setStatus('done');
|
||||
setTimeout(() => router.push(target), 1500);
|
||||
|
||||
// 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);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
verifyBookingPaid(bookingId).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result === 'SUCCEEDED') {
|
||||
updateStatus('SUCCEEDED');
|
||||
setView('succeeded');
|
||||
setTimeout(() => router.push(target), 1500);
|
||||
} else if (result === 'FAILED') {
|
||||
updateStatus('FAILED');
|
||||
setView('failed');
|
||||
} else {
|
||||
// Still not confirmed after polling — don't claim success or failure. Hand off to
|
||||
// /booking/confirmation, which now reflects the booking's real (pending) status.
|
||||
setView('unknown');
|
||||
setTimeout(() => router.push(target), 1500);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
|
||||
{status === 'processing' && (
|
||||
{view === 'checking' && (
|
||||
<>
|
||||
<Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Confirming payment…</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Please wait while we confirm your D-Money payment.</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'done' && (
|
||||
{view === 'succeeded' && (
|
||||
<>
|
||||
<CheckCircle className="w-14 h-14 text-green-500 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Successful!</h1>
|
||||
@@ -52,15 +105,25 @@ function DmoneySuccessContent() {
|
||||
<p className="text-xs text-gray-400 mt-3">Redirecting…</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
{view === 'failed' && (
|
||||
<>
|
||||
<div className="w-14 h-14 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-3xl">⚠️</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Something went wrong</h1>
|
||||
<p className="text-sm text-red-500 mb-4">Unable to confirm payment</p>
|
||||
<XCircle className="w-14 h-14 text-red-500 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Failed</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">Your D-Money payment was not completed.</p>
|
||||
<button onClick={() => router.push(returnTarget)}
|
||||
className="btn-primary w-full">Continue</button>
|
||||
className="btn-secondary w-full flex items-center justify-center gap-2">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{view === 'unknown' && (
|
||||
<>
|
||||
<Loader2 className="w-14 h-14 text-amber-500 animate-spin mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Still confirming…</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
We haven't received final confirmation from D-Money yet. Taking you to your booking status.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,15 +3,42 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { usePaymentStore } from '@/lib/payment-store';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
|
||||
import { CheckCircle, Loader2 } from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { CheckCircle, XCircle, Loader2, ChevronLeft } from 'lucide-react';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
type IntentStatus = 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED';
|
||||
type ViewState = 'checking' | 'succeeded' | 'failed' | 'unknown';
|
||||
|
||||
// Telebirr redirects the browser to this ONE url regardless of outcome — a hit here is not
|
||||
// proof of payment. Poll the backend (which reconciles with the provider) before showing
|
||||
// "Payment Successful". Real confirmation still happens via the webhook; this only decides
|
||||
// what the browser shows.
|
||||
async function verifyBookingPaid(bookingId: string): Promise<'SUCCEEDED' | 'FAILED' | 'UNKNOWN'> {
|
||||
const maxAttempts = 5;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
const intent = await apiClient.get<{ status: IntentStatus }>(`/payments/intents/${bookingId}`);
|
||||
if (intent?.status === 'SUCCEEDED') return 'SUCCEEDED';
|
||||
if (intent?.status === 'FAILED') return 'FAILED';
|
||||
} catch {
|
||||
// transient lookup failure — keep retrying until attempts are exhausted
|
||||
}
|
||||
if (attempt < maxAttempts - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
}
|
||||
}
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
function TelebirrSuccessContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { updateStatus } = usePaymentStore();
|
||||
const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
|
||||
const { bookingId } = useBookingStore();
|
||||
const [view, setView] = useState<ViewState>('checking');
|
||||
const [returnTarget, setReturnTarget] = useState('/booking/confirmation');
|
||||
|
||||
// Telebirr callback query params
|
||||
@@ -19,30 +46,56 @@ function TelebirrSuccessContent() {
|
||||
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
|
||||
|
||||
useEffect(() => {
|
||||
// Actual booking confirmation happens server-side via the provider webhook — this page
|
||||
// only reflects that back to the user. A Manage Booking payment (paying for an
|
||||
// already-existing booking) has no in-progress booking-store session to show a
|
||||
// confirmation from, so it goes back to that booking's detail view instead.
|
||||
let cancelled = false;
|
||||
|
||||
const manageBookingRef = consumeManageBookingPaymentReturn();
|
||||
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
|
||||
setReturnTarget(target);
|
||||
updateStatus('SUCCEEDED');
|
||||
setStatus('done');
|
||||
setTimeout(() => router.push(target), 1500);
|
||||
|
||||
// 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);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
verifyBookingPaid(bookingId).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result === 'SUCCEEDED') {
|
||||
updateStatus('SUCCEEDED');
|
||||
setView('succeeded');
|
||||
setTimeout(() => router.push(target), 1500);
|
||||
} else if (result === 'FAILED') {
|
||||
updateStatus('FAILED');
|
||||
setView('failed');
|
||||
} else {
|
||||
// Still not confirmed after polling — don't claim success or failure. Hand off to
|
||||
// /booking/confirmation, which now reflects the booking's real (pending) status.
|
||||
setView('unknown');
|
||||
setTimeout(() => router.push(target), 1500);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
|
||||
{status === 'processing' && (
|
||||
{view === 'checking' && (
|
||||
<>
|
||||
<Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Confirming payment…</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Please wait while we confirm your Telebirr payment.</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'done' && (
|
||||
{view === 'succeeded' && (
|
||||
<>
|
||||
<CheckCircle className="w-14 h-14 text-green-500 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Successful!</h1>
|
||||
@@ -52,15 +105,25 @@ function TelebirrSuccessContent() {
|
||||
<p className="text-xs text-gray-400 mt-3">Redirecting…</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
{view === 'failed' && (
|
||||
<>
|
||||
<div className="w-14 h-14 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-3xl">⚠️</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Something went wrong</h1>
|
||||
<p className="text-sm text-red-500 mb-4">Unable to confirm payment</p>
|
||||
<XCircle className="w-14 h-14 text-red-500 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Failed</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">Your Telebirr payment was not completed.</p>
|
||||
<button onClick={() => router.push(returnTarget)}
|
||||
className="btn-primary w-full">Continue</button>
|
||||
className="btn-secondary w-full flex items-center justify-center gap-2">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{view === 'unknown' && (
|
||||
<>
|
||||
<Loader2 className="w-14 h-14 text-amber-500 animate-spin mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Still confirming…</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
We haven't received final confirmation from Telebirr yet. Taking you to your booking status.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,14 +3,41 @@
|
||||
import { useEffect, useState, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { usePaymentStore } from '@/lib/payment-store';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
|
||||
import { CheckCircle, Loader2 } from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { CheckCircle, XCircle, Loader2, ChevronLeft } from 'lucide-react';
|
||||
|
||||
type IntentStatus = 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED';
|
||||
type ViewState = 'checking' | 'succeeded' | 'failed' | 'unknown';
|
||||
|
||||
// Waafi registers a dedicated success URL, but a hit here still isn't proof of payment on
|
||||
// its own (gateway redirect vs. real settlement can disagree). Poll the backend (which
|
||||
// reconciles with the provider) before showing "Payment Successful".
|
||||
async function verifyBookingPaid(bookingId: string): Promise<'SUCCEEDED' | 'FAILED' | 'UNKNOWN'> {
|
||||
const maxAttempts = 5;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
const intent = await apiClient.get<{ status: IntentStatus }>(`/payments/intents/${bookingId}`);
|
||||
if (intent?.status === 'SUCCEEDED') return 'SUCCEEDED';
|
||||
if (intent?.status === 'FAILED') return 'FAILED';
|
||||
} catch {
|
||||
// transient lookup failure — keep retrying until attempts are exhausted
|
||||
}
|
||||
if (attempt < maxAttempts - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
}
|
||||
}
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
function WaafiSuccessContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { updateStatus } = usePaymentStore();
|
||||
const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
|
||||
const { bookingId } = useBookingStore();
|
||||
const [view, setView] = useState<ViewState>('checking');
|
||||
const [returnTarget, setReturnTarget] = useState('/booking/confirmation');
|
||||
|
||||
// Waafi callback query params
|
||||
const referenceId = searchParams.get('referenceId') || '';
|
||||
@@ -19,29 +46,56 @@ function WaafiSuccessContent() {
|
||||
const currency = searchParams.get('currency') || '';
|
||||
|
||||
useEffect(() => {
|
||||
// Actual booking confirmation happens server-side via the provider webhook — this page
|
||||
// only reflects that back to the user. A Manage Booking payment (paying for an
|
||||
// already-existing booking) has no in-progress booking-store session to show a
|
||||
// confirmation from, so it goes back to that booking's detail view instead.
|
||||
let cancelled = false;
|
||||
|
||||
const manageBookingRef = consumeManageBookingPaymentReturn();
|
||||
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
|
||||
updateStatus('SUCCEEDED');
|
||||
setStatus('done');
|
||||
setTimeout(() => router.push(target), 1500);
|
||||
setReturnTarget(target);
|
||||
|
||||
// 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);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
verifyBookingPaid(bookingId).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result === 'SUCCEEDED') {
|
||||
updateStatus('SUCCEEDED');
|
||||
setView('succeeded');
|
||||
setTimeout(() => router.push(target), 1500);
|
||||
} else if (result === 'FAILED') {
|
||||
updateStatus('FAILED');
|
||||
setView('failed');
|
||||
} else {
|
||||
// Still not confirmed after polling — don't claim success or failure. Hand off to
|
||||
// /booking/confirmation, which now reflects the booking's real (pending) status.
|
||||
setView('unknown');
|
||||
setTimeout(() => router.push(target), 1500);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
|
||||
{status === 'processing' && (
|
||||
{view === 'checking' && (
|
||||
<>
|
||||
<Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Confirming payment…</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Please wait while we confirm your Waafi payment.</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'done' && (
|
||||
{view === 'succeeded' && (
|
||||
<>
|
||||
<CheckCircle className="w-14 h-14 text-green-500 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Successful!</h1>
|
||||
@@ -54,6 +108,27 @@ function WaafiSuccessContent() {
|
||||
<p className="text-xs text-gray-400 mt-3">Redirecting…</p>
|
||||
</>
|
||||
)}
|
||||
{view === 'failed' && (
|
||||
<>
|
||||
<XCircle className="w-14 h-14 text-red-500 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Failed</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">Your Waafi payment was not completed.</p>
|
||||
<button onClick={() => router.push(returnTarget)}
|
||||
className="btn-secondary w-full flex items-center justify-center gap-2">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{view === 'unknown' && (
|
||||
<>
|
||||
<Loader2 className="w-14 h-14 text-amber-500 animate-spin mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Still confirming…</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
We haven't received final confirmation from Waafi yet. Taking you to your booking status.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -951,7 +951,7 @@ export default function ResultsPage() {
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="card text-center">
|
||||
<div className="w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<div className="hidden sm:flex w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full items-center justify-center mx-auto mb-6">
|
||||
<Calendar className="w-10 h-10 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">
|
||||
@@ -986,7 +986,7 @@ export default function ResultsPage() {
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="card text-center mb-8 max-w-3xl mx-auto">
|
||||
<div className="w-20 h-20 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<div className="hidden sm:flex w-20 h-20 bg-amber-100 dark:bg-amber-900/30 rounded-full items-center justify-center mx-auto mb-6">
|
||||
<Calendar className="w-10 h-10 text-amber-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">
|
||||
@@ -1035,7 +1035,7 @@ export default function ResultsPage() {
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="card text-center">
|
||||
<div className="w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<div className="hidden sm:flex w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full items-center justify-center mx-auto mb-6">
|
||||
<Calendar className="w-10 h-10 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">
|
||||
@@ -1173,7 +1173,7 @@ export default function ResultsPage() {
|
||||
alternativeOutbound.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="card text-center mb-6 max-w-3xl mx-auto">
|
||||
<div className="w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<div className="hidden sm:flex w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full items-center justify-center mx-auto mb-4">
|
||||
<Calendar className="w-8 h-8 text-amber-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">
|
||||
@@ -1273,7 +1273,7 @@ export default function ResultsPage() {
|
||||
alternativeInbound.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="card text-center mb-6 max-w-3xl mx-auto">
|
||||
<div className="w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<div className="hidden sm:flex w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full items-center justify-center mx-auto mb-4">
|
||||
<Calendar className="w-8 h-8 text-amber-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">
|
||||
|
||||
@@ -52,11 +52,14 @@ const PAGE_MARGIN = 18;
|
||||
// Encodes everything a gate scanner needs to verify this specific ticket without
|
||||
// a network round-trip: booking reference, ticket number, passenger, train, seat(s),
|
||||
// departure time and fare. Kept as compact JSON so any generic QR reader can parse it.
|
||||
// Field names (`ref`/`ticketNumber`) must match what the backoffice boarding scanner and
|
||||
// tickets.service.ts's scanAndBoard() read from the QR payload — see apps/edr-passenger-api/
|
||||
// src/modules/tickets/tickets.service.ts.
|
||||
function buildTicketQrPayload(data: PassengerVoucherData): string {
|
||||
return JSON.stringify({
|
||||
type: 'EDR_TICKET',
|
||||
pnr: data.bookingRef,
|
||||
ticket: data.ticketNumber,
|
||||
ref: data.bookingRef,
|
||||
ticketNumber: data.ticketNumber,
|
||||
passenger: data.passengerName,
|
||||
status: data.status,
|
||||
train: data.outboundSchedule.trainNumber,
|
||||
@@ -165,6 +168,10 @@ function drawTicketHero(doc: jsPDF, bookingRef: string, ticketNumber: string, st
|
||||
const qrSize = 22;
|
||||
const qrPad = 2.5;
|
||||
const cardSize = qrSize + qrPad * 2;
|
||||
const qrGap = 3;
|
||||
// The QR box is anchored to the right edge of the card — reserve that space so the
|
||||
// status pill (also right-anchored) never draws underneath/over it.
|
||||
const qrCardX = pageWidth - margin - cardSize - qrGap;
|
||||
|
||||
doc.setFillColor(...SURFACE);
|
||||
doc.setDrawColor(...HAIRLINE);
|
||||
@@ -172,7 +179,8 @@ function drawTicketHero(doc: jsPDF, bookingRef: string, ticketNumber: string, st
|
||||
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'FD');
|
||||
|
||||
const padX = 7;
|
||||
drawStatusPill(doc, status, pageWidth - margin - padX, y + 5.5, 'right');
|
||||
const pillRightX = qrDataUrl ? qrCardX - qrGap : pageWidth - margin - padX;
|
||||
drawStatusPill(doc, status, pillRightX, y + 5.5, 'right');
|
||||
|
||||
label(doc, 'Booking reference', margin + padX, y + 12);
|
||||
doc.setTextColor(...INK); doc.setFontSize(21); doc.setFont('helvetica', 'bold');
|
||||
@@ -183,13 +191,12 @@ function drawTicketHero(doc: jsPDF, bookingRef: string, ticketNumber: string, st
|
||||
doc.text(ticketNumber, margin + padX + 22, y + 28.7);
|
||||
|
||||
if (qrDataUrl) {
|
||||
const cardX = pageWidth - margin - cardSize - 3;
|
||||
const cardY = y + (cardH - cardSize) / 2;
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.setDrawColor(...HAIRLINE);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.roundedRect(cardX, cardY, cardSize, cardSize, 2, 2, 'FD');
|
||||
doc.addImage(qrDataUrl, 'PNG', cardX + qrPad, cardY + qrPad, qrSize, qrSize);
|
||||
doc.roundedRect(qrCardX, cardY, cardSize, cardSize, 2, 2, 'FD');
|
||||
doc.addImage(qrDataUrl, 'PNG', qrCardX + qrPad, cardY + qrPad, qrSize, qrSize);
|
||||
}
|
||||
|
||||
return y + cardH + 10;
|
||||
|
||||
Reference in New Issue
Block a user