diff --git a/env-example/.env.development.example b/env-example/.env.development.example index 74aedaa3..bc69877b 100644 --- a/env-example/.env.development.example +++ b/env-example/.env.development.example @@ -10,6 +10,8 @@ NEXT_PUBLIC_API_BASE_URL=http://172.16.48.49:3002 # WebSocket URL(开发环境,可选) NEXT_PUBLIC_WS_BASE_URL=ws://172.16.48.49:3002/ws +# Stripe 支付(publishable key 可暴露在浏览器端) +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R # API 超时时间(毫秒) NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000 diff --git a/env-example/.env.local.example b/env-example/.env.local.example index b0596f29..869256ad 100644 --- a/env-example/.env.local.example +++ b/env-example/.env.local.example @@ -5,6 +5,7 @@ NEXTAUTH_URL=https://frontend-test.banlv-ai.com NEXTAUTH_URL_INTERNAL=http://localhost:3000 NEXT_PUBLIC_API_BASE_URL=https://api.cozsweet.com 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_RECEIVE_TIMEOUT=60000 diff --git a/env-example/.env.production.example b/env-example/.env.production.example index a3a41a69..b5a7e624 100644 --- a/env-example/.env.production.example +++ b/env-example/.env.production.example @@ -11,6 +11,8 @@ NEXT_PUBLIC_API_BASE_URL=https://api.cozsweet.com # WebSocket URL(生产环境) NEXT_PUBLIC_WS_BASE_URL=wss://api.cozsweet.com/ws +# Stripe 支付(publishable key 可暴露在浏览器端) +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R # API 超时时间(毫秒) NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000 diff --git a/package.json b/package.json index e455ddf5..1ef85886 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ }, "dependencies": { "@fingerprintjs/fingerprintjs": "^5.2.0", + "@stripe/react-stripe-js": "^6.6.0", + "@stripe/stripe-js": "^9.8.0", "@xstate/react": "^5", "classnames": "^2.5.1", "dexie": "^4.4.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85e5f096..ebe88d73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@fingerprintjs/fingerprintjs': specifier: ^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': specifier: ^5 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': 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': resolution: {integrity: sha512-/YWMvJDPu+AAwuUsM2G+DNQ/7zhodURGzdQyewEqcvgklAdDHs3LwQmLLnyn6SJl8DT8UOxkbzK+D1PmPeelRg==} engines: {node: '>=10'} @@ -3970,6 +3987,15 @@ snapshots: '@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': optional: true diff --git a/src/app/chat/components/user-message-avatar.tsx b/src/app/chat/components/user-message-avatar.tsx index 78b9e615..50ddd196 100644 --- a/src/app/chat/components/user-message-avatar.tsx +++ b/src/app/chat/components/user-message-avatar.tsx @@ -6,24 +6,41 @@ import styles from "./message-avatar.module.css"; export interface UserMessageAvatarProps { 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) { return ( -
- +
+
); } return ( -
+
Guest
); diff --git a/src/app/sidebar/components/user-header.tsx b/src/app/sidebar/components/user-header.tsx index 68e31d83..fcc01a36 100644 --- a/src/app/sidebar/components/user-header.tsx +++ b/src/app/sidebar/components/user-header.tsx @@ -1,6 +1,6 @@ "use client"; -import Image from "next/image"; +import { UserMessageAvatar } from "@/app/chat/components/user-message-avatar"; import styles from "./user-header.module.css"; @@ -49,31 +49,11 @@ export function UserHeader({ return (
- +

{name}

diff --git a/src/app/subscription/components/index.ts b/src/app/subscription/components/index.ts index a1da90f9..ddc3133b 100644 --- a/src/app/subscription/components/index.ts +++ b/src/app/subscription/components/index.ts @@ -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"; diff --git a/src/app/subscription/components/stripe-payment-dialog.module.css b/src/app/subscription/components/stripe-payment-dialog.module.css new file mode 100644 index 00000000..2cd64a18 --- /dev/null +++ b/src/app/subscription/components/stripe-payment-dialog.module.css @@ -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); +} diff --git a/src/app/subscription/components/stripe-payment-dialog.tsx b/src/app/subscription/components/stripe-payment-dialog.tsx new file mode 100644 index 00000000..8121b2b8 --- /dev/null +++ b/src/app/subscription/components/stripe-payment-dialog.tsx @@ -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 ( +
+
+
+

Payment unavailable

+

+ Stripe publishable key is not configured for this build. +

+
+
+ +
+
+
+ ); + } + + return ( +
+
+
+

+ Complete payment +

+

+ Enter your payment details securely through Stripe. +

+
+ + + +
+
+ ); +} + +function StripePaymentForm({ + onClose, + onConfirmed, +}: Pick) { + const stripe = useStripe(); + const elements = useElements(); + const [errorMessage, setErrorMessage] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (event: FormEvent) => { + 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 ( +
+ + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} +
+ + +
+ + ); +} diff --git a/src/app/subscription/components/subscription-checkout-button.tsx b/src/app/subscription/components/subscription-checkout-button.tsx index 5c33852c..192d5d20 100644 --- a/src/app/subscription/components/subscription-checkout-button.tsx +++ b/src/app/subscription/components/subscription-checkout-button.tsx @@ -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 ( <> ) : null} + {shouldShowStripeDialog ? ( + + ) : null} ); } @@ -107,3 +148,22 @@ function getPaymentUrl(payParams: Record): string | null { return null; } + +function getStripeClientSecret( + payParams: Record, +): 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; +} diff --git a/src/app/subscription/components/subscription-plan-card.module.css b/src/app/subscription/components/subscription-plan-card.module.css index 0c9dd96a..4a0d2217 100644 --- a/src/app/subscription/components/subscription-plan-card.module.css +++ b/src/app/subscription/components/subscription-plan-card.module.css @@ -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; diff --git a/src/app/subscription/components/subscription-plan-card.tsx b/src/app/subscription/components/subscription-plan-card.tsx index 27c5cb7f..28e9b038 100644 --- a/src/app/subscription/components/subscription-plan-card.tsx +++ b/src/app/subscription/components/subscription-plan-card.tsx @@ -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) diff --git a/src/app/subscription/components/subscription-user-row.tsx b/src/app/subscription/components/subscription-user-row.tsx index 97d3c5b6..4f69fbc6 100644 --- a/src/app/subscription/components/subscription-user-row.tsx +++ b/src/app/subscription/components/subscription-user-row.tsx @@ -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 (
- +

{name}

{subtitle}

diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx index 52047cca..4203351d 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -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], ); diff --git a/src/stores/payment/payment-machine.ts b/src/stores/payment/payment-machine.ts index d74f1762..e5b2de0f 100644 --- a/src/stores/payment/payment-machine.ts +++ b/src/stores/payment/payment-machine.ts @@ -209,6 +209,12 @@ export const paymentMachine = setup({ })), }, }, + on: { + PaymentReset: { + target: "ready", + actions: assign(resetOrderState), + }, + }, }, waitingForPayment: {