feat(subscription): support Stripe payment element
This commit is contained in:
@@ -9,3 +9,4 @@ export * from "./subscription-checkout-button";
|
||||
export * from "./subscription-cta-button";
|
||||
export * from "./subscription-plan-card";
|
||||
export * from "./subscription-user-row";
|
||||
export * from "./stripe-payment-dialog";
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 70;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow-y: auto;
|
||||
border-radius: 32px;
|
||||
background: var(--color-page-background, #ffffff);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.16);
|
||||
padding: 24px 18px 18px;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 18px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 8px;
|
||||
color: var(--color-text-foreground, #171717);
|
||||
font-size: var(--font-size-22, 22px);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: 0;
|
||||
color: #393939;
|
||||
font-size: var(--font-size-md, 14px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: #c0392b;
|
||||
font-size: var(--font-size-sm, 13px);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md, 12px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button {
|
||||
flex: 1 1 auto;
|
||||
min-height: var(--pwa-button-height, 44px);
|
||||
border: 0;
|
||||
border-radius: var(--radius-bottom-sheet, 28px);
|
||||
font-size: var(--font-size-lg, 16px);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: var(--color-text-secondary, #9e9e9e);
|
||||
color: var(--color-page-background, #ffffff);
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: linear-gradient(
|
||||
var(--color-button-gradient-start, #ff67e0),
|
||||
var(--color-button-gradient-end, #ff52a2)
|
||||
);
|
||||
color: var(--color-page-background, #ffffff);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
/**
|
||||
* Stripe Payment Element 弹窗
|
||||
*
|
||||
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
|
||||
*/
|
||||
import { useState, type FormEvent } from "react";
|
||||
import {
|
||||
Elements,
|
||||
PaymentElement,
|
||||
useElements,
|
||||
useStripe,
|
||||
} from "@stripe/react-stripe-js";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
|
||||
import { ROUTES } from "@/router/routes";
|
||||
|
||||
import styles from "./stripe-payment-dialog.module.css";
|
||||
|
||||
const stripePublishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
|
||||
const stripePromise = stripePublishableKey
|
||||
? loadStripe(stripePublishableKey)
|
||||
: null;
|
||||
|
||||
export interface StripePaymentDialogProps {
|
||||
clientSecret: string;
|
||||
onClose: () => void;
|
||||
onConfirmed?: () => void;
|
||||
}
|
||||
|
||||
export function StripePaymentDialog({
|
||||
clientSecret,
|
||||
onClose,
|
||||
onConfirmed,
|
||||
}: StripePaymentDialogProps) {
|
||||
if (!stripePromise) {
|
||||
return (
|
||||
<div className={styles.overlay} role="alertdialog" aria-modal="true">
|
||||
<div className={styles.dialog}>
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.title}>Payment unavailable</h2>
|
||||
<p className={styles.content}>
|
||||
Stripe publishable key is not configured for this build.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.button} ${styles.primary}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="stripe-payment-title"
|
||||
>
|
||||
<div className={styles.dialog}>
|
||||
<div className={styles.header}>
|
||||
<h2 id="stripe-payment-title" className={styles.title}>
|
||||
Complete payment
|
||||
</h2>
|
||||
<p className={styles.content}>
|
||||
Enter your payment details securely through Stripe.
|
||||
</p>
|
||||
</div>
|
||||
<Elements
|
||||
key={clientSecret}
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret,
|
||||
appearance: {
|
||||
theme: "stripe",
|
||||
variables: {
|
||||
borderRadius: "14px",
|
||||
colorPrimary: "#ff52a2",
|
||||
colorText: "#171717",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<StripePaymentForm onClose={onClose} onConfirmed={onConfirmed} />
|
||||
</Elements>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StripePaymentForm({
|
||||
onClose,
|
||||
onConfirmed,
|
||||
}: Pick<StripePaymentDialogProps, "onClose" | "onConfirmed">) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!stripe || !elements || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
const { error: submitError } = await elements.submit();
|
||||
if (submitError) {
|
||||
setErrorMessage(submitError.message ?? "Please check your payment info.");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const returnUrl = new URL(
|
||||
ROUTES.subscription + "/success",
|
||||
window.location.origin,
|
||||
);
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: returnUrl.toString(),
|
||||
},
|
||||
redirect: "if_required",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setErrorMessage(error.message ?? "Payment confirmation failed.");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onConfirmed?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<PaymentElement />
|
||||
{errorMessage ? (
|
||||
<p role="alert" className={styles.error}>
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.button} ${styles.secondary}`}
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={`${styles.button} ${styles.primary}`}
|
||||
disabled={!stripe || !elements || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Processing..." : "Pay now"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -4,13 +4,14 @@
|
||||
*
|
||||
* 包装 `SubscriptionCtaButton` —— 通过 payment 状态机创建后端订单并拉起支付。
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
usePaymentDispatch,
|
||||
usePaymentState,
|
||||
} from "@/stores/payment/payment-context";
|
||||
|
||||
import { StripePaymentDialog } from "./stripe-payment-dialog";
|
||||
import { SubscriptionCtaButton } from "./subscription-cta-button";
|
||||
import styles from "./subscription-cta-button.module.css";
|
||||
|
||||
@@ -25,6 +26,9 @@ export function SubscriptionCheckoutButton({
|
||||
const payment = usePaymentState();
|
||||
const paymentDispatch = usePaymentDispatch();
|
||||
const launchedNonceRef = useRef(0);
|
||||
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
|
||||
@@ -33,15 +37,21 @@ export function SubscriptionCheckoutButton({
|
||||
|
||||
launchedNonceRef.current = payment.launchNonce;
|
||||
const paymentUrl = getPaymentUrl(payment.payParams);
|
||||
if (!paymentUrl) {
|
||||
paymentDispatch({
|
||||
type: "PaymentLaunchFailed",
|
||||
errorMessage: "Payment parameters did not include a supported URL.",
|
||||
});
|
||||
if (paymentUrl) {
|
||||
window.location.href = paymentUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = paymentUrl;
|
||||
const clientSecret = getStripeClientSecret(payment.payParams);
|
||||
if (clientSecret) {
|
||||
return;
|
||||
}
|
||||
|
||||
paymentDispatch({
|
||||
type: "PaymentLaunchFailed",
|
||||
errorMessage:
|
||||
"Payment parameters did not include a supported URL or Stripe client secret.",
|
||||
});
|
||||
}, [payment.launchNonce, payment.payParams, paymentDispatch]);
|
||||
|
||||
const isLoading =
|
||||
@@ -58,9 +68,33 @@ export function SubscriptionCheckoutButton({
|
||||
|
||||
const handleClick = () => {
|
||||
if (disabled || isLoading) return;
|
||||
setHiddenStripeClientSecret(null);
|
||||
paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
|
||||
};
|
||||
|
||||
const handleStripeClose = () => {
|
||||
if (stripeClientSecret) {
|
||||
setHiddenStripeClientSecret(stripeClientSecret);
|
||||
}
|
||||
if (payment.orderStatus !== "paid") {
|
||||
paymentDispatch({ type: "PaymentReset" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleStripeConfirmed = () => {
|
||||
if (stripeClientSecret) {
|
||||
setHiddenStripeClientSecret(stripeClientSecret);
|
||||
}
|
||||
};
|
||||
|
||||
const stripeClientSecret = payment.payParams
|
||||
? getStripeClientSecret(payment.payParams)
|
||||
: null;
|
||||
const shouldShowStripeDialog =
|
||||
stripeClientSecret !== null &&
|
||||
stripeClientSecret !== hiddenStripeClientSecret &&
|
||||
!payment.isPaid;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubscriptionCtaButton
|
||||
@@ -85,6 +119,13 @@ export function SubscriptionCheckoutButton({
|
||||
{payment.errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
{shouldShowStripeDialog ? (
|
||||
<StripePaymentDialog
|
||||
clientSecret={stripeClientSecret}
|
||||
onClose={handleStripeClose}
|
||||
onConfirmed={handleStripeConfirmed}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -107,3 +148,22 @@ function getPaymentUrl(payParams: Record<string, unknown>): string | null {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getStripeClientSecret(
|
||||
payParams: Record<string, unknown>,
|
||||
): string | null {
|
||||
const provider = payParams.provider;
|
||||
const clientSecret = payParams.clientSecret ?? payParams.client_secret;
|
||||
const isStripeProvider =
|
||||
typeof provider !== "string" || provider.toLowerCase() === "stripe";
|
||||
|
||||
if (
|
||||
isStripeProvider &&
|
||||
typeof clientSecret === "string" &&
|
||||
clientSecret.length > 0
|
||||
) {
|
||||
return clientSecret;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -26,11 +26,6 @@
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.highlight {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 4px 12px rgba(248, 77, 150, 0.18);
|
||||
}
|
||||
|
||||
.selected {
|
||||
border-color: var(--color-accent);
|
||||
border-width: 2px;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* - 卡片顶部:套餐名(12px / secondary)
|
||||
* - 中部:大字价格(28px / 300)+ 划线原价(12px / line-through / 999)
|
||||
* - 底部 28px 高渐变条:白字 12px/600 每日折算价
|
||||
* - 选中/高亮:2px 粉色边框 + 浅粉阴影
|
||||
* - 选中:2px 粉色边框 + 浅粉阴影
|
||||
* - 三个卡片底部渐变不同:粉 → 紫红 → 紫
|
||||
*/
|
||||
|
||||
@@ -25,7 +25,6 @@ export interface SubscriptionPlanView {
|
||||
price: string;
|
||||
originalPrice: string | null;
|
||||
perDay: string;
|
||||
highlight: boolean;
|
||||
currencySymbol: string;
|
||||
}
|
||||
|
||||
@@ -47,7 +46,6 @@ export function SubscriptionPlanCard({
|
||||
const classes = [
|
||||
styles.card,
|
||||
selected ? styles.selected : "",
|
||||
!selected && plan.highlight ? styles.highlight : "",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - 头像右侧两行文本:用户名(16px/600)、副标题(12px/secondary)
|
||||
* - 无头像时显示默认用户图标
|
||||
*/
|
||||
import Image from "next/image";
|
||||
import { UserMessageAvatar } from "@/app/chat/components/user-message-avatar";
|
||||
|
||||
import styles from "./subscription-user-row.module.css";
|
||||
|
||||
@@ -27,31 +27,11 @@ export function SubscriptionUserRow({
|
||||
}: SubscriptionUserRowProps) {
|
||||
return (
|
||||
<div className={[styles.row, className].filter(Boolean).join(" ")}>
|
||||
<div className={styles.avatar} aria-hidden="true">
|
||||
{avatarUrl ? (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
width={56}
|
||||
height={56}
|
||||
className={styles.avatarImg}
|
||||
/>
|
||||
) : (
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className={styles.avatarIcon}
|
||||
>
|
||||
<circle cx="12" cy="8" r="4" fill="currentColor" />
|
||||
<path
|
||||
d="M4 20c0-4.418 3.582-7 8-7s8 2.582 8 7"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<UserMessageAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
className={styles.avatar}
|
||||
size={56}
|
||||
/>
|
||||
<div className={styles.text}>
|
||||
<p className={styles.name}>{name}</p>
|
||||
<p className={styles.subtitle}>{subtitle}</p>
|
||||
|
||||
@@ -60,14 +60,13 @@ function planCaption(plan: PaymentPlan): string {
|
||||
return `${currencySymbol(plan.currency)}${perDay.toFixed(2)}/day`;
|
||||
}
|
||||
|
||||
function toPlanView(plan: PaymentPlan, index: number): SubscriptionPlanView {
|
||||
function toPlanView(plan: PaymentPlan): SubscriptionPlanView {
|
||||
return {
|
||||
id: plan.planId,
|
||||
name: plan.planName,
|
||||
price: formatAmount(plan.amountCents),
|
||||
originalPrice: null,
|
||||
perDay: planCaption(plan),
|
||||
highlight: plan.planId === "vip_monthly" || index === 0,
|
||||
currencySymbol: currencySymbol(plan.currency),
|
||||
};
|
||||
}
|
||||
@@ -96,7 +95,7 @@ export function SubscriptionScreen() {
|
||||
() =>
|
||||
payment.plans
|
||||
.filter(isVipPlan)
|
||||
.map((plan, index) => toPlanView(plan, index)),
|
||||
.map((plan) => toPlanView(plan)),
|
||||
[payment.plans],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user