mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -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}¤cy=${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>
|
||||
|
||||
@@ -568,9 +568,8 @@ function createFormSchema(adultCount: number) {
|
||||
// Contact fields are only collected from — and validated against — adults.
|
||||
// Children's phone/email are inherited from the primary adult, not user-entered.
|
||||
if (isAdult) {
|
||||
if (!p.email || p.email.trim().length === 0) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Email is required', path: ['passengers', i, 'email'] });
|
||||
} else {
|
||||
// Email is optional — only validate its format when the user actually provides one.
|
||||
if (p.email && p.email.trim().length > 0) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(p.email)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['passengers', i, 'email'] });
|
||||
@@ -1191,7 +1190,7 @@ export default function PassengersPage() {
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email *</label>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email (optional)</label>
|
||||
<input
|
||||
type="email"
|
||||
{...register(`passengers.${index}.email`)}
|
||||
@@ -1280,7 +1279,7 @@ export default function PassengersPage() {
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email *</label>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email (optional)</label>
|
||||
<input
|
||||
type="email"
|
||||
{...register(`passengers.${index}.email`)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>>({});
|
||||
@@ -184,6 +184,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) {
|
||||
@@ -202,7 +207,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');
|
||||
|
||||
@@ -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,
|
||||
@@ -179,6 +181,7 @@ export default function SeatsPage() {
|
||||
packageDepartureStationName,
|
||||
setPackageContext,
|
||||
} = 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>>({});
|
||||
@@ -220,6 +223,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
|
||||
@@ -276,6 +302,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,
|
||||
@@ -856,6 +905,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
|
||||
@@ -866,7 +943,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 {
|
||||
@@ -1047,7 +1124,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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user