Update booking amount currency converstion

This commit is contained in:
Roba Boru
2026-07-07 16:52:58 +03:00
parent fb4f571a6c
commit fb1d510bcf
11 changed files with 516 additions and 243 deletions

View File

@@ -5,28 +5,46 @@ import { useSearchParams, useRouter } from 'next/navigation';
import { useQuery, useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { useState } from 'react';
import {
Clock,
Users,
CheckCircle2,
import {
Clock,
Users,
CheckCircle2,
AlertCircle,
Download,
Share2,
Copy,
Check,
CreditCard,
Wallet
Wallet,
Smartphone,
Loader2,
ChevronLeft,
} from 'lucide-react';
import { format } from 'date-fns';
import { formatTime, getTimePeriod } from '@/utils/format';
import { markManageBookingPaymentReturn, consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
import QRCode from 'qrcode.react';
// Same convention as /booking/payment — payment methods are ETB-settled by default;
// a method only needs a currency conversion when its own currency differs.
const displayCurrency = 'ETB' as const;
const getIconForMethod = (methodType: string) => {
if (methodType.includes('CARD')) return CreditCard;
if (methodType.includes('WALLET')) return Wallet;
return Smartphone;
};
function BookingDetailContent() {
const router = useRouter();
const searchParams = useSearchParams();
const bookingRef = searchParams.get('ref') || searchParams.get('bookingRef') || searchParams.get('pnr');
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>('');
// Mirrors /booking/payment's state shape: selectedMethod is the PaymentMethod `type`
// (used both for lookup and to decide provider-specific redirect handling), not the id.
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null);
const [paymentError, setPaymentError] = useState<string | null>(null);
const [copiedPNR, setCopiedPNR] = useState(false);
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
@@ -42,40 +60,84 @@ function BookingDetailContent() {
retry: 1,
});
const { data: paymentMethods } = useQuery({
const { data: paymentMethods } = useQuery<any[]>({
queryKey: ['payment-methods'],
queryFn: () => apiClient.get('/payments/methods'),
enabled: booking?.status === 'PENDING_PAYMENT' || booking?.status === 'DRAFT',
});
const paymentMutation = useMutation({
mutationFn: async (paymentData: any) => {
const response = await apiClient.post('/payments/intent', paymentData);
const selectedPaymentMethod = (paymentMethods || []).find((m: any) => m.type === selectedMethod) || null;
// Same conversion logic as /booking/payment: only hit the booking-amount-changer API
// when the selected method actually settles in a different currency than ETB.
const isConversionNeeded = !!selectedMethodCurrency && selectedMethodCurrency !== displayCurrency;
const amountCurrency = isConversionNeeded ? selectedMethodCurrency! : displayCurrency;
const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({
queryKey: ['bookingAmount', booking?.id, amountCurrency],
queryFn: async () => {
const url = `/payments/booking-amount?bookingId=${booking?.id}&currency=${amountCurrency}`;
const response: any = await apiClient.get(url);
return response;
},
onSuccess: async (data: any) => {
await apiClient.patch(`/bookings/${booking?.id}/confirm`, {
paymentIntentId: data.id,
paymentMethod: selectedPaymentMethod,
enabled: !!booking?.id && isConversionNeeded,
});
const totalAmountDisplay = isConversionNeeded
? (bookingAmountData != null ? bookingAmountData.amount : null)
: ((booking?.totalMinor ?? 0) / 100);
const confirmedCurrency = isConversionNeeded ? (bookingAmountData?.currency || amountCurrency) : displayCurrency;
const awaitingAmount = isConversionNeeded && loadingAmount && totalAmountDisplay === null;
const paymentMutation = useMutation({
mutationFn: async (data: any) => {
return await apiClient.post('/payments/initiate', {
bookingId: data.bookingId,
method: data.method,
paymentMethodId: data.paymentMethodId,
platform: 'web',
});
refetch();
},
onSuccess: async (data: any) => {
setPaymentError(null);
if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI') && data?.clientAction?.type === 'REDIRECT') {
window.location.href = data.clientAction.url;
return;
}
// No redirect needed (e.g. WALLET) — the marker set in handlePayment is now moot.
consumeManageBookingPaymentReturn();
await refetch();
},
onError: (error: any) => {
alert(error?.response?.data?.message || 'Payment failed. Please try again.');
setPaymentError(
error?.response?.data?.message ||
error?.message ||
'Payment failed. Please try again.',
);
},
});
const handlePayment = () => {
if (!selectedPaymentMethod) {
alert('Please select a payment method');
if (!selectedMethod || !booking?.id) {
setPaymentError('Please select a payment method');
return;
}
if (!selectedPaymentMethod) {
setPaymentError('Invalid payment method selected');
return;
}
setPaymentError(null);
// Flag this as a Manage Booking payment so the gateway's success/failure return page
// sends the user back here instead of the new-booking confirmation flow.
markManageBookingPaymentReturn(booking.bookingRef);
paymentMutation.mutate({
bookingId: booking?.id,
amount: booking?.totalMinor || 0,
currency: booking?.currency || 'ETB',
paymentMethodId: selectedPaymentMethod,
bookingId: booking.id,
method: selectedMethod,
paymentMethodId: selectedPaymentMethod.id,
});
};
@@ -163,38 +225,115 @@ function BookingDetailContent() {
);
};
// Order summary card — mirrors /booking/payment's OrderSummary: fare breakdown per
// passenger, Total with a loading spinner while a currency conversion is in flight, and
// a note confirming what will actually be charged once a payment method is selected.
const OrderSummary = () => (
<div className="card space-y-4">
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100 pb-2 border-b border-gray-100 dark:border-gray-800">
Order summary
<span className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
Ref: <span className="font-bold text-gray-900 dark:text-gray-100">{booking.bookingRef}</span>
</span>
</h2>
<div className="space-y-2">
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">Fare breakdown</h3>
{(booking.passengers || []).map((passenger: any, idx: number) => (
<div key={idx} className="flex justify-between border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[60%]">
{passenger.fullName || `Passenger ${idx + 1}`}
{passenger.category === 'CHILD' && (
<span className="text-xs font-semibold ml-1 text-blue-600">(CHILD)</span>
)}
</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{displayCurrency} {((passenger.fareMinor ?? 0) / 100).toFixed(2)}
</span>
</div>
))}
</div>
<div className="pt-2 border-t-2 border-gray-200 dark:border-gray-700">
<div className="flex justify-between items-center">
<span className="font-bold text-gray-900 dark:text-gray-100">Total</span>
<span className="text-xl font-bold text-primary flex items-center gap-1.5">
{awaitingAmount ? (
<Loader2 className="w-4 h-4 animate-spin text-primary" />
) : (
<>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}</>
)}
</span>
</div>
{selectedPaymentMethod && !awaitingAmount && (
<p className="text-xs text-gray-500 dark:text-gray-400 text-right mt-1">
You will be charged {confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)} via {selectedPaymentMethod.displayName}
</p>
)}
</div>
{/* Pay + back buttons — desktop sidebar only */}
<div className="hidden lg:flex flex-col gap-2 pt-1">
{paymentError && (
<p className="text-red-600 dark:text-red-400 text-xs"> {paymentError}</p>
)}
<button
onClick={handlePayment}
disabled={!selectedMethod || paymentMutation.isPending || awaitingAmount}
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
>
{paymentMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
</span>
) : awaitingAmount ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Calculating amount...
</span>
) : (
`Pay ${confirmedCurrency} ${(totalAmountDisplay ?? 0).toFixed(2)}`
)}
</button>
<button onClick={() => router.push('/booking/lookup')} disabled={paymentMutation.isPending} className="btn-secondary w-full flex items-center justify-center gap-2">
<ChevronLeft className="w-4 h-4" />
Back
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 text-center pt-1">
🔒 Secure & encrypted payment
</p>
</div>
</div>
);
if (isPendingPayment && !isExpired) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6">
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6 pb-28 lg:pb-10">
<div className="container mx-auto px-4">
<div className="max-w-5xl mx-auto">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 mb-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Complete Payment</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Booking Reference: <span className="font-mono font-semibold">{booking.bookingRef}</span>
</p>
</div>
<StatusBadge />
</div>
{booking.createdAt && (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3 flex items-center gap-2">
<Clock className="w-5 h-5 text-amber-600 dark:text-amber-400" />
<span className="text-sm text-amber-800 dark:text-amber-300">
<div className="max-w-6xl mx-auto">
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">Complete payment</h1>
<div className="card mb-4 flex items-start justify-between gap-3">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">
Booking Reference: <span className="font-mono font-semibold text-gray-900 dark:text-gray-100">{booking.bookingRef}</span>
</p>
{booking.createdAt && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
Booking created on {format(new Date(booking.createdAt), 'PPpp')}
</span>
</div>
)}
</p>
)}
</div>
<StatusBadge />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">
{/* Two-column grid — matches /booking/payment's layout */}
<div className="lg:grid lg:grid-cols-3 lg:gap-6 lg:items-start">
{/* Left column — trip/payment method (2/3 width) */}
<div className="lg:col-span-2 space-y-4">
<div className="card">
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Trip Summary</h2>
<div className="flex items-center gap-2 mb-4">
@@ -302,43 +441,40 @@ function BookingDetailContent() {
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Select Payment Method</h2>
<div className="card">
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Select payment method</h2>
{paymentMethods && Array.isArray(paymentMethods) && paymentMethods.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{paymentMethods.map((method: any) => (
<button
key={method.id}
onClick={() => setSelectedPaymentMethod(method.id)}
className={`p-4 rounded-xl border-2 text-left transition-all ${
selectedPaymentMethod === method.id
? 'border-primary bg-primary/5 dark:bg-primary/10'
: 'border-gray-200 dark:border-gray-700 hover:border-primary/50'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
selectedPaymentMethod === method.id
? 'bg-primary/20 dark:bg-primary/30'
: 'bg-gray-100 dark:bg-gray-700'
}`}>
{method.type === 'WALLET' ? (
<Wallet className={`w-5 h-5 ${selectedPaymentMethod === method.id ? 'text-primary' : 'text-gray-600 dark:text-gray-400'}`} />
) : (
<CreditCard className={`w-5 h-5 ${selectedPaymentMethod === method.id ? 'text-primary' : 'text-gray-600 dark:text-gray-400'}`} />
<div className="space-y-3">
{paymentMethods.map((method: any) => {
const Icon = getIconForMethod(method.type);
const isSelected = selectedMethod === method.type;
return (
<button
key={method.id}
onClick={() => { setSelectedMethod(method.type); setSelectedMethodCurrency(method.currency ?? null); }}
disabled={paymentMutation.isPending || method.enabled === false}
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
isSelected
? 'border-primary bg-primary/8 dark:bg-primary/15 shadow-md'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary/50'
} ${paymentMutation.isPending || method.enabled === false ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div className="flex items-center gap-3">
<div className={`w-11 h-11 rounded-lg flex items-center justify-center flex-shrink-0 ${isSelected ? 'bg-primary' : 'bg-gray-100 dark:bg-gray-700'}`}>
<Icon className={`w-5 h-5 ${isSelected ? 'text-white' : 'text-primary'}`} />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-gray-900 dark:text-gray-100">{method.displayName}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{method.region} · {method.currency}</p>
</div>
{isSelected && (
<CheckCircle2 className="w-5 h-5 text-primary flex-shrink-0" />
)}
</div>
<div className="flex-1">
<div className="font-semibold text-gray-900 dark:text-white">{method.displayName}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{method.currency}</div>
</div>
{selectedPaymentMethod === method.id && (
<Check className="w-5 h-5 text-primary" />
)}
</div>
</button>
))}
</button>
);
})}
</div>
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
@@ -347,39 +483,60 @@ function BookingDetailContent() {
)}
</div>
<button
onClick={handlePayment}
disabled={!selectedPaymentMethod || paymentMutation.isPending}
className="w-full py-4 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-lg rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg"
>
{paymentMutation.isPending ? 'Processing Payment...' : `Pay ${booking.displayCurrency} ${((booking.displayTotalMinor || booking.totalMinor || 0) / 100).toFixed(2)}`}
</button>
</div>
<div className="lg:col-span-1">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700 sticky top-6">
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Order Summary</h2>
<div className="space-y-3 mb-4">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Subtotal ({booking.adultCount} Adult{booking.adultCount > 1 ? 's' : ''}{booking.childCount > 0 ? `, ${booking.childCount} Child${booking.childCount > 1 ? 'ren' : ''}` : ''})</span>
<span className="font-semibold text-gray-900 dark:text-white">
{booking.currency} {((booking.totalMinor || 0) / 100).toFixed(2)}
</span>
</div>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
<div className="flex justify-between">
<span className="text-lg font-bold text-gray-900 dark:text-white">Total</span>
<span className="text-2xl font-bold text-primary">
{booking.displayCurrency} {((booking.displayTotalMinor || booking.totalMinor || 0) / 100).toFixed(2)}
</span>
</div>
</div>
{/* Order summary inline — mobile only */}
<div className="lg:hidden">
<OrderSummary />
</div>
</div>
</div>
{/* Right column — sticky order summary (desktop only) */}
<div className="hidden lg:block">
<div className="sticky top-6">
<OrderSummary />
</div>
</div>
</div>{/* end grid */}
</div>
</div>
{/* Mobile sticky bottom bar */}
<div className="lg:hidden fixed bottom-0 inset-x-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-4 py-3 z-40 shadow-lg">
<div className="flex items-center justify-between mb-2.5">
<span className="text-sm text-gray-600 dark:text-gray-400">Total</span>
<span className="text-lg font-bold text-primary flex items-center gap-1.5">
{awaitingAmount ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}</>
)}
</span>
</div>
{paymentError && (
<p className="text-red-600 dark:text-red-400 text-xs mb-2"> {paymentError}</p>
)}
<div className="flex gap-3">
<button onClick={() => router.push('/booking/lookup')} disabled={paymentMutation.isPending} className="btn-secondary flex-1 py-2.5 flex items-center justify-center gap-2">
<ChevronLeft className="w-4 h-4" />
Back
</button>
<button
onClick={handlePayment}
disabled={!selectedMethod || paymentMutation.isPending || awaitingAmount}
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
{paymentMutation.isPending ? (
<span className="flex items-center justify-center gap-1.5">
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
</span>
) : awaitingAmount ? (
<span className="flex items-center justify-center gap-1.5">
<Loader2 className="w-4 h-4 animate-spin" /> Calculating...
</span>
) : (
`Pay ${confirmedCurrency} ${(totalAmountDisplay ?? 0).toFixed(2)}`
)}
</button>
</div>
</div>
</div>

View File

@@ -2,45 +2,33 @@
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useBookingStore } from '@/lib/booking-store';
import { usePaymentStore } from '@/lib/payment-store';
import { apiClient } from '@/lib/api-client';
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
import { CheckCircle, Loader2 } from 'lucide-react';
import { Suspense } from 'react';
function DmoneySuccessContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { bookingId } = useBookingStore();
const { updateStatus } = usePaymentStore();
const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
const [returnTarget, setReturnTarget] = useState('/booking/confirmation');
// D-Money callback query params (mirrors Telebirr)
const orderid = searchParams.get('orderid') || '';
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
const bookingIdQp = searchParams.get('bookingId') || bookingId || '';
const orderid = searchParams.get('orderid') || '';
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
useEffect(() => {
const confirm = async () => {
try {
if (bookingIdQp) {
await apiClient.patch(`/bookings/${bookingIdQp}/confirm`, {
paymentReference: orderid || trxRef,
paymentMethod: 'DMONEY',
});
}
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push('/booking/confirmation'), 1500);
} catch (err: any) {
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push('/booking/confirmation'), 1500);
}
};
confirm();
// 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.
const manageBookingRef = consumeManageBookingPaymentReturn();
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
setReturnTarget(target);
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push(target), 1500);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -61,7 +49,7 @@ function DmoneySuccessContent() {
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Your D-Money payment was received.</p>
{orderid && <p className="text-xs text-gray-400">Order ID: {orderid}</p>}
{trxRef && <p className="text-xs text-gray-400">Transaction Ref: {trxRef}</p>}
<p className="text-xs text-gray-400 mt-3">Redirecting to your booking confirmation</p>
<p className="text-xs text-gray-400 mt-3">Redirecting</p>
</>
)}
{status === 'error' && (
@@ -71,8 +59,8 @@ function DmoneySuccessContent() {
</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>
<button onClick={() => router.push('/booking/confirmation')}
className="btn-primary w-full">Go to confirmation</button>
<button onClick={() => router.push(returnTarget)}
className="btn-primary w-full">Continue</button>
</>
)}
</div>

View File

@@ -48,9 +48,16 @@ export default function PaymentPage() {
},
});
// Fetch actual booking amount from API when a payment method is selected
const amountCurrency = selectedMethodCurrency || displayCurrency;
const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod) || null;
// A payment method only needs a currency conversion when its own currency differs from
// the default booking currency (e.g. Waafi settles in USD) — otherwise the reviewed ETB
// total already shown on the review page is exact and there's nothing to convert.
const isConversionNeeded = !!selectedMethodCurrency && selectedMethodCurrency !== displayCurrency;
const amountCurrency = isConversionNeeded ? selectedMethodCurrency! : displayCurrency;
// Fetch the converted booking amount from the booking-amount-changer API whenever a
// currency-specific payment method is selected.
const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({
queryKey: ['bookingAmount', bookingId, amountCurrency],
queryFn: async () => {
@@ -58,7 +65,7 @@ export default function PaymentPage() {
const response: any = await apiClient.get(url);
return response;
},
enabled: !!bookingId,
enabled: !!bookingId && isConversionNeeded,
});
// Per-leg subtotals for the journey header — sum each paying passenger's reviewed fare
@@ -71,32 +78,39 @@ export default function PaymentPage() {
? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0)
: 0;
// reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display
// they were computed and shown to the user on the review page, so the Total here must match.
// The API booking-amount is used only as the charge amount sent to the payment provider.
// reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display
// in the booking's default currency (ETB) — they were computed and shown to the user on
// the review page. But once a payment method with its own currency is selected (e.g.
// Waafi/USD), the converted amount from the booking-amount API takes over so the user
// sees the actual amount they'll be charged in that currency.
const reviewedTotal = reviewedTotalMinor ?? (reviewedPassengerFares?.reduce((s, f) => s + f.fareMinor, 0) ?? null);
const totalAmountDisplay = reviewedTotal != null ? reviewedTotal / 100 : (bookingAmountData != null ? bookingAmountData.amount : null);
const totalAmount = bookingAmountData != null
? Math.round(bookingAmountData.amount * 100)
: (reviewedTotal ?? 0);
const confirmedCurrency = bookingAmountData?.currency || amountCurrency;
const totalAmountDisplay = isConversionNeeded
? (bookingAmountData != null ? bookingAmountData.amount : null)
: (reviewedTotal != null ? reviewedTotal / 100 : (bookingAmountData != null ? bookingAmountData.amount : null));
const totalAmount = isConversionNeeded
? (bookingAmountData != null ? Math.round(bookingAmountData.amount * 100) : (reviewedTotal ?? 0))
: (reviewedTotal ?? (bookingAmountData != null ? Math.round(bookingAmountData.amount * 100) : 0));
const confirmedCurrency = isConversionNeeded ? (bookingAmountData?.currency || amountCurrency) : displayCurrency;
// Show loading spinner only when the API hasn't responded AND we have no review-page
// total to fall back on — once reviewedTotalMinor is set the button is always enabled.
const awaitingAmount = !isPackage && loadingAmount && totalAmountDisplay === null;
// Show loading spinner while the converted amount is still in flight for a
// currency-specific method; ETB methods always have the reviewed total instantly.
const awaitingAmount = !isPackage && isConversionNeeded && loadingAmount && totalAmountDisplay === null;
useEffect(() => {
// Always store the reviewed total (minor, ETB) as the paid amount — it's what was
// shown to the user and matches the fare breakdown. The API amount is only used as
// the charge sent to the provider (may differ due to currency conversion).
if (reviewedTotal != null) {
// Once a currency-specific payment method's converted amount has loaded, that's the
// real charge amount and currency — store it as the paid amount. Otherwise fall back
// to the reviewed ETB total shown on the review page.
if (isConversionNeeded && bookingAmountData != null) {
setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD');
setPaidAmount(Math.round(bookingAmountData.amount * 100));
} else if (reviewedTotal != null) {
setCurrency('ETB');
setPaidAmount(reviewedTotal);
} else if (bookingAmountData != null) {
setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD');
setPaidAmount(Math.round(bookingAmountData.amount * 100));
}
}, [bookingAmountData, confirmedCurrency, reviewedTotal, setCurrency, setPaidAmount]);
}, [isConversionNeeded, bookingAmountData, confirmedCurrency, reviewedTotal, setCurrency, setPaidAmount]);
const paymentMutation = useMutation({
mutationFn: async (data: any) => {
@@ -145,9 +159,6 @@ export default function PaymentPage() {
setIsProcessing(true);
setPaymentError(null);
// Find the selected payment method to get its ID
const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod);
if (!selectedPaymentMethod) {
alert("Invalid payment method selected");
setIsProcessing(false);
@@ -320,6 +331,11 @@ export default function PaymentPage() {
)}
</span>
</div>
{selectedPaymentMethod && !awaitingAmount && (
<p className="text-xs text-gray-500 dark:text-gray-400 text-right mt-1">
You will be charged {confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)} via {selectedPaymentMethod.displayName}
</p>
)}
</div>
{/* Pay + back buttons — desktop sidebar only */}

View File

@@ -2,13 +2,18 @@
import { useSearchParams, useRouter } from 'next/navigation';
import { usePaymentStore } from '@/lib/payment-store';
import { useEffect, Suspense } from 'react';
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
import { useEffect, useState, Suspense } from 'react';
import { XCircle, Loader2, ChevronLeft } from 'lucide-react';
function TelebirrFailureContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { updateStatus } = usePaymentStore();
// A Manage Booking payment (paying for an already-existing booking) has no in-progress
// booking-store session to go "back to review" from — send it back to that booking's
// detail view instead, where the user can pick a different payment method.
const [backTarget, setBackTarget] = useState('/booking/review');
const merchantOrderId = searchParams.get('merchantOrderId') || '';
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
@@ -16,6 +21,10 @@ function TelebirrFailureContent() {
const resultMsg = searchParams.get('resultMsg') || searchParams.get('message') || 'Payment was not completed.';
useEffect(() => {
const manageBookingRef = consumeManageBookingPaymentReturn();
if (manageBookingRef) {
setBackTarget(`/booking/detail?ref=${encodeURIComponent(manageBookingRef)}`);
}
updateStatus('FAILED');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -30,10 +39,10 @@ function TelebirrFailureContent() {
{merchantOrderId && <p className="text-xs text-gray-400 mb-1">Order ID: {merchantOrderId}</p>}
{trxRef && <p className="text-xs text-gray-400 mb-4">Ref: {trxRef}</p>}
<div className="flex flex-col gap-3 mt-4">
<button onClick={() => router.push('/booking/review')}
<button onClick={() => router.push(backTarget)}
className="btn-secondary w-full flex items-center justify-center gap-2">
<ChevronLeft className="w-4 h-4" />
Back to Review
Back
</button>
</div>
</div>

View File

@@ -2,45 +2,33 @@
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useBookingStore } from '@/lib/booking-store';
import { usePaymentStore } from '@/lib/payment-store';
import { apiClient } from '@/lib/api-client';
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
import { CheckCircle, Loader2 } from 'lucide-react';
import { Suspense } from 'react';
function TelebirrSuccessContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { bookingId } = useBookingStore();
const { updateStatus } = usePaymentStore();
const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
const [returnTarget, setReturnTarget] = useState('/booking/confirmation');
// Telebirr callback query params
const orderid = searchParams.get('orderid') || '';
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
const bookingIdQp = searchParams.get('bookingId') || bookingId || '';
const orderid = searchParams.get('orderid') || '';
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
useEffect(() => {
const confirm = async () => {
try {
if (bookingIdQp) {
await apiClient.patch(`/bookings/${bookingIdQp}/confirm`, {
paymentReference: orderid || trxRef,
paymentMethod: 'TELEBIRR',
});
}
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push('/booking/confirmation'), 1500);
} catch (err: any) {
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push('/booking/confirmation'), 1500);
}
};
confirm();
// 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.
const manageBookingRef = consumeManageBookingPaymentReturn();
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
setReturnTarget(target);
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push(target), 1500);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -61,7 +49,7 @@ function TelebirrSuccessContent() {
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Your Telebirr payment was received.</p>
{orderid && <p className="text-xs text-gray-400">Order ID: {orderid}</p>}
{trxRef && <p className="text-xs text-gray-400">Transaction Ref: {trxRef}</p>}
<p className="text-xs text-gray-400 mt-3">Redirecting to your booking confirmation</p>
<p className="text-xs text-gray-400 mt-3">Redirecting</p>
</>
)}
{status === 'error' && (
@@ -71,8 +59,8 @@ function TelebirrSuccessContent() {
</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>
<button onClick={() => router.push('/booking/confirmation')}
className="btn-primary w-full">Go to confirmation</button>
<button onClick={() => router.push(returnTarget)}
className="btn-primary w-full">Continue</button>
</>
)}
</div>

View File

@@ -2,13 +2,18 @@
import { useSearchParams, useRouter } from 'next/navigation';
import { usePaymentStore } from '@/lib/payment-store';
import { useEffect, Suspense } from 'react';
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
import { useEffect, useState, Suspense } from 'react';
import { XCircle, Loader2, ChevronLeft } from 'lucide-react';
function WaafiFailureContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { updateStatus } = usePaymentStore();
// A Manage Booking payment (paying for an already-existing booking) has no in-progress
// booking-store session to go "back to review" from — send it back to that booking's
// detail view instead, where the user can pick a different payment method.
const [backTarget, setBackTarget] = useState('/booking/review');
const referenceId = searchParams.get('referenceId') || '';
const responseCode = searchParams.get('responseCode') || '';
@@ -17,6 +22,10 @@ function WaafiFailureContent() {
const state = searchParams.get('state') || '';
useEffect(() => {
const manageBookingRef = consumeManageBookingPaymentReturn();
if (manageBookingRef) {
setBackTarget(`/booking/detail?ref=${encodeURIComponent(manageBookingRef)}`);
}
updateStatus('FAILED');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -33,10 +42,10 @@ function WaafiFailureContent() {
<p className="text-xs text-gray-400 mb-4">Ref: {referenceId || transactionId}</p>
)}
<div className="flex flex-col gap-3 mt-4">
<button onClick={() => router.push('/booking/review')}
<button onClick={() => router.push(backTarget)}
className="btn-secondary w-full flex items-center justify-center gap-2">
<ChevronLeft className="w-4 h-4" />
Back to Review
Back
</button>
</div>
</div>

View File

@@ -2,57 +2,32 @@
import { useEffect, useState, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useBookingStore } from '@/lib/booking-store';
import { usePaymentStore } from '@/lib/payment-store';
import { apiClient } from '@/lib/api-client';
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
import { CheckCircle, Loader2 } from 'lucide-react';
function WaafiSuccessContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { bookingId } = useBookingStore();
const { updateStatus } = usePaymentStore();
const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
// Waafi callback query params
const accountNo = searchParams.get('accountNo') || '';
const currency = searchParams.get('currency') || '';
const referenceId = searchParams.get('referenceId') || '';
const state = searchParams.get('state') || '';
const referenceId = searchParams.get('referenceId') || '';
const transactionId = searchParams.get('transactionId') || '';
const txAmount = searchParams.get('txAmount') || '';
const timestamp = searchParams.get('timestamp') || '';
const bookingIdQp = searchParams.get('bookingId') || bookingId || '';
const txAmount = searchParams.get('txAmount') || '';
const currency = searchParams.get('currency') || '';
useEffect(() => {
const confirm = async () => {
try {
if (bookingIdQp) {
await apiClient.patch(`/bookings/${bookingIdQp}/confirm`, {
paymentReference: referenceId || transactionId,
paymentMethod: 'WAAFI',
transactionDetails: {
transactionId,
accountNo,
amount: txAmount,
currency,
state,
timestamp,
},
});
}
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push('/booking/confirmation'), 1500);
} catch (err: any) {
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push('/booking/confirmation'), 1500);
}
};
confirm();
// 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.
const manageBookingRef = consumeManageBookingPaymentReturn();
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push(target), 1500);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -76,7 +51,7 @@ function WaafiSuccessContent() {
{txAmount && currency && (
<p className="text-xs text-gray-400">Amount: {txAmount} {currency}</p>
)}
<p className="text-xs text-gray-400 mt-3">Redirecting to your booking confirmation</p>
<p className="text-xs text-gray-400 mt-3">Redirecting</p>
</>
)}
</div>

View File

@@ -47,7 +47,7 @@ function getPassengerIdFromToken(token: string): string | null {
export default function ReviewPage() {
const router = useRouter();
const { selectedSchedule, outboundSchedule, inboundSchedule, passengers, seatHold, setBookingId, setPNR, setReviewedTotal, createAccount, passengerId: storedPassengerId, searchCriteria, packageId, priceTierId, packageTierPriceMinor, packageName } = useBookingStore();
const { selectedSchedule, outboundSchedule, inboundSchedule, passengers, seatHold, bookingId, pnr, bookingHoldId, bookingReturnHoldId, reviewedTotalMinor, setBookingId, setPNR, setBookingHoldReference, setReviewedTotal, createAccount, passengerId: storedPassengerId, searchCriteria, packageId, priceTierId, packageTierPriceMinor, packageName } = useBookingStore();
const { user, isAuthenticated } = useAuthStore();
const [timeLeft, setTimeLeft] = useState<string>('');
const [seatDetails, setSeatDetails] = useState<Record<string, string>>({});
@@ -159,6 +159,11 @@ export default function ReviewPage() {
const pnrValue = data.pnr || data.bookingReference || data.bookingRef;
setBookingId(bookingIdValue);
setPNR(pnrValue);
// Remember which hold(s) this booking was created from, so if the user comes back
// here (e.g. hitting "back" from the payment gateway) with the same hold still in
// the store, we can detect it's the same booking and reuse it instead of creating
// another one.
setBookingHoldReference(seatHold?.holdId || null, seatHold?.returnHoldId || null);
const totalAmount = data.totalMinor || data.totalAmount || 0;
setTimeout(() => {
if (totalAmount > 0) {
@@ -177,7 +182,21 @@ export default function ReviewPage() {
const handleConfirm = async () => {
try {
const { searchCriteria } = useBookingStore.getState();
// A booking already exists for the exact hold(s) currently in the store — e.g. the
// user was sent to the payment gateway and hit "back". Reuse it instead of creating
// a duplicate booking; just resume the payment step.
const sameHoldAsExistingBooking =
!!bookingId &&
!!pnr &&
!!seatHold?.holdId &&
seatHold.holdId === bookingHoldId &&
(isRoundTrip ? (seatHold.returnHoldId || null) === bookingReturnHoldId : true);
if (sameHoldAsExistingBooking) {
router.push((reviewedTotalMinor ?? 0) > 0 ? '/booking/payment' : '/booking/confirmation');
return;
}
if (!seatHold?.holdId) {
alert('Please select seats before continuing.');
router.push('/booking/seats');

View File

@@ -4,6 +4,7 @@ export const dynamic = "force-dynamic";
import { useRouter } from "next/navigation";
import { useBookingStore } from "@/lib/booking-store";
import { useAuthStore } from "@/lib/auth-store";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client";
import { useState, useEffect, useCallback, useMemo, useRef, memo } from "react";
@@ -164,6 +165,7 @@ export default function SeatsPage() {
outboundSchedule,
inboundSchedule,
passengers,
seatHold,
setSeatHold,
setPassengers,
setSelectedSchedule,
@@ -173,6 +175,7 @@ export default function SeatsPage() {
bookingId,
packageName,
} = useBookingStore();
const { isAuthenticated } = useAuthStore();
// Maps passenger index -> assigned seat id. A passenger can only get a seat while
// they are the "active" passenger, which prevents bulk/batch selection across passengers.
const [passengerSeatMap, setPassengerSeatMap] = useState<Record<number, string>>({});
@@ -211,6 +214,29 @@ export default function SeatsPage() {
? currentJourneyType === "inbound" ? "RETURN" : "OUTBOUND"
: "ONE_WAY";
// The hold id/expiry that already covers the CURRENT leg, if any — used to avoid
// creating a second, redundant hold when the user navigates back to this page (e.g.
// browser back button, or bouncing between passengers/seats) without actually changing
// their seat pick.
const currentLegHoldId = isRoundTrip && currentJourneyType === "inbound" ? seatHold?.returnHoldId : seatHold?.holdId;
const currentLegHoldExpiresAt = isRoundTrip && currentJourneyType === "inbound" ? seatHold?.returnExpiresAt : seatHold?.expiresAt;
const isCurrentLegHoldValid =
!!currentLegHoldId && (!currentLegHoldExpiresAt || new Date(currentLegHoldExpiresAt).getTime() > Date.now());
// The seat id(s) already recorded against each passenger for the current leg from a
// previous pass through this page (persisted in the store) — the counterpart of the
// hold above, so we can tell whether the current on-screen selection actually differs
// from what's already held.
const previouslyHeldSeatIds = useMemo(
() =>
passengers.map((p) =>
isRoundTrip
? (currentJourneyType === "inbound" ? (p as any).inboundSeatId : (p as any).outboundSeatId)
: (p as any).seatId,
),
[passengers, isRoundTrip, currentJourneyType],
);
// Baseline fare for each leg as it was when this page first loaded — i.e. whatever was
// picked on the results page ("starting from" price). Captured once and never
// overwritten, so a later coach-type switch (or just picking a pricier berth) can still
@@ -267,6 +293,29 @@ export default function SeatsPage() {
[passengers, seatEligibility],
);
// If this leg already has a valid (unexpired) hold from a previous pass through this
// page — e.g. the user hit "back" from a later step — restore the seat(s) that hold
// actually covers instead of leaving the seat map blank and letting them pick (and
// hold) another seat on top of it. Runs once per leg; the ref stops it from fighting a
// deliberate deselect/re-pick afterwards.
const restoredLegRef = useRef<string | null>(null);
useEffect(() => {
const legKey = `${currentSchedule?.id || ''}-${currentJourneyType}`;
if (restoredLegRef.current === legKey) return;
restoredLegRef.current = legKey;
if (!isCurrentLegHoldValid) return;
const restored: Record<number, string> = {};
seatEligibleIndices.forEach((i) => {
const seatId = previouslyHeldSeatIds[i];
if (seatId) restored[i] = seatId;
});
if (Object.keys(restored).length > 0) {
setPassengerSeatMap(restored);
}
}, [currentSchedule?.id, currentJourneyType, isCurrentLegHoldValid, seatEligibleIndices, previouslyHeldSeatIds]);
const {
data: seatMapData,
isLoading,
@@ -741,6 +790,34 @@ export default function SeatsPage() {
seatEligibleIndices.length > 0 &&
seatEligibleIndices.every((i) => !!passengerSeatMap[i]);
// Only ever hold seats once per leg. If the current on-screen picks are exactly what's
// already held (valid, unexpired), skip the API call entirely and reuse that hold — this
// is what stops "back, then Continue again" from stacking up a second hold on the same
// seats. If the user genuinely picked different seats than what was previously held,
// best-effort release the stale hold first (authenticated sessions only — the release
// endpoint requires a login) before holding the new selection, so at most one hold for
// this leg is ever active at a time.
const ensureLegHold = async (seatIdsForHold: string[]) => {
const selectionMatchesExistingHold =
isCurrentLegHoldValid &&
seatEligibleIndices.every((i) => passengerSeatMap[i] === previouslyHeldSeatIds[i]);
if (selectionMatchesExistingHold) {
return;
}
if (isCurrentLegHoldValid && isAuthenticated && currentLegHoldId) {
try {
await apiClient.delete(`/seats/hold/${currentLegHoldId}`);
} catch {
// Best-effort — an expired/already-released hold, or a guest session that can't
// call this endpoint, shouldn't block picking the new seat(s).
}
}
await holdMutation.mutateAsync(seatIdsForHold);
};
const handleContinue = async () => {
if (!allSeatsAssigned) return;
// Indexed by original passenger position — holes for passengers who share a seat
@@ -751,7 +828,7 @@ export default function SeatsPage() {
if (isRoundTrip && currentJourneyType === "outbound") {
try {
await holdMutation.mutateAsync(seatIdsForHold);
await ensureLegHold(seatIdsForHold);
const updatedPassengers = passengers.map((p, i) => {
const seatData = validSeats?.find((s: any) => s.id === seatIds[i]);
return {
@@ -785,7 +862,7 @@ export default function SeatsPage() {
}
try {
await holdMutation.mutateAsync(seatIdsForHold);
await ensureLegHold(seatIdsForHold);
const updatedPassengers = passengers.map((p, i) => {
const seatData = validSeats?.find((s: any) => s.id === seatIds[i]);
if (isRoundTrip && currentJourneyType === "inbound") {

View File

@@ -109,6 +109,11 @@ interface BookingState {
seatHold: SeatHold | null;
bookingId: string | null;
pnr: string | null;
// The hold id(s) the current bookingId was actually created with — lets the review
// page detect "user came back from the payment gateway with the same booking still
// valid" and reuse it instead of creating a duplicate booking.
bookingHoldId: string | null;
bookingReturnHoldId: string | null;
selectedPaymentMethod: string | null;
createAccount: boolean;
passengerId: string | null;
@@ -133,6 +138,7 @@ interface BookingState {
setSeatHold: (hold: SeatHold | null) => void;
setBookingId: (id: string) => void;
setPNR: (pnr: string) => void;
setBookingHoldReference: (holdId: string | null, returnHoldId?: string | null) => void;
setPaymentMethod: (method: string) => void;
setCreateAccount: (create: boolean) => void;
setPassengerId: (id: string | null) => void;
@@ -151,6 +157,8 @@ export const useBookingStore = create<BookingState>()(persist(
seatHold: null,
bookingId: null,
pnr: null,
bookingHoldId: null,
bookingReturnHoldId: null,
selectedPaymentMethod: null,
createAccount: false,
passengerId: null,
@@ -172,6 +180,7 @@ export const useBookingStore = create<BookingState>()(persist(
setSeatHold: (hold) => set({ seatHold: hold }),
setBookingId: (id) => set({ bookingId: id }),
setPNR: (pnr) => set({ pnr }),
setBookingHoldReference: (holdId, returnHoldId) => set({ bookingHoldId: holdId, bookingReturnHoldId: returnHoldId ?? null }),
setPaymentMethod: (method) => set({ selectedPaymentMethod: method }),
setCreateAccount: (create) => set({ createAccount: create }),
setPassengerId: (id) => set({ passengerId: id }),
@@ -185,6 +194,8 @@ export const useBookingStore = create<BookingState>()(persist(
seatHold: null,
bookingId: null,
pnr: null,
bookingHoldId: null,
bookingReturnHoldId: null,
selectedPaymentMethod: null,
createAccount: false,
passengerId: null,

View File

@@ -0,0 +1,24 @@
// Payment gateway return URLs (Telebirr/Waafi/D-Money success & failure pages) are fixed,
// app-wide URLs configured once in the payment provider — they can't carry a per-request
// query param telling the return page which flow initiated payment. The normal
// results -> seats -> review -> payment flow always ends at /booking/confirmation, which
// reads its data from useBookingStore. But paying for an existing booking from the
// Manage Booking page (/booking/detail) doesn't populate that store, so returning to
// /booking/confirmation there would render blank/broken.
//
// This marker records "the last payment was initiated from Manage Booking for booking
// ref X" right before redirecting to the gateway, so the return page can send the user
// back to that booking's detail view instead. It's consumed (read + cleared) exactly once.
const STORAGE_KEY = 'edr_manage_booking_payment_ref';
export function markManageBookingPaymentReturn(bookingRef: string) {
if (typeof window === 'undefined' || !bookingRef) return;
localStorage.setItem(STORAGE_KEY, bookingRef);
}
export function consumeManageBookingPaymentReturn(): string | null {
if (typeof window === 'undefined') return null;
const ref = localStorage.getItem(STORAGE_KEY);
if (ref) localStorage.removeItem(STORAGE_KEY);
return ref;
}