feat(payment): restore ezpay return orders
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user