fix(payment): confirm ezpay redirect before opening
This commit is contained in:
@@ -14,10 +14,10 @@ import {
|
|||||||
import { AppEnvUtil, Logger, Result } from "@/utils";
|
import { AppEnvUtil, Logger, Result } from "@/utils";
|
||||||
|
|
||||||
import { StripePaymentDialog } from "./stripe-payment-dialog";
|
import { StripePaymentDialog } from "./stripe-payment-dialog";
|
||||||
|
import dialogStyles from "./stripe-payment-dialog.module.css";
|
||||||
import { SubscriptionCtaButton } from "./subscription-cta-button";
|
import { SubscriptionCtaButton } from "./subscription-cta-button";
|
||||||
import styles from "./subscription-cta-button.module.css";
|
import styles from "./subscription-cta-button.module.css";
|
||||||
|
|
||||||
const EZPAY_DEVELOPMENT_REDIRECT_DELAY_MS = 10000;
|
|
||||||
const log = new Logger("SubscriptionCheckoutButton");
|
const log = new Logger("SubscriptionCheckoutButton");
|
||||||
|
|
||||||
export interface SubscriptionCheckoutButtonProps {
|
export interface SubscriptionCheckoutButtonProps {
|
||||||
@@ -33,18 +33,10 @@ export function SubscriptionCheckoutButton({
|
|||||||
const payment = usePaymentState();
|
const payment = usePaymentState();
|
||||||
const paymentDispatch = usePaymentDispatch();
|
const paymentDispatch = usePaymentDispatch();
|
||||||
const launchedNonceRef = useRef(0);
|
const launchedNonceRef = useRef(0);
|
||||||
const ezpayRedirectTimeoutRef = useRef<number | null>(null);
|
|
||||||
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
|
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
|
const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false);
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (ezpayRedirectTimeoutRef.current !== null) {
|
|
||||||
window.clearTimeout(ezpayRedirectTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
|
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
|
||||||
@@ -62,18 +54,10 @@ export function SubscriptionCheckoutButton({
|
|||||||
const isEzpay = isEzpayPayment(payment.payParams);
|
const isEzpay = isEzpayPayment(payment.payParams);
|
||||||
|
|
||||||
if (!AppEnvUtil.isProduction() && isEzpay) {
|
if (!AppEnvUtil.isProduction() && isEzpay) {
|
||||||
if (ezpayRedirectTimeoutRef.current !== null) {
|
log.debug("[subscription-checkout] ezpay confirmation required", {
|
||||||
window.clearTimeout(ezpayRedirectTimeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
void redirectToEzpay({
|
|
||||||
orderId: payment.currentOrderId,
|
orderId: payment.currentOrderId,
|
||||||
paymentUrl,
|
paymentUrl,
|
||||||
subscriptionType,
|
subscriptionType,
|
||||||
delayMs: EZPAY_DEVELOPMENT_REDIRECT_DELAY_MS,
|
|
||||||
setTimeoutId: (timeoutId) => {
|
|
||||||
ezpayRedirectTimeoutRef.current = timeoutId;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -118,10 +102,7 @@ export function SubscriptionCheckoutButton({
|
|||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (disabled || isLoading) return;
|
if (disabled || isLoading) return;
|
||||||
if (ezpayRedirectTimeoutRef.current !== null) {
|
setIsConfirmingEzpay(false);
|
||||||
window.clearTimeout(ezpayRedirectTimeoutRef.current);
|
|
||||||
ezpayRedirectTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
setHiddenStripeClientSecret(null);
|
setHiddenStripeClientSecret(null);
|
||||||
paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
|
paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
|
||||||
};
|
};
|
||||||
@@ -148,14 +129,36 @@ export function SubscriptionCheckoutButton({
|
|||||||
stripeClientSecret !== null &&
|
stripeClientSecret !== null &&
|
||||||
stripeClientSecret !== hiddenStripeClientSecret &&
|
stripeClientSecret !== hiddenStripeClientSecret &&
|
||||||
!payment.isPaid;
|
!payment.isPaid;
|
||||||
const pendingEzpayRedirectOrderId =
|
const ezpayPaymentUrl = payment.payParams
|
||||||
|
? getPaymentUrl(payment.payParams)
|
||||||
|
: null;
|
||||||
|
const shouldShowEzpayConfirmDialog =
|
||||||
!AppEnvUtil.isProduction() &&
|
!AppEnvUtil.isProduction() &&
|
||||||
payment.payParams &&
|
payment.payParams &&
|
||||||
isEzpayPayment(payment.payParams) &&
|
isEzpayPayment(payment.payParams) &&
|
||||||
getPaymentUrl(payment.payParams) &&
|
ezpayPaymentUrl &&
|
||||||
payment.currentOrderId
|
payment.currentOrderId
|
||||||
? payment.currentOrderId
|
? true
|
||||||
: null;
|
: 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -181,15 +184,13 @@ export function SubscriptionCheckoutButton({
|
|||||||
{payment.errorMessage}
|
{payment.errorMessage}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{pendingEzpayRedirectOrderId ? (
|
{shouldShowEzpayConfirmDialog ? (
|
||||||
<div className={styles.pendingRedirect} role="status">
|
<EzpayRedirectConfirmDialog
|
||||||
<span className={styles.pendingRedirectLabel}>
|
orderId={payment.currentOrderId ?? ""}
|
||||||
Ezpay order created. Redirecting in 5 seconds.
|
isConfirming={isConfirmingEzpay}
|
||||||
</span>
|
onCancel={handleEzpayCancel}
|
||||||
<span className={styles.pendingRedirectOrder}>
|
onConfirm={handleEzpayConfirm}
|
||||||
Order No. {pendingEzpayRedirectOrderId}
|
/>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
{shouldShowStripeDialog ? (
|
{shouldShowStripeDialog ? (
|
||||||
<StripePaymentDialog
|
<StripePaymentDialog
|
||||||
@@ -234,22 +235,17 @@ interface RedirectToEzpayInput {
|
|||||||
orderId: string | null;
|
orderId: string | null;
|
||||||
paymentUrl: string;
|
paymentUrl: string;
|
||||||
subscriptionType: "vip" | "voice";
|
subscriptionType: "vip" | "voice";
|
||||||
delayMs?: number;
|
|
||||||
setTimeoutId?: (timeoutId: number) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function redirectToEzpay({
|
async function redirectToEzpay({
|
||||||
orderId,
|
orderId,
|
||||||
paymentUrl,
|
paymentUrl,
|
||||||
subscriptionType,
|
subscriptionType,
|
||||||
delayMs = 0,
|
|
||||||
setTimeoutId,
|
|
||||||
}: RedirectToEzpayInput): Promise<void> {
|
}: RedirectToEzpayInput): Promise<void> {
|
||||||
log.debug("[subscription-checkout] redirectToEzpay START", {
|
log.debug("[subscription-checkout] redirectToEzpay START", {
|
||||||
hasOrderId: Boolean(orderId),
|
hasOrderId: Boolean(orderId),
|
||||||
orderId,
|
orderId,
|
||||||
subscriptionType,
|
subscriptionType,
|
||||||
delayMs,
|
|
||||||
paymentUrl,
|
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", {
|
log.debug("[subscription-checkout] redirectToEzpay NOW", {
|
||||||
orderId,
|
orderId,
|
||||||
subscriptionType,
|
subscriptionType,
|
||||||
@@ -306,6 +283,60 @@ async function redirectToEzpay({
|
|||||||
window.location.href = paymentUrl;
|
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(
|
function getStripeClientSecret(
|
||||||
payParams: Record<string, unknown>,
|
payParams: Record<string, unknown>,
|
||||||
): string | null {
|
): string | null {
|
||||||
|
|||||||
@@ -60,32 +60,6 @@
|
|||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pendingRedirect {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
margin-top: var(--spacing-sm);
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid rgba(246, 87, 160, 0.2);
|
|
||||||
border-radius: 14px;
|
|
||||||
background: #ffffff;
|
|
||||||
color: #3c3b3b;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pendingRedirectLabel {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pendingRedirectOrder {
|
|
||||||
color: #1e1e1e;
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.3;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
|
|||||||
Reference in New Issue
Block a user