feat(payment): restore ezpay return orders

This commit is contained in:
2026-06-22 17:29:47 +08:00
parent 890c955712
commit 21b9954351
14 changed files with 1083 additions and 1 deletions
@@ -6,30 +6,45 @@
*/
import { useEffect, useRef, useState } from "react";
import { PendingPaymentOrderStorage } from "@/data/storage";
import {
usePaymentDispatch,
usePaymentState,
} from "@/stores/payment/payment-context";
import { AppEnvUtil } from "@/utils";
import { StripePaymentDialog } from "./stripe-payment-dialog";
import { SubscriptionCtaButton } from "./subscription-cta-button";
import styles from "./subscription-cta-button.module.css";
const EZPAY_DEVELOPMENT_REDIRECT_DELAY_MS = 3000;
export interface SubscriptionCheckoutButtonProps {
/** 是否可用(未选套餐 / 未勾选协议 → false) */
disabled?: boolean;
subscriptionType: "vip" | "voice";
}
export function SubscriptionCheckoutButton({
disabled = false,
subscriptionType,
}: SubscriptionCheckoutButtonProps) {
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);
}
};
}, []);
useEffect(() => {
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
return;
@@ -43,6 +58,34 @@ export function SubscriptionCheckoutButton({
const paymentUrl = getPaymentUrl(payment.payParams);
if (paymentUrl) {
const isEzpay = isEzpayPayment(payment.payParams);
if (AppEnvUtil.isDevelopment() && isEzpay) {
if (ezpayRedirectTimeoutRef.current !== null) {
window.clearTimeout(ezpayRedirectTimeoutRef.current);
}
void redirectToEzpay({
orderId: payment.currentOrderId,
paymentUrl,
subscriptionType,
delayMs: EZPAY_DEVELOPMENT_REDIRECT_DELAY_MS,
setTimeoutId: (timeoutId) => {
ezpayRedirectTimeoutRef.current = timeoutId;
},
});
return;
}
if (isEzpay) {
void redirectToEzpay({
orderId: payment.currentOrderId,
paymentUrl,
subscriptionType,
});
return;
}
window.location.href = paymentUrl;
return;
}
@@ -52,7 +95,13 @@ export function SubscriptionCheckoutButton({
errorMessage:
"Payment parameters did not include a supported URL or Stripe client secret.",
});
}, [payment.launchNonce, payment.payParams, paymentDispatch]);
}, [
payment.currentOrderId,
payment.launchNonce,
payment.payParams,
paymentDispatch,
subscriptionType,
]);
const isLoading =
payment.isCreatingOrder ||
@@ -70,6 +119,10 @@ export function SubscriptionCheckoutButton({
const handleClick = () => {
if (disabled || isLoading) return;
if (ezpayRedirectTimeoutRef.current !== null) {
window.clearTimeout(ezpayRedirectTimeoutRef.current);
ezpayRedirectTimeoutRef.current = null;
}
setHiddenStripeClientSecret(null);
paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
};
@@ -155,6 +208,46 @@ function getPaymentUrl(payParams: Record<string, unknown>): string | null {
return null;
}
function isEzpayPayment(payParams: Record<string, unknown>): boolean {
const provider = payParams.provider;
return typeof provider === "string" && provider.toLowerCase() === "ezpay";
}
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> {
if (orderId) {
await PendingPaymentOrderStorage.setPendingOrder({
orderId,
payChannel: "ezpay",
subscriptionType,
createdAt: Date.now(),
});
}
if (delayMs > 0) {
const timeoutId = window.setTimeout(() => {
window.location.href = paymentUrl;
}, delayMs);
setTimeoutId?.(timeoutId);
return;
}
window.location.href = paymentUrl;
}
function getStripeClientSecret(
payParams: Record<string, unknown>,
): string | null {
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { MobileShell } from "@/app/_components/core";
import { PendingPaymentOrderStorage } from "@/data/storage";
import { ROUTES } from "@/router/routes";
type ReturnStatus = "loading" | "missing";
function buildSubscriptionUrl(subscriptionType: "vip" | "voice"): string {
if (subscriptionType === "voice") return `${ROUTES.subscription}?type=voice`;
return `${ROUTES.subscription}?type=vip`;
}
export default function SubscriptionReturnPage() {
const router = useRouter();
const [status, setStatus] = useState<ReturnStatus>("loading");
useEffect(() => {
let cancelled = false;
const redirectToSubscription = async () => {
const result = await PendingPaymentOrderStorage.getPendingOrder();
if (cancelled) return;
if (result.success && result.data !== null) {
router.replace(buildSubscriptionUrl(result.data.subscriptionType));
return;
}
setStatus("missing");
};
void redirectToSubscription();
return () => {
cancelled = true;
};
}, [router]);
return (
<MobileShell>
<div
style={{
minHeight: "60vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "0.75rem",
padding: "2rem 1.5rem",
textAlign: "center",
}}
>
<h1 style={{ margin: 0, fontSize: "1.5rem", fontWeight: 600 }}>
Returning to subscription
</h1>
<p style={{ margin: 0, color: "#666", lineHeight: 1.5 }}>
{status === "loading"
? "We are restoring your payment order."
: "No pending payment order was found. Please return to the subscription page."}
</p>
{status === "missing" ? (
<button
type="button"
onClick={() => router.replace(ROUTES.subscription)}
style={{
marginTop: "0.5rem",
padding: "0.75rem 1.5rem",
border: "none",
borderRadius: "12px",
background: "linear-gradient(135deg, #f96ADE, #f657A0)",
color: "white",
cursor: "pointer",
fontSize: "1rem",
fontWeight: 600,
}}
>
Back to subscription
</button>
) : null}
</div>
</MobileShell>
);
}
@@ -20,6 +20,7 @@ import { Checkbox, MobileShell } from "@/app/_components/core";
import { AppConstants } from "@/core/app_constants";
import type { PaymentPlan } from "@/data/dto/payment";
import { VIP_BENEFITS } from "@/data/constants/vip-benefits";
import { PendingPaymentOrderStorage } from "@/data/storage";
import {
usePaymentDispatch,
usePaymentState,
@@ -122,6 +123,7 @@ export function SubscriptionScreen({
const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch();
const refreshedPaidOrderRef = useRef<string | null>(null);
const resumedPendingOrderRef = useRef<string | null>(null);
useEffect(() => {
if (payment.status === "idle") {
@@ -136,6 +138,39 @@ export function SubscriptionScreen({
userDispatch({ type: "UserFetch" });
}, [payment.currentOrderId, payment.isPaid, userDispatch]);
useEffect(() => {
if (payment.status !== "ready") return;
let cancelled = false;
const resumePendingOrder = async () => {
const result = await PendingPaymentOrderStorage.getPendingOrderForType(
subscriptionType,
);
if (cancelled || !result.success || result.data === null) return;
if (payment.currentOrderId === result.data.orderId) return;
if (resumedPendingOrderRef.current === result.data.orderId) return;
resumedPendingOrderRef.current = result.data.orderId;
paymentDispatch({
type: "PaymentReturned",
orderId: result.data.orderId,
});
};
void resumePendingOrder();
return () => {
cancelled = true;
};
}, [payment.currentOrderId, payment.status, paymentDispatch, subscriptionType]);
useEffect(() => {
if (!payment.currentOrderId) return;
if (!payment.isPaid && payment.status !== "failed") return;
void PendingPaymentOrderStorage.clearPendingOrder();
}, [payment.currentOrderId, payment.isPaid, payment.status]);
const plans = useMemo(
() =>
payment.plans
@@ -274,6 +309,7 @@ export function SubscriptionScreen({
) : (
<SubscriptionCheckoutButton
disabled={!canActivate}
subscriptionType={subscriptionType}
/>
)}
</div>