233 lines
6.6 KiB
TypeScript
233 lines
6.6 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"
|
|
| "handleQrisClose"
|
|
| "handleStripeClose"
|
|
| "handleStripeConfirmed"
|
|
| "isConfirmingEzpay"
|
|
| "qrisErrorMessage"
|
|
| "qrisPayment"
|
|
| "qrisStatus"
|
|
| "shouldShowEzpayConfirmDialog"
|
|
| "shouldShowQrisDialog"
|
|
| "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.shouldShowQrisDialog && launch.qrisPayment ? (
|
|
<QrisPaymentDialog
|
|
payment={launch.qrisPayment}
|
|
status={launch.qrisStatus}
|
|
errorMessage={launch.qrisErrorMessage}
|
|
onClose={launch.handleQrisClose}
|
|
/>
|
|
) : null}
|
|
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
|
|
<LazyStripePaymentDialog
|
|
clientSecret={launch.stripeClientSecret}
|
|
returnPath={stripeReturnPath}
|
|
onClose={launch.handleStripeClose}
|
|
onConfirmed={launch.handleStripeConfirmed}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface QrisPaymentDialogProps {
|
|
errorMessage: string | null;
|
|
onClose: () => void;
|
|
payment: NonNullable<PaymentLaunchFlow["qrisPayment"]>;
|
|
status: PaymentLaunchFlow["qrisStatus"];
|
|
}
|
|
|
|
function formatQrisAmount(amountCents: number, currency: string): string {
|
|
const normalizedCurrency = currency.trim().toUpperCase() || "IDR";
|
|
try {
|
|
return new Intl.NumberFormat("id-ID", {
|
|
style: "currency",
|
|
currency: normalizedCurrency,
|
|
maximumFractionDigits: normalizedCurrency === "IDR" ? 0 : 2,
|
|
}).format(amountCents / 100);
|
|
} catch {
|
|
return `${normalizedCurrency} ${(amountCents / 100).toFixed(2)}`;
|
|
}
|
|
}
|
|
|
|
function qrisStatusMessage(
|
|
status: PaymentLaunchFlow["qrisStatus"],
|
|
errorMessage: string | null,
|
|
): string {
|
|
if (status === "failed") return errorMessage || "Payment failed. Please try again.";
|
|
if (status === "expired") return errorMessage || "This QRIS order has expired.";
|
|
return "Waiting for payment";
|
|
}
|
|
|
|
function QrisPaymentDialog({
|
|
errorMessage,
|
|
onClose,
|
|
payment,
|
|
status,
|
|
}: QrisPaymentDialogProps) {
|
|
const titleId = useId();
|
|
const statusMessage = qrisStatusMessage(status, errorMessage);
|
|
|
|
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}>
|
|
Scan to pay with QRIS
|
|
</h2>
|
|
<p className={styles.content}>
|
|
Open a QRIS-compatible banking or wallet app and scan this code.
|
|
</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="QRIS payment QR code"
|
|
size={256}
|
|
level="M"
|
|
marginSize={2}
|
|
className="h-auto w-full max-w-64"
|
|
/>
|
|
) : (
|
|
<p className={styles.error} role="alert">
|
|
QRIS data is unavailable. Please close this dialog and try again.
|
|
</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">
|
|
{formatQrisAmount(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>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|