diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index 5087b7e4d..a52a10612 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -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(''); + + // 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(null); + const [selectedMethodCurrency, setSelectedMethodCurrency] = useState(null); + const [paymentError, setPaymentError] = useState(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({ 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 = () => ( +
+

+ Order summary + + Ref: {booking.bookingRef} + +

+ +
+

Fare breakdown

+ {(booking.passengers || []).map((passenger: any, idx: number) => ( +
+ + {passenger.fullName || `Passenger ${idx + 1}`} + {passenger.category === 'CHILD' && ( + (CHILD) + )} + + + {displayCurrency} {((passenger.fareMinor ?? 0) / 100).toFixed(2)} + +
+ ))} +
+ +
+
+ Total + + {awaitingAmount ? ( + + ) : ( + <>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)} + )} + +
+ {selectedPaymentMethod && !awaitingAmount && ( +

+ You will be charged {confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)} via {selectedPaymentMethod.displayName} +

+ )} +
+ + {/* Pay + back buttons — desktop sidebar only */} +
+ {paymentError && ( +

⚠️ {paymentError}

+ )} + + +

+ 🔒 Secure & encrypted payment +

+
+
+ ); + if (isPendingPayment && !isExpired) { return ( -
+
-
- -
-
-
-

Complete Payment

-

- Booking Reference: {booking.bookingRef} -

-
- -
- - {booking.createdAt && ( -
- - +
+

Complete payment

+ +
+
+

+ Booking Reference: {booking.bookingRef} +

+ {booking.createdAt && ( +

+ Booking created on {format(new Date(booking.createdAt), 'PPpp')} - -

- )} +

+ )} +
+
-
- -
- -
+ {/* Two-column grid — matches /booking/payment's layout */} +
+ + {/* Left column — trip/payment method (2/3 width) */} +
+ +

Trip Summary

@@ -302,43 +441,40 @@ function BookingDetailContent() {
-
-

Select Payment Method

- +
+

Select payment method

+ {paymentMethods && Array.isArray(paymentMethods) && paymentMethods.length > 0 ? ( -
- {paymentMethods.map((method: any) => ( -
- - ))} + + ); + })}
) : (
@@ -347,39 +483,60 @@ function BookingDetailContent() { )}
- -
- -
-
-

Order Summary

- -
-
- Subtotal ({booking.adultCount} Adult{booking.adultCount > 1 ? 's' : ''}{booking.childCount > 0 ? `, ${booking.childCount} Child${booking.childCount > 1 ? 'ren' : ''}` : ''}) - - {booking.currency} {((booking.totalMinor || 0) / 100).toFixed(2)} - -
-
- -
-
- Total - - {booking.displayCurrency} {((booking.displayTotalMinor || booking.totalMinor || 0) / 100).toFixed(2)} - -
-
+ {/* Order summary inline — mobile only */} +
+
-
+ + {/* Right column — sticky order summary (desktop only) */} +
+
+ +
+
+ +
{/* end grid */} +
+
+ + {/* Mobile sticky bottom bar */} +
+
+ Total + + {awaitingAmount ? ( + + ) : ( + <>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)} + )} + +
+ {paymentError && ( +

⚠️ {paymentError}

+ )} +
+ +
diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index e443ccf07..814a5009e 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -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 */}
- + - + ('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() {

Your D-Money payment was received.

{orderid &&

Order ID: {orderid}

} {trxRef &&

Transaction Ref: {trxRef}

} -

Redirecting to your booking confirmation…

+

Redirecting…

)} {status === 'error' && ( @@ -71,8 +59,8 @@ function DmoneySuccessContent() {

Something went wrong

Unable to confirm payment

- + )}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 0b27dfa87..a95179762 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -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() { )}
+ {selectedPaymentMethod && !awaitingAmount && ( +

+ You will be charged {confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)} via {selectedPaymentMethod.displayName} +

+ )}
{/* Pay + back buttons — desktop sidebar only */} diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx index 773afc79f..6ea64036b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx @@ -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 &&

Order ID: {merchantOrderId}

} {trxRef &&

Ref: {trxRef}

}
-
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx index 9b190fbf2..1a830cee3 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx @@ -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() {

Your Telebirr payment was received.

{orderid &&

Order ID: {orderid}

} {trxRef &&

Transaction Ref: {trxRef}

} -

Redirecting to your booking confirmation…

+

Redirecting…

)} {status === 'error' && ( @@ -71,8 +59,8 @@ function TelebirrSuccessContent() {

Something went wrong

Unable to confirm payment

- + )}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx index 6a32c388b..7d5d4566b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx @@ -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() {

Ref: {referenceId || transactionId}

)}
-
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx index 073cd610b..c75a0c390 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx @@ -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 && (

Amount: {txAmount} {currency}

)} -

Redirecting to your booking confirmation…

+

Redirecting…

)} diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index c07a812c4..c5d897a13 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -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(''); const [seatDetails, setSeatDetails] = useState>({}); @@ -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'); diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index a5c30f323..d8ae5c0e5 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -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>({}); @@ -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(null); + useEffect(() => { + const legKey = `${currentSchedule?.id || ''}-${currentJourneyType}`; + if (restoredLegRef.current === legKey) return; + restoredLegRef.current = legKey; + + if (!isCurrentLegHoldValid) return; + + const restored: Record = {}; + 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") { diff --git a/apps/edr-passenger-web/portal/src/lib/booking-store.ts b/apps/edr-passenger-web/portal/src/lib/booking-store.ts index 0972e0881..e5d1d83aa 100644 --- a/apps/edr-passenger-web/portal/src/lib/booking-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/booking-store.ts @@ -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()(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()(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()(persist( seatHold: null, bookingId: null, pnr: null, + bookingHoldId: null, + bookingReturnHoldId: null, selectedPaymentMethod: null, createAccount: false, passengerId: null, diff --git a/apps/edr-passenger-web/portal/src/utils/manage-booking-return.ts b/apps/edr-passenger-web/portal/src/utils/manage-booking-return.ts new file mode 100644 index 000000000..14d82415c --- /dev/null +++ b/apps/edr-passenger-web/portal/src/utils/manage-booking-return.ts @@ -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; +}