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 ( -
{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 ( ++ Stripe publishable key is not configured for this build. +
++ Enter your payment details securely through Stripe. +
+{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: {