fix(payment): confirm ezpay redirect before opening

This commit is contained in:
2026-06-22 18:59:01 +08:00
parent 9a65f2f9ad
commit e68cabdc54
2 changed files with 91 additions and 86 deletions
@@ -14,10 +14,10 @@ import {
import { AppEnvUtil, Logger, Result } from "@/utils";
import { StripePaymentDialog } from "./stripe-payment-dialog";
import dialogStyles from "./stripe-payment-dialog.module.css";
import { SubscriptionCtaButton } from "./subscription-cta-button";
import styles from "./subscription-cta-button.module.css";
const EZPAY_DEVELOPMENT_REDIRECT_DELAY_MS = 10000;
const log = new Logger("SubscriptionCheckoutButton");
export interface SubscriptionCheckoutButtonProps {
@@ -33,18 +33,10 @@ export function SubscriptionCheckoutButton({
const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch();
const launchedNonceRef = useRef(0);
const ezpayRedirectTimeoutRef = useRef<number | null>(null);
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
string | null
>(null);
useEffect(() => {
return () => {
if (ezpayRedirectTimeoutRef.current !== null) {
window.clearTimeout(ezpayRedirectTimeoutRef.current);
}
};
}, []);
const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false);
useEffect(() => {
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
@@ -62,18 +54,10 @@ export function SubscriptionCheckoutButton({
const isEzpay = isEzpayPayment(payment.payParams);
if (!AppEnvUtil.isProduction() && isEzpay) {
if (ezpayRedirectTimeoutRef.current !== null) {
window.clearTimeout(ezpayRedirectTimeoutRef.current);
}
void redirectToEzpay({
log.debug("[subscription-checkout] ezpay confirmation required", {
orderId: payment.currentOrderId,
paymentUrl,
subscriptionType,
delayMs: EZPAY_DEVELOPMENT_REDIRECT_DELAY_MS,
setTimeoutId: (timeoutId) => {
ezpayRedirectTimeoutRef.current = timeoutId;
},
});
return;
}
@@ -118,10 +102,7 @@ export function SubscriptionCheckoutButton({
const handleClick = () => {
if (disabled || isLoading) return;
if (ezpayRedirectTimeoutRef.current !== null) {
window.clearTimeout(ezpayRedirectTimeoutRef.current);
ezpayRedirectTimeoutRef.current = null;
}
setIsConfirmingEzpay(false);
setHiddenStripeClientSecret(null);
paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
};
@@ -148,14 +129,36 @@ export function SubscriptionCheckoutButton({
stripeClientSecret !== null &&
stripeClientSecret !== hiddenStripeClientSecret &&
!payment.isPaid;
const pendingEzpayRedirectOrderId =
const ezpayPaymentUrl = payment.payParams
? getPaymentUrl(payment.payParams)
: null;
const shouldShowEzpayConfirmDialog =
!AppEnvUtil.isProduction() &&
payment.payParams &&
isEzpayPayment(payment.payParams) &&
getPaymentUrl(payment.payParams) &&
ezpayPaymentUrl &&
payment.currentOrderId
? payment.currentOrderId
: null;
? true
: false;
const handleEzpayConfirm = () => {
if (!payment.currentOrderId || !ezpayPaymentUrl) return;
setIsConfirmingEzpay(true);
void redirectToEzpay({
orderId: payment.currentOrderId,
paymentUrl: ezpayPaymentUrl,
subscriptionType,
});
};
const handleEzpayCancel = () => {
log.debug("[subscription-checkout] ezpay confirmation cancelled", {
orderId: payment.currentOrderId,
subscriptionType,
});
setIsConfirmingEzpay(false);
paymentDispatch({ type: "PaymentReset" });
};
return (
<>
@@ -181,15 +184,13 @@ export function SubscriptionCheckoutButton({
{payment.errorMessage}
</p>
) : null}
{pendingEzpayRedirectOrderId ? (
<div className={styles.pendingRedirect} role="status">
<span className={styles.pendingRedirectLabel}>
Ezpay order created. Redirecting in 5 seconds.
</span>
<span className={styles.pendingRedirectOrder}>
Order No. {pendingEzpayRedirectOrderId}
</span>
</div>
{shouldShowEzpayConfirmDialog ? (
<EzpayRedirectConfirmDialog
orderId={payment.currentOrderId ?? ""}
isConfirming={isConfirmingEzpay}
onCancel={handleEzpayCancel}
onConfirm={handleEzpayConfirm}
/>
) : null}
{shouldShowStripeDialog ? (
<StripePaymentDialog
@@ -234,22 +235,17 @@ interface RedirectToEzpayInput {
orderId: string | null;
paymentUrl: string;
subscriptionType: "vip" | "voice";
delayMs?: number;
setTimeoutId?: (timeoutId: number) => void;
}
async function redirectToEzpay({
orderId,
paymentUrl,
subscriptionType,
delayMs = 0,
setTimeoutId,
}: RedirectToEzpayInput): Promise<void> {
log.debug("[subscription-checkout] redirectToEzpay START", {
hasOrderId: Boolean(orderId),
orderId,
subscriptionType,
delayMs,
paymentUrl,
});
@@ -279,25 +275,6 @@ async function redirectToEzpay({
});
}
if (delayMs > 0) {
const timeoutId = window.setTimeout(() => {
log.debug("[subscription-checkout] redirectToEzpay DELAY elapsed", {
orderId,
subscriptionType,
paymentUrl,
});
window.location.href = paymentUrl;
}, delayMs);
setTimeoutId?.(timeoutId);
log.debug("[subscription-checkout] redirectToEzpay DELAY scheduled", {
orderId,
subscriptionType,
delayMs,
timeoutId,
});
return;
}
log.debug("[subscription-checkout] redirectToEzpay NOW", {
orderId,
subscriptionType,
@@ -306,6 +283,60 @@ async function redirectToEzpay({
window.location.href = paymentUrl;
}
interface EzpayRedirectConfirmDialogProps {
orderId: string;
isConfirming: boolean;
onCancel: () => void;
onConfirm: () => void;
}
function EzpayRedirectConfirmDialog({
orderId,
isConfirming,
onCancel,
onConfirm,
}: EzpayRedirectConfirmDialogProps) {
return (
<div
className={dialogStyles.overlay}
role="alertdialog"
aria-modal="true"
aria-labelledby="ezpay-redirect-title"
>
<div className={dialogStyles.dialog}>
<div className={dialogStyles.header}>
<h2 id="ezpay-redirect-title" className={dialogStyles.title}>
Continue to Ezpay?
</h2>
<p className={dialogStyles.content}>
Your order has been created. Continue to Ezpay to finish the
payment.
</p>
<p className={dialogStyles.content}>Order No. {orderId}</p>
</div>
<div className={dialogStyles.actions}>
<button
type="button"
className={`${dialogStyles.button} ${dialogStyles.secondary}`}
disabled={isConfirming}
onClick={onCancel}
>
Cancel
</button>
<button
type="button"
className={`${dialogStyles.button} ${dialogStyles.primary}`}
disabled={isConfirming}
onClick={onConfirm}
>
{isConfirming ? "Opening..." : "Continue"}
</button>
</div>
</div>
</div>
);
}
function getStripeClientSecret(
payParams: Record<string, unknown>,
): string | null {