全新popiart网站 react项目重构
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

612 lines
22 KiB

/**
* 订阅 / 积分支付弹窗
*
* 微信:同意后下单拉二维码;拿到有效二维码后移除蒙层并轮询。
* 支付宝:无二维码,蒙层始终存在;每次点「同意并支付」才下单拿链接并跳转,再轮询。
*/
import { Message, Modal } from "@arco-design/web-react";
import { useTranslation } from "react-i18next";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getToken } from "@/utils/auth";
import { useUserStore } from "@/store/useUserStore";
import { checkGatewayPayment, checkGatewayWxPayment, checkPointsPayment, checkPointsWxPayment, createPointsOrder, createPointsOrderByWxPay, createSubscriptionOrder, createSubscriptionOrderByWxPay } from "@/api/api";
import type { PaymentOrderData, PaymentStatusData, ProductPlan } from "@/api/api";
import { openLatestPrivacyPolicyLink, PRIVACY_POLICY_KEY_FUTURE_CREATOR, PRIVACY_POLICY_KEY_PAYMENT_TERMS } from "@/utils/openLatestPrivacyPolicyLink";
import "./index.scss";
type PayType = "subscribe" | "points";
type PaymentMethod = "WXPAY" | "ALIPAY";
type PaymentInfo = { type: "subscribe"; price: number; subscriptionId?: number } | { type: "points"; price: number; packageId?: number };
type PollContext = { paymentMethod: PaymentMethod; payType: PayType };
const QrCodeSvg = lazy(() => import("qrcode.react").then((m) => ({ default: m.QRCodeSVG })));
export interface PaySubscribeProps {
onClose: () => void;
type?: PayType;
plan?: ProductPlan;
afterPaySuccess?: () => void;
}
const PAYMENT_POLL_MAX_MS = 5 * 60 * 1000;
const PAYMENT_POLL_INTERVAL_MS = 8000;
const PAY_RESULT_MODAL_WRAP = "pay_subscribe_result_modal";
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
type PayNoticeKind = "success" | "warning" | "error";
function openPayNoticeModal(
kind: PayNoticeKind,
options: {
title: string;
content: string;
okText: string;
onOk?: () => void;
},
): void {
const payload = {
title: options.title,
content: options.content,
okText: options.okText,
wrapClassName: PAY_RESULT_MODAL_WRAP,
alignCenter: true,
maskClosable: false,
escToExit: true,
simple: true,
okButtonProps: { type: "primary" as const, shape: "round" as const },
onOk: () => options.onOk?.(),
};
if (kind === "success") Modal.success(payload);
else if (kind === "warning") Modal.warning(payload);
else Modal.error(payload);
}
function buildPaymentReturnUrl(payType: PayType, paymentMethod: PaymentMethod): string {
if (typeof window === "undefined") return "";
const u = new URL(window.location.origin + window.location.pathname);
u.searchParams.set("type", payType);
u.searchParams.set("paymentMethod", paymentMethod);
return u.toString();
}
function extractTradeStatus(statusRes: PaymentStatusData | undefined, method: PaymentMethod): string {
if (method === "ALIPAY") return statusRes?.trade_status ?? statusRes?.tradeStatus ?? "";
return statusRes?.data?.trade_state ?? statusRes?.data?.tradeState ?? statusRes?.trade_state ?? statusRes?.tradeState ?? "";
}
function isWaitingStatus(tradeStatus: string, method: PaymentMethod): boolean {
if (method === "ALIPAY") return tradeStatus === "WAIT_BUYER_PAY";
return tradeStatus === "NOTPAY" || tradeStatus === "USERPAYING";
}
function isSuccessStatus(tradeStatus: string, method: PaymentMethod): boolean {
if (method === "ALIPAY") return tradeStatus === "TRADE_SUCCESS" || tradeStatus === "TRADE_FINISHED";
return tradeStatus === "SUCCESS";
}
function isFailedStatus(tradeStatus: string, method: PaymentMethod): boolean {
if (method === "ALIPAY") return tradeStatus === "TRADE_CLOSED" || tradeStatus === "TRADE_REFOUND";
return tradeStatus === "REFUND" || tradeStatus === "CLOSED" || tradeStatus === "PAYERROR";
}
function fetchPaymentStatus(tradeNo: string, method: PaymentMethod, payType: PayType) {
if (method === "ALIPAY") {
return payType === "subscribe" ? checkGatewayPayment({ tradeNo }) : checkPointsPayment({ tradeNo });
}
return payType === "subscribe" ? checkGatewayWxPayment({ tradeNo }) : checkPointsWxPayment({ tradeNo });
}
export default function PaySubscribe({ onClose, type, plan, afterPaySuccess }: PaySubscribeProps) {
const { t } = useTranslation();
const payType: PayType = type ?? "subscribe";
const user = useUserStore((s) => s.user);
const setUser = useUserStore((s) => s.setUser);
const afterPaySuccessRef = useRef(afterPaySuccess);
useEffect(() => {
afterPaySuccessRef.current = afterPaySuccess;
}, [afterPaySuccess]);
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>("WXPAY");
/** 微信侧是否已点过「同意并支付」(本会话内切回微信不再出蒙层) */
const [wxAgreed, setWxAgreed] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [codeUrl, setCodeUrl] = useState("");
const wxOrderRef = useRef<PaymentOrderData | null>(null);
const mountedRef = useRef(true);
const requestSeqRef = useRef(0);
const activePollSeqRef = useRef(0);
const submittingRef = useRef(false);
const pollAbortRef = useRef(false);
const pollStartTimeRef = useRef(0);
const pollTimerRef = useRef<number | null>(null);
const paymentInfo = useMemo<PaymentInfo>(() => {
if (payType === "subscribe") {
return {
type: "subscribe" as const,
price: Number(plan?.price ?? 0) / 100,
subscriptionId: plan?.id == null ? undefined : Number(plan.id),
};
}
return {
type: "points" as const,
price: Number(plan?.price_amount ?? 0),
packageId: plan?.id == null ? undefined : Number(plan.id),
};
}, [plan?.id, plan?.price, plan?.price_amount, payType]);
const planId = useMemo(() => {
if (paymentInfo.type === "subscribe") return paymentInfo.subscriptionId;
return paymentInfo.packageId;
}, [paymentInfo]);
const planTitle = useMemo(() => {
if (payType === "subscribe") return (plan?.title ?? "").trim();
return (plan?.name ?? t("subscribe.points_package")).trim();
}, [payType, plan?.name, plan?.title, t]);
const originalPrice = Number(plan?.original_price_amount ?? 0);
const discountInfo = plan?.custom_info?.discount_info;
const isFutureCreatorPlan = Number(plan?.level ?? 0) === 4;
/** 支付宝始终蒙层;微信仅首次同意前蒙层 */
const showAgreementOverlay = paymentMethod === "ALIPAY" || !wxAgreed;
const requireLogin = useCallback((): boolean => {
if (!getToken() || !user) {
Message.warning(t("auth.please_login_first"));
return false;
}
return true;
}, [t, user]);
const stopPolling = useCallback(() => {
pollAbortRef.current = true;
activePollSeqRef.current += 1;
if (pollTimerRef.current != null) {
window.clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
}, []);
const isPollingActive = useCallback((pollSeq: number) => mountedRef.current && !pollAbortRef.current && activePollSeqRef.current === pollSeq, []);
const setSubmittingState = useCallback((nextSubmitting: boolean) => {
submittingRef.current = nextSubmitting;
if (mountedRef.current) {
setSubmitting(nextSubmitting);
}
}, []);
useEffect(() => {
return () => {
mountedRef.current = false;
requestSeqRef.current += 1;
stopPolling();
submittingRef.current = false;
};
}, [stopPolling]);
const safeClose = useCallback(() => {
onClose();
}, [onClose]);
const nextPaymentRequestSeq = useCallback(() => {
requestSeqRef.current += 1;
return requestSeqRef.current;
}, []);
const isPaymentRequestActive = useCallback((requestSeq: number) => mountedRef.current && requestSeqRef.current === requestSeq, []);
// 轮询支付状态
const startPaymentPoll = useCallback(
(orderRes: PaymentOrderData, ctx: PollContext) => {
const tradeNo = orderRes.trade_no ?? orderRes.tradeNo;
if (!tradeNo) return;
stopPolling();
pollAbortRef.current = false;
pollStartTimeRef.current = Date.now();
const pollSeq = activePollSeqRef.current + 1;
activePollSeqRef.current = pollSeq;
void (async () => {
const { paymentMethod: method, payType: ctxPayType } = ctx;
for (;;) {
if (!isPollingActive(pollSeq)) return;
if (Date.now() - pollStartTimeRef.current >= PAYMENT_POLL_MAX_MS) {
openPayNoticeModal("warning", {
title: t("subscribe.pay_notice_timeout_title"),
content: t("subscribe.pay_result_timeout"),
okText: t("button.okay"),
onOk: safeClose,
});
return;
}
try {
const { data: statusRes } = await fetchPaymentStatus(tradeNo, method, ctxPayType);
if (!isPollingActive(pollSeq)) return;
const tradeStatus = extractTradeStatus(statusRes, method);
if (!tradeStatus) {
await sleep(PAYMENT_POLL_INTERVAL_MS);
continue;
}
if (isWaitingStatus(tradeStatus, method)) {
await sleep(PAYMENT_POLL_INTERVAL_MS);
continue;
}
if (isSuccessStatus(tradeStatus, method)) {
void setUser();
openPayNoticeModal("success", {
title: t("subscribe.pay_success"),
content: t("subscribe.pay_success_detail"),
okText: t("button.okay"),
onOk: () => {
afterPaySuccessRef.current?.();
safeClose();
},
});
return;
}
if (isFailedStatus(tradeStatus, method)) {
openPayNoticeModal("error", {
title: t("subscribe.pay_failed"),
content: t("subscribe.pay_failed_detail"),
okText: t("button.okay"),
onOk: safeClose,
});
return;
}
await sleep(PAYMENT_POLL_INTERVAL_MS);
} catch {
if (!isPollingActive(pollSeq)) return;
await sleep(PAYMENT_POLL_INTERVAL_MS);
}
}
})();
},
[isPollingActive, safeClose, setUser, stopPolling, t],
);
// 调度轮询
const schedulePoll = useCallback(
(orderRes: PaymentOrderData, method: PaymentMethod) => {
if (!mountedRef.current) return;
stopPolling();
const ctx: PollContext = { paymentMethod: method, payType };
pollTimerRef.current = window.setTimeout(() => {
if (!mountedRef.current) return;
startPaymentPoll(orderRes, ctx);
}, PAYMENT_POLL_INTERVAL_MS);
},
[payType, startPaymentPoll, stopPolling],
);
// 解析套餐ID
const resolvePlanIds = useCallback(() => {
const subscriptionId = paymentInfo.type === "subscribe" ? paymentInfo.subscriptionId : undefined;
const packageId = paymentInfo.type === "points" ? paymentInfo.packageId : undefined;
const id = payType === "subscribe" ? subscriptionId : packageId;
return { subscriptionId, packageId, id };
}, [paymentInfo, payType]);
// 断言套餐并登录
const assertPlanAndLogin = useCallback((): boolean => {
const { id } = resolvePlanIds();
if (!id) {
openPayNoticeModal("warning", {
title: t("subscribe.pay_notice_missing_plan_title"),
content: t("subscribe.missing_plan_info"),
okText: t("button.okay"),
onOk: safeClose,
});
return false;
}
return requireLogin();
}, [requireLogin, resolvePlanIds, safeClose, t]);
/** 微信下单:仅拉 code_url,不跳转 */
const fetchWxOrder = useCallback(
async () => {
if (submittingRef.current) return null;
const requestSeq = nextPaymentRequestSeq();
stopPolling();
const { id, subscriptionId, packageId } = resolvePlanIds();
if (!id) {
openPayNoticeModal("warning", {
title: t("subscribe.pay_notice_missing_plan_title"),
content: t("subscribe.missing_plan_info"),
okText: t("button.okay"),
onOk: safeClose,
});
return null;
}
if (!requireLogin()) {
return null;
}
setSubmittingState(true);
const returnUrl = buildPaymentReturnUrl(payType, "WXPAY");
try {
if (payType === "subscribe") {
const { data: res } = await createSubscriptionOrderByWxPay({
returnUrl,
subscriptionId,
});
if (!isPaymentRequestActive(requestSeq)) return null;
if (!res) return null;
if (!res.code_url) {
openPayNoticeModal("error", {
title: t("subscribe.pay_notice_start_failed_title"),
content: t("subscribe.pay_start_failed"),
okText: t("button.okay"),
});
return null;
}
wxOrderRef.current = res;
setCodeUrl(res.code_url);
return res;
}
const { data: res } = await createPointsOrderByWxPay({ returnUrl, packageId });
if (!isPaymentRequestActive(requestSeq)) return null;
if (!res) return null;
if (!res.code_url) {
openPayNoticeModal("error", {
title: t("subscribe.pay_notice_start_failed_title"),
content: t("subscribe.pay_start_failed"),
okText: t("button.okay"),
});
return null;
}
wxOrderRef.current = res;
setCodeUrl(res.code_url);
return res;
} catch {
if (isPaymentRequestActive(requestSeq)) {
openPayNoticeModal("error", {
title: t("subscribe.pay_notice_start_failed_title"),
content: t("subscribe.pay_start_failed"),
okText: t("button.okay"),
});
}
return null;
} finally {
if (isPaymentRequestActive(requestSeq)) {
setSubmittingState(false);
}
}
},
[isPaymentRequestActive, nextPaymentRequestSeq, payType, requireLogin, resolvePlanIds, safeClose, setSubmittingState, stopPolling, t],
);
/** 支付宝下单:同意后才调用,拿链接并跳转 */
const fetchAlipayAndRedirect = useCallback(async () => {
if (submittingRef.current) return null;
if (!assertPlanAndLogin()) return null;
const requestSeq = nextPaymentRequestSeq();
stopPolling();
const { subscriptionId, packageId } = resolvePlanIds();
setSubmittingState(true);
const returnUrl = buildPaymentReturnUrl(payType, "ALIPAY");
try {
const res = payType === "subscribe" ? (await createSubscriptionOrder({ returnUrl, subscriptionId })).data : (await createPointsOrder({ returnUrl, packageId })).data;
if (!isPaymentRequestActive(requestSeq)) return null;
if (res?.url) {
schedulePoll(res, "ALIPAY");
location.href = res.url;
return res;
}
openPayNoticeModal("error", {
title: t("subscribe.pay_notice_start_failed_title"),
content: t("subscribe.pay_start_failed"),
okText: t("button.okay"),
});
return null;
} catch {
if (isPaymentRequestActive(requestSeq)) {
openPayNoticeModal("error", {
title: t("subscribe.pay_notice_start_failed_title"),
content: t("subscribe.pay_start_failed"),
okText: t("button.okay"),
});
}
return null;
} finally {
if (isPaymentRequestActive(requestSeq)) {
setSubmittingState(false);
}
}
}, [assertPlanAndLogin, isPaymentRequestActive, nextPaymentRequestSeq, payType, resolvePlanIds, schedulePoll, setSubmittingState, stopPolling, t]);
const resumeWxPolling = useCallback(() => {
if (wxOrderRef.current) {
schedulePoll(wxOrderRef.current, "WXPAY");
}
}, [schedulePoll]);
/** 换套餐:重置支付态,不自动创建订单 */
useEffect(() => {
nextPaymentRequestSeq();
stopPolling();
setSubmittingState(false);
setWxAgreed(false);
setPaymentMethod("WXPAY");
wxOrderRef.current = null;
setCodeUrl("");
}, [nextPaymentRequestSeq, planId, setSubmittingState, stopPolling]);
const handlePaymentMethodChange = useCallback(
(method: PaymentMethod) => {
if (method === paymentMethod) return;
if (submittingRef.current) return;
nextPaymentRequestSeq();
stopPolling();
setPaymentMethod(method);
setWxAgreed(false);
setCodeUrl("");
wxOrderRef.current = null;
},
[nextPaymentRequestSeq, paymentMethod, stopPolling],
);
const handleAgreeAndPay = useCallback(async () => {
if (submittingRef.current) return;
if (paymentMethod === "ALIPAY") {
await fetchAlipayAndRedirect();
return;
}
const order = await fetchWxOrder();
if (order?.code_url) {
setWxAgreed(true);
resumeWxPolling();
}
}, [fetchAlipayAndRedirect, fetchWxOrder, paymentMethod, resumeWxPolling]);
const wxLogo = new URL("@/assets/images/logo_wxpay.png", import.meta.url).href;
const alipayLogo = new URL("@/assets/images/logo_alipay.png", import.meta.url).href;
const loadingImg = new URL("@/assets/images/wxgh_loading.png", import.meta.url).href;
return (
<div className="pay_subscribe_modal">
{planTitle ? <h2 className="pay_subscribe_modal__title">{planTitle}</h2> : null}
<div className="pay_subscribe_modal__methods">
<button
type="button"
className={["pay_subscribe_modal__method", paymentMethod === "WXPAY" ? "is_active" : ""].filter(Boolean).join(" ")}
disabled={submitting}
onClick={() => {
handlePaymentMethodChange("WXPAY");
}}
>
<img src={wxLogo} alt="" className="pay_subscribe_modal__method-icon" />
<span></span>
</button>
<button
type="button"
className={["pay_subscribe_modal__method", paymentMethod === "ALIPAY" ? "is_active" : ""].filter(Boolean).join(" ")}
disabled={submitting}
onClick={() => {
handlePaymentMethodChange("ALIPAY");
}}
>
<img src={alipayLogo} alt="" className="pay_subscribe_modal__method-icon" />
<span></span>
</button>
</div>
<div className="pay_subscribe_modal__price-row">
<span className="pay_subscribe_modal__price-current">{paymentInfo.price}</span>
{payType === "subscribe" && originalPrice > paymentInfo.price ? <span className="pay_subscribe_modal__price-original">{originalPrice}</span> : null}
{discountInfo ? <span className="pay_subscribe_modal__price-badge">{discountInfo}</span> : null}
</div>
<div className="pay_subscribe_modal__qrcode-box">
{paymentMethod === "WXPAY" ? (
<div className="pay_subscribe_modal__qrcode-inner">
{codeUrl ? (
<Suspense fallback={<img src={loadingImg} alt="" className="pay_subscribe_modal__loading" />}>
<QrCodeSvg value={codeUrl} size={207} level="M" />
</Suspense>
) : (
<img src={loadingImg} alt="" className="pay_subscribe_modal__loading" />
)}
</div>
) : (
<div className="pay_subscribe_modal__qrcode-inner is_alipay" aria-hidden />
)}
{showAgreementOverlay ? (
<div className="pay_subscribe_modal__overlay">
<p className="pay_subscribe_modal__overlay-text">
{t("subscribe.read_before_pay")}
<br />
{isFutureCreatorPlan ? (
<a
href="#"
className="pay_subscribe_modal__overlay-link"
onClick={(e) => {
e.preventDefault();
void openLatestPrivacyPolicyLink(PRIVACY_POLICY_KEY_FUTURE_CREATOR);
}}
>
{t("subscribe.future_creator_payment_terms")}
</a>
) : (
<a
href="#"
className="pay_subscribe_modal__overlay-link"
onClick={(e) => {
e.preventDefault();
void openLatestPrivacyPolicyLink(PRIVACY_POLICY_KEY_PAYMENT_TERMS);
}}
>
{t("subscribe.payment_terms")}
</a>
)}
</p>
<button type="button" className="pay_subscribe_modal__agree-btn" disabled={submitting} onClick={() => void handleAgreeAndPay()}>
{t("subscribe.agree_and_pay")}
</button>
</div>
) : null}
</div>
{paymentMethod === "WXPAY" ? (
<div className="pay_subscribe_modal__scan-tip">
<img src={wxLogo} alt="" className="pay_subscribe_modal__scan-tip-icon" />
<span>{t("subscribe.scan_code_tip")}</span>
</div>
) : (
<p className="pay_subscribe_modal__scan-tip is_alipay">{t("subscribe.please_complete_alipay_payment")}</p>
)}
<p className="pay_subscribe_modal__footer-terms">
{t("subscribe.read_before_pay")}
{isFutureCreatorPlan ? (
<a
href="#"
className="pay_subscribe_modal__footer-link"
onClick={(e) => {
e.preventDefault();
void openLatestPrivacyPolicyLink(PRIVACY_POLICY_KEY_FUTURE_CREATOR);
}}
>
{t("subscribe.future_creator_payment_terms")}
</a>
) : (
<a
href="#"
className="pay_subscribe_modal__footer-link"
onClick={(e) => {
e.preventDefault();
void openLatestPrivacyPolicyLink(PRIVACY_POLICY_KEY_PAYMENT_TERMS);
}}
>
{t("subscribe.payment_terms")}
</a>
)}
</p>
</div>
);
}