Files
cozsweet-frontend-nextjs/src/app/_components/payment/payment-launch-dialogs.tsx
T

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>
);
}