281 lines
7.9 KiB
TypeScript
281 lines
7.9 KiB
TypeScript
"use client";
|
|
|
|
import { useId, type ReactNode } from "react";
|
|
import { QRCodeSVG } from "qrcode.react";
|
|
|
|
import { ModalPortal } from "@/app/_components/core/modal-portal";
|
|
import type { PaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
|
|
|
import { LazyStripePaymentDialog } from "./lazy-stripe-payment-dialog";
|
|
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
|
|
|
|
type PaymentLaunchDialogFlow = Pick<
|
|
PaymentLaunchFlow,
|
|
| "handleEzpayCancel"
|
|
| "handleEzpayConfirm"
|
|
| "handleRegionalQrClose"
|
|
| "handleStripeClose"
|
|
| "handleStripeConfirmed"
|
|
| "isConfirmingEzpay"
|
|
| "regionalQrErrorMessage"
|
|
| "regionalQrPayment"
|
|
| "regionalQrStatus"
|
|
| "shouldShowEzpayConfirmDialog"
|
|
| "shouldShowRegionalQrDialog"
|
|
| "shouldShowStripeDialog"
|
|
| "stripeClientSecret"
|
|
>;
|
|
|
|
export interface PaymentLaunchDialogsProps {
|
|
currentOrderId: string | null;
|
|
externalCheckoutAnalyticsKey: string;
|
|
ezpayDescription: ReactNode;
|
|
launch: PaymentLaunchDialogFlow;
|
|
stripeReturnPath?: string;
|
|
}
|
|
|
|
export function PaymentLaunchDialogs({
|
|
currentOrderId,
|
|
externalCheckoutAnalyticsKey,
|
|
ezpayDescription,
|
|
launch,
|
|
stripeReturnPath,
|
|
}: PaymentLaunchDialogsProps) {
|
|
return (
|
|
<>
|
|
{launch.shouldShowEzpayConfirmDialog ? (
|
|
<EzpayRedirectConfirmDialog
|
|
orderId={currentOrderId ?? ""}
|
|
description={ezpayDescription}
|
|
externalCheckoutAnalyticsKey={externalCheckoutAnalyticsKey}
|
|
isConfirming={launch.isConfirmingEzpay}
|
|
onCancel={launch.handleEzpayCancel}
|
|
onConfirm={launch.handleEzpayConfirm}
|
|
/>
|
|
) : null}
|
|
{launch.shouldShowRegionalQrDialog && launch.regionalQrPayment ? (
|
|
<RegionalQrPaymentDialog
|
|
payment={launch.regionalQrPayment}
|
|
status={launch.regionalQrStatus}
|
|
errorMessage={launch.regionalQrErrorMessage}
|
|
onClose={launch.handleRegionalQrClose}
|
|
/>
|
|
) : null}
|
|
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
|
|
<LazyStripePaymentDialog
|
|
clientSecret={launch.stripeClientSecret}
|
|
returnPath={stripeReturnPath}
|
|
onClose={launch.handleStripeClose}
|
|
onConfirmed={launch.handleStripeConfirmed}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface RegionalQrPaymentDialogProps {
|
|
errorMessage: string | null;
|
|
onClose: () => void;
|
|
payment: NonNullable<PaymentLaunchFlow["regionalQrPayment"]>;
|
|
status: PaymentLaunchFlow["regionalQrStatus"];
|
|
}
|
|
|
|
function formatRegionalQrAmount(amountCents: number, currency: string): string {
|
|
const normalizedCurrency = currency.trim().toUpperCase() || "IDR";
|
|
try {
|
|
return new Intl.NumberFormat(
|
|
normalizedCurrency === "PHP" ? "en-PH" : "id-ID",
|
|
{
|
|
style: "currency",
|
|
currency: normalizedCurrency,
|
|
maximumFractionDigits: normalizedCurrency === "IDR" ? 0 : 2,
|
|
},
|
|
).format(amountCents / 100);
|
|
} catch {
|
|
return `${normalizedCurrency} ${(amountCents / 100).toFixed(2)}`;
|
|
}
|
|
}
|
|
|
|
function regionalQrStatusMessage(
|
|
status: PaymentLaunchFlow["regionalQrStatus"],
|
|
errorMessage: string | null,
|
|
paymentName: string,
|
|
): string {
|
|
if (status === "failed") {
|
|
return errorMessage || "Payment failed. Please try again.";
|
|
}
|
|
if (status === "expired") {
|
|
return errorMessage || `This ${paymentName} order has expired.`;
|
|
}
|
|
return "Waiting for payment";
|
|
}
|
|
|
|
function RegionalQrPaymentDialog({
|
|
errorMessage,
|
|
onClose,
|
|
payment,
|
|
status,
|
|
}: RegionalQrPaymentDialogProps) {
|
|
const titleId = useId();
|
|
const copy = regionalQrCopy(payment.experience);
|
|
const statusMessage = regionalQrStatusMessage(
|
|
status,
|
|
errorMessage,
|
|
copy.paymentName,
|
|
);
|
|
|
|
return (
|
|
<ModalPortal
|
|
open
|
|
persistent
|
|
onClose={onClose}
|
|
scrimClassName={styles.overlay}
|
|
panelClassName={styles.dialog}
|
|
scrimOpacity={0.5}
|
|
ariaLabelledBy={titleId}
|
|
>
|
|
<div className={`${styles.header} text-center`}>
|
|
<h2 id={titleId} className={styles.title}>
|
|
{copy.title}
|
|
</h2>
|
|
<p className={styles.content}>
|
|
{copy.description}
|
|
</p>
|
|
</div>
|
|
<div className="mb-4 flex min-h-64 items-center justify-center rounded-2xl bg-white p-4">
|
|
{payment.qrData ? (
|
|
<QRCodeSVG
|
|
value={payment.qrData}
|
|
title={copy.qrTitle}
|
|
size={256}
|
|
level="M"
|
|
marginSize={2}
|
|
className="h-auto w-full max-w-64"
|
|
/>
|
|
) : (
|
|
<p className={styles.error} role="alert">
|
|
{copy.unavailableMessage}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="mb-4 flex flex-col gap-2 text-center">
|
|
<p className={styles.content}>Order No. {payment.orderId}</p>
|
|
<p className="m-0 text-xl font-bold text-text-foreground">
|
|
{formatRegionalQrAmount(payment.amountCents, payment.currency)}
|
|
</p>
|
|
<p
|
|
className={status === "failed" || status === "expired" ? styles.error : styles.content}
|
|
role="status"
|
|
>
|
|
{statusMessage}
|
|
</p>
|
|
</div>
|
|
<div className={styles.actions}>
|
|
<button
|
|
type="button"
|
|
className={`${styles.button} ${styles.secondary}`}
|
|
onClick={onClose}
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</ModalPortal>
|
|
);
|
|
}
|
|
|
|
function regionalQrCopy(
|
|
experience: NonNullable<PaymentLaunchFlow["regionalQrPayment"]>["experience"],
|
|
) {
|
|
if (experience === "gcashQrPh") {
|
|
return {
|
|
paymentName: "GCash / QR Ph",
|
|
title: "Pay with GCash / QR Ph",
|
|
description:
|
|
"Open GCash or another QR Ph-compatible app and scan this code.",
|
|
qrTitle: "GCash / QR Ph payment QR code",
|
|
unavailableMessage:
|
|
"GCash / QR Ph data is unavailable. Please close this dialog and try again.",
|
|
};
|
|
}
|
|
if (experience === "qris") {
|
|
return {
|
|
paymentName: "QRIS",
|
|
title: "Scan to pay with QRIS",
|
|
description:
|
|
"Open a QRIS-compatible banking or wallet app and scan this code.",
|
|
qrTitle: "QRIS payment QR code",
|
|
unavailableMessage:
|
|
"QRIS data is unavailable. Please close this dialog and try again.",
|
|
};
|
|
}
|
|
return {
|
|
paymentName: "payment QR",
|
|
title: "Scan to pay",
|
|
description: "Open a compatible banking or wallet app and scan this code.",
|
|
qrTitle: "Payment QR code",
|
|
unavailableMessage:
|
|
"Payment QR data is unavailable. Please close this dialog and try again.",
|
|
};
|
|
}
|
|
|
|
interface EzpayRedirectConfirmDialogProps {
|
|
description: ReactNode;
|
|
externalCheckoutAnalyticsKey: string;
|
|
isConfirming: boolean;
|
|
onCancel: () => void;
|
|
onConfirm: () => void;
|
|
orderId: string;
|
|
}
|
|
|
|
function EzpayRedirectConfirmDialog({
|
|
description,
|
|
externalCheckoutAnalyticsKey,
|
|
isConfirming,
|
|
onCancel,
|
|
onConfirm,
|
|
orderId,
|
|
}: EzpayRedirectConfirmDialogProps) {
|
|
const titleId = useId();
|
|
|
|
return (
|
|
<ModalPortal
|
|
open
|
|
persistent
|
|
onClose={onCancel}
|
|
scrimClassName={styles.overlay}
|
|
panelClassName={styles.dialog}
|
|
scrimOpacity={0.5}
|
|
role="alertdialog"
|
|
ariaLabelledBy={titleId}
|
|
>
|
|
<div className={styles.header}>
|
|
<h2 id={titleId} className={styles.title}>
|
|
Continue to payment?
|
|
</h2>
|
|
<p className={styles.content}>{description}</p>
|
|
<p className={styles.content}>Order No. {orderId}</p>
|
|
</div>
|
|
<div className={styles.actions}>
|
|
<button
|
|
type="button"
|
|
className={`${styles.button} ${styles.secondary}`}
|
|
disabled={isConfirming}
|
|
onClick={onCancel}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-analytics-key={externalCheckoutAnalyticsKey}
|
|
data-analytics-label="Continue to external checkout"
|
|
className={`${styles.button} ${styles.primary}`}
|
|
disabled={isConfirming}
|
|
onClick={onConfirm}
|
|
>
|
|
{isConfirming ? "Opening..." : "Continue"}
|
|
</button>
|
|
</div>
|
|
</ModalPortal>
|
|
);
|
|
}
|