feat(payment): support Indonesia QRIS checkout

This commit is contained in:
Codex
2026-07-22 18:57:22 +08:00
parent a0b2d44ec6
commit 1e25279e8f
19 changed files with 806 additions and 42 deletions
@@ -1,6 +1,7 @@
"use client";
import { useId, type ReactNode } from "react";
import { QRCodeSVG } from "qrcode.react";
import type { PaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
@@ -11,10 +12,15 @@ type PaymentLaunchDialogFlow = Pick<
PaymentLaunchFlow,
| "handleEzpayCancel"
| "handleEzpayConfirm"
| "handleQrisClose"
| "handleStripeClose"
| "handleStripeConfirmed"
| "isConfirmingEzpay"
| "qrisErrorMessage"
| "qrisPayment"
| "qrisStatus"
| "shouldShowEzpayConfirmDialog"
| "shouldShowQrisDialog"
| "shouldShowStripeDialog"
| "stripeClientSecret"
>;
@@ -46,6 +52,14 @@ export function PaymentLaunchDialogs({
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}
@@ -58,6 +72,102 @@ export function PaymentLaunchDialogs({
);
}
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 (
<div
className={styles.overlay}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className={styles.dialog}>
<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>
</div>
</div>
);
}
interface EzpayRedirectConfirmDialogProps {
description: ReactNode;
externalCheckoutAnalyticsKey: string;
@@ -87,7 +197,7 @@ function EzpayRedirectConfirmDialog({
<div className={styles.dialog}>
<div className={styles.header}>
<h2 id={titleId} className={styles.title}>
Continue to GCash?
Continue to payment?
</h2>
<p className={styles.content}>{description}</p>
<p className={styles.content}>Order No. {orderId}</p>