mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
Fixed voucher qr value
This commit is contained in:
@@ -8,7 +8,7 @@ import { usePaymentStore } from '@/lib/payment-store';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useEffect, useState, useRef } from 'react';
|
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 { format } from 'date-fns';
|
||||||
import { isChild, isFirstChild } from '@/utils/fare-utils';
|
import { isChild, isFirstChild } from '@/utils/fare-utils';
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ export default function ConfirmationPage() {
|
|||||||
return {
|
return {
|
||||||
id: bookingId || '',
|
id: bookingId || '',
|
||||||
pnr: pnr || undefined,
|
pnr: pnr || undefined,
|
||||||
status: 'CONFIRMED',
|
status: 'PENDING_PAYMENT',
|
||||||
totalMinor: passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0),
|
totalMinor: passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -53,6 +53,10 @@ export default function ConfirmationPage() {
|
|||||||
enabled: !!bookingId,
|
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(() => {
|
useEffect(() => {
|
||||||
if (bookingId && !confirmAttempted.current) {
|
if (bookingId && !confirmAttempted.current) {
|
||||||
confirmAttempted.current = true;
|
confirmAttempted.current = true;
|
||||||
@@ -190,14 +194,33 @@ export default function ConfirmationPage() {
|
|||||||
{/* Success Header */}
|
{/* Success Header */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<div className="flex justify-center mb-4">
|
<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">
|
{isConfirmed ? (
|
||||||
<CheckCircle className="w-12 h-12 text-green-600 dark:text-green-400" />
|
<div className="w-20 h-20 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center animate-bounce">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
<h1 className="text-4xl font-bold text-green-600 dark:text-green-400 mb-2">
|
{isConfirmed ? (
|
||||||
{packageName ? `${packageName} booking confirmed!` : 'Booking confirmed!'}
|
<>
|
||||||
</h1>
|
<h1 className="text-4xl font-bold text-green-600 dark:text-green-400 mb-2">
|
||||||
<p className="text-gray-600 dark:text-gray-400 text-lg">Your train tickets are ready</p>
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* PNR Card */}
|
{/* PNR Card */}
|
||||||
@@ -340,7 +363,9 @@ export default function ConfirmationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">Status</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">Passengers</p>
|
<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">
|
<div className="space-y-4">
|
||||||
{passengers.map((passenger, index) => {
|
{passengers.map((passenger, index) => {
|
||||||
const backendTicket = _booking?.ticket || null;
|
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 (
|
return (
|
||||||
<div key={index} className="card hover:shadow-lg transition-shadow">
|
<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>
|
<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>
|
<p className="text-sm text-gray-600 dark:text-gray-400">Passenger {index + 1}</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-600 dark:text-gray-400">Ticket Number</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-600 dark:text-gray-400">Date of Birth</p>
|
<p className="text-gray-600 dark:text-gray-400">Date of Birth</p>
|
||||||
|
|||||||
@@ -3,15 +3,42 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { usePaymentStore } from '@/lib/payment-store';
|
import { usePaymentStore } from '@/lib/payment-store';
|
||||||
|
import { useBookingStore } from '@/lib/booking-store';
|
||||||
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
|
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';
|
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() {
|
function DmoneySuccessContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { updateStatus } = usePaymentStore();
|
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');
|
const [returnTarget, setReturnTarget] = useState('/booking/confirmation');
|
||||||
|
|
||||||
// D-Money callback query params (mirrors Telebirr)
|
// D-Money callback query params (mirrors Telebirr)
|
||||||
@@ -19,30 +46,56 @@ function DmoneySuccessContent() {
|
|||||||
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
|
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Actual booking confirmation happens server-side via the provider webhook — this page
|
let cancelled = false;
|
||||||
// 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.
|
|
||||||
const manageBookingRef = consumeManageBookingPaymentReturn();
|
const manageBookingRef = consumeManageBookingPaymentReturn();
|
||||||
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
|
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
|
||||||
setReturnTarget(target);
|
setReturnTarget(target);
|
||||||
updateStatus('SUCCEEDED');
|
|
||||||
setStatus('done');
|
// Manage Booking sessions don't carry a bookingId in the client store — the detail page
|
||||||
setTimeout(() => router.push(target), 1500);
|
// 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
|
<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">
|
<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" />
|
<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>
|
<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>
|
<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" />
|
<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>
|
<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>
|
<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">
|
<XCircle className="w-14 h-14 text-red-500 mx-auto mb-4" />
|
||||||
<span className="text-3xl">⚠️</span>
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Failed</h1>
|
||||||
</div>
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">Your D-Money payment was not completed.</p>
|
||||||
<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>
|
|
||||||
<button onClick={() => router.push(returnTarget)}
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -3,15 +3,42 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { usePaymentStore } from '@/lib/payment-store';
|
import { usePaymentStore } from '@/lib/payment-store';
|
||||||
|
import { useBookingStore } from '@/lib/booking-store';
|
||||||
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
|
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';
|
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() {
|
function TelebirrSuccessContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { updateStatus } = usePaymentStore();
|
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');
|
const [returnTarget, setReturnTarget] = useState('/booking/confirmation');
|
||||||
|
|
||||||
// Telebirr callback query params
|
// Telebirr callback query params
|
||||||
@@ -19,30 +46,56 @@ function TelebirrSuccessContent() {
|
|||||||
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
|
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Actual booking confirmation happens server-side via the provider webhook — this page
|
let cancelled = false;
|
||||||
// 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.
|
|
||||||
const manageBookingRef = consumeManageBookingPaymentReturn();
|
const manageBookingRef = consumeManageBookingPaymentReturn();
|
||||||
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
|
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
|
||||||
setReturnTarget(target);
|
setReturnTarget(target);
|
||||||
updateStatus('SUCCEEDED');
|
|
||||||
setStatus('done');
|
// Manage Booking sessions don't carry a bookingId in the client store — the detail page
|
||||||
setTimeout(() => router.push(target), 1500);
|
// 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
|
<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">
|
<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" />
|
<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>
|
<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>
|
<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" />
|
<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>
|
<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>
|
<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">
|
<XCircle className="w-14 h-14 text-red-500 mx-auto mb-4" />
|
||||||
<span className="text-3xl">⚠️</span>
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Failed</h1>
|
||||||
</div>
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">Your Telebirr payment was not completed.</p>
|
||||||
<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>
|
|
||||||
<button onClick={() => router.push(returnTarget)}
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -3,14 +3,41 @@
|
|||||||
import { useEffect, useState, Suspense } from 'react';
|
import { useEffect, useState, Suspense } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { usePaymentStore } from '@/lib/payment-store';
|
import { usePaymentStore } from '@/lib/payment-store';
|
||||||
|
import { useBookingStore } from '@/lib/booking-store';
|
||||||
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
|
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() {
|
function WaafiSuccessContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { updateStatus } = usePaymentStore();
|
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
|
// Waafi callback query params
|
||||||
const referenceId = searchParams.get('referenceId') || '';
|
const referenceId = searchParams.get('referenceId') || '';
|
||||||
@@ -19,29 +46,56 @@ function WaafiSuccessContent() {
|
|||||||
const currency = searchParams.get('currency') || '';
|
const currency = searchParams.get('currency') || '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Actual booking confirmation happens server-side via the provider webhook — this page
|
let cancelled = false;
|
||||||
// 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.
|
|
||||||
const manageBookingRef = consumeManageBookingPaymentReturn();
|
const manageBookingRef = consumeManageBookingPaymentReturn();
|
||||||
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
|
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
|
||||||
updateStatus('SUCCEEDED');
|
setReturnTarget(target);
|
||||||
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
|
<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">
|
<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" />
|
<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>
|
<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>
|
<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" />
|
<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>
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -52,11 +52,14 @@ const PAGE_MARGIN = 18;
|
|||||||
// Encodes everything a gate scanner needs to verify this specific ticket without
|
// Encodes everything a gate scanner needs to verify this specific ticket without
|
||||||
// a network round-trip: booking reference, ticket number, passenger, train, seat(s),
|
// 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.
|
// 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 {
|
function buildTicketQrPayload(data: PassengerVoucherData): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
type: 'EDR_TICKET',
|
type: 'EDR_TICKET',
|
||||||
pnr: data.bookingRef,
|
ref: data.bookingRef,
|
||||||
ticket: data.ticketNumber,
|
ticketNumber: data.ticketNumber,
|
||||||
passenger: data.passengerName,
|
passenger: data.passengerName,
|
||||||
status: data.status,
|
status: data.status,
|
||||||
train: data.outboundSchedule.trainNumber,
|
train: data.outboundSchedule.trainNumber,
|
||||||
@@ -165,6 +168,10 @@ function drawTicketHero(doc: jsPDF, bookingRef: string, ticketNumber: string, st
|
|||||||
const qrSize = 22;
|
const qrSize = 22;
|
||||||
const qrPad = 2.5;
|
const qrPad = 2.5;
|
||||||
const cardSize = qrSize + qrPad * 2;
|
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.setFillColor(...SURFACE);
|
||||||
doc.setDrawColor(...HAIRLINE);
|
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');
|
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'FD');
|
||||||
|
|
||||||
const padX = 7;
|
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);
|
label(doc, 'Booking reference', margin + padX, y + 12);
|
||||||
doc.setTextColor(...INK); doc.setFontSize(21); doc.setFont('helvetica', 'bold');
|
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);
|
doc.text(ticketNumber, margin + padX + 22, y + 28.7);
|
||||||
|
|
||||||
if (qrDataUrl) {
|
if (qrDataUrl) {
|
||||||
const cardX = pageWidth - margin - cardSize - 3;
|
|
||||||
const cardY = y + (cardH - cardSize) / 2;
|
const cardY = y + (cardH - cardSize) / 2;
|
||||||
doc.setFillColor(255, 255, 255);
|
doc.setFillColor(255, 255, 255);
|
||||||
doc.setDrawColor(...HAIRLINE);
|
doc.setDrawColor(...HAIRLINE);
|
||||||
doc.setLineWidth(0.3);
|
doc.setLineWidth(0.3);
|
||||||
doc.roundedRect(cardX, cardY, cardSize, cardSize, 2, 2, 'FD');
|
doc.roundedRect(qrCardX, cardY, cardSize, cardSize, 2, 2, 'FD');
|
||||||
doc.addImage(qrDataUrl, 'PNG', cardX + qrPad, cardY + qrPad, qrSize, qrSize);
|
doc.addImage(qrDataUrl, 'PNG', qrCardX + qrPad, cardY + qrPad, qrSize, qrSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
return y + cardH + 10;
|
return y + cardH + 10;
|
||||||
|
|||||||
Reference in New Issue
Block a user