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(开发环境,可选)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+26
@@ -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
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.avatar} aria-label="User avatar">
|
||||
<Image src={avatarUrl} alt="" width={43} height={43} />
|
||||
<div
|
||||
className={avatarClassName}
|
||||
style={avatarStyle}
|
||||
aria-label="User avatar"
|
||||
>
|
||||
<Image src={avatarUrl} alt="" width={size} height={size} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.avatar} aria-label="Guest avatar">
|
||||
<div
|
||||
className={avatarClassName}
|
||||
style={avatarStyle}
|
||||
aria-label="Guest avatar"
|
||||
>
|
||||
<Image
|
||||
src="/images/chat/pic-chat-guest.png"
|
||||
alt="Guest"
|
||||
width={43}
|
||||
height={43}
|
||||
width={size}
|
||||
height={size}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.row}>
|
||||
<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>
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -209,6 +209,12 @@ export const paymentMachine = setup({
|
||||
})),
|
||||
},
|
||||
},
|
||||
on: {
|
||||
PaymentReset: {
|
||||
target: "ready",
|
||||
actions: assign(resetOrderState),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
waitingForPayment: {
|
||||
|
||||
Reference in New Issue
Block a user