feat(payment): connect payment service flow
This commit is contained in:
@@ -2,75 +2,63 @@
|
||||
/**
|
||||
* 订阅 checkout 触发按钮
|
||||
*
|
||||
* 包装 `SubscriptionCtaButton` —— 加上调 `/api/payment/create-checkout-session` 的逻辑
|
||||
*
|
||||
* 流程:
|
||||
* 1. 用户点 "Confirm the agreement and Activate"
|
||||
* 2. fetch POST /api/payment/create-checkout-session { planId, successUrl, cancelUrl }
|
||||
* 3. 拿到 `{ url }` → `window.location.href = url` 跳转到 Stripe 托管页
|
||||
* 4. 用户结账 → Stripe 回跳到 successUrl / cancelUrl
|
||||
* 5. 同时发 webhook → 后端设置 user.isVip + voiceMinutes
|
||||
* 包装 `SubscriptionCtaButton` —— 通过 payment 状态机创建后端订单并拉起支付。
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import type { SubscriptionPlanId } from "@/data/constants/subscription-plans";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import {
|
||||
usePaymentDispatch,
|
||||
usePaymentState,
|
||||
} from "@/stores/payment/payment-context";
|
||||
|
||||
import { SubscriptionCtaButton } from "./subscription-cta-button";
|
||||
import styles from "./subscription-cta-button.module.css";
|
||||
import { Logger } from "@/utils";
|
||||
|
||||
const log = new Logger("AppSubscriptionComponentsSubscriptionCheckoutButton");
|
||||
|
||||
export interface SubscriptionCheckoutButtonProps {
|
||||
planId: SubscriptionPlanId;
|
||||
/** 是否可用(未选套餐 / 未勾选协议 → false) */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SubscriptionCheckoutButton({
|
||||
planId,
|
||||
disabled = false,
|
||||
}: SubscriptionCheckoutButtonProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const payment = usePaymentState();
|
||||
const paymentDispatch = usePaymentDispatch();
|
||||
const launchedNonceRef = useRef(0);
|
||||
|
||||
const handleClick = async () => {
|
||||
if (disabled || isLoading) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// success / cancel URL —— 走当前 origin 的绝对路径
|
||||
const origin = window.location.origin;
|
||||
const res = await fetch("/api/payment/create-checkout-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
planId,
|
||||
successUrl: `${origin}${ROUTES.subscription}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancelUrl: `${origin}${ROUTES.subscription}/cancel`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const { url } = (await res.json()) as { url: string };
|
||||
if (!url) {
|
||||
throw new Error("No checkout URL returned");
|
||||
}
|
||||
|
||||
// 跳转到 Stripe 托管页
|
||||
window.location.href = url;
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
log.error("[subscription-checkout-button] error", err);
|
||||
setError(err.message);
|
||||
setIsLoading(false);
|
||||
useEffect(() => {
|
||||
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
launchedNonceRef.current = payment.launchNonce;
|
||||
const paymentUrl = getPaymentUrl(payment.payParams);
|
||||
if (!paymentUrl) {
|
||||
paymentDispatch({
|
||||
type: "PaymentLaunchFailed",
|
||||
errorMessage: "Payment parameters did not include a supported URL.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = paymentUrl;
|
||||
}, [payment.launchNonce, payment.payParams, paymentDispatch]);
|
||||
|
||||
const isLoading =
|
||||
payment.isCreatingOrder ||
|
||||
payment.isPollingOrder ||
|
||||
payment.isCheckingVipStatus;
|
||||
const label = payment.isPaid
|
||||
? "Activated"
|
||||
: payment.isPollingOrder
|
||||
? "Processing payment..."
|
||||
: payment.isCreatingOrder
|
||||
? "Creating order..."
|
||||
: "Confirm the agreement and Activate";
|
||||
|
||||
const handleClick = () => {
|
||||
if (disabled || isLoading) return;
|
||||
paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -82,9 +70,9 @@ export function SubscriptionCheckoutButton({
|
||||
onClick={handleClick}
|
||||
className={styles.button}
|
||||
>
|
||||
Confirm the agreement and Activate
|
||||
{label}
|
||||
</SubscriptionCtaButton>
|
||||
{error ? (
|
||||
{payment.errorMessage ? (
|
||||
<p
|
||||
role="alert"
|
||||
style={{
|
||||
@@ -94,9 +82,28 @@ export function SubscriptionCheckoutButton({
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
{payment.errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getPaymentUrl(payParams: Record<string, unknown>): string | null {
|
||||
const keys = [
|
||||
"url",
|
||||
"checkoutUrl",
|
||||
"checkout_url",
|
||||
"paymentUrl",
|
||||
"payment_url",
|
||||
"redirectUrl",
|
||||
"redirect_url",
|
||||
];
|
||||
|
||||
for (const key of keys) {
|
||||
const value = payParams[key];
|
||||
if (typeof value === "string" && value.length > 0) return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user