Files
cozsweet-frontend-nextjs/src/app/_components/payment/payment-launch-dialogs.tsx
T
Codex 2e04260ebd
Docker Image / Build and Push Docker Image (push) Successful in 2m15s
revert(payment): remove unsupported Stripe and EzPay upgrade
2026-07-29 18:15:20 +08:00

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