Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Stephanos A
2026-07-13 18:58:59 +03:00
25 changed files with 1094 additions and 401 deletions

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "PaymentMethodType" ADD VALUE 'CAC_BANK';

View File

@@ -146,6 +146,7 @@ enum PaymentMethodType {
WALLET
WAAFI
DMONEY
CAC_BANK
@@schema("passenger")
}

View File

@@ -1,4 +1,9 @@
import { BadGatewayException, Injectable, Logger } from "@nestjs/common";
import {
BadGatewayException,
BadRequestException,
Injectable,
Logger,
} from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { AxiosError } from "axios";
import { firstValueFrom } from "rxjs";
@@ -50,6 +55,52 @@ export class PaymentClientService {
}
}
/**
* POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider (CAC Bank).
* A wrong/expired OTP comes back as 400 from the payment service; surface that as a
* BadRequest (retryable) rather than a 502, so the payer can re-enter the code.
*/
async confirmOtp(
intentId: string,
otp: string,
): Promise<PaymentIntentSnapshot> {
const url = `${this.baseUrl}/payments/intents/${intentId}/confirm`;
try {
const response = await firstValueFrom(
this.http.post<PaymentIntentSnapshot>(
url,
{ otp },
{
headers: this.serviceToken
? { "x-service-token": this.serviceToken }
: {},
},
),
);
return response.data;
} catch (err) {
if (err instanceof AxiosError && err.response) {
const detail =
(err.response.data as { message?: string | string[] })?.message ??
err.message;
// 400 = wrong/expired OTP, 404 = unknown intent → both are client-fixable.
if (err.response.status === 400 || err.response.status === 404) {
throw new BadRequestException(detail);
}
this.logger.error(
`payment service confirm ${intentId}${err.response.status}: ${detail}`,
);
throw new BadGatewayException(`Payment service error: ${detail}`);
}
this.logger.error(
`payment service unreachable (confirm ${intentId}): ${
err instanceof Error ? err.message : String(err)
}`,
);
throw new BadGatewayException("Payment service unreachable");
}
}
private async call<T>(
method: "GET" | "POST",
path: string,

View File

@@ -34,6 +34,7 @@ import {
PaymentPlatformDto,
BookingAmountResponseDto,
ForceConfirmDto,
ConfirmOtpDto,
} from "./payments.dto";
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@@ -94,6 +95,21 @@ export class PaymentsController {
return this.service.getIntentByBookingId(bookingId);
}
@Post(":bookingId/confirm")
@SetMetadata('isPublic', true)
@ApiOperation({
summary: "Confirm an OTP-debit payment (CAC Bank)",
description:
"Submits the OTP the payer received by SMS. Returns the updated intent status. " +
"A wrong or expired OTP returns 400 and the payment stays open for retry.",
})
confirmOtp(
@Param("bookingId") bookingId: string,
@Body() dto: ConfirmOtpDto,
) {
return this.service.confirmOtpPayment(bookingId, dto.otp);
}
@Get("waafi/return")
@SetMetadata('isPublic', true)
@ApiOperation({

View File

@@ -22,6 +22,7 @@ export enum PaymentMethodTypeEnum {
EBIRR = "EBIRR", // Ethiopia
WAAFI = "WAAFI",
DMONEY= "DMONEY",// Djibouti
CAC_BANK = "CAC_BANK", // Djibouti (OTP debit)
CARD = "CARD", // International
WALLET = "WALLET", // Internal
}
@@ -50,6 +51,24 @@ export class InitiatePaymentDto {
@IsOptional()
@IsIn(["web", "mobile"])
platform?: PaymentPlatformDto;
@ApiPropertyOptional({
description:
"Payer account / mobile number. Required for OTP-debit methods (CAC_BANK) — " +
"the bank sends the OTP to this number.",
example: "77112233",
})
@IsOptional()
@IsString()
payerAccount?: string;
}
export class ConfirmOtpDto {
@ApiProperty({
description: "One-time password the payer received by SMS (e.g. CAC Bank).",
example: "4530",
})
@IsString()
otp: string;
}
export class RefundDto {

View File

@@ -53,7 +53,11 @@ function rabbitMQImport(): DynamicModule[] {
SeatsModule,
TicketsModule,
CurrencyModule,
HttpModule.register({ timeout: 10_000 }),
// The payment service proxies slow provider calls (e.g. CAC Bank initiate, which SMSes an
// OTP and can take tens of seconds). Keep this hop generous; overridable via env.
HttpModule.register({
timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000,
}),
...rabbitMQImport(),
],
controllers: [PaymentsController, InternalPaymentsController],

View File

@@ -183,6 +183,14 @@ export class PaymentsService {
}
const method = dto.method as PaymentMethodType;
// CAC Bank is an OTP debit — the bank SMSes the OTP to this number, so it's required.
if (method === PaymentMethodType.CAC_BANK && !dto.payerAccount?.trim()) {
throw new BadRequestException(
"payerAccount (mobile number) is required for CAC Bank",
);
}
const correctTotalMinor = await this.resolveBookingTotal(booking as any);
// Patch the DB if the stored total is wrong (single-leg for a round-trip package booking)
@@ -230,6 +238,7 @@ export class PaymentsService {
currency: chargeCurrency,
provider: method as unknown as ProviderMethod,
platform: dto.platform,
payerAccount: dto.payerAccount,
returnUrl,
failureUrl,
});
@@ -248,6 +257,43 @@ export class PaymentsService {
}
return this.formatIntentResponse(intent);
}
/**
* Submit an OTP for a COLLECT_OTP provider (CAC Bank). Keyed by bookingId: the active
* remote intent is looked up by reference, the OTP is forwarded to the payment service,
* and the projection is refreshed. On success the booking is converged immediately
* (idempotent — the outbox → mark-paid path also converges it). A wrong/expired OTP
* bubbles up as a 400 so the payer can retry; the intent stays REQUIRES_ACTION.
*/
async confirmOtpPayment(
bookingId: string,
otp: string,
): Promise<IntentStatusDto> {
const snapshot = await this.paymentClient.getIntentByReference(
PaymentReferenceType.BOOKING,
bookingId,
);
if (!snapshot) {
throw new NotFoundException("No active payment to confirm for this booking");
}
const confirmed = await this.paymentClient.confirmOtp(snapshot.intentId, otp);
let intent = await this.syncIntentProjection(bookingId, confirmed);
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
await this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: confirmed.providerTxnId,
paidAt: confirmed.paidAt ? new Date(confirmed.paidAt) : undefined,
});
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
where: { id: intent.id },
});
}
return this.formatIntentStatus(intent);
}
private resolveReturnUrls(method: PaymentMethodType): {
returnUrl?: string;
failureUrl?: string;

View File

@@ -203,6 +203,8 @@ export default function PaymentMethodsPage() {
{ value: 'CBE_BIRR', label: 'CBE Birr' },
{ value: 'EBIRR', label: 'eBirr' },
{ value: 'WAAFI', label: 'Waafi' },
{ value: 'DMONEY', label: 'dMoney' },
{ value: 'CAC_BANK', label: 'CAC Bank' },
{ value: 'CARD', label: 'Card Payment' },
{ value: 'WALLET', label: 'Internal Wallet' },
];

View File

@@ -119,10 +119,13 @@ export default function ConfirmationPage() {
const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
// The server-confirmed settled amount/currency (what was actually charged) is
// authoritative — prefer it over the ETB booking fare once it's available.
// authoritative — prefer it over the ETB booking fare once available. Shown exactly
// as returned by the API (no /100, no per-passenger split) on every passenger's
// voucher — see fareIsMajorUnits below.
const settledAmountMinor = _booking?.payment?.amountMinor;
const settledCurrency = _booking?.payment?.currency;
const voucherCurrency = settledCurrency || "ETB";
const hasSettledAmount = settledAmountMinor != null && !!settledCurrency;
const voucherCurrency = hasSettledAmount ? settledCurrency! : "ETB";
const createdAt = _booking?.createdAt || new Date().toISOString();
const status = _booking?.status || "CONFIRMED";
@@ -154,21 +157,6 @@ export default function ConfirmationPage() {
return Math.round(totalFare / passengers.length);
};
// Real conversion happened (payment settled in something other than ETB) — scale each
// passenger's ETB fare proportionally into the settled currency, rather than showing
// ETB-denominated numbers next to a foreign currency label.
const etbFares = passengers.map((_, idx) => getEtbFare(idx));
const etbTotal = etbFares.reduce((sum, f) => sum + f, 0);
const needsConversion =
settledAmountMinor != null &&
settledCurrency &&
settledCurrency !== "ETB" &&
etbTotal > 0;
const getVoucherFare = (idx: number): number => {
if (!needsConversion) return etbFares[idx];
return Math.round(etbFares[idx] * (settledAmountMinor! / etbTotal));
};
const outbound = {
trainNumber: activeSchedule?.trainNumber || "N/A",
trainName: "EDR Express",
@@ -234,8 +222,9 @@ export default function ConfirmationPage() {
outboundSchedule: outbound,
inboundSchedule: inbound,
isRoundTrip,
fareMinor: getVoucherFare(i),
fareMinor: hasSettledAmount ? settledAmountMinor! : getEtbFare(i),
currency: voucherCurrency,
fareIsMajorUnits: hasSettledAmount,
createdAt,
});
}

View File

@@ -18,6 +18,7 @@ import {
Smartphone,
Loader2,
ChevronLeft,
X,
} from "lucide-react";
import { format } from "date-fns";
import { formatTime, getTimePeriod } from "@/utils/format";
@@ -53,6 +54,11 @@ function BookingDetailContent() {
string | null
>(null);
const [paymentError, setPaymentError] = useState<string | null>(null);
// The mobile Pay trigger opens this modal instead of living in a `fixed bottom-0`
// bar — that bar kept getting covered by the phone's own home-indicator/gesture
// nav bar. The modal's footer button is a normal in-flow flex item instead, so it
// can't end up pinned underneath system chrome.
const [paymentModalOpen, setPaymentModalOpen] = useState(false);
const [copiedPNR, setCopiedPNR] = useState(false);
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
@@ -486,7 +492,7 @@ function BookingDetailContent() {
if (isPendingPayment && !isExpired) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6 pb-28 lg:pb-10">
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6 pb-6 lg:pb-10">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">
@@ -676,6 +682,11 @@ function BookingDetailContent() {
setSelectedMethodCurrency(
method.currency ?? null,
);
// Mobile only — desktop's Pay button lives inline in the
// sidebar OrderSummary, not behind a modal.
if (window.innerWidth < 1024) {
setPaymentModalOpen(true);
}
}}
disabled={
paymentMutation.isPending ||
@@ -735,57 +746,93 @@ function BookingDetailContent() {
</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>
{/* Mobile payment dialog — the amount + Back/Pay buttons live here now,
opened by picking a payment method above. Centered dialog-box style
(not a full-screen sheet) so it can't end up pinned under system chrome. */}
{paymentModalOpen && (
<div
className="lg:hidden fixed inset-0 z-[100] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="payment-modal-title"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setPaymentModalOpen(false)}
/>
{/* Dialog */}
<div className="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl max-w-sm w-full animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between px-5 pt-5">
<h2
id="payment-modal-title"
className="text-base font-bold text-gray-900 dark:text-gray-100"
>
Confirm payment
</h2>
<button
type="button"
onClick={() => setPaymentModalOpen(false)}
aria-label="Close"
className="p-1.5 -mr-1.5 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-5 pt-3 pb-1">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-600 dark:text-gray-400">
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" />
) : (
<>
{confirmedCurrency}{" "}
{(totalAmountDisplay ?? 0).toFixed(2)}
</>
)}
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
{selectedPaymentMethod
? `via ${selectedPaymentMethod.displayName}`
: "Select a payment method to continue."}
</p>
{paymentError && (
<p className="text-red-600 dark:text-red-400 text-xs mt-2">
{paymentError}
</p>
)}
</div>
<div className="px-5 pt-3 pb-5">
<button
onClick={handlePayment}
disabled={
!selectedMethod || paymentMutation.isPending || awaitingAmount
}
className="btn-primary w-full 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>
{paymentError && (
<p className="text-red-600 dark:text-red-400 text-xs mb-2">
{paymentError}
</p>
)}
<div className="flex gap-3">
<button
onClick={() => router.push("/booking/lookup")}
disabled={paymentMutation.isPending}
className="btn-secondary flex-1 py-2.5 flex items-center justify-center gap-2"
>
<ChevronLeft className="w-4 h-4" />
Back
</button>
<button
onClick={handlePayment}
disabled={
!selectedMethod || paymentMutation.isPending || awaitingAmount
}
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
{paymentMutation.isPending ? (
<span className="flex items-center justify-center gap-1.5">
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
</span>
) : awaitingAmount ? (
<span className="flex items-center justify-center gap-1.5">
<Loader2 className="w-4 h-4 animate-spin" /> Calculating...
</span>
) : (
`Pay ${confirmedCurrency} ${(totalAmountDisplay ?? 0).toFixed(2)}`
)}
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -17,11 +17,14 @@ import {
Loader2,
CheckCircle,
ChevronLeft,
KeyRound,
Landmark,
} from "lucide-react";
const getIconForMethod = (methodId: string) => {
if (methodId.includes('CARD')) return CreditCard;
if (methodId.includes('WALLET')) return Wallet;
if (methodId.includes('CAC')) return Landmark;
return Smartphone;
};
@@ -34,6 +37,14 @@ export default function PaymentPage() {
const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [paymentError, setPaymentError] = useState<string | null>(null);
// CAC Bank OTP debit: on Pay, collect the payer's mobile in a modal, then the SMS'd OTP.
const [payerMobile, setPayerMobile] = useState("");
const [phoneModalOpen, setPhoneModalOpen] = useState(false);
const [phoneError, setPhoneError] = useState<string | null>(null);
const [otpModalOpen, setOtpModalOpen] = useState(false);
const [otpCode, setOtpCode] = useState("");
const [otpMessage, setOtpMessage] = useState<string | null>(null);
const [otpError, setOtpError] = useState<string | null>(null);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
const isPackage = !!packageName;
@@ -118,13 +129,26 @@ export default function PaymentPage() {
bookingId: data.bookingId,
method: data.method,
paymentMethodId: data.paymentMethodId,
payerAccount: data.payerAccount,
platform: 'web',
});
},
onSuccess: async (data: any) => {
setPaymentError(null);
if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI') && data?.clientAction?.type === 'REDIRECT') {
// CAC Bank: no redirect — the bank SMS'd an OTP. Collect it in-app and confirm.
if (data?.clientAction?.type === 'COLLECT_OTP') {
setPaymentIntent(data.intentId);
updateStatus("REQUIRES_ACTION");
setOtpMessage(data.clientAction.message ?? "Enter the OTP sent to your phone");
setOtpCode("");
setOtpError(null);
setOtpModalOpen(true);
setIsProcessing(false);
return;
}
if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI' || selectedMethod === 'DMONEY') && data?.clientAction?.type === 'REDIRECT') {
setPaymentIntent(data.intentId);
updateStatus("REQUIRES_ACTION");
window.location.href = data.clientAction.url;
@@ -148,32 +172,73 @@ export default function PaymentPage() {
},
});
// CAC Bank OTP confirmation. A 200 means the payment settled; a 400 is a wrong/expired
// OTP — keep the modal open so the payer can re-enter it (the intent stays open).
const otpMutation = useMutation({
mutationFn: async (otp: string) => {
return await apiClient.post(`/payments/${bookingId}/confirm`, { otp });
},
onSuccess: () => {
setOtpModalOpen(false);
updateStatus("SUCCEEDED");
router.push("/booking/confirmation");
},
onError: (error: any) => {
setOtpError(
error?.response?.data?.message ||
error?.message ||
"Invalid or expired OTP. Please try again.",
);
},
});
const handlePayment = async () => {
if (!selectedMethod || !bookingId) {
alert("Please select a payment method");
return;
}
// Fire the actual initiate. `mobile` is only used for CAC (OTP debit).
const startPayment = (mobile?: string) => {
if (!selectedMethod || !bookingId || !selectedPaymentMethod) return;
setIsProcessing(true);
setPaymentError(null);
if (!selectedPaymentMethod) {
alert("Invalid payment method selected");
setIsProcessing(false);
return;
}
paymentMutation.mutate({
bookingId,
method: selectedMethod,
paymentMethodId: selectedPaymentMethod.id,
currency: displayCurrency,
amountMinor: totalAmount,
payerAccount: selectedMethod === 'CAC_BANK' ? mobile?.trim() : undefined,
});
};
const handlePayment = () => {
if (!selectedMethod || !bookingId) {
alert("Please select a payment method");
return;
}
if (!selectedPaymentMethod) {
alert("Invalid payment method selected");
return;
}
setPaymentError(null);
// CAC Bank needs the payer's mobile for the OTP — collect it in a modal before initiating.
if (selectedMethod === 'CAC_BANK') {
setPhoneError(null);
setPhoneModalOpen(true);
return;
}
startPayment();
};
const submitPhone = () => {
if (!payerMobile.trim()) {
setPhoneError("Please enter your mobile number");
return;
}
setPhoneModalOpen(false);
startPayment(payerMobile);
};
// Redirect if no booking data (but not during navigation)
useEffect(() => {
// Add a small delay to allow state to be set from previous page
@@ -372,7 +437,7 @@ export default function PaymentPage() {
);
return (
<div className="booking-page pb-28 lg:pb-10">
<div className="booking-page pb-[calc(7rem+env(safe-area-inset-bottom))] lg:pb-10">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<h1 className="section-title">Complete payment</h1>
@@ -414,6 +479,100 @@ export default function PaymentPage() {
</div>
)}
{/* CAC Bank — collect payer mobile before initiating */}
{phoneModalOpen && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-sm w-full shadow-2xl">
<div className="flex items-center gap-2 mb-1">
<Smartphone className="w-5 h-5 text-primary" />
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Your mobile number</h3>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
CAC Bank will send a one-time password to this number to authorize the payment.
</p>
<input
type="tel"
inputMode="numeric"
autoFocus
value={payerMobile}
onChange={(e) => { setPayerMobile(e.target.value); setPhoneError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter') submitPhone(); }}
placeholder="77 XX XX XX"
className="w-full px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none"
/>
{phoneError && (
<p className="text-red-600 dark:text-red-400 text-xs mt-2"> {phoneError}</p>
)}
<div className="flex gap-2 mt-4">
<button
onClick={() => setPhoneModalOpen(false)}
className="btn-secondary flex-1 py-2.5"
>
Cancel
</button>
<button
onClick={submitPhone}
disabled={!payerMobile.trim()}
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
Continue
</button>
</div>
</div>
</div>
)}
{/* CAC Bank OTP entry */}
{otpModalOpen && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-sm w-full shadow-2xl">
<div className="flex items-center gap-2 mb-1">
<KeyRound className="w-5 h-5 text-primary" />
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Enter OTP</h3>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
{otpMessage}
</p>
<input
type="text"
inputMode="numeric"
autoFocus
value={otpCode}
onChange={(e) => { setOtpCode(e.target.value.replace(/\D/g, '')); setOtpError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter' && otpCode.trim() && !otpMutation.isPending) otpMutation.mutate(otpCode.trim()); }}
placeholder="Enter code"
maxLength={10}
className="w-full text-center tracking-[0.4em] text-lg font-semibold px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none"
/>
{otpError && (
<p className="text-red-600 dark:text-red-400 text-xs mt-2"> {otpError}</p>
)}
<div className="flex gap-2 mt-4">
<button
onClick={() => { setOtpModalOpen(false); updateStatus("REQUIRES_ACTION"); }}
disabled={otpMutation.isPending}
className="btn-secondary flex-1 py-2.5"
>
Cancel
</button>
<button
onClick={() => otpCode.trim() && otpMutation.mutate(otpCode.trim())}
disabled={otpMutation.isPending || !otpCode.trim()}
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
{otpMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Verifying...
</span>
) : (
"Confirm payment"
)}
</button>
</div>
</div>
</div>
)}
{/* Two-column grid */}
<div className="lg:grid lg:grid-cols-3 lg:gap-6 lg:items-start">
@@ -487,7 +646,7 @@ export default function PaymentPage() {
</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="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 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] 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">

View File

@@ -25,14 +25,16 @@ import { useEffect, useRef, useState, useCallback } from "react";
import ModernDatePicker from "@/components/ModernDatePicker";
function useDarkMode() {
const [dark, setDark] = useState(() =>
typeof window !== 'undefined' && document.documentElement.classList.contains('dark')
const [dark, setDark] = useState(
() =>
typeof window !== "undefined" &&
document.documentElement.classList.contains("dark"),
);
useEffect(() => {
const obs = new MutationObserver(() =>
setDark(document.documentElement.classList.contains('dark'))
setDark(document.documentElement.classList.contains("dark")),
);
obs.observe(document.documentElement, { attributeFilter: ['class'] });
obs.observe(document.documentElement, { attributeFilter: ["class"] });
return () => obs.disconnect();
}, []);
return dark;
@@ -344,7 +346,9 @@ function PassengerModal({
{nationalityError && (
<p className="text-xs text-red-500 mb-2">{nationalityError}</p>
)}
<div className={`grid grid-cols-3 gap-2 ${nationalityError ? "mt-1" : "mt-2"}`}>
<div
className={`grid grid-cols-3 gap-2 ${nationalityError ? "mt-1" : "mt-2"}`}
>
{natOptions.map((opt) => (
<button
key={opt.value}
@@ -556,6 +560,10 @@ export default function SearchPage() {
// doesn't paint anything until the target route's JS has loaded, which
// otherwise reads as a dead click.
const [isSearching, setIsSearching] = useState(false);
// Mobile only: collapsed view shows just From/To + Search; tapping Search opens this
// modal with the full field set (trip type, dates, passengers/nationality) instead of
// showing everything inline on the page.
const [mobileSearchModalOpen, setMobileSearchModalOpen] = useState(false);
// Warms the results route's JS chunk ahead of time so clicking Search
// doesn't have to wait for it to download/compile on top of the actual
@@ -592,7 +600,10 @@ export default function SearchPage() {
error,
} = useQuery<Station[]>({
queryKey: ["stations"],
queryFn: async () => (await apiClient.get("/stations")) as Station[],
// Bounded so a stalled request surfaces the "Unable to load stations"
// error below instead of leaving the widget stuck loading indefinitely.
queryFn: async () =>
(await apiClient.get("/stations", { timeout: 8000 })) as Station[],
});
const {
@@ -728,7 +739,9 @@ export default function SearchPage() {
const onInvalid = (formErrors: typeof errors) => {
setHasInteracted(true);
setIsSearching(false);
const hasOtherErrors = Object.keys(formErrors).some((k) => k !== "nationality");
const hasOtherErrors = Object.keys(formErrors).some(
(k) => k !== "nationality",
);
if (formErrors.nationality && !hasOtherErrors) {
setPassengerModalOpen(true);
}
@@ -748,7 +761,167 @@ export default function SearchPage() {
// No default nationality anymore — only render a flag once one is actually picked, rather
// than falling through to the "Other" 🌍 flag and implying a selection that hasn't happened.
const nationalityFlag = (nat?: string) =>
nat === "ETHIOPIAN" ? "🇪🇹" : nat === "DJIBOUTIAN" ? "🇩🇯" : nat === "OTHER" ? "🌍" : null;
nat === "ETHIOPIAN"
? "🇪🇹"
: nat === "DJIBOUTIAN"
? "🇩🇯"
: nat === "OTHER"
? "🌍"
: null;
// Shared between the desktop layout and the mobile search modal — kept out of the
// collapsed mobile view (see mobileSearchModalOpen).
const renderTripTypeTabs = () => (
<div className="mb-4">
<div className="inline-flex rounded-xl bg-gray-100 dark:bg-gray-800 p-1 w-full md:w-auto">
<button
type="button"
onClick={() => setValue("tripType", "ONE_WAY")}
className={`flex-1 md:flex-none px-6 py-2.5 rounded-lg text-sm font-semibold transition-all ${
tripType === "ONE_WAY"
? "bg-white dark:bg-gray-900 text-primary shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
}`}
>
One Way
</button>
<button
type="button"
onClick={() => setValue("tripType", "ROUND_TRIP")}
className={`flex-1 md:flex-none px-6 py-2.5 rounded-lg text-sm font-semibold transition-all ${
tripType === "ROUND_TRIP"
? "bg-white dark:bg-gray-900 text-primary shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
}`}
>
Round Trip
</button>
</div>
</div>
);
// From/To fields — rendered both in the mobile collapsed view (so a station can be
// picked without opening the full modal) and inside the modal itself (prefilled with
// whatever was already picked). Tapping either still opens StationModal directly.
// stacked: single column for the collapsed first view; the modal keeps the 2-column grid.
const renderStationFields = (stacked = false) => (
<div className={stacked ? "flex flex-col gap-3" : "grid grid-cols-2 gap-3"}>
<div className="space-y-1.5 min-w-0">
<div className="h-5 flex items-center">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
From
</label>
</div>
<button
type="button"
onClick={() => {
setHasInteracted(true);
window.scrollTo({
top: 0,
behavior: "instant" as ScrollBehavior,
});
setStationModal("origin");
}}
className="w-full"
>
<div
className={`flex items-center gap-2 px-2.5 py-3 border-2 rounded-xl transition-all ${
hasInteracted && errors.originStationId
? "border-red-400"
: originId
? "border-primary bg-primary/5"
: "border-gray-200 dark:border-gray-700"
}`}
style={{ backgroundColor: originId ? undefined : undefined }}
>
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
<span
style={{
color: originStation
? dark
? "#ffffff"
: "#111827"
: dark
? "#6b7280"
: "#9ca3af",
}}
className={`text-sm truncate ${originStation ? "font-semibold" : ""}`}
>
{originStation?.name ??
(stacked ? "Select departure station" : "Departure")}
</span>
</div>
</button>
{hasInteracted && errors.originStationId && (
<p className="text-xs text-red-500">
{errors.originStationId.message}
</p>
)}
</div>
<div className="space-y-1.5 min-w-0">
<div className="h-5 flex items-center justify-between">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
To
</label>
<button
type="button"
onClick={handleSwap}
disabled={!originId || !destId}
aria-label="Swap origin and destination"
className="flex items-center justify-center gap-1 text-xs text-primary font-medium disabled:opacity-30 p-0 h-5 w-5"
>
<ArrowLeftRight
className={`w-3.5 h-3.5 transition-transform duration-300 ${swapping ? "rotate-180" : ""}`}
/>
</button>
</div>
<button
type="button"
onClick={() => {
setHasInteracted(true);
window.scrollTo({
top: 0,
behavior: "instant" as ScrollBehavior,
});
setStationModal("destination");
}}
className="w-full"
>
<div
className={`flex items-center gap-2 px-2.5 py-3 border-2 rounded-xl transition-all ${
hasInteracted && errors.destinationStationId
? "border-red-400"
: destId
? "border-primary bg-primary/5"
: "border-gray-200 dark:border-gray-700"
}`}
>
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
<span
style={{
color: destStation
? dark
? "#ffffff"
: "#111827"
: dark
? "#6b7280"
: "#9ca3af",
}}
className={`text-sm truncate ${destStation ? "font-semibold" : ""}`}
>
{destStation?.name ??
(stacked ? "Select destination station" : "Destination")}
</span>
</div>
</button>
{hasInteracted && errors.destinationStationId && (
<p className="text-xs text-red-500">
{errors.destinationStationId.message}
</p>
)}
</div>
</div>
);
return (
<div className="bg-gray-50 dark:bg-gray-950">
@@ -765,7 +938,9 @@ export default function SearchPage() {
clearErrors("nationality");
}}
onClose={() => setPassengerModalOpen(false)}
nationalityError={showNationalityError ? errors.nationality?.message : undefined}
nationalityError={
showNationalityError ? errors.nationality?.message : undefined
}
/>
)}
@@ -870,241 +1045,182 @@ export default function SearchPage() {
)}
<div className="p-4 md:p-5">
{/* Trip Type Tabs */}
<div className="mb-4">
<div className="inline-flex rounded-xl bg-gray-100 dark:bg-gray-800 p-1 w-full md:w-auto">
<button
type="button"
onClick={() => setValue("tripType", "ONE_WAY")}
className={`flex-1 md:flex-none px-6 py-2.5 rounded-lg text-sm font-semibold transition-all ${
tripType === "ONE_WAY"
? "bg-white dark:bg-gray-900 text-primary shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
}`}
>
One Way
</button>
<button
type="button"
onClick={() => setValue("tripType", "ROUND_TRIP")}
className={`flex-1 md:flex-none px-6 py-2.5 rounded-lg text-sm font-semibold transition-all ${
tripType === "ROUND_TRIP"
? "bg-white dark:bg-gray-900 text-primary shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
}`}
>
Round Trip
</button>
</div>
{/* Trip Type Tabs — always visible on desktop; on mobile only inside the
search modal (hidden in the collapsed view). */}
<div
className={`${mobileSearchModalOpen ? "block" : "hidden"} md:block`}
>
{renderTripTypeTabs()}
</div>
{/* Mobile: stacked, but From/To and Date/Return Date pair up into two
columns each to save vertical space (station names/dates truncate
rather than wrap) — same fields, same behavior, just denser. */}
<div className="flex flex-col gap-3 md:hidden">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5 min-w-0">
<div className="h-5 flex items-center">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
From
</label>
</div>
<button
type="button"
onClick={() => {
setHasInteracted(true);
window.scrollTo({
top: 0,
behavior: "instant" as ScrollBehavior,
});
setStationModal("origin");
}}
className="w-full"
>
<div
className={`flex items-center gap-2 px-2.5 py-3 border-2 rounded-xl transition-all ${
hasInteracted && errors.originStationId
? "border-red-400"
: originId
? "border-primary bg-primary/5"
: "border-gray-200 dark:border-gray-700"
}`}
style={{ backgroundColor: originId ? undefined : undefined }}
>
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
<span
style={{ color: originStation ? (dark ? '#ffffff' : '#111827') : (dark ? '#6b7280' : '#9ca3af') }}
className={`text-sm truncate ${originStation ? 'font-semibold' : ''}`}
>
{originStation?.name ?? "Departure"}
</span>
</div>
</button>
{hasInteracted && errors.originStationId && (
<p className="text-xs text-red-500">
{errors.originStationId.message}
</p>
)}
</div>
<div className="space-y-1.5 min-w-0">
<div className="h-5 flex items-center justify-between">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
To
</label>
<button
type="button"
onClick={handleSwap}
disabled={!originId || !destId}
aria-label="Swap origin and destination"
className="flex items-center justify-center gap-1 text-xs text-primary font-medium disabled:opacity-30 p-0 h-5 w-5"
>
<ArrowLeftRight
className={`w-3.5 h-3.5 transition-transform duration-300 ${swapping ? "rotate-180" : ""}`}
/>
</button>
</div>
<button
type="button"
onClick={() => {
setHasInteracted(true);
window.scrollTo({
top: 0,
behavior: "instant" as ScrollBehavior,
});
setStationModal("destination");
}}
className="w-full"
>
<div
className={`flex items-center gap-2 px-2.5 py-3 border-2 rounded-xl transition-all ${
hasInteracted && errors.destinationStationId
? "border-red-400"
: destId
? "border-primary bg-primary/5"
: "border-gray-200 dark:border-gray-700"
}`}
>
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
<span
style={{ color: destStation ? (dark ? '#ffffff' : '#111827') : (dark ? '#6b7280' : '#9ca3af') }}
className={`text-sm truncate ${destStation ? 'font-semibold' : ''}`}
>
{destStation?.name ?? "Destination"}
</span>
</div>
</button>
{hasInteracted && errors.destinationStationId && (
<p className="text-xs text-red-500">
{errors.destinationStationId.message}
</p>
)}
</div>
</div>
<div className={tripType === "ROUND_TRIP" ? "grid grid-cols-2 gap-3" : ""}>
<div className="space-y-1.5 min-w-0">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Date
</label>
<div>
<ModernDatePicker
value={
departureDate
? new Date(departureDate + "T00:00:00")
: undefined
}
onChange={(date) => {
setValue(
"departureDate",
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
);
trigger("departureDate");
}}
minDate={new Date()}
placeholder="Departure date"
error={!!errors.departureDate}
/>
</div>
{errors.departureDate && (
<p className="text-xs text-red-500">
{errors.departureDate.message}
</p>
)}
</div>
{tripType === "ROUND_TRIP" && (
<div className="space-y-1.5 min-w-0">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Return Date
</label>
<div>
<ModernDatePicker
value={
returnDate
? new Date(returnDate + "T00:00:00")
: undefined
}
onChange={(date) => {
setValue(
"returnDate",
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
);
trigger("returnDate");
}}
minDate={
departureDate
? new Date(departureDate + "T00:00:00")
: new Date()
}
placeholder="Return date"
error={!!errors.returnDate}
/>
</div>
{errors.returnDate && (
<p className="text-xs text-red-500">
{errors.returnDate.message}
</p>
)}
</div>
)}
</div>
{/* Pax + Nationality combined trigger */}
{/* Mobile collapsed view — just From/To + Search. Tapping a station still
opens StationModal directly; tapping Search opens the full modal below
instead of submitting (validation happens inside that modal). */}
<div
className={`${mobileSearchModalOpen ? "hidden" : "flex"} flex-col gap-3 md:hidden`}
>
{renderStationFields(true)}
<button
type="button"
onClick={() => setPassengerModalOpen(true)}
className={`w-full flex items-center justify-between px-3.5 py-3 border-2 rounded-xl bg-white dark:bg-gray-800 ${
showNationalityError ? "border-red-400" : "border-gray-200 dark:border-gray-700"
}`}
onClick={() => setMobileSearchModalOpen(true)}
className="btn-primary w-full text-sm flex items-center justify-center gap-2"
>
<span className="flex items-center gap-2 text-sm font-medium" style={{ color: dark ? '#ffffff' : '#111827' }}>
<Users className="w-4 h-4 text-primary" />
{totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"}
{nationalityFlag(watch("nationality"))
? ` · ${nationalityFlag(watch("nationality"))}`
: " · Nationality"}
</span>
<ChevronDown className="w-4 h-4 text-primary" />
</button>
{showNationalityError && (
<p className="text-xs text-red-500">{errors.nationality?.message}</p>
)}
<button
type="submit"
disabled={isLoading || isSearching}
className="btn-primary w-full text-sm flex items-center justify-center gap-2 disabled:opacity-80"
>
{isSearching ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Searching...
</>
) : (
<>
<Search className="w-5 h-5" />
Search
</>
)}
<Search className="w-5 h-5" />
Search
</button>
</div>
{/* Mobile search modal — full field set (trip type, From/To, dates,
passengers/nationality). Its Search button is the real form submit —
same onSubmit/onInvalid/validation as before, just relocated here. */}
{mobileSearchModalOpen && (
<div
className="fixed inset-0 z-[100] bg-white dark:bg-gray-900 flex flex-col md:hidden animate-slide-up"
role="dialog"
aria-modal="true"
aria-labelledby="mobile-search-modal-title"
>
<div className="flex items-center gap-3 px-4 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
<button
type="button"
onClick={() => setMobileSearchModalOpen(false)}
className="w-10 h-10 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Close"
>
<X className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</button>
<h2
id="mobile-search-modal-title"
className="text-lg font-semibold text-gray-900 dark:text-white"
>
Plan and Book Your Trip
</h2>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
{renderTripTypeTabs()}
{renderStationFields()}
<div
className={
tripType === "ROUND_TRIP"
? "grid grid-cols-2 gap-3"
: ""
}
>
<div className="space-y-1.5 min-w-0">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Date
</label>
<div>
<ModernDatePicker
value={
departureDate
? new Date(departureDate + "T00:00:00")
: undefined
}
onChange={(date) => {
setValue(
"departureDate",
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
);
trigger("departureDate");
}}
minDate={new Date()}
placeholder="Departure date"
error={!!errors.departureDate}
/>
</div>
{errors.departureDate && (
<p className="text-xs text-red-500">
{errors.departureDate.message}
</p>
)}
</div>
{tripType === "ROUND_TRIP" && (
<div className="space-y-1.5 min-w-0">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Return Date
</label>
<div>
<ModernDatePicker
value={
returnDate
? new Date(returnDate + "T00:00:00")
: undefined
}
onChange={(date) => {
setValue(
"returnDate",
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
);
trigger("returnDate");
}}
minDate={
departureDate
? new Date(departureDate + "T00:00:00")
: new Date()
}
placeholder="Return date"
error={!!errors.returnDate}
/>
</div>
{errors.returnDate && (
<p className="text-xs text-red-500">
{errors.returnDate.message}
</p>
)}
</div>
)}
</div>
{/* Pax + Nationality combined trigger */}
<button
type="button"
onClick={() => setPassengerModalOpen(true)}
className={`w-full flex items-center justify-between px-3.5 py-3 border-2 rounded-xl bg-white dark:bg-gray-800 ${
showNationalityError
? "border-red-400"
: "border-gray-200 dark:border-gray-700"
}`}
>
<span
className="flex items-center gap-2 text-sm font-medium"
style={{ color: dark ? "#ffffff" : "#111827" }}
>
<Users className="w-4 h-4 text-primary" />
{totalPassengers}{" "}
{totalPassengers === 1 ? "Passenger" : "Passengers"}
{nationalityFlag(watch("nationality"))
? ` · ${nationalityFlag(watch("nationality"))}`
: " · Nationality"}
</span>
<ChevronDown className="w-4 h-4 text-primary" />
</button>
{showNationalityError && (
<p className="text-xs text-red-500">
{errors.nationality?.message}
</p>
)}
<button
type="submit"
disabled={isLoading || isSearching}
className="btn-primary w-full text-sm flex items-center justify-center gap-2 disabled:opacity-80"
>
{isSearching ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Searching...
</>
) : (
<>
<Search className="w-5 h-5" />
Search
</>
)}
</button>
</div>
</div>
)}
{/* Desktop: dynamic layout based on trip type */}
<div className={`hidden md:block`}>
{tripType === "ONE_WAY" ? (
@@ -1219,12 +1335,17 @@ export default function SearchPage() {
type="button"
onClick={() => setPassengerModalOpen(true)}
className={`w-full flex items-center justify-between px-3 py-3.5 border-2 rounded-xl bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all ${
showNationalityError ? "border-red-400" : "border-gray-200 dark:border-gray-700"
showNationalityError
? "border-red-400"
: "border-gray-200 dark:border-gray-700"
}`}
>
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 dark:text-white truncate">
<Users className="w-4 h-4 text-primary flex-shrink-0" />
{totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"}
{totalPassengers}{" "}
{totalPassengers === 1
? "Passenger"
: "Passengers"}
{nationalityFlag(watch("nationality"))
? ` · ${nationalityFlag(watch("nationality"))}`
: " · Nationality"}
@@ -1232,7 +1353,9 @@ export default function SearchPage() {
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
</button>
{showNationalityError && (
<p className="text-xs text-red-500">{errors.nationality?.message}</p>
<p className="text-xs text-red-500">
{errors.nationality?.message}
</p>
)}
</div>
{/* Search */}
@@ -1259,7 +1382,9 @@ export default function SearchPage() {
<div className="flex items-end gap-2">
{/* From */}
<div className="flex-1 min-w-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">From</label>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
From
</label>
<StationDropdown
stations={stations}
value={originId}
@@ -1273,11 +1398,17 @@ export default function SearchPage() {
clearErrors("originStationId");
clearErrors("destinationStationId");
}}
error={hasInteracted ? errors.originStationId?.message : undefined}
error={
hasInteracted
? errors.originStationId?.message
: undefined
}
onOpen={scrollWidgetIntoView}
/>
{hasInteracted && errors.originStationId && (
<p className="text-xs text-red-500">{errors.originStationId.message}</p>
<p className="text-xs text-red-500">
{errors.originStationId.message}
</p>
)}
</div>
{/* Swap */}
@@ -1291,7 +1422,9 @@ export default function SearchPage() {
</button>
{/* To */}
<div className="flex-1 min-w-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">To</label>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
To
</label>
<StationDropdown
stations={stations}
value={destId}
@@ -1304,20 +1437,35 @@ export default function SearchPage() {
if (s.id) saveRecent(s.id);
clearErrors("destinationStationId");
}}
error={hasInteracted ? errors.destinationStationId?.message : undefined}
error={
hasInteracted
? errors.destinationStationId?.message
: undefined
}
onOpen={scrollWidgetIntoView}
/>
{hasInteracted && errors.destinationStationId && (
<p className="text-xs text-red-500">{errors.destinationStationId.message}</p>
<p className="text-xs text-red-500">
{errors.destinationStationId.message}
</p>
)}
</div>
{/* Departure Date */}
<div className="w-40 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Departure</label>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Departure
</label>
<ModernDatePicker
value={departureDate ? new Date(departureDate + "T00:00:00") : undefined}
value={
departureDate
? new Date(departureDate + "T00:00:00")
: undefined
}
onChange={(date) => {
setValue("departureDate", `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`);
setValue(
"departureDate",
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
);
trigger("departureDate");
trigger("returnDate");
}}
@@ -1325,44 +1473,72 @@ export default function SearchPage() {
placeholder="Departure date"
/>
{errors.departureDate && (
<p className="text-xs text-red-500">{errors.departureDate.message}</p>
<p className="text-xs text-red-500">
{errors.departureDate.message}
</p>
)}
</div>
{/* Return Date */}
<div className="w-40 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Return</label>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Return
</label>
<ModernDatePicker
value={returnDate ? new Date(returnDate + "T00:00:00") : undefined}
value={
returnDate
? new Date(returnDate + "T00:00:00")
: undefined
}
onChange={(date) => {
setValue("returnDate", `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`);
setValue(
"returnDate",
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
);
trigger("returnDate");
}}
minDate={departureDate ? new Date(departureDate + "T00:00:00") : new Date()}
minDate={
departureDate
? new Date(departureDate + "T00:00:00")
: new Date()
}
placeholder="Return date"
/>
{errors.returnDate && (
<p className="text-xs text-red-500">{errors.returnDate.message}</p>
<p className="text-xs text-red-500">
{errors.returnDate.message}
</p>
)}
</div>
{/* Passengers */}
<div className="w-44 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Passengers</label>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Passengers
</label>
<button
type="button"
onClick={() => setPassengerModalOpen(true)}
className={`w-full flex items-center justify-between px-3 py-3.5 border-2 rounded-xl bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all ${
showNationalityError ? "border-red-400" : "border-gray-200 dark:border-gray-700"
showNationalityError
? "border-red-400"
: "border-gray-200 dark:border-gray-700"
}`}
>
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 dark:text-white truncate">
<Users className="w-4 h-4 text-primary flex-shrink-0" />
{totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"}
{nationalityFlag(watch("nationality")) ? ` · ${nationalityFlag(watch("nationality"))}` : " · Nationality"}
{totalPassengers}{" "}
{totalPassengers === 1
? "Passenger"
: "Passengers"}
{nationalityFlag(watch("nationality"))
? ` · ${nationalityFlag(watch("nationality"))}`
: " · Nationality"}
</span>
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
</button>
{showNationalityError && (
<p className="text-xs text-red-500">{errors.nationality?.message}</p>
<p className="text-xs text-red-500">
{errors.nationality?.message}
</p>
)}
</div>
{/* Search */}
@@ -1386,7 +1562,6 @@ export default function SearchPage() {
</div>
)}
</div>
</div>
</div>
</form>

View File

@@ -23,7 +23,10 @@ import { useSearchParams } from "next/navigation";
*/
const ALLOWED_HOSTS = (
process.env.NEXT_PUBLIC_DMONEY_ALLOWED_HOSTS ?? "d-money.dj"
// Default allows both D-Money environments:
// test/sandbox → pgtest.d-money.dj (base domain d-money.dj)
// production → pg.d-moneyservice.dj (base domain d-moneyservice.dj)
process.env.NEXT_PUBLIC_DMONEY_ALLOWED_HOSTS ?? "d-money.dj,d-moneyservice.dj"
)
.split(",")
.map((h) => h.trim().toLowerCase())

View File

@@ -1,4 +1,4 @@
import type { Metadata } from 'next';
import type { Metadata, Viewport } from 'next';
import { headers } from 'next/headers';
import './globals.css';
import { Providers } from './providers';
@@ -13,6 +13,15 @@ export const metadata: Metadata = {
description: 'Book train tickets on the Ethio-Djibouti Railway',
};
// viewportFit: 'cover' lets fixed bottom bars (e.g. the payment page's Pay
// button) read env(safe-area-inset-bottom) so they pad above the home
// indicator / gesture nav bar instead of being covered by it.
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
};
export default function RootLayout({
children,
}: {

View File

@@ -38,10 +38,21 @@ async function StationsPrefetch({ children }: { children: React.ReactNode }) {
await queryClient.prefetchQuery({
queryKey: ['stations'],
queryFn: async () => {
const res = await fetch(`${apiUrl}/stations`, { next: { revalidate: 3600 } });
// Caps how long the home page can be blocked on a slow/unresponsive API —
// without this, a hung request leaves the Suspense boundary (and the
// skeleton) stuck indefinitely instead of falling through to the
// client-side fetch, which has its own timeout and a visible error state.
const res = await fetch(`${apiUrl}/stations`, {
next: { revalidate: 3600 },
signal: AbortSignal.timeout(8000),
});
const json = await res.json();
return json?.data ?? json;
},
// prefetchQuery defaults to 3 retries on failure — uncapped, that's up to
// ~24s of retries on top of the 8s timeout above before this ever resolves.
// Match the client's global default (providers.tsx) instead.
retry: 1,
});
return (
@@ -53,11 +64,16 @@ async function StationsPrefetch({ children }: { children: React.ReactNode }) {
export default function Home() {
return (
<Suspense fallback={<SearchFormSkeleton />}>
<StationsPrefetch>
<SearchPage />
<PackagesSection />
</StationsPrefetch>
</Suspense>
<>
<Suspense fallback={<SearchFormSkeleton />}>
<StationsPrefetch>
<SearchPage />
</StationsPrefetch>
</Suspense>
{/* Has its own independent data fetch (no dependency on stations) —
rendered outside the StationsPrefetch boundary so it isn't stuck
waiting on that fetch to resolve before it can start its own. */}
<PackagesSection />
</>
);
}

View File

@@ -27,6 +27,10 @@ interface PassengerVoucherData {
fareMinor: number;
currency: string;
createdAt: string;
// True when fareMinor is already a display-ready amount (e.g. the settled
// payment.amountMinor straight from the API) and must NOT be divided by 100 — as
// opposed to the normal case where fareMinor is genuine minor units (cents).
fareIsMajorUnits?: boolean;
}
// ─── palette ───────────────────────────────────────────────────────────────
@@ -327,7 +331,7 @@ function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number,
// ─── fare summary ──────────────────────────────────────────────────────────
function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: number, margin: number, pageWidth: number): number {
function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: number, margin: number, pageWidth: number, fareIsMajorUnits = false): number {
const cardH = 20;
doc.setFillColor(...BRAND_SOFT);
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'F');
@@ -338,7 +342,8 @@ function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: num
doc.text('✓ PAID', margin + padX, y + 15);
doc.setTextColor(...BRAND); doc.setFontSize(16); doc.setFont('helvetica', 'bold');
doc.text(`${currency} ${(fareMinor / 100).toFixed(2)}`, pageWidth - margin - padX, y + 13, { align: 'right' });
const displayAmount = fareIsMajorUnits ? fareMinor : fareMinor / 100;
doc.text(`${currency} ${displayAmount.toFixed(2)}`, pageWidth - margin - padX, y + 13, { align: 'right' });
return y + cardH + 8;
}
@@ -398,7 +403,7 @@ async function drawPassengerVoucherPage(doc: jsPDF, data: PassengerVoucherData):
}
y = drawPassengerDetails(doc, data, y, margin, pageW);
y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW);
y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW, data.fareIsMajorUnits);
drawInstructions(doc, y, margin, pageW);
drawFooter(doc, data.createdAt);
}
@@ -432,11 +437,12 @@ interface VoucherData {
}
export const generateVoucherPDF = async (booking: VoucherData): Promise<void> => {
// The settled payment amount, when available, is shown exactly as returned by the API
// (no /100, no per-passenger split) on every passenger's voucher — see fareIsMajorUnits.
const settledAmountMinor = booking.payment?.amountMinor;
const settledCurrency = booking.payment?.currency;
const useSettledAmount = settledAmountMinor != null && !!settledCurrency;
const voucherCurrency = useSettledAmount ? settledCurrency! : booking.currency;
const totalForSplit = useSettledAmount ? settledAmountMinor! : booking.totalMinor;
// Separate file per passenger, saved back-to-back with no macrotask (setTimeout) between
// them — a setTimeout delay here would push later saves outside the click's synchronous
@@ -459,8 +465,9 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
status: booking.status,
outboundSchedule: { ...booking.schedule, seatClass: p.seat?.seatClass },
isRoundTrip: false,
fareMinor: Math.round(totalForSplit / booking.passengers.length),
fareMinor: useSettledAmount ? settledAmountMinor! : Math.round(booking.totalMinor / booking.passengers.length),
currency: voucherCurrency,
fareIsMajorUnits: useSettledAmount,
createdAt: booking.createdAt,
});
}

View File

@@ -10,4 +10,7 @@ export default registerAs("cac", () => ({
currency: process.env.CAC_CURRENCY || "DJF",
tokenTtlMs: Number(process.env.CAC_TOKEN_TTL_MS || 23 * 60 * 60 * 1000),
otpExpiryMs: Number(process.env.CAC_OTP_EXPIRY_MS || 10 * 60 * 1000),
// The bank's PaymentInitiateRequest sends an OTP by SMS and can be slow; the bank asked us
// to raise the client timeout. Generous default, overridable via env.
httpTimeoutMs: Number(process.env.CAC_HTTP_TIMEOUT_MS || 60_000),
}));

View File

@@ -180,7 +180,12 @@ export class IntentsService {
);
}
if (intent.status !== ProviderPaymentStatus.REQUIRES_ACTION) {
// REQUIRES_ACTION is the normal awaiting-OTP state; PROCESSING is tolerated so an intent
// that a poll/sweep nudged forward can still be confirmed. Terminal states are rejected.
if (
intent.status !== ProviderPaymentStatus.REQUIRES_ACTION &&
intent.status !== ProviderPaymentStatus.PROCESSING
) {
throw new BadRequestException(
`Intent is not awaiting confirmation (status=${intent.status})`,
);
@@ -211,15 +216,44 @@ export class IntentsService {
providerTxnId: confirmResult.providerTxnId,
paidAt: new Date(),
});
} else {
await this.applyProviderResult(intent.id, {
status: ProviderPaymentStatus.FAILED,
failureCode: confirmResult.failureCode,
failureMessage: confirmResult.failureMessage,
});
return this.snapshotOf(intent.id);
}
const updated = await this.intentsRepository.findById(intent.id);
// Confirm did not clearly succeed. CAC has no callback and the confirm response can be
// lost after the customer was charged, so before failing anything verify the source of
// truth by paymentRequestId (GetPaymentByReferenceRequest keys on it).
const verified = await this.cacBankProvider
.queryStatus(intent.providerOrderId)
.catch((err: unknown) => {
this.logger.warn(
`CAC verify after failed confirm errored for intent ${intent.id}: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
});
if (verified?.status === ProviderPaymentStatus.SUCCEEDED) {
await this.applyProviderResult(intent.id, {
status: ProviderPaymentStatus.SUCCEEDED,
providerTxnId: verified.providerTxnId,
paidAt: new Date(),
});
return this.snapshotOf(intent.id);
}
// Genuinely not paid — almost always a wrong or expired OTP. Leave the intent in
// REQUIRES_ACTION so the payer can re-enter the code, and do NOT emit payment.failed:
// a mistyped OTP must not cancel the booking. The reconciliation sweep CANCELs the
// intent once its OTP window (expiresAt) passes.
throw new BadRequestException(
confirmResult.failureMessage ??
"OTP confirmation failed — please re-enter the code sent to your phone",
);
}
private async snapshotOf(intentId: string): Promise<PaymentIntentSnapshot> {
const updated = await this.intentsRepository.findById(intentId);
if (!updated) throw new NotFoundException("PaymentIntent not found");
return this.toSnapshot(updated);
}
@@ -306,12 +340,12 @@ export class IntentsService {
}
if (intent.provider === ProviderMethod.CAC_BANK) {
const reference = (intent.rawInitiation as { reference?: string })
?.reference;
return this.cacBankProvider.queryStatus(
intent.merchantOrderId,
reference,
);
if (!intent.providerOrderId) {
throw new Error(
`CAC intent ${intent.id} has no providerOrderId to verify`,
);
}
return this.cacBankProvider.queryStatus(intent.providerOrderId);
}
return provider.queryStatus(intent.merchantOrderId);

View File

@@ -87,8 +87,7 @@ export class ReconciliationService implements OnModuleInit, OnModuleDestroy {
const status =
intent.provider === ProviderMethod.CAC_BANK
? await this.cacBankProvider.queryStatus(
intent.merchantOrderId,
(intent.rawInitiation as { reference?: string })?.reference,
intent.providerOrderId ?? intent.merchantOrderId,
)
: await provider.queryStatus(intent.merchantOrderId);
const result = this.intentsService.fromProviderStatus(status);