mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "PaymentMethodType" ADD VALUE 'CAC_BANK';
|
||||||
@@ -146,6 +146,7 @@ enum PaymentMethodType {
|
|||||||
WALLET
|
WALLET
|
||||||
WAAFI
|
WAAFI
|
||||||
DMONEY
|
DMONEY
|
||||||
|
CAC_BANK
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { HttpService } from "@nestjs/axios";
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import { firstValueFrom } from "rxjs";
|
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>(
|
private async call<T>(
|
||||||
method: "GET" | "POST",
|
method: "GET" | "POST",
|
||||||
path: string,
|
path: string,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
PaymentPlatformDto,
|
PaymentPlatformDto,
|
||||||
BookingAmountResponseDto,
|
BookingAmountResponseDto,
|
||||||
ForceConfirmDto,
|
ForceConfirmDto,
|
||||||
|
ConfirmOtpDto,
|
||||||
} from "./payments.dto";
|
} from "./payments.dto";
|
||||||
import { PassengerStaff } from "../../common/passenger-guards";
|
import { PassengerStaff } from "../../common/passenger-guards";
|
||||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||||
@@ -94,6 +95,21 @@ export class PaymentsController {
|
|||||||
return this.service.getIntentByBookingId(bookingId);
|
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")
|
@Get("waafi/return")
|
||||||
@SetMetadata('isPublic', true)
|
@SetMetadata('isPublic', true)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export enum PaymentMethodTypeEnum {
|
|||||||
EBIRR = "EBIRR", // Ethiopia
|
EBIRR = "EBIRR", // Ethiopia
|
||||||
WAAFI = "WAAFI",
|
WAAFI = "WAAFI",
|
||||||
DMONEY= "DMONEY",// Djibouti
|
DMONEY= "DMONEY",// Djibouti
|
||||||
|
CAC_BANK = "CAC_BANK", // Djibouti (OTP debit)
|
||||||
CARD = "CARD", // International
|
CARD = "CARD", // International
|
||||||
WALLET = "WALLET", // Internal
|
WALLET = "WALLET", // Internal
|
||||||
}
|
}
|
||||||
@@ -50,6 +51,24 @@ export class InitiatePaymentDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(["web", "mobile"])
|
@IsIn(["web", "mobile"])
|
||||||
platform?: PaymentPlatformDto;
|
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 {
|
export class RefundDto {
|
||||||
|
|||||||
@@ -53,7 +53,11 @@ function rabbitMQImport(): DynamicModule[] {
|
|||||||
SeatsModule,
|
SeatsModule,
|
||||||
TicketsModule,
|
TicketsModule,
|
||||||
CurrencyModule,
|
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(),
|
...rabbitMQImport(),
|
||||||
],
|
],
|
||||||
controllers: [PaymentsController, InternalPaymentsController],
|
controllers: [PaymentsController, InternalPaymentsController],
|
||||||
|
|||||||
@@ -183,6 +183,14 @@ export class PaymentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const method = dto.method as PaymentMethodType;
|
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);
|
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)
|
// 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,
|
currency: chargeCurrency,
|
||||||
provider: method as unknown as ProviderMethod,
|
provider: method as unknown as ProviderMethod,
|
||||||
platform: dto.platform,
|
platform: dto.platform,
|
||||||
|
payerAccount: dto.payerAccount,
|
||||||
returnUrl,
|
returnUrl,
|
||||||
failureUrl,
|
failureUrl,
|
||||||
});
|
});
|
||||||
@@ -248,6 +257,43 @@ export class PaymentsService {
|
|||||||
}
|
}
|
||||||
return this.formatIntentResponse(intent);
|
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): {
|
private resolveReturnUrls(method: PaymentMethodType): {
|
||||||
returnUrl?: string;
|
returnUrl?: string;
|
||||||
failureUrl?: string;
|
failureUrl?: string;
|
||||||
|
|||||||
@@ -203,6 +203,8 @@ export default function PaymentMethodsPage() {
|
|||||||
{ value: 'CBE_BIRR', label: 'CBE Birr' },
|
{ value: 'CBE_BIRR', label: 'CBE Birr' },
|
||||||
{ value: 'EBIRR', label: 'eBirr' },
|
{ value: 'EBIRR', label: 'eBirr' },
|
||||||
{ value: 'WAAFI', label: 'Waafi' },
|
{ value: 'WAAFI', label: 'Waafi' },
|
||||||
|
{ value: 'DMONEY', label: 'dMoney' },
|
||||||
|
{ value: 'CAC_BANK', label: 'CAC Bank' },
|
||||||
{ value: 'CARD', label: 'Card Payment' },
|
{ value: 'CARD', label: 'Card Payment' },
|
||||||
{ value: 'WALLET', label: 'Internal Wallet' },
|
{ value: 'WALLET', label: 'Internal Wallet' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -119,10 +119,13 @@ export default function ConfirmationPage() {
|
|||||||
|
|
||||||
const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
|
const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
|
||||||
// The server-confirmed settled amount/currency (what was actually charged) is
|
// 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 settledAmountMinor = _booking?.payment?.amountMinor;
|
||||||
const settledCurrency = _booking?.payment?.currency;
|
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 createdAt = _booking?.createdAt || new Date().toISOString();
|
||||||
const status = _booking?.status || "CONFIRMED";
|
const status = _booking?.status || "CONFIRMED";
|
||||||
|
|
||||||
@@ -154,21 +157,6 @@ export default function ConfirmationPage() {
|
|||||||
return Math.round(totalFare / passengers.length);
|
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 = {
|
const outbound = {
|
||||||
trainNumber: activeSchedule?.trainNumber || "N/A",
|
trainNumber: activeSchedule?.trainNumber || "N/A",
|
||||||
trainName: "EDR Express",
|
trainName: "EDR Express",
|
||||||
@@ -234,8 +222,9 @@ export default function ConfirmationPage() {
|
|||||||
outboundSchedule: outbound,
|
outboundSchedule: outbound,
|
||||||
inboundSchedule: inbound,
|
inboundSchedule: inbound,
|
||||||
isRoundTrip,
|
isRoundTrip,
|
||||||
fareMinor: getVoucherFare(i),
|
fareMinor: hasSettledAmount ? settledAmountMinor! : getEtbFare(i),
|
||||||
currency: voucherCurrency,
|
currency: voucherCurrency,
|
||||||
|
fareIsMajorUnits: hasSettledAmount,
|
||||||
createdAt,
|
createdAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Smartphone,
|
Smartphone,
|
||||||
Loader2,
|
Loader2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { formatTime, getTimePeriod } from "@/utils/format";
|
import { formatTime, getTimePeriod } from "@/utils/format";
|
||||||
@@ -53,6 +54,11 @@ function BookingDetailContent() {
|
|||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
const [paymentError, setPaymentError] = useState<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 [copiedPNR, setCopiedPNR] = useState(false);
|
||||||
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
|
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
|
||||||
|
|
||||||
@@ -486,7 +492,7 @@ function BookingDetailContent() {
|
|||||||
|
|
||||||
if (isPendingPayment && !isExpired) {
|
if (isPendingPayment && !isExpired) {
|
||||||
return (
|
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="container mx-auto px-4">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">
|
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">
|
||||||
@@ -676,6 +682,11 @@ function BookingDetailContent() {
|
|||||||
setSelectedMethodCurrency(
|
setSelectedMethodCurrency(
|
||||||
method.currency ?? null,
|
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={
|
disabled={
|
||||||
paymentMutation.isPending ||
|
paymentMutation.isPending ||
|
||||||
@@ -735,57 +746,93 @@ function BookingDetailContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile sticky bottom bar */}
|
{/* Mobile payment dialog — the amount + Back/Pay buttons live here now,
|
||||||
<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">
|
opened by picking a payment method above. Centered dialog-box style
|
||||||
<div className="flex items-center justify-between mb-2.5">
|
(not a full-screen sheet) so it can't end up pinned under system chrome. */}
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
{paymentModalOpen && (
|
||||||
Total
|
<div
|
||||||
</span>
|
className="lg:hidden fixed inset-0 z-[100] flex items-center justify-center p-4"
|
||||||
<span className="text-lg font-bold text-primary flex items-center gap-1.5">
|
role="dialog"
|
||||||
{awaitingAmount ? (
|
aria-modal="true"
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
aria-labelledby="payment-modal-title"
|
||||||
) : (
|
>
|
||||||
<>
|
{/* Backdrop */}
|
||||||
{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}
|
<div
|
||||||
</>
|
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||||
)}
|
onClick={() => setPaymentModalOpen(false)}
|
||||||
</span>
|
/>
|
||||||
|
|
||||||
|
{/* 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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,14 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
|
KeyRound,
|
||||||
|
Landmark,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const getIconForMethod = (methodId: string) => {
|
const getIconForMethod = (methodId: string) => {
|
||||||
if (methodId.includes('CARD')) return CreditCard;
|
if (methodId.includes('CARD')) return CreditCard;
|
||||||
if (methodId.includes('WALLET')) return Wallet;
|
if (methodId.includes('WALLET')) return Wallet;
|
||||||
|
if (methodId.includes('CAC')) return Landmark;
|
||||||
return Smartphone;
|
return Smartphone;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,6 +37,14 @@ export default function PaymentPage() {
|
|||||||
const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null);
|
const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [paymentError, setPaymentError] = useState<string | null>(null);
|
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 isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||||
const isPackage = !!packageName;
|
const isPackage = !!packageName;
|
||||||
@@ -118,13 +129,26 @@ export default function PaymentPage() {
|
|||||||
bookingId: data.bookingId,
|
bookingId: data.bookingId,
|
||||||
method: data.method,
|
method: data.method,
|
||||||
paymentMethodId: data.paymentMethodId,
|
paymentMethodId: data.paymentMethodId,
|
||||||
|
payerAccount: data.payerAccount,
|
||||||
platform: 'web',
|
platform: 'web',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: async (data: any) => {
|
onSuccess: async (data: any) => {
|
||||||
setPaymentError(null);
|
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);
|
setPaymentIntent(data.intentId);
|
||||||
updateStatus("REQUIRES_ACTION");
|
updateStatus("REQUIRES_ACTION");
|
||||||
window.location.href = data.clientAction.url;
|
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);
|
setIsProcessing(true);
|
||||||
setPaymentError(null);
|
setPaymentError(null);
|
||||||
|
|
||||||
if (!selectedPaymentMethod) {
|
|
||||||
alert("Invalid payment method selected");
|
|
||||||
setIsProcessing(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
paymentMutation.mutate({
|
paymentMutation.mutate({
|
||||||
bookingId,
|
bookingId,
|
||||||
method: selectedMethod,
|
method: selectedMethod,
|
||||||
paymentMethodId: selectedPaymentMethod.id,
|
paymentMethodId: selectedPaymentMethod.id,
|
||||||
currency: displayCurrency,
|
currency: displayCurrency,
|
||||||
amountMinor: totalAmount,
|
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)
|
// Redirect if no booking data (but not during navigation)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Add a small delay to allow state to be set from previous page
|
// Add a small delay to allow state to be set from previous page
|
||||||
@@ -372,7 +437,7 @@ export default function PaymentPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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="container mx-auto px-4">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<h1 className="section-title">Complete payment</h1>
|
<h1 className="section-title">Complete payment</h1>
|
||||||
@@ -414,6 +479,100 @@ export default function PaymentPage() {
|
|||||||
</div>
|
</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 */}
|
{/* Two-column grid */}
|
||||||
<div className="lg:grid lg:grid-cols-3 lg:gap-6 lg:items-start">
|
<div className="lg:grid lg:grid-cols-3 lg:gap-6 lg:items-start">
|
||||||
|
|
||||||
@@ -487,7 +646,7 @@ export default function PaymentPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile sticky bottom bar */}
|
{/* 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">
|
<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-sm text-gray-600 dark:text-gray-400">Total</span>
|
||||||
<span className="text-lg font-bold text-primary flex items-center gap-1.5">
|
<span className="text-lg font-bold text-primary flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -25,14 +25,16 @@ import { useEffect, useRef, useState, useCallback } from "react";
|
|||||||
import ModernDatePicker from "@/components/ModernDatePicker";
|
import ModernDatePicker from "@/components/ModernDatePicker";
|
||||||
|
|
||||||
function useDarkMode() {
|
function useDarkMode() {
|
||||||
const [dark, setDark] = useState(() =>
|
const [dark, setDark] = useState(
|
||||||
typeof window !== 'undefined' && document.documentElement.classList.contains('dark')
|
() =>
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
document.documentElement.classList.contains("dark"),
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const obs = new MutationObserver(() =>
|
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 () => obs.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
return dark;
|
return dark;
|
||||||
@@ -344,7 +346,9 @@ function PassengerModal({
|
|||||||
{nationalityError && (
|
{nationalityError && (
|
||||||
<p className="text-xs text-red-500 mb-2">{nationalityError}</p>
|
<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) => (
|
{natOptions.map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
@@ -556,6 +560,10 @@ export default function SearchPage() {
|
|||||||
// doesn't paint anything until the target route's JS has loaded, which
|
// doesn't paint anything until the target route's JS has loaded, which
|
||||||
// otherwise reads as a dead click.
|
// otherwise reads as a dead click.
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
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
|
// 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
|
// doesn't have to wait for it to download/compile on top of the actual
|
||||||
@@ -592,7 +600,10 @@ export default function SearchPage() {
|
|||||||
error,
|
error,
|
||||||
} = useQuery<Station[]>({
|
} = useQuery<Station[]>({
|
||||||
queryKey: ["stations"],
|
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 {
|
const {
|
||||||
@@ -728,7 +739,9 @@ export default function SearchPage() {
|
|||||||
const onInvalid = (formErrors: typeof errors) => {
|
const onInvalid = (formErrors: typeof errors) => {
|
||||||
setHasInteracted(true);
|
setHasInteracted(true);
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
const hasOtherErrors = Object.keys(formErrors).some((k) => k !== "nationality");
|
const hasOtherErrors = Object.keys(formErrors).some(
|
||||||
|
(k) => k !== "nationality",
|
||||||
|
);
|
||||||
if (formErrors.nationality && !hasOtherErrors) {
|
if (formErrors.nationality && !hasOtherErrors) {
|
||||||
setPassengerModalOpen(true);
|
setPassengerModalOpen(true);
|
||||||
}
|
}
|
||||||
@@ -748,7 +761,167 @@ export default function SearchPage() {
|
|||||||
// No default nationality anymore — only render a flag once one is actually picked, rather
|
// 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.
|
// than falling through to the "Other" 🌍 flag and implying a selection that hasn't happened.
|
||||||
const nationalityFlag = (nat?: string) =>
|
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 (
|
return (
|
||||||
<div className="bg-gray-50 dark:bg-gray-950">
|
<div className="bg-gray-50 dark:bg-gray-950">
|
||||||
@@ -765,7 +938,9 @@ export default function SearchPage() {
|
|||||||
clearErrors("nationality");
|
clearErrors("nationality");
|
||||||
}}
|
}}
|
||||||
onClose={() => setPassengerModalOpen(false)}
|
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">
|
<div className="p-4 md:p-5">
|
||||||
{/* Trip Type Tabs */}
|
{/* Trip Type Tabs — always visible on desktop; on mobile only inside the
|
||||||
<div className="mb-4">
|
search modal (hidden in the collapsed view). */}
|
||||||
<div className="inline-flex rounded-xl bg-gray-100 dark:bg-gray-800 p-1 w-full md:w-auto">
|
<div
|
||||||
<button
|
className={`${mobileSearchModalOpen ? "block" : "hidden"} md:block`}
|
||||||
type="button"
|
>
|
||||||
onClick={() => setValue("tripType", "ONE_WAY")}
|
{renderTripTypeTabs()}
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Mobile: stacked, but From/To and Date/Return Date pair up into two
|
{/* Mobile collapsed view — just From/To + Search. Tapping a station still
|
||||||
columns each to save vertical space (station names/dates truncate
|
opens StationModal directly; tapping Search opens the full modal below
|
||||||
rather than wrap) — same fields, same behavior, just denser. */}
|
instead of submitting (validation happens inside that modal). */}
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<div
|
||||||
<div className="grid grid-cols-2 gap-3">
|
className={`${mobileSearchModalOpen ? "hidden" : "flex"} flex-col gap-3 md:hidden`}
|
||||||
<div className="space-y-1.5 min-w-0">
|
>
|
||||||
<div className="h-5 flex items-center">
|
{renderStationFields(true)}
|
||||||
<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 */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPassengerModalOpen(true)}
|
onClick={() => setMobileSearchModalOpen(true)}
|
||||||
className={`w-full flex items-center justify-between px-3.5 py-3 border-2 rounded-xl bg-white dark:bg-gray-800 ${
|
className="btn-primary w-full text-sm flex items-center justify-center gap-2"
|
||||||
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' }}>
|
<Search className="w-5 h-5" />
|
||||||
<Users className="w-4 h-4 text-primary" />
|
Search
|
||||||
{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>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Desktop: dynamic layout based on trip type */}
|
||||||
<div className={`hidden md:block`}>
|
<div className={`hidden md:block`}>
|
||||||
{tripType === "ONE_WAY" ? (
|
{tripType === "ONE_WAY" ? (
|
||||||
@@ -1219,12 +1335,17 @@ export default function SearchPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPassengerModalOpen(true)}
|
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 ${
|
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">
|
<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" />
|
<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"))
|
||||||
? ` · ${nationalityFlag(watch("nationality"))}`
|
? ` · ${nationalityFlag(watch("nationality"))}`
|
||||||
: " · Nationality"}
|
: " · Nationality"}
|
||||||
@@ -1232,7 +1353,9 @@ export default function SearchPage() {
|
|||||||
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
|
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
{showNationalityError && (
|
{showNationalityError && (
|
||||||
<p className="text-xs text-red-500">{errors.nationality?.message}</p>
|
<p className="text-xs text-red-500">
|
||||||
|
{errors.nationality?.message}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
@@ -1259,7 +1382,9 @@ export default function SearchPage() {
|
|||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
{/* From */}
|
{/* From */}
|
||||||
<div className="flex-1 min-w-0 space-y-1">
|
<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
|
<StationDropdown
|
||||||
stations={stations}
|
stations={stations}
|
||||||
value={originId}
|
value={originId}
|
||||||
@@ -1273,11 +1398,17 @@ export default function SearchPage() {
|
|||||||
clearErrors("originStationId");
|
clearErrors("originStationId");
|
||||||
clearErrors("destinationStationId");
|
clearErrors("destinationStationId");
|
||||||
}}
|
}}
|
||||||
error={hasInteracted ? errors.originStationId?.message : undefined}
|
error={
|
||||||
|
hasInteracted
|
||||||
|
? errors.originStationId?.message
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onOpen={scrollWidgetIntoView}
|
onOpen={scrollWidgetIntoView}
|
||||||
/>
|
/>
|
||||||
{hasInteracted && errors.originStationId && (
|
{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>
|
</div>
|
||||||
{/* Swap */}
|
{/* Swap */}
|
||||||
@@ -1291,7 +1422,9 @@ export default function SearchPage() {
|
|||||||
</button>
|
</button>
|
||||||
{/* To */}
|
{/* To */}
|
||||||
<div className="flex-1 min-w-0 space-y-1">
|
<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
|
<StationDropdown
|
||||||
stations={stations}
|
stations={stations}
|
||||||
value={destId}
|
value={destId}
|
||||||
@@ -1304,20 +1437,35 @@ export default function SearchPage() {
|
|||||||
if (s.id) saveRecent(s.id);
|
if (s.id) saveRecent(s.id);
|
||||||
clearErrors("destinationStationId");
|
clearErrors("destinationStationId");
|
||||||
}}
|
}}
|
||||||
error={hasInteracted ? errors.destinationStationId?.message : undefined}
|
error={
|
||||||
|
hasInteracted
|
||||||
|
? errors.destinationStationId?.message
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onOpen={scrollWidgetIntoView}
|
onOpen={scrollWidgetIntoView}
|
||||||
/>
|
/>
|
||||||
{hasInteracted && errors.destinationStationId && (
|
{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>
|
</div>
|
||||||
{/* Departure Date */}
|
{/* Departure Date */}
|
||||||
<div className="w-40 flex-shrink-0 space-y-1">
|
<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
|
<ModernDatePicker
|
||||||
value={departureDate ? new Date(departureDate + "T00:00:00") : undefined}
|
value={
|
||||||
|
departureDate
|
||||||
|
? new Date(departureDate + "T00:00:00")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onChange={(date) => {
|
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("departureDate");
|
||||||
trigger("returnDate");
|
trigger("returnDate");
|
||||||
}}
|
}}
|
||||||
@@ -1325,44 +1473,72 @@ export default function SearchPage() {
|
|||||||
placeholder="Departure date"
|
placeholder="Departure date"
|
||||||
/>
|
/>
|
||||||
{errors.departureDate && (
|
{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>
|
</div>
|
||||||
{/* Return Date */}
|
{/* Return Date */}
|
||||||
<div className="w-40 flex-shrink-0 space-y-1">
|
<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
|
<ModernDatePicker
|
||||||
value={returnDate ? new Date(returnDate + "T00:00:00") : undefined}
|
value={
|
||||||
|
returnDate
|
||||||
|
? new Date(returnDate + "T00:00:00")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onChange={(date) => {
|
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");
|
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"
|
placeholder="Return date"
|
||||||
/>
|
/>
|
||||||
{errors.returnDate && (
|
{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>
|
</div>
|
||||||
{/* Passengers */}
|
{/* Passengers */}
|
||||||
<div className="w-44 flex-shrink-0 space-y-1">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPassengerModalOpen(true)}
|
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 ${
|
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">
|
<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" />
|
<Users className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
{totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"}
|
{totalPassengers}{" "}
|
||||||
{nationalityFlag(watch("nationality")) ? ` · ${nationalityFlag(watch("nationality"))}` : " · Nationality"}
|
{totalPassengers === 1
|
||||||
|
? "Passenger"
|
||||||
|
: "Passengers"}
|
||||||
|
{nationalityFlag(watch("nationality"))
|
||||||
|
? ` · ${nationalityFlag(watch("nationality"))}`
|
||||||
|
: " · Nationality"}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
|
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
{showNationalityError && (
|
{showNationalityError && (
|
||||||
<p className="text-xs text-red-500">{errors.nationality?.message}</p>
|
<p className="text-xs text-red-500">
|
||||||
|
{errors.nationality?.message}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
@@ -1386,7 +1562,6 @@ export default function SearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ import { useSearchParams } from "next/navigation";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const ALLOWED_HOSTS = (
|
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(",")
|
.split(",")
|
||||||
.map((h) => h.trim().toLowerCase())
|
.map((h) => h.trim().toLowerCase())
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import { Providers } from './providers';
|
import { Providers } from './providers';
|
||||||
@@ -13,6 +13,15 @@ export const metadata: Metadata = {
|
|||||||
description: 'Book train tickets on the Ethio-Djibouti Railway',
|
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({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -38,10 +38,21 @@ async function StationsPrefetch({ children }: { children: React.ReactNode }) {
|
|||||||
await queryClient.prefetchQuery({
|
await queryClient.prefetchQuery({
|
||||||
queryKey: ['stations'],
|
queryKey: ['stations'],
|
||||||
queryFn: async () => {
|
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();
|
const json = await res.json();
|
||||||
return json?.data ?? 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 (
|
return (
|
||||||
@@ -53,11 +64,16 @@ async function StationsPrefetch({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<SearchFormSkeleton />}>
|
<>
|
||||||
<StationsPrefetch>
|
<Suspense fallback={<SearchFormSkeleton />}>
|
||||||
<SearchPage />
|
<StationsPrefetch>
|
||||||
<PackagesSection />
|
<SearchPage />
|
||||||
</StationsPrefetch>
|
</StationsPrefetch>
|
||||||
</Suspense>
|
</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 />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ interface PassengerVoucherData {
|
|||||||
fareMinor: number;
|
fareMinor: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
createdAt: 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 ───────────────────────────────────────────────────────────────
|
// ─── palette ───────────────────────────────────────────────────────────────
|
||||||
@@ -327,7 +331,7 @@ function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number,
|
|||||||
|
|
||||||
// ─── fare summary ──────────────────────────────────────────────────────────
|
// ─── 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;
|
const cardH = 20;
|
||||||
doc.setFillColor(...BRAND_SOFT);
|
doc.setFillColor(...BRAND_SOFT);
|
||||||
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'F');
|
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.text('✓ PAID', margin + padX, y + 15);
|
||||||
|
|
||||||
doc.setTextColor(...BRAND); doc.setFontSize(16); doc.setFont('helvetica', 'bold');
|
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;
|
return y + cardH + 8;
|
||||||
}
|
}
|
||||||
@@ -398,7 +403,7 @@ async function drawPassengerVoucherPage(doc: jsPDF, data: PassengerVoucherData):
|
|||||||
}
|
}
|
||||||
|
|
||||||
y = drawPassengerDetails(doc, data, y, margin, pageW);
|
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);
|
drawInstructions(doc, y, margin, pageW);
|
||||||
drawFooter(doc, data.createdAt);
|
drawFooter(doc, data.createdAt);
|
||||||
}
|
}
|
||||||
@@ -432,11 +437,12 @@ interface VoucherData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const generateVoucherPDF = async (booking: VoucherData): Promise<void> => {
|
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 settledAmountMinor = booking.payment?.amountMinor;
|
||||||
const settledCurrency = booking.payment?.currency;
|
const settledCurrency = booking.payment?.currency;
|
||||||
const useSettledAmount = settledAmountMinor != null && !!settledCurrency;
|
const useSettledAmount = settledAmountMinor != null && !!settledCurrency;
|
||||||
const voucherCurrency = useSettledAmount ? settledCurrency! : booking.currency;
|
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
|
// 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
|
// 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,
|
status: booking.status,
|
||||||
outboundSchedule: { ...booking.schedule, seatClass: p.seat?.seatClass },
|
outboundSchedule: { ...booking.schedule, seatClass: p.seat?.seatClass },
|
||||||
isRoundTrip: false,
|
isRoundTrip: false,
|
||||||
fareMinor: Math.round(totalForSplit / booking.passengers.length),
|
fareMinor: useSettledAmount ? settledAmountMinor! : Math.round(booking.totalMinor / booking.passengers.length),
|
||||||
currency: voucherCurrency,
|
currency: voucherCurrency,
|
||||||
|
fareIsMajorUnits: useSettledAmount,
|
||||||
createdAt: booking.createdAt,
|
createdAt: booking.createdAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,7 @@ export default registerAs("cac", () => ({
|
|||||||
currency: process.env.CAC_CURRENCY || "DJF",
|
currency: process.env.CAC_CURRENCY || "DJF",
|
||||||
tokenTtlMs: Number(process.env.CAC_TOKEN_TTL_MS || 23 * 60 * 60 * 1000),
|
tokenTtlMs: Number(process.env.CAC_TOKEN_TTL_MS || 23 * 60 * 60 * 1000),
|
||||||
otpExpiryMs: Number(process.env.CAC_OTP_EXPIRY_MS || 10 * 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),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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(
|
throw new BadRequestException(
|
||||||
`Intent is not awaiting confirmation (status=${intent.status})`,
|
`Intent is not awaiting confirmation (status=${intent.status})`,
|
||||||
);
|
);
|
||||||
@@ -211,15 +216,44 @@ export class IntentsService {
|
|||||||
providerTxnId: confirmResult.providerTxnId,
|
providerTxnId: confirmResult.providerTxnId,
|
||||||
paidAt: new Date(),
|
paidAt: new Date(),
|
||||||
});
|
});
|
||||||
} else {
|
return this.snapshotOf(intent.id);
|
||||||
await this.applyProviderResult(intent.id, {
|
|
||||||
status: ProviderPaymentStatus.FAILED,
|
|
||||||
failureCode: confirmResult.failureCode,
|
|
||||||
failureMessage: confirmResult.failureMessage,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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");
|
if (!updated) throw new NotFoundException("PaymentIntent not found");
|
||||||
return this.toSnapshot(updated);
|
return this.toSnapshot(updated);
|
||||||
}
|
}
|
||||||
@@ -306,12 +340,12 @@ export class IntentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (intent.provider === ProviderMethod.CAC_BANK) {
|
if (intent.provider === ProviderMethod.CAC_BANK) {
|
||||||
const reference = (intent.rawInitiation as { reference?: string })
|
if (!intent.providerOrderId) {
|
||||||
?.reference;
|
throw new Error(
|
||||||
return this.cacBankProvider.queryStatus(
|
`CAC intent ${intent.id} has no providerOrderId to verify`,
|
||||||
intent.merchantOrderId,
|
);
|
||||||
reference,
|
}
|
||||||
);
|
return this.cacBankProvider.queryStatus(intent.providerOrderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return provider.queryStatus(intent.merchantOrderId);
|
return provider.queryStatus(intent.merchantOrderId);
|
||||||
|
|||||||
@@ -87,8 +87,7 @@ export class ReconciliationService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const status =
|
const status =
|
||||||
intent.provider === ProviderMethod.CAC_BANK
|
intent.provider === ProviderMethod.CAC_BANK
|
||||||
? await this.cacBankProvider.queryStatus(
|
? await this.cacBankProvider.queryStatus(
|
||||||
intent.merchantOrderId,
|
intent.providerOrderId ?? intent.merchantOrderId,
|
||||||
(intent.rawInitiation as { reference?: string })?.reference,
|
|
||||||
)
|
)
|
||||||
: await provider.queryStatus(intent.merchantOrderId);
|
: await provider.queryStatus(intent.merchantOrderId);
|
||||||
const result = this.intentsService.fromProviderStatus(status);
|
const result = this.intentsService.fromProviderStatus(status);
|
||||||
|
|||||||
Binary file not shown.
@@ -14,6 +14,7 @@ export interface CacAuthConfig {
|
|||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
tokenTtlMs: number;
|
tokenTtlMs: number;
|
||||||
|
httpTimeoutMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,7 +64,7 @@ export class CacBankAuth {
|
|||||||
const res = await firstValueFrom(
|
const res = await firstValueFrom(
|
||||||
this.http.post<CacSigninResponse>(url, body, {
|
this.http.post<CacSigninResponse>(url, body, {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
timeout: 10_000,
|
timeout: this.config.httpTimeoutMs,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const token = res.data.accessToken;
|
const token = res.data.accessToken;
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Lossless JSON handling for CAC Bank.
|
||||||
|
*
|
||||||
|
* CAC returns 17-digit identifiers — `paymentRequestId`, `confirmReference`,
|
||||||
|
* `transactionNo` (spec: numeric, <=18) — that exceed `Number.MAX_SAFE_INTEGER`
|
||||||
|
* (9,007,199,254,740,991). A plain `JSON.parse` silently rounds them
|
||||||
|
* (…240611 → …240610), corrupting the id we send back to CONFIRM the payment and use
|
||||||
|
* to VERIFY it via GetPaymentByReferenceRequest. So we quote those fields to strings
|
||||||
|
* before parsing and carry them as strings end-to-end, then re-emit id fields as raw
|
||||||
|
* JSON numbers when building requests. This avoids a bigint-JSON dependency for the
|
||||||
|
* three fields that need it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Response id fields that must survive as exact strings, not JS numbers. */
|
||||||
|
const RESPONSE_ID_FIELDS = [
|
||||||
|
"paymentRequestId",
|
||||||
|
"confirmReference",
|
||||||
|
"transactionNo",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request id fields we carry as strings but the bank types as `numeric`, so they must
|
||||||
|
* go on the wire unquoted. Only PaymentConfirmationRequest.payment_request_id qualifies;
|
||||||
|
* GetPaymentByReferenceRequest.reference is a string field and stays quoted.
|
||||||
|
*/
|
||||||
|
const REQUEST_NUMERIC_ID_FIELDS = ["payment_request_id"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a CAC JSON response body, keeping oversized integer ids as exact strings.
|
||||||
|
* `raw` is the untouched response text (axios response transform is disabled for CAC).
|
||||||
|
*/
|
||||||
|
export function parseCacResponse<T>(raw: string): T {
|
||||||
|
const pattern = new RegExp(
|
||||||
|
`"(${RESPONSE_ID_FIELDS.join("|")})"\\s*:\\s*(-?\\d+)`,
|
||||||
|
"g",
|
||||||
|
);
|
||||||
|
const quoted = raw.replace(pattern, '"$1":"$2"');
|
||||||
|
return JSON.parse(quoted) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a CAC request body. Numeric id fields we hold as strings are emitted as raw
|
||||||
|
* JSON numbers (unquoted) so their full precision reaches the bank, matching the spec's
|
||||||
|
* `numeric` type. All other fields serialize normally.
|
||||||
|
*/
|
||||||
|
export function serializeCacRequest(body: unknown): string {
|
||||||
|
let json = JSON.stringify(body);
|
||||||
|
for (const field of REQUEST_NUMERIC_ID_FIELDS) {
|
||||||
|
json = json.replace(
|
||||||
|
new RegExp(`("${field}"\\s*:\\s*)"(-?\\d+)"`, "g"),
|
||||||
|
"$1$2",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a Djibouti mobile number to the bare 8-digit national form the bank expects
|
||||||
|
* (spec example `77112233`). Strips a leading `+253` / `00253` / `253` country code and any
|
||||||
|
* spaces or dashes. Returns the input trimmed if it doesn't match the expected shape.
|
||||||
|
*/
|
||||||
|
export function normalizeCacMobile(mobile: string): string {
|
||||||
|
const digits = mobile.replace(/[\s-]/g, "").replace(/^\+/, "");
|
||||||
|
const national = digits.replace(/^(?:00)?253/, "");
|
||||||
|
return national || digits;
|
||||||
|
}
|
||||||
@@ -12,6 +12,11 @@ import {
|
|||||||
import { AxiosError, AxiosRequestConfig } from "axios";
|
import { AxiosError, AxiosRequestConfig } from "axios";
|
||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom } from "rxjs";
|
||||||
import { CacBankAuth } from "./cac-bank.auth";
|
import { CacBankAuth } from "./cac-bank.auth";
|
||||||
|
import {
|
||||||
|
normalizeCacMobile,
|
||||||
|
parseCacResponse,
|
||||||
|
serializeCacRequest,
|
||||||
|
} from "./cac-bank.json";
|
||||||
import type {
|
import type {
|
||||||
CacConfirmResult,
|
CacConfirmResult,
|
||||||
CacGetPaymentByReferenceRequest,
|
CacGetPaymentByReferenceRequest,
|
||||||
@@ -22,6 +27,10 @@ import type {
|
|||||||
CacPaymentInitiateResponse,
|
CacPaymentInitiateResponse,
|
||||||
} from "./cac-bank.types";
|
} from "./cac-bank.types";
|
||||||
|
|
||||||
|
/** Bank-enforced amount bounds (PaymentInitiateRequest spec: between 10 and 100,000 DJF). */
|
||||||
|
const CAC_MIN_AMOUNT = 10;
|
||||||
|
const CAC_MAX_AMOUNT = 100_000;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
||||||
readonly method = ProviderMethod.CAC_BANK;
|
readonly method = ProviderMethod.CAC_BANK;
|
||||||
@@ -63,19 +72,28 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
|||||||
throw new Error("CAC Bank requires payerAccount (customer mobile number)");
|
throw new Error("CAC Bank requires payerAccount (customer mobile number)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const customerMobile = normalizeCacMobile(input.payerAccount);
|
||||||
|
const amount = this.toMajorAmount(input.amountMinor, input.currency);
|
||||||
|
if (amount < CAC_MIN_AMOUNT || amount > CAC_MAX_AMOUNT) {
|
||||||
|
throw new Error(
|
||||||
|
`CAC Bank amount ${amount} ${input.currency} is outside the accepted range ` +
|
||||||
|
`(${CAC_MIN_AMOUNT}–${CAC_MAX_AMOUNT} DJF)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const requestBody: CacPaymentInitiateRequest = {
|
const requestBody: CacPaymentInitiateRequest = {
|
||||||
app_key: this.appKey,
|
app_key: this.appKey,
|
||||||
api_key: this.apiKey,
|
api_key: this.apiKey,
|
||||||
customer_mobile: input.payerAccount,
|
customer_mobile: customerMobile,
|
||||||
currency: input.currency || this.defaultCurrency,
|
currency: input.currency || this.defaultCurrency,
|
||||||
desc: `${input.orderRef}`.slice(0, 500),
|
desc: `${input.orderRef}`.slice(0, 500),
|
||||||
vender_ref: input.merchantOrderId,
|
vender_ref: input.merchantOrderId,
|
||||||
amount: this.toMajorAmount(input.amountMinor, input.currency),
|
amount,
|
||||||
company_services_id: this.companyServicesId,
|
company_services_id: this.companyServicesId,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`CAC Bank initiate → ${this.baseUrl}/paymentapi/PaymentInitiateRequest | currency=${requestBody.currency} amount=${requestBody.amount} (amountMinorIn=${input.amountMinor}) mobile=${input.payerAccount} ref=${input.merchantOrderId}`,
|
`CAC Bank initiate → ${this.baseUrl}/paymentapi/PaymentInitiateRequest | currency=${requestBody.currency} amount=${requestBody.amount} (amountMinorIn=${input.amountMinor}) mobile=${customerMobile} ref=${input.merchantOrderId}`,
|
||||||
);
|
);
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`CAC Bank initiate request body: ${JSON.stringify(this.sanitizeKeys(requestBody))}`,
|
`CAC Bank initiate request body: ${JSON.stringify(this.sanitizeKeys(requestBody))}`,
|
||||||
@@ -98,7 +116,8 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerOrderId = String(response.paymentRequestId);
|
// Already an exact string (parseCacResponse keeps the 17-digit id lossless).
|
||||||
|
const providerOrderId = response.paymentRequestId;
|
||||||
const expiresAt = new Date(Date.now() + this.otpExpiryMs);
|
const expiresAt = new Date(Date.now() + this.otpExpiryMs);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -124,7 +143,9 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
|||||||
const requestBody: CacPaymentConfirmRequest = {
|
const requestBody: CacPaymentConfirmRequest = {
|
||||||
app_key: this.appKey,
|
app_key: this.appKey,
|
||||||
api_key: this.apiKey,
|
api_key: this.apiKey,
|
||||||
payment_request_id: Number(paymentRequestId),
|
// Kept as a string here; serializeCacRequest emits it as a raw JSON number so the
|
||||||
|
// full 17-digit precision reaches the bank.
|
||||||
|
payment_request_id: paymentRequestId,
|
||||||
otp,
|
otp,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -169,15 +190,20 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async queryStatus(
|
/**
|
||||||
merchantOrderId: string,
|
* Verify a payment via GetPaymentByReferenceRequest, keyed on the paymentRequestId. This
|
||||||
reference?: string,
|
* is CAC's callback replacement: the bank sends no webhook, but the id is known from
|
||||||
): Promise<ProviderStatus> {
|
* initiate and the lookup accepts it, so a lost/failed confirm can still be reconciled.
|
||||||
const lookupRef = reference ?? merchantOrderId;
|
* A settled payment carries a transactionNo; anything else means the OTP hasn't been
|
||||||
|
* confirmed yet — that's REQUIRES_ACTION (still awaiting the payer), NOT PROCESSING.
|
||||||
|
* Returning PROCESSING would let a poll/sweep advance the intent out of REQUIRES_ACTION
|
||||||
|
* and block the confirm() call.
|
||||||
|
*/
|
||||||
|
async queryStatus(paymentRequestId: string): Promise<ProviderStatus> {
|
||||||
const requestBody: CacGetPaymentByReferenceRequest = {
|
const requestBody: CacGetPaymentByReferenceRequest = {
|
||||||
app_key: this.appKey,
|
app_key: this.appKey,
|
||||||
api_key: this.apiKey,
|
api_key: this.apiKey,
|
||||||
reference: lookupRef,
|
reference: paymentRequestId,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -195,14 +221,14 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: ProviderPaymentStatus.PROCESSING,
|
status: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||||
rawResponse: response as unknown as Record<string, unknown>,
|
rawResponse: response as unknown as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AxiosError && err.response?.status === 404) {
|
if (err instanceof AxiosError && err.response?.status === 404) {
|
||||||
return {
|
return {
|
||||||
status: ProviderPaymentStatus.PROCESSING,
|
status: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||||
rawResponse: { notFound: true, reference: lookupRef },
|
rawResponse: { notFound: true, reference: paymentRequestId },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
@@ -212,21 +238,28 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
|||||||
private async postJson<T>(path: string, body: unknown): Promise<T> {
|
private async postJson<T>(path: string, body: unknown): Promise<T> {
|
||||||
const token = await this.getAuth().getAccessToken();
|
const token = await this.getAuth().getAccessToken();
|
||||||
const url = `${this.baseUrl}${path}`;
|
const url = `${this.baseUrl}${path}`;
|
||||||
|
// Serialize ourselves so numeric ids we carry as strings go on the wire unquoted.
|
||||||
|
const payload = serializeCacRequest(body);
|
||||||
const config: AxiosRequestConfig = {
|
const config: AxiosRequestConfig = {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
timeout: 10_000,
|
timeout: this.httpTimeoutMs,
|
||||||
|
// Keep the raw response text — 17-digit ids would lose precision under axios's
|
||||||
|
// default JSON.parse. We parse losslessly with parseCacResponse.
|
||||||
|
transformResponse: [(data) => data],
|
||||||
};
|
};
|
||||||
|
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
try {
|
try {
|
||||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
const res = await firstValueFrom(
|
||||||
|
this.http.post<string>(url, payload, config),
|
||||||
|
);
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`CAC Bank POST ${path} status=${res.status} latency=${Date.now() - started}ms`,
|
`CAC Bank POST ${path} status=${res.status} latency=${Date.now() - started}ms`,
|
||||||
);
|
);
|
||||||
return res.data;
|
return parseCacResponse<T>(res.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AxiosError && err.response?.status === 401) {
|
if (err instanceof AxiosError && err.response?.status === 401) {
|
||||||
this.getAuth().invalidate();
|
this.getAuth().invalidate();
|
||||||
@@ -239,9 +272,9 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const res = await firstValueFrom(
|
const res = await firstValueFrom(
|
||||||
this.http.post<T>(url, body, retryConfig),
|
this.http.post<string>(url, payload, retryConfig),
|
||||||
);
|
);
|
||||||
return res.data;
|
return parseCacResponse<T>(res.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err instanceof AxiosError) {
|
if (err instanceof AxiosError) {
|
||||||
@@ -264,6 +297,7 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
|||||||
username: this.username,
|
username: this.username,
|
||||||
password: this.password,
|
password: this.password,
|
||||||
tokenTtlMs: this.tokenTtlMs,
|
tokenTtlMs: this.tokenTtlMs,
|
||||||
|
httpTimeoutMs: this.httpTimeoutMs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return this.auth;
|
return this.auth;
|
||||||
@@ -311,4 +345,7 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
|||||||
private get otpExpiryMs(): number {
|
private get otpExpiryMs(): number {
|
||||||
return this.config.get<number>("cac.otpExpiryMs") ?? 10 * 60 * 1000;
|
return this.config.get<number>("cac.otpExpiryMs") ?? 10 * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
private get httpTimeoutMs(): number {
|
||||||
|
return this.config.get<number>("cac.httpTimeoutMs") ?? 60_000;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,19 +24,25 @@ export interface CacPaymentInitiateRequest {
|
|||||||
|
|
||||||
export interface CacPaymentInitiateResponse {
|
export interface CacPaymentInitiateResponse {
|
||||||
description: string;
|
description: string;
|
||||||
paymentRequestId: number;
|
/** Numeric id (<=18 digits) kept as a string — it exceeds JS's safe integer range. */
|
||||||
|
paymentRequestId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CacPaymentConfirmRequest {
|
export interface CacPaymentConfirmRequest {
|
||||||
app_key: string;
|
app_key: string;
|
||||||
api_key: string;
|
api_key: string;
|
||||||
payment_request_id: number;
|
/**
|
||||||
|
* Carried as a string for precision; emitted as a raw JSON number on the wire by
|
||||||
|
* `serializeCacRequest` (the bank types this field as `numeric`).
|
||||||
|
*/
|
||||||
|
payment_request_id: string;
|
||||||
otp: string;
|
otp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CacPaymentConfirmResponse {
|
export interface CacPaymentConfirmResponse {
|
||||||
description: string;
|
description: string;
|
||||||
confirmReference: number;
|
/** Numeric id kept as a string — see CacPaymentInitiateResponse.paymentRequestId. */
|
||||||
|
confirmReference: string;
|
||||||
reference: string;
|
reference: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +58,8 @@ export interface CacPaymentByReferenceResponse {
|
|||||||
reference: string;
|
reference: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
transactionDate: string;
|
transactionDate: string;
|
||||||
transactionNo: number;
|
/** Numeric id kept as a string — see CacPaymentInitiateResponse.paymentRequestId. */
|
||||||
|
transactionNo: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CacConfirmResult {
|
export interface CacConfirmResult {
|
||||||
|
|||||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@@ -4416,7 +4416,7 @@ packages:
|
|||||||
typeorm: ^0.3.0
|
typeorm: ^0.3.0
|
||||||
|
|
||||||
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.12.tgz':
|
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.12.tgz':
|
||||||
resolution: {integrity: sha512-9h87gfCQBqnUY8TOCK6jbnONwIV4pFKn4MF2Hte3p6K/kxsppgOR3KyOI9oBg5j5wre/RXbZLi1U1meEKBrWJw==, tarball: file:local-packages/tria-plc-iamapi-common-0.7.12.tgz}
|
resolution: {integrity: sha512-9lZ5t3WzjcRrIEJu0rywzWXlmV+YteYqaYjiYP4T6ETZ9K7LIOXKM1gd2vK5hfrlIh6BPPcsIIhairmJgmpwEQ==, tarball: file:local-packages/tria-plc-iamapi-common-0.7.12.tgz}
|
||||||
version: 0.7.12
|
version: 0.7.12
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user