feat(subscription): support Stripe payment element
This commit is contained in:
@@ -10,6 +10,8 @@ NEXT_PUBLIC_API_BASE_URL=http://172.16.48.49:3002
|
|||||||
# WebSocket URL(开发环境,可选)
|
# WebSocket URL(开发环境,可选)
|
||||||
NEXT_PUBLIC_WS_BASE_URL=ws://172.16.48.49:3002/ws
|
NEXT_PUBLIC_WS_BASE_URL=ws://172.16.48.49:3002/ws
|
||||||
|
|
||||||
|
# Stripe 支付(publishable key 可暴露在浏览器端)
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
|
||||||
|
|
||||||
# API 超时时间(毫秒)
|
# API 超时时间(毫秒)
|
||||||
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
|
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ NEXTAUTH_URL=https://frontend-test.banlv-ai.com
|
|||||||
NEXTAUTH_URL_INTERNAL=http://localhost:3000
|
NEXTAUTH_URL_INTERNAL=http://localhost:3000
|
||||||
NEXT_PUBLIC_API_BASE_URL=https://api.cozsweet.com
|
NEXT_PUBLIC_API_BASE_URL=https://api.cozsweet.com
|
||||||
NEXT_PUBLIC_WS_BASE_URL=wss://api.cozsweet.com/ws
|
NEXT_PUBLIC_WS_BASE_URL=wss://api.cozsweet.com/ws
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
|
||||||
|
|
||||||
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
|
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
|
||||||
NEXT_PUBLIC_API_RECEIVE_TIMEOUT=60000
|
NEXT_PUBLIC_API_RECEIVE_TIMEOUT=60000
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ NEXT_PUBLIC_API_BASE_URL=https://api.cozsweet.com
|
|||||||
# WebSocket URL(生产环境)
|
# WebSocket URL(生产环境)
|
||||||
NEXT_PUBLIC_WS_BASE_URL=wss://api.cozsweet.com/ws
|
NEXT_PUBLIC_WS_BASE_URL=wss://api.cozsweet.com/ws
|
||||||
|
|
||||||
|
# Stripe 支付(publishable key 可暴露在浏览器端)
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
|
||||||
|
|
||||||
# API 超时时间(毫秒)
|
# API 超时时间(毫秒)
|
||||||
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
|
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
||||||
|
"@stripe/react-stripe-js": "^6.6.0",
|
||||||
|
"@stripe/stripe-js": "^9.8.0",
|
||||||
"@xstate/react": "^5",
|
"@xstate/react": "^5",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"dexie": "^4.4.3",
|
"dexie": "^4.4.3",
|
||||||
|
|||||||
Generated
+26
@@ -11,6 +11,12 @@ importers:
|
|||||||
'@fingerprintjs/fingerprintjs':
|
'@fingerprintjs/fingerprintjs':
|
||||||
specifier: ^5.2.0
|
specifier: ^5.2.0
|
||||||
version: 5.2.0
|
version: 5.2.0
|
||||||
|
'@stripe/react-stripe-js':
|
||||||
|
specifier: ^6.6.0
|
||||||
|
version: 6.6.0(@stripe/stripe-js@9.8.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
'@stripe/stripe-js':
|
||||||
|
specifier: ^9.8.0
|
||||||
|
version: 9.8.0
|
||||||
'@xstate/react':
|
'@xstate/react':
|
||||||
specifier: ^5
|
specifier: ^5
|
||||||
version: 5.0.5(@types/react@19.2.16)(react@19.2.4)(xstate@5.32.0)
|
version: 5.0.5(@types/react@19.2.16)(react@19.2.4)(xstate@5.32.0)
|
||||||
@@ -890,6 +896,17 @@ packages:
|
|||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
|
'@stripe/react-stripe-js@6.6.0':
|
||||||
|
resolution: {integrity: sha512-utODPiu2/JGjCnh5BX1M1F2uyjCwDKum4Bo8CeWdTCNOlzM0980YadzBMe7YoIwjfu3uadX4PMe3L2SderejqA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@stripe/stripe-js': '>=9.5.0 <10.0.0'
|
||||||
|
react: '>=16.8.0 <20.0.0'
|
||||||
|
react-dom: '>=16.8.0 <20.0.0'
|
||||||
|
|
||||||
|
'@stripe/stripe-js@9.8.0':
|
||||||
|
resolution: {integrity: sha512-DHJpol/98VKyojNSYmpkB5vOMnlf87hPe0wPxyaYTNiTMk5QjKMXDfSZLwGctYIXAgAWDFeRABc8lFAj0BELyw==}
|
||||||
|
engines: {node: '>=12.16'}
|
||||||
|
|
||||||
'@swc/core-darwin-arm64@1.15.32':
|
'@swc/core-darwin-arm64@1.15.32':
|
||||||
resolution: {integrity: sha512-/YWMvJDPu+AAwuUsM2G+DNQ/7zhodURGzdQyewEqcvgklAdDHs3LwQmLLnyn6SJl8DT8UOxkbzK+D1PmPeelRg==}
|
resolution: {integrity: sha512-/YWMvJDPu+AAwuUsM2G+DNQ/7zhodURGzdQyewEqcvgklAdDHs3LwQmLLnyn6SJl8DT8UOxkbzK+D1PmPeelRg==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -3970,6 +3987,15 @@ snapshots:
|
|||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
|
'@stripe/react-stripe-js@6.6.0(@stripe/stripe-js@9.8.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
'@stripe/stripe-js': 9.8.0
|
||||||
|
prop-types: 15.8.1
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
||||||
|
'@stripe/stripe-js@9.8.0': {}
|
||||||
|
|
||||||
'@swc/core-darwin-arm64@1.15.32':
|
'@swc/core-darwin-arm64@1.15.32':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|||||||
@@ -6,24 +6,41 @@ import styles from "./message-avatar.module.css";
|
|||||||
|
|
||||||
export interface UserMessageAvatarProps {
|
export interface UserMessageAvatarProps {
|
||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
|
className?: string;
|
||||||
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserMessageAvatar({ avatarUrl }: UserMessageAvatarProps) {
|
export function UserMessageAvatar({
|
||||||
|
avatarUrl,
|
||||||
|
className,
|
||||||
|
size = 43,
|
||||||
|
}: UserMessageAvatarProps) {
|
||||||
|
const avatarClassName = [styles.avatar, className].filter(Boolean).join(" ");
|
||||||
|
const avatarStyle = { width: size, height: size };
|
||||||
|
|
||||||
if (avatarUrl && avatarUrl.length > 0) {
|
if (avatarUrl && avatarUrl.length > 0) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.avatar} aria-label="User avatar">
|
<div
|
||||||
<Image src={avatarUrl} alt="" width={43} height={43} />
|
className={avatarClassName}
|
||||||
|
style={avatarStyle}
|
||||||
|
aria-label="User avatar"
|
||||||
|
>
|
||||||
|
<Image src={avatarUrl} alt="" width={size} height={size} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.avatar} aria-label="Guest avatar">
|
<div
|
||||||
|
className={avatarClassName}
|
||||||
|
style={avatarStyle}
|
||||||
|
aria-label="Guest avatar"
|
||||||
|
>
|
||||||
<Image
|
<Image
|
||||||
src="/images/chat/pic-chat-guest.png"
|
src="/images/chat/pic-chat-guest.png"
|
||||||
alt="Guest"
|
alt="Guest"
|
||||||
width={43}
|
width={size}
|
||||||
height={43}
|
height={size}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import { UserMessageAvatar } from "@/app/chat/components/user-message-avatar";
|
||||||
|
|
||||||
import styles from "./user-header.module.css";
|
import styles from "./user-header.module.css";
|
||||||
|
|
||||||
@@ -49,31 +49,11 @@ export function UserHeader({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.row}>
|
<div className={styles.row}>
|
||||||
<div className={styles.avatar} aria-hidden="true">
|
<UserMessageAvatar
|
||||||
{avatarUrl ? (
|
avatarUrl={avatarUrl}
|
||||||
<Image
|
className={styles.avatar}
|
||||||
src={avatarUrl}
|
size={56}
|
||||||
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>
|
|
||||||
|
|
||||||
<div className={styles.text}>
|
<div className={styles.text}>
|
||||||
<p className={styles.name}>{name}</p>
|
<p className={styles.name}>{name}</p>
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export * from "./subscription-checkout-button";
|
|||||||
export * from "./subscription-cta-button";
|
export * from "./subscription-cta-button";
|
||||||
export * from "./subscription-plan-card";
|
export * from "./subscription-plan-card";
|
||||||
export * from "./subscription-user-row";
|
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 状态机创建后端订单并拉起支付。
|
* 包装 `SubscriptionCtaButton` —— 通过 payment 状态机创建后端订单并拉起支付。
|
||||||
*/
|
*/
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
usePaymentDispatch,
|
usePaymentDispatch,
|
||||||
usePaymentState,
|
usePaymentState,
|
||||||
} from "@/stores/payment/payment-context";
|
} from "@/stores/payment/payment-context";
|
||||||
|
|
||||||
|
import { StripePaymentDialog } from "./stripe-payment-dialog";
|
||||||
import { SubscriptionCtaButton } from "./subscription-cta-button";
|
import { SubscriptionCtaButton } from "./subscription-cta-button";
|
||||||
import styles from "./subscription-cta-button.module.css";
|
import styles from "./subscription-cta-button.module.css";
|
||||||
|
|
||||||
@@ -25,6 +26,9 @@ export function SubscriptionCheckoutButton({
|
|||||||
const payment = usePaymentState();
|
const payment = usePaymentState();
|
||||||
const paymentDispatch = usePaymentDispatch();
|
const paymentDispatch = usePaymentDispatch();
|
||||||
const launchedNonceRef = useRef(0);
|
const launchedNonceRef = useRef(0);
|
||||||
|
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
|
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
|
||||||
@@ -33,15 +37,21 @@ export function SubscriptionCheckoutButton({
|
|||||||
|
|
||||||
launchedNonceRef.current = payment.launchNonce;
|
launchedNonceRef.current = payment.launchNonce;
|
||||||
const paymentUrl = getPaymentUrl(payment.payParams);
|
const paymentUrl = getPaymentUrl(payment.payParams);
|
||||||
if (!paymentUrl) {
|
if (paymentUrl) {
|
||||||
paymentDispatch({
|
window.location.href = paymentUrl;
|
||||||
type: "PaymentLaunchFailed",
|
|
||||||
errorMessage: "Payment parameters did not include a supported URL.",
|
|
||||||
});
|
|
||||||
return;
|
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]);
|
}, [payment.launchNonce, payment.payParams, paymentDispatch]);
|
||||||
|
|
||||||
const isLoading =
|
const isLoading =
|
||||||
@@ -58,9 +68,33 @@ export function SubscriptionCheckoutButton({
|
|||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (disabled || isLoading) return;
|
if (disabled || isLoading) return;
|
||||||
|
setHiddenStripeClientSecret(null);
|
||||||
paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<SubscriptionCtaButton
|
<SubscriptionCtaButton
|
||||||
@@ -85,6 +119,13 @@ export function SubscriptionCheckoutButton({
|
|||||||
{payment.errorMessage}
|
{payment.errorMessage}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{shouldShowStripeDialog ? (
|
||||||
|
<StripePaymentDialog
|
||||||
|
clientSecret={stripeClientSecret}
|
||||||
|
onClose={handleStripeClose}
|
||||||
|
onConfirmed={handleStripeConfirmed}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -107,3 +148,22 @@ function getPaymentUrl(payParams: Record<string, unknown>): string | null {
|
|||||||
|
|
||||||
return 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);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.highlight {
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
box-shadow: 0 4px 12px rgba(248, 77, 150, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected {
|
.selected {
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* - 卡片顶部:套餐名(12px / secondary)
|
* - 卡片顶部:套餐名(12px / secondary)
|
||||||
* - 中部:大字价格(28px / 300)+ 划线原价(12px / line-through / 999)
|
* - 中部:大字价格(28px / 300)+ 划线原价(12px / line-through / 999)
|
||||||
* - 底部 28px 高渐变条:白字 12px/600 每日折算价
|
* - 底部 28px 高渐变条:白字 12px/600 每日折算价
|
||||||
* - 选中/高亮:2px 粉色边框 + 浅粉阴影
|
* - 选中:2px 粉色边框 + 浅粉阴影
|
||||||
* - 三个卡片底部渐变不同:粉 → 紫红 → 紫
|
* - 三个卡片底部渐变不同:粉 → 紫红 → 紫
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -25,7 +25,6 @@ export interface SubscriptionPlanView {
|
|||||||
price: string;
|
price: string;
|
||||||
originalPrice: string | null;
|
originalPrice: string | null;
|
||||||
perDay: string;
|
perDay: string;
|
||||||
highlight: boolean;
|
|
||||||
currencySymbol: string;
|
currencySymbol: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +46,6 @@ export function SubscriptionPlanCard({
|
|||||||
const classes = [
|
const classes = [
|
||||||
styles.card,
|
styles.card,
|
||||||
selected ? styles.selected : "",
|
selected ? styles.selected : "",
|
||||||
!selected && plan.highlight ? styles.highlight : "",
|
|
||||||
className,
|
className,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* - 头像右侧两行文本:用户名(16px/600)、副标题(12px/secondary)
|
* - 头像右侧两行文本:用户名(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";
|
import styles from "./subscription-user-row.module.css";
|
||||||
|
|
||||||
@@ -27,31 +27,11 @@ export function SubscriptionUserRow({
|
|||||||
}: SubscriptionUserRowProps) {
|
}: SubscriptionUserRowProps) {
|
||||||
return (
|
return (
|
||||||
<div className={[styles.row, className].filter(Boolean).join(" ")}>
|
<div className={[styles.row, className].filter(Boolean).join(" ")}>
|
||||||
<div className={styles.avatar} aria-hidden="true">
|
<UserMessageAvatar
|
||||||
{avatarUrl ? (
|
avatarUrl={avatarUrl}
|
||||||
<Image
|
className={styles.avatar}
|
||||||
src={avatarUrl}
|
size={56}
|
||||||
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>
|
|
||||||
<div className={styles.text}>
|
<div className={styles.text}>
|
||||||
<p className={styles.name}>{name}</p>
|
<p className={styles.name}>{name}</p>
|
||||||
<p className={styles.subtitle}>{subtitle}</p>
|
<p className={styles.subtitle}>{subtitle}</p>
|
||||||
|
|||||||
@@ -60,14 +60,13 @@ function planCaption(plan: PaymentPlan): string {
|
|||||||
return `${currencySymbol(plan.currency)}${perDay.toFixed(2)}/day`;
|
return `${currencySymbol(plan.currency)}${perDay.toFixed(2)}/day`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPlanView(plan: PaymentPlan, index: number): SubscriptionPlanView {
|
function toPlanView(plan: PaymentPlan): SubscriptionPlanView {
|
||||||
return {
|
return {
|
||||||
id: plan.planId,
|
id: plan.planId,
|
||||||
name: plan.planName,
|
name: plan.planName,
|
||||||
price: formatAmount(plan.amountCents),
|
price: formatAmount(plan.amountCents),
|
||||||
originalPrice: null,
|
originalPrice: null,
|
||||||
perDay: planCaption(plan),
|
perDay: planCaption(plan),
|
||||||
highlight: plan.planId === "vip_monthly" || index === 0,
|
|
||||||
currencySymbol: currencySymbol(plan.currency),
|
currencySymbol: currencySymbol(plan.currency),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -96,7 +95,7 @@ export function SubscriptionScreen() {
|
|||||||
() =>
|
() =>
|
||||||
payment.plans
|
payment.plans
|
||||||
.filter(isVipPlan)
|
.filter(isVipPlan)
|
||||||
.map((plan, index) => toPlanView(plan, index)),
|
.map((plan) => toPlanView(plan)),
|
||||||
[payment.plans],
|
[payment.plans],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -209,6 +209,12 @@ export const paymentMachine = setup({
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
on: {
|
||||||
|
PaymentReset: {
|
||||||
|
target: "ready",
|
||||||
|
actions: assign(resetOrderState),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
waitingForPayment: {
|
waitingForPayment: {
|
||||||
|
|||||||
Reference in New Issue
Block a user