From 35c30ac31ea8626e345dedf577cf25137ffb0f84 Mon Sep 17 00:00:00 2001 From: chenhang Date: Thu, 18 Jun 2026 12:53:06 +0800 Subject: [PATCH 1/3] feat(data): add paywall payment APIs --- src/data/dto/chat/chat_history_response.ts | 4 + src/data/dto/chat/chat_message.ts | 3 + src/data/dto/chat/chat_send_response.ts | 10 ++- src/data/dto/chat/index.ts | 2 + src/data/dto/chat/unlock_private_request.ts | 30 +++++++ src/data/dto/chat/unlock_private_response.ts | 37 +++++++++ .../payment/create_payment_order_request.ts | 39 +++++++++ .../payment/create_payment_order_response.ts | 35 ++++++++ src/data/dto/payment/index.ts | 11 +++ .../payment/payment_order_status_response.ts | 38 +++++++++ src/data/dto/payment/payment_plan.ts | 36 +++++++++ .../dto/payment/payment_plans_response.ts | 36 +++++++++ .../payment/payment_vip_status_response.ts | 33 ++++++++ .../dto/payment/payment_websocket_event.ts | 33 ++++++++ src/data/repositories/chat_repository.ts | 11 +++ src/data/repositories/index.ts | 2 + .../interfaces/ichat_repository.ts | 4 + src/data/repositories/interfaces/index.ts | 1 + .../interfaces/ipayment_repository.ts | 31 +++++++ src/data/repositories/payment_repository.ts | 56 +++++++++++++ .../schemas/chat/chat_history_response.ts | 4 + src/data/schemas/chat/chat_message.ts | 21 +++-- src/data/schemas/chat/chat_send_response.ts | 14 ++++ src/data/schemas/chat/index.ts | 2 + .../schemas/chat/unlock_private_request.ts | 15 ++++ .../schemas/chat/unlock_private_response.ts | 29 +++++++ .../payment/create_payment_order_request.ts | 20 +++++ .../payment/create_payment_order_response.ts | 16 ++++ src/data/schemas/payment/index.ts | 11 +++ .../payment/payment_order_status_response.ts | 21 +++++ src/data/schemas/payment/payment_plan.ts | 44 ++++++++++ .../schemas/payment/payment_plans_response.ts | 17 ++++ .../payment/payment_vip_status_response.ts | 16 ++++ .../payment/payment_websocket_event.ts | 52 ++++++++++++ src/data/services/api/api_path.ts | 15 +++- src/data/services/api/chat_api.ts | 20 +++++ src/data/services/api/index.ts | 1 + src/data/services/api/payment_api.ts | 80 +++++++++++++++++++ 38 files changed, 840 insertions(+), 10 deletions(-) create mode 100644 src/data/dto/chat/unlock_private_request.ts create mode 100644 src/data/dto/chat/unlock_private_response.ts create mode 100644 src/data/dto/payment/create_payment_order_request.ts create mode 100644 src/data/dto/payment/create_payment_order_response.ts create mode 100644 src/data/dto/payment/index.ts create mode 100644 src/data/dto/payment/payment_order_status_response.ts create mode 100644 src/data/dto/payment/payment_plan.ts create mode 100644 src/data/dto/payment/payment_plans_response.ts create mode 100644 src/data/dto/payment/payment_vip_status_response.ts create mode 100644 src/data/dto/payment/payment_websocket_event.ts create mode 100644 src/data/repositories/interfaces/ipayment_repository.ts create mode 100644 src/data/repositories/payment_repository.ts create mode 100644 src/data/schemas/chat/unlock_private_request.ts create mode 100644 src/data/schemas/chat/unlock_private_response.ts create mode 100644 src/data/schemas/payment/create_payment_order_request.ts create mode 100644 src/data/schemas/payment/create_payment_order_response.ts create mode 100644 src/data/schemas/payment/index.ts create mode 100644 src/data/schemas/payment/payment_order_status_response.ts create mode 100644 src/data/schemas/payment/payment_plan.ts create mode 100644 src/data/schemas/payment/payment_plans_response.ts create mode 100644 src/data/schemas/payment/payment_vip_status_response.ts create mode 100644 src/data/schemas/payment/payment_websocket_event.ts create mode 100644 src/data/services/api/payment_api.ts diff --git a/src/data/dto/chat/chat_history_response.ts b/src/data/dto/chat/chat_history_response.ts index 6b4abee6..90926da1 100644 --- a/src/data/dto/chat/chat_history_response.ts +++ b/src/data/dto/chat/chat_history_response.ts @@ -13,6 +13,10 @@ export class ChatHistoryResponse { declare readonly total: number; declare readonly limit: number; declare readonly offset: number; + declare readonly isVip: boolean; + declare readonly privateFreeLimit: number; + declare readonly privateUsedToday: number; + declare readonly privateCanViewFree: boolean; private constructor(input: ChatHistoryResponseInput) { const data = ChatHistoryResponseSchema.parse(input); diff --git a/src/data/dto/chat/chat_message.ts b/src/data/dto/chat/chat_message.ts index 87dbc0e6..085844e5 100644 --- a/src/data/dto/chat/chat_message.ts +++ b/src/data/dto/chat/chat_message.ts @@ -12,6 +12,9 @@ export class ChatMessage { declare readonly content: string; declare readonly id: string; declare readonly createdAt: string; + declare readonly isPrivate: boolean | null; + declare readonly privateLocked: boolean | null; + declare readonly privateHint: string | null; private constructor(input: ChatMessageInput) { const data = ChatMessageSchema.parse(input); diff --git a/src/data/dto/chat/chat_send_response.ts b/src/data/dto/chat/chat_send_response.ts index a1523c4f..592b97d1 100644 --- a/src/data/dto/chat/chat_send_response.ts +++ b/src/data/dto/chat/chat_send_response.ts @@ -5,6 +5,7 @@ import { ChatSendResponseSchema, type ChatSendResponseInput, type ChatSendResponseData, + type ChatBlockDetailData, } from "@/data/schemas/chat/chat_send_response"; export class ChatSendResponse { @@ -12,13 +13,20 @@ export class ChatSendResponse { declare readonly reply: string; declare readonly voiceUrl: string; declare readonly audioUrl: string; - declare readonly intimidadChange: number; + declare readonly intimacyChange: number; declare readonly newIntimacy: number; declare readonly relationshipStage: string; declare readonly currentMood: string; declare readonly messageId: string; declare readonly isGuest: boolean; declare readonly timestamp: number; + declare readonly blocked: boolean | null; + declare readonly blockReason: string | null; + declare readonly blockDetail: ChatBlockDetailData | null; + declare readonly paywallTriggered: boolean; + declare readonly showUpgrade: boolean; + declare readonly imageType: string | null; + declare readonly imageUrl: string | null; private constructor(input: ChatSendResponseInput) { const data = ChatSendResponseSchema.parse(input); diff --git a/src/data/dto/chat/index.ts b/src/data/dto/chat/index.ts index abd3fbee..2d11b76e 100644 --- a/src/data/dto/chat/index.ts +++ b/src/data/dto/chat/index.ts @@ -14,3 +14,5 @@ export * from "./send_message_request"; export * from "./stt_data"; export * from "./sync_message"; export * from "./ui_message"; +export * from "./unlock_private_request"; +export * from "./unlock_private_response"; diff --git a/src/data/dto/chat/unlock_private_request.ts b/src/data/dto/chat/unlock_private_request.ts new file mode 100644 index 00000000..45ff81c6 --- /dev/null +++ b/src/data/dto/chat/unlock_private_request.ts @@ -0,0 +1,30 @@ +/** + * 私密消息解锁请求 DTO + */ +import { + UnlockPrivateRequestSchema, + type UnlockPrivateRequestData, + type UnlockPrivateRequestInput, +} from "@/data/schemas/chat/unlock_private_request"; + +export class UnlockPrivateRequest { + declare readonly messageId: string; + + private constructor(input: UnlockPrivateRequestInput) { + const data = UnlockPrivateRequestSchema.parse(input); + Object.assign(this, data); + Object.freeze(this); + } + + static from(input: UnlockPrivateRequestInput): UnlockPrivateRequest { + return new UnlockPrivateRequest(input); + } + + static fromJson(json: unknown): UnlockPrivateRequest { + return UnlockPrivateRequest.from(json as UnlockPrivateRequestInput); + } + + toJson(): UnlockPrivateRequestData { + return UnlockPrivateRequestSchema.parse(this); + } +} diff --git a/src/data/dto/chat/unlock_private_response.ts b/src/data/dto/chat/unlock_private_response.ts new file mode 100644 index 00000000..fb4eeed7 --- /dev/null +++ b/src/data/dto/chat/unlock_private_response.ts @@ -0,0 +1,37 @@ +/** + * 私密消息解锁响应 DTO + */ +import { + UnlockPrivateResponseSchema, + type UnlockPrivateReason, + type UnlockPrivateResponseData, + type UnlockPrivateResponseInput, +} from "@/data/schemas/chat/unlock_private_response"; + +export class UnlockPrivateResponse { + declare readonly unlocked: boolean; + declare readonly content: string | null; + declare readonly showUpgrade: boolean; + declare readonly paywallTriggered: boolean; + declare readonly privateFreeLimit: number; + declare readonly privateUsedToday: number; + declare readonly reason: UnlockPrivateReason; + + private constructor(input: UnlockPrivateResponseInput) { + const data = UnlockPrivateResponseSchema.parse(input); + Object.assign(this, data); + Object.freeze(this); + } + + static from(input: UnlockPrivateResponseInput): UnlockPrivateResponse { + return new UnlockPrivateResponse(input); + } + + static fromJson(json: unknown): UnlockPrivateResponse { + return UnlockPrivateResponse.from(json as UnlockPrivateResponseInput); + } + + toJson(): UnlockPrivateResponseData { + return UnlockPrivateResponseSchema.parse(this); + } +} diff --git a/src/data/dto/payment/create_payment_order_request.ts b/src/data/dto/payment/create_payment_order_request.ts new file mode 100644 index 00000000..064241c9 --- /dev/null +++ b/src/data/dto/payment/create_payment_order_request.ts @@ -0,0 +1,39 @@ +/** + * 创建支付订单请求 DTO + */ +import { + CreatePaymentOrderRequestSchema, + type CreatePaymentOrderRequestData, + type CreatePaymentOrderRequestInput, + type PayChannel, +} from "@/data/schemas/payment/create_payment_order_request"; + +export type { PayChannel }; + +export class CreatePaymentOrderRequest { + declare readonly planId: string; + declare readonly payChannel: PayChannel; + declare readonly autoRenew: boolean; + + private constructor(input: CreatePaymentOrderRequestInput) { + const data = CreatePaymentOrderRequestSchema.parse(input); + Object.assign(this, data); + Object.freeze(this); + } + + static from( + input: CreatePaymentOrderRequestInput, + ): CreatePaymentOrderRequest { + return new CreatePaymentOrderRequest(input); + } + + static fromJson(json: unknown): CreatePaymentOrderRequest { + return CreatePaymentOrderRequest.from( + json as CreatePaymentOrderRequestInput, + ); + } + + toJson(): CreatePaymentOrderRequestData { + return CreatePaymentOrderRequestSchema.parse(this); + } +} diff --git a/src/data/dto/payment/create_payment_order_response.ts b/src/data/dto/payment/create_payment_order_response.ts new file mode 100644 index 00000000..15e57b24 --- /dev/null +++ b/src/data/dto/payment/create_payment_order_response.ts @@ -0,0 +1,35 @@ +/** + * 创建支付订单响应 DTO + */ +import { + CreatePaymentOrderResponseSchema, + type CreatePaymentOrderResponseData, + type CreatePaymentOrderResponseInput, +} from "@/data/schemas/payment/create_payment_order_response"; + +export class CreatePaymentOrderResponse { + declare readonly orderId: string; + declare readonly payParams: Record; + + private constructor(input: CreatePaymentOrderResponseInput) { + const data = CreatePaymentOrderResponseSchema.parse(input); + Object.assign(this, data); + Object.freeze(this); + } + + static from( + input: CreatePaymentOrderResponseInput, + ): CreatePaymentOrderResponse { + return new CreatePaymentOrderResponse(input); + } + + static fromJson(json: unknown): CreatePaymentOrderResponse { + return CreatePaymentOrderResponse.from( + json as CreatePaymentOrderResponseInput, + ); + } + + toJson(): CreatePaymentOrderResponseData { + return CreatePaymentOrderResponseSchema.parse(this); + } +} diff --git a/src/data/dto/payment/index.ts b/src/data/dto/payment/index.ts new file mode 100644 index 00000000..058dbf94 --- /dev/null +++ b/src/data/dto/payment/index.ts @@ -0,0 +1,11 @@ +/** + * @file Payment DTO barrel. + */ + +export * from "./create_payment_order_request"; +export * from "./create_payment_order_response"; +export * from "./payment_order_status_response"; +export * from "./payment_plan"; +export * from "./payment_plans_response"; +export * from "./payment_vip_status_response"; +export * from "./payment_websocket_event"; diff --git a/src/data/dto/payment/payment_order_status_response.ts b/src/data/dto/payment/payment_order_status_response.ts new file mode 100644 index 00000000..c9d95a01 --- /dev/null +++ b/src/data/dto/payment/payment_order_status_response.ts @@ -0,0 +1,38 @@ +/** + * 支付订单状态响应 DTO + */ +import { + PaymentOrderStatusResponseSchema, + type PaymentOrderStatus, + type PaymentOrderStatusResponseData, + type PaymentOrderStatusResponseInput, +} from "@/data/schemas/payment/payment_order_status_response"; + +export class PaymentOrderStatusResponse { + declare readonly orderId: string; + declare readonly status: PaymentOrderStatus; + declare readonly orderType: string; + declare readonly planId: string; + + private constructor(input: PaymentOrderStatusResponseInput) { + const data = PaymentOrderStatusResponseSchema.parse(input); + Object.assign(this, data); + Object.freeze(this); + } + + static from( + input: PaymentOrderStatusResponseInput, + ): PaymentOrderStatusResponse { + return new PaymentOrderStatusResponse(input); + } + + static fromJson(json: unknown): PaymentOrderStatusResponse { + return PaymentOrderStatusResponse.from( + json as PaymentOrderStatusResponseInput, + ); + } + + toJson(): PaymentOrderStatusResponseData { + return PaymentOrderStatusResponseSchema.parse(this); + } +} diff --git a/src/data/dto/payment/payment_plan.ts b/src/data/dto/payment/payment_plan.ts new file mode 100644 index 00000000..d5f933c1 --- /dev/null +++ b/src/data/dto/payment/payment_plan.ts @@ -0,0 +1,36 @@ +/** + * 支付套餐 DTO + */ +import { + PaymentPlanSchema, + type PaymentPlanData, + type PaymentPlanInput, +} from "@/data/schemas/payment/payment_plan"; + +export class PaymentPlan { + declare readonly planId: string; + declare readonly planName: string; + declare readonly orderType: string; + declare readonly amountCents: number; + declare readonly currency: string; + declare readonly vipDays: number | null; + declare readonly dolAmount: number | null; + + private constructor(input: PaymentPlanInput) { + const data = PaymentPlanSchema.parse(input); + Object.assign(this, data); + Object.freeze(this); + } + + static from(input: PaymentPlanInput): PaymentPlan { + return new PaymentPlan(input); + } + + static fromJson(json: unknown): PaymentPlan { + return PaymentPlan.from(json as PaymentPlanInput); + } + + toJson(): PaymentPlanData { + return PaymentPlanSchema.parse(this); + } +} diff --git a/src/data/dto/payment/payment_plans_response.ts b/src/data/dto/payment/payment_plans_response.ts new file mode 100644 index 00000000..03390e1d --- /dev/null +++ b/src/data/dto/payment/payment_plans_response.ts @@ -0,0 +1,36 @@ +/** + * 支付套餐列表响应 DTO + */ +import { + PaymentPlansResponseSchema, + type PaymentPlansResponseData, + type PaymentPlansResponseInput, +} from "@/data/schemas/payment/payment_plans_response"; + +import { PaymentPlan } from "./payment_plan"; + +export class PaymentPlansResponse { + declare readonly plans: PaymentPlan[]; + + private constructor(input: PaymentPlansResponseInput) { + const data = PaymentPlansResponseSchema.parse(input); + Object.assign(this, { + plans: data.plans.map((plan) => PaymentPlan.from(plan)), + }); + Object.freeze(this); + } + + static from(input: PaymentPlansResponseInput): PaymentPlansResponse { + return new PaymentPlansResponse(input); + } + + static fromJson(json: unknown): PaymentPlansResponse { + return PaymentPlansResponse.from(json as PaymentPlansResponseInput); + } + + toJson(): PaymentPlansResponseData { + return { + plans: this.plans.map((plan) => plan.toJson()), + }; + } +} diff --git a/src/data/dto/payment/payment_vip_status_response.ts b/src/data/dto/payment/payment_vip_status_response.ts new file mode 100644 index 00000000..b90bd917 --- /dev/null +++ b/src/data/dto/payment/payment_vip_status_response.ts @@ -0,0 +1,33 @@ +/** + * VIP 状态响应 DTO + */ +import { + PaymentVipStatusResponseSchema, + type PaymentVipStatusResponseData, + type PaymentVipStatusResponseInput, +} from "@/data/schemas/payment/payment_vip_status_response"; + +export class PaymentVipStatusResponse { + declare readonly isVip: boolean; + declare readonly vipExpiresAt: string | null; + + private constructor(input: PaymentVipStatusResponseInput) { + const data = PaymentVipStatusResponseSchema.parse(input); + Object.assign(this, data); + Object.freeze(this); + } + + static from(input: PaymentVipStatusResponseInput): PaymentVipStatusResponse { + return new PaymentVipStatusResponse(input); + } + + static fromJson(json: unknown): PaymentVipStatusResponse { + return PaymentVipStatusResponse.from( + json as PaymentVipStatusResponseInput, + ); + } + + toJson(): PaymentVipStatusResponseData { + return PaymentVipStatusResponseSchema.parse(this); + } +} diff --git a/src/data/dto/payment/payment_websocket_event.ts b/src/data/dto/payment/payment_websocket_event.ts new file mode 100644 index 00000000..cf963b99 --- /dev/null +++ b/src/data/dto/payment/payment_websocket_event.ts @@ -0,0 +1,33 @@ +/** + * 支付 WebSocket 事件 DTO + */ +import { + PaymentWebSocketEventSchema, + type PaymentWebSocketEventData, + type PaymentWebSocketEventInput, +} from "@/data/schemas/payment/payment_websocket_event"; + +export class PaymentWebSocketEvent { + declare readonly type: PaymentWebSocketEventData["type"]; + declare readonly orderId: string; + + private constructor(input: PaymentWebSocketEventInput) { + const data = PaymentWebSocketEventSchema.parse(input); + Object.assign(this, data); + Object.freeze(this); + } + + static from(input: PaymentWebSocketEventInput): PaymentWebSocketEvent { + return new PaymentWebSocketEvent(input); + } + + static fromJson(json: unknown): PaymentWebSocketEvent { + return PaymentWebSocketEvent.from(json as PaymentWebSocketEventInput); + } + + toJson(): PaymentWebSocketEventData { + return PaymentWebSocketEventSchema.parse(this); + } +} + +export type { PaymentWebSocketEventData }; diff --git a/src/data/repositories/chat_repository.ts b/src/data/repositories/chat_repository.ts index 7d5224b4..a7146450 100644 --- a/src/data/repositories/chat_repository.ts +++ b/src/data/repositories/chat_repository.ts @@ -21,6 +21,8 @@ import { ChatMessage, ChatSendResponse, SendMessageRequest, + UnlockPrivateRequest, + UnlockPrivateResponse, } from "@/data/dto/chat"; import { Result } from "@/utils"; import type { IChatRepository } from "@/data/repositories/interfaces"; @@ -57,6 +59,15 @@ export class ChatRepository implements IChatRepository { return Result.wrap(() => this.api.getHistory(limit, offset)); } + /** 解锁私密消息。 */ + async unlockPrivateMessage( + messageId: string, + ): Promise> { + return Result.wrap(() => + this.api.unlockPrivateMessage(UnlockPrivateRequest.from({ messageId })), + ); + } + // ============ 本地(Dexie)操作 ============ /** 把一条消息写入本地存储。 */ diff --git a/src/data/repositories/index.ts b/src/data/repositories/index.ts index a45833f1..84e43c4c 100644 --- a/src/data/repositories/index.ts +++ b/src/data/repositories/index.ts @@ -5,8 +5,10 @@ export * from "./auth_repository"; export * from "./chat_repository"; export * from "./metrics_repository"; +export * from "./payment_repository"; export * from "./user_repository"; export * from "./interfaces/iauth_repository"; export * from "./interfaces/ichat_repository"; export * from "./interfaces/imetrics_repository"; +export * from "./interfaces/ipayment_repository"; export * from "./interfaces/iuser_repository"; diff --git a/src/data/repositories/interfaces/ichat_repository.ts b/src/data/repositories/interfaces/ichat_repository.ts index 2074ea88..92b5dd43 100644 --- a/src/data/repositories/interfaces/ichat_repository.ts +++ b/src/data/repositories/interfaces/ichat_repository.ts @@ -16,6 +16,7 @@ import type { ChatHistoryResponse, ChatMessage, ChatSendResponse, + UnlockPrivateResponse, } from "@/data/dto/chat"; export interface IChatRepository { @@ -28,6 +29,9 @@ export interface IChatRepository { /** 获取聊天历史,分页参数默认 limit=50, offset=0。 */ getHistory(limit?: number, offset?: number): Promise>; + /** 解锁私密消息。 */ + unlockPrivateMessage(messageId: string): Promise>; + /** 把一条消息写入本地存储。 */ saveMessageToLocal(message: ChatMessage): Promise>; diff --git a/src/data/repositories/interfaces/index.ts b/src/data/repositories/interfaces/index.ts index 3d0eb5b3..4fb1821a 100644 --- a/src/data/repositories/interfaces/index.ts +++ b/src/data/repositories/interfaces/index.ts @@ -5,4 +5,5 @@ export * from "./iauth_repository"; export * from "./ichat_repository"; export * from "./imetrics_repository"; +export * from "./ipayment_repository"; export * from "./iuser_repository"; diff --git a/src/data/repositories/interfaces/ipayment_repository.ts b/src/data/repositories/interfaces/ipayment_repository.ts new file mode 100644 index 00000000..9fd43fa5 --- /dev/null +++ b/src/data/repositories/interfaces/ipayment_repository.ts @@ -0,0 +1,31 @@ +"use client"; + +/** + * IPaymentRepository 接口 + */ +import type { + CreatePaymentOrderResponse, + PayChannel, + PaymentOrderStatusResponse, + PaymentPlansResponse, + PaymentVipStatusResponse, +} from "@/data/dto/payment"; +import type { Result } from "@/utils/result"; + +export interface IPaymentRepository { + /** 获取套餐列表。 */ + getPlans(): Promise>; + + /** 创建支付订单。 */ + createOrder( + planId: string, + payChannel: PayChannel, + autoRenew: boolean, + ): Promise>; + + /** 查询支付订单状态。 */ + getOrderStatus(orderId: string): Promise>; + + /** 查询当前用户 VIP 状态。 */ + getVipStatus(): Promise>; +} diff --git a/src/data/repositories/payment_repository.ts b/src/data/repositories/payment_repository.ts new file mode 100644 index 00000000..62d94bcf --- /dev/null +++ b/src/data/repositories/payment_repository.ts @@ -0,0 +1,56 @@ +"use client"; + +/** + * PaymentRepository + * + * 支付 / 付费墙相关远程调用。 + */ +import { + CreatePaymentOrderRequest, + CreatePaymentOrderResponse, + PayChannel, + PaymentOrderStatusResponse, + PaymentPlansResponse, + PaymentVipStatusResponse, +} from "@/data/dto/payment"; +import { PaymentApi, paymentApi } from "@/data/services/api"; +import type { IPaymentRepository } from "@/data/repositories/interfaces"; +import { Result } from "@/utils/result"; + +export class PaymentRepository implements IPaymentRepository { + constructor(private readonly api: PaymentApi) {} + + /** 获取套餐列表。 */ + async getPlans(): Promise> { + return Result.wrap(() => this.api.getPlans()); + } + + /** 创建支付订单。 */ + async createOrder( + planId: string, + payChannel: PayChannel, + autoRenew: boolean, + ): Promise> { + const request = CreatePaymentOrderRequest.from({ + planId, + payChannel, + autoRenew, + }); + return Result.wrap(() => this.api.createOrder(request)); + } + + /** 查询支付订单状态。 */ + async getOrderStatus( + orderId: string, + ): Promise> { + return Result.wrap(() => this.api.getOrderStatus(orderId)); + } + + /** 查询当前用户 VIP 状态。 */ + async getVipStatus(): Promise> { + return Result.wrap(() => this.api.getVipStatus()); + } +} + +/** 全局单例。 */ +export const paymentRepository = new PaymentRepository(paymentApi); diff --git a/src/data/schemas/chat/chat_history_response.ts b/src/data/schemas/chat/chat_history_response.ts index 824405be..8a5757c0 100644 --- a/src/data/schemas/chat/chat_history_response.ts +++ b/src/data/schemas/chat/chat_history_response.ts @@ -10,6 +10,10 @@ export const ChatHistoryResponseSchema = z.object({ total: z.number().default(0), limit: z.number(), offset: z.number(), + isVip: z.boolean(), + privateFreeLimit: z.number(), + privateUsedToday: z.number(), + privateCanViewFree: z.boolean(), }); export type ChatHistoryResponseInput = z.input; diff --git a/src/data/schemas/chat/chat_message.ts b/src/data/schemas/chat/chat_message.ts index 3e4ab1b6..24d64353 100644 --- a/src/data/schemas/chat/chat_message.ts +++ b/src/data/schemas/chat/chat_message.ts @@ -4,12 +4,21 @@ */ import { z } from "zod"; -export const ChatMessageSchema = z.object({ - role: z.string(), - content: z.string(), - id: z.string().default(""), - createdAt: z.string().default(""), -}); +export const ChatMessageSchema = z + .object({ + role: z.string(), + content: z.string(), + id: z.string().default(""), + createdAt: z.string().default(""), + created_at: z.string().optional(), + isPrivate: z.boolean().nullable().default(null), + privateLocked: z.boolean().nullable().default(null), + privateHint: z.string().nullable().default(null), + }) + .transform(({ created_at, ...data }) => ({ + ...data, + createdAt: data.createdAt || created_at || "", + })); export type ChatMessageInput = z.input; export type ChatMessageData = z.output; diff --git a/src/data/schemas/chat/chat_send_response.ts b/src/data/schemas/chat/chat_send_response.ts index 35ca13d9..6d7167ff 100644 --- a/src/data/schemas/chat/chat_send_response.ts +++ b/src/data/schemas/chat/chat_send_response.ts @@ -4,6 +4,12 @@ */ import { z } from "zod"; +export const ChatBlockDetailSchema = z.object({ + type: z.string(), + usedToday: z.number(), + limit: z.number(), +}); + export const ChatSendResponseSchema = z.object({ mode: z.string().default(""), reply: z.string(), @@ -17,7 +23,15 @@ export const ChatSendResponseSchema = z.object({ isGuest: z.boolean().default(false), // 函数式 default —— 每次 parse 时重新调 Date.now()(后端不返回 timestamp 时用当下时间) timestamp: z.number().default(() => Date.now()), + blocked: z.boolean().nullable().default(null), + blockReason: z.string().nullable().default(null), + blockDetail: ChatBlockDetailSchema.nullable().default(null), + paywallTriggered: z.boolean(), + showUpgrade: z.boolean(), + imageType: z.string().nullable().default(null), + imageUrl: z.string().nullable().default(null), }); export type ChatSendResponseInput = z.input; export type ChatSendResponseData = z.output; +export type ChatBlockDetailData = z.output; diff --git a/src/data/schemas/chat/index.ts b/src/data/schemas/chat/index.ts index b11282a1..90049d31 100644 --- a/src/data/schemas/chat/index.ts +++ b/src/data/schemas/chat/index.ts @@ -12,3 +12,5 @@ export * from "./image_upload_response"; export * from "./send_message_request"; export * from "./stt_data"; export * from "./sync_message"; +export * from "./unlock_private_request"; +export * from "./unlock_private_response"; diff --git a/src/data/schemas/chat/unlock_private_request.ts b/src/data/schemas/chat/unlock_private_request.ts new file mode 100644 index 00000000..cd7d1de2 --- /dev/null +++ b/src/data/schemas/chat/unlock_private_request.ts @@ -0,0 +1,15 @@ +/** + * 私密消息解锁请求 + */ +import { z } from "zod"; + +export const UnlockPrivateRequestSchema = z.object({ + messageId: z.string(), +}); + +export type UnlockPrivateRequestInput = z.input< + typeof UnlockPrivateRequestSchema +>; +export type UnlockPrivateRequestData = z.output< + typeof UnlockPrivateRequestSchema +>; diff --git a/src/data/schemas/chat/unlock_private_response.ts b/src/data/schemas/chat/unlock_private_response.ts new file mode 100644 index 00000000..378d7936 --- /dev/null +++ b/src/data/schemas/chat/unlock_private_response.ts @@ -0,0 +1,29 @@ +/** + * 私密消息解锁响应 + */ +import { z } from "zod"; + +export const UnlockPrivateReasonSchema = z.enum([ + "ok", + "quota_exceeded", + "not_private", + "not_found", +]); + +export const UnlockPrivateResponseSchema = z.object({ + unlocked: z.boolean(), + content: z.string().nullable(), + showUpgrade: z.boolean(), + paywallTriggered: z.boolean(), + privateFreeLimit: z.number(), + privateUsedToday: z.number(), + reason: UnlockPrivateReasonSchema, +}); + +export type UnlockPrivateReason = z.output; +export type UnlockPrivateResponseInput = z.input< + typeof UnlockPrivateResponseSchema +>; +export type UnlockPrivateResponseData = z.output< + typeof UnlockPrivateResponseSchema +>; diff --git a/src/data/schemas/payment/create_payment_order_request.ts b/src/data/schemas/payment/create_payment_order_request.ts new file mode 100644 index 00000000..396d1e0c --- /dev/null +++ b/src/data/schemas/payment/create_payment_order_request.ts @@ -0,0 +1,20 @@ +/** + * 创建支付订单请求 + */ +import { z } from "zod"; + +export const PayChannelSchema = z.enum(["stripe", "ezpay"]); + +export const CreatePaymentOrderRequestSchema = z.object({ + planId: z.string(), + payChannel: PayChannelSchema, + autoRenew: z.boolean(), +}); + +export type PayChannel = z.output; +export type CreatePaymentOrderRequestInput = z.input< + typeof CreatePaymentOrderRequestSchema +>; +export type CreatePaymentOrderRequestData = z.output< + typeof CreatePaymentOrderRequestSchema +>; diff --git a/src/data/schemas/payment/create_payment_order_response.ts b/src/data/schemas/payment/create_payment_order_response.ts new file mode 100644 index 00000000..d134c632 --- /dev/null +++ b/src/data/schemas/payment/create_payment_order_response.ts @@ -0,0 +1,16 @@ +/** + * 创建支付订单响应 + */ +import { z } from "zod"; + +export const CreatePaymentOrderResponseSchema = z.object({ + orderId: z.string(), + payParams: z.record(z.string(), z.unknown()), +}); + +export type CreatePaymentOrderResponseInput = z.input< + typeof CreatePaymentOrderResponseSchema +>; +export type CreatePaymentOrderResponseData = z.output< + typeof CreatePaymentOrderResponseSchema +>; diff --git a/src/data/schemas/payment/index.ts b/src/data/schemas/payment/index.ts new file mode 100644 index 00000000..119c6cf2 --- /dev/null +++ b/src/data/schemas/payment/index.ts @@ -0,0 +1,11 @@ +/** + * @file Payment schema barrel. + */ + +export * from "./create_payment_order_request"; +export * from "./create_payment_order_response"; +export * from "./payment_order_status_response"; +export * from "./payment_plan"; +export * from "./payment_plans_response"; +export * from "./payment_vip_status_response"; +export * from "./payment_websocket_event"; diff --git a/src/data/schemas/payment/payment_order_status_response.ts b/src/data/schemas/payment/payment_order_status_response.ts new file mode 100644 index 00000000..2cf4fbfc --- /dev/null +++ b/src/data/schemas/payment/payment_order_status_response.ts @@ -0,0 +1,21 @@ +/** + * 支付订单状态响应 + */ +import { z } from "zod"; + +export const PaymentOrderStatusSchema = z.enum(["pending", "paid", "failed"]); + +export const PaymentOrderStatusResponseSchema = z.object({ + orderId: z.string(), + status: PaymentOrderStatusSchema, + orderType: z.string(), + planId: z.string(), +}); + +export type PaymentOrderStatus = z.output; +export type PaymentOrderStatusResponseInput = z.input< + typeof PaymentOrderStatusResponseSchema +>; +export type PaymentOrderStatusResponseData = z.output< + typeof PaymentOrderStatusResponseSchema +>; diff --git a/src/data/schemas/payment/payment_plan.ts b/src/data/schemas/payment/payment_plan.ts new file mode 100644 index 00000000..249e51f3 --- /dev/null +++ b/src/data/schemas/payment/payment_plan.ts @@ -0,0 +1,44 @@ +/** + * 支付套餐 + */ +import { z } from "zod"; + +const PaymentPlanWireSchema = z.object({ + plan_id: z.string(), + plan_name: z.string(), + order_type: z.string(), + amount_cents: z.number(), + currency: z.string(), + vip_days: z.number().nullable(), + dol_amount: z.number().nullable(), +}); + +export const PaymentPlanSchema = z + .preprocess((value) => { + if (!value || typeof value !== "object") return value; + const data = value as Record; + if ("planId" in data) { + return { + plan_id: data.planId, + plan_name: data.planName, + order_type: data.orderType, + amount_cents: data.amountCents, + currency: data.currency, + vip_days: data.vipDays, + dol_amount: data.dolAmount, + }; + } + return value; + }, PaymentPlanWireSchema) + .transform((data) => ({ + planId: data.plan_id, + planName: data.plan_name, + orderType: data.order_type, + amountCents: data.amount_cents, + currency: data.currency, + vipDays: data.vip_days, + dolAmount: data.dol_amount, + })); + +export type PaymentPlanInput = z.input; +export type PaymentPlanData = z.output; diff --git a/src/data/schemas/payment/payment_plans_response.ts b/src/data/schemas/payment/payment_plans_response.ts new file mode 100644 index 00000000..b39c4248 --- /dev/null +++ b/src/data/schemas/payment/payment_plans_response.ts @@ -0,0 +1,17 @@ +/** + * 支付套餐列表响应 + */ +import { z } from "zod"; + +import { PaymentPlanSchema } from "./payment_plan"; + +export const PaymentPlansResponseSchema = z.object({ + plans: z.array(PaymentPlanSchema), +}); + +export type PaymentPlansResponseInput = z.input< + typeof PaymentPlansResponseSchema +>; +export type PaymentPlansResponseData = z.output< + typeof PaymentPlansResponseSchema +>; diff --git a/src/data/schemas/payment/payment_vip_status_response.ts b/src/data/schemas/payment/payment_vip_status_response.ts new file mode 100644 index 00000000..65a31fc4 --- /dev/null +++ b/src/data/schemas/payment/payment_vip_status_response.ts @@ -0,0 +1,16 @@ +/** + * VIP 状态响应 + */ +import { z } from "zod"; + +export const PaymentVipStatusResponseSchema = z.object({ + isVip: z.boolean(), + vipExpiresAt: z.string().nullable(), +}); + +export type PaymentVipStatusResponseInput = z.input< + typeof PaymentVipStatusResponseSchema +>; +export type PaymentVipStatusResponseData = z.output< + typeof PaymentVipStatusResponseSchema +>; diff --git a/src/data/schemas/payment/payment_websocket_event.ts b/src/data/schemas/payment/payment_websocket_event.ts new file mode 100644 index 00000000..4d6b872d --- /dev/null +++ b/src/data/schemas/payment/payment_websocket_event.ts @@ -0,0 +1,52 @@ +/** + * 支付 WebSocket 事件 + */ +import { z } from "zod"; + +export const PaymentSuccessVipEventSchema = z.object({ + type: z.literal("payment_success"), + orderId: z.string(), + payType: z.literal("vip"), + planName: z.string(), + vipExpiresAt: z.string().nullable(), +}); + +export const PaymentSuccessDolEventSchema = z.object({ + type: z.literal("payment_success"), + orderId: z.string(), + payType: z.literal("dol"), + planName: z.string(), + dolAmount: z.number(), + dolBalance: z.number(), +}); + +export const PaymentFailedEventSchema = z.object({ + type: z.literal("payment_failed"), + orderId: z.string(), + info: z.string(), +}); + +export const PaymentSuccessEventSchema = z.discriminatedUnion("payType", [ + PaymentSuccessVipEventSchema, + PaymentSuccessDolEventSchema, +]); + +export const PaymentWebSocketEventSchema = z.union([ + PaymentSuccessEventSchema, + PaymentFailedEventSchema, +]); + +export type PaymentSuccessVipEventData = z.output< + typeof PaymentSuccessVipEventSchema +>; +export type PaymentSuccessDolEventData = z.output< + typeof PaymentSuccessDolEventSchema +>; +export type PaymentFailedEventData = z.output; +export type PaymentSuccessEventData = z.output; +export type PaymentWebSocketEventInput = z.input< + typeof PaymentWebSocketEventSchema +>; +export type PaymentWebSocketEventData = z.output< + typeof PaymentWebSocketEventSchema +>; diff --git a/src/data/services/api/api_path.ts b/src/data/services/api/api_path.ts index 6a779246..c424e3b1 100644 --- a/src/data/services/api/api_path.ts +++ b/src/data/services/api/api_path.ts @@ -71,13 +71,19 @@ export class ApiPath { // ============ 支付相关 ============ /** 创建充值订单 */ - static readonly paymentCreateOrder = `${ApiPath._payment}/order/create`; + static readonly paymentCreateOrder = `${ApiPath._payment}/create-order`; /** 查询订单状态 */ - static readonly paymentOrderStatus = `${ApiPath._payment}/order/status`; + static readonly paymentOrderStatus = `${ApiPath._payment}/order-status`; /** 获取商品套餐列表 */ - static readonly paymentProducts = `${ApiPath._payment}/products`; + static readonly paymentPlans = `${ApiPath._payment}/plans`; + + /** 查询当前用户 VIP 状态 */ + static readonly paymentVipStatus = `${ApiPath._payment}/vip-status`; + + /** @deprecated PAYWALL_API 使用 paymentPlans。 */ + static readonly paymentProducts = ApiPath.paymentPlans; /** 申请退款 */ static readonly paymentRefund = `${ApiPath._payment}/refund`; @@ -103,6 +109,9 @@ export class ApiPath { /** 上传图片 */ static readonly chatUploadImage = `${ApiPath._chat}/upload-image`; + /** 解锁私密消息 */ + static readonly chatUnlockPrivate = `${ApiPath._chat}/unlock-private`; + // ============ 数据看板相关 ============ private static readonly _metrics = `${ApiPath._baseUrl}/metrics`; diff --git a/src/data/services/api/chat_api.ts b/src/data/services/api/chat_api.ts index ae780c88..11b720f8 100644 --- a/src/data/services/api/chat_api.ts +++ b/src/data/services/api/chat_api.ts @@ -16,6 +16,8 @@ import { SendMessageRequest, SttData, SyncMessage, + UnlockPrivateRequest, + UnlockPrivateResponse, } from "@/data/dto/chat"; export class ChatApi { @@ -81,6 +83,24 @@ export class ChatApi { unwrap(env) as Record ); } + + /** + * 解锁私密消息 + */ + async unlockPrivateMessage( + body: UnlockPrivateRequest, + ): Promise { + const env = await httpClient>( + ApiPath.chatUnlockPrivate, + { + method: "POST", + body: body.toJson(), + }, + ); + return UnlockPrivateResponse.fromJson( + unwrap(env) as Record, + ); + } } /** diff --git a/src/data/services/api/index.ts b/src/data/services/api/index.ts index 3768108e..d2bad177 100644 --- a/src/data/services/api/index.ts +++ b/src/data/services/api/index.ts @@ -8,5 +8,6 @@ export * from "./auth_api"; export * from "./chat_api"; export * from "./http_client"; export * from "./metrics_api"; +export * from "./payment_api"; export * from "./response_helper"; export * from "./user_api"; diff --git a/src/data/services/api/payment_api.ts b/src/data/services/api/payment_api.ts new file mode 100644 index 00000000..41153cc7 --- /dev/null +++ b/src/data/services/api/payment_api.ts @@ -0,0 +1,80 @@ +/** + * Payment API + * + * 付费墙 / 充值相关接口 + */ +import { + CreatePaymentOrderRequest, + CreatePaymentOrderResponse, + PaymentOrderStatusResponse, + PaymentPlansResponse, + PaymentVipStatusResponse, +} from "@/data/dto/payment"; + +import { ApiPath } from "./api_path"; +import { httpClient } from "./http_client"; +import { ApiEnvelope, unwrap } from "./response_helper"; + +export class PaymentApi { + /** + * 获取套餐列表 + */ + async getPlans(): Promise { + const env = await httpClient>(ApiPath.paymentPlans); + return PaymentPlansResponse.fromJson( + unwrap(env) as Record, + ); + } + + /** + * 创建支付订单 + */ + async createOrder( + body: CreatePaymentOrderRequest, + ): Promise { + const env = await httpClient>( + ApiPath.paymentCreateOrder, + { + method: "POST", + body: body.toJson(), + }, + ); + return CreatePaymentOrderResponse.fromJson( + unwrap(env) as Record, + ); + } + + /** + * 查询支付订单状态 + */ + async getOrderStatus( + orderId: string, + ): Promise { + const env = await httpClient>( + ApiPath.paymentOrderStatus, + { + query: { order_id: orderId }, + }, + ); + return PaymentOrderStatusResponse.fromJson( + unwrap(env) as Record, + ); + } + + /** + * 查询当前用户 VIP 状态 + */ + async getVipStatus(): Promise { + const env = await httpClient>( + ApiPath.paymentVipStatus, + ); + return PaymentVipStatusResponse.fromJson( + unwrap(env) as Record, + ); + } +} + +/** + * 全局单例 + */ +export const paymentApi = new PaymentApi(); From a347b39001d955491c6e68b10d9e84423a9fdbbb Mon Sep 17 00:00:00 2001 From: chenhang Date: Thu, 18 Jun 2026 15:40:59 +0800 Subject: [PATCH 2/3] feat(payment): connect payment service flow --- env-example/.env.development.example | 19 -- env-example/.env.local.example | 15 - env-example/.env.production.example | 17 - package.json | 2 - pnpm-lock.yaml | 25 -- .../payment/create-checkout-session/route.ts | 105 ------ src/app/api/payment/customer-portal/route.ts | 76 ----- src/app/api/payment/webhook/route.ts | 103 ------ src/app/subscription/cancel/page.tsx | 4 +- src/app/subscription/components/index.ts | 1 - .../subscription-checkout-button.tsx | 119 +++---- .../components/subscription-manage-button.tsx | 101 ------ .../components/subscription-plan-card.tsx | 38 ++- .../components/subscription-screen.module.css | 12 +- src/app/subscription/subscription-screen.tsx | 147 ++++++--- src/app/subscription/success/page.tsx | 10 +- src/data/constants/subscription-plans.ts | 8 +- .../payment/payment_order_status_response.ts | 2 + src/data/dto/user/user.ts | 8 +- src/data/dto/user/user_view.ts | 1 - .../schemas/chat/chat_history_response.ts | 8 +- src/data/schemas/chat/chat_send_response.ts | 4 +- src/data/schemas/user/user.ts | 4 +- src/lib/stripe/stripe-client.ts | 44 --- src/lib/stripe/stripe-events.ts | 191 ----------- src/lib/stripe/stripe-products.ts | 48 --- src/providers/root-providers.tsx | 17 +- src/stores/payment/index.ts | 9 + src/stores/payment/payment-context.tsx | 96 ++++++ src/stores/payment/payment-events.ts | 16 + src/stores/payment/payment-machine.actors.ts | 54 ++++ src/stores/payment/payment-machine.ts | 305 ++++++++++++++++++ src/stores/payment/payment-state.ts | 37 +++ src/stores/user/user-machine.helpers.ts | 2 - 34 files changed, 759 insertions(+), 889 deletions(-) delete mode 100644 src/app/api/payment/create-checkout-session/route.ts delete mode 100644 src/app/api/payment/customer-portal/route.ts delete mode 100644 src/app/api/payment/webhook/route.ts delete mode 100644 src/app/subscription/components/subscription-manage-button.tsx delete mode 100644 src/lib/stripe/stripe-client.ts delete mode 100644 src/lib/stripe/stripe-events.ts delete mode 100644 src/lib/stripe/stripe-products.ts create mode 100644 src/stores/payment/index.ts create mode 100644 src/stores/payment/payment-context.tsx create mode 100644 src/stores/payment/payment-events.ts create mode 100644 src/stores/payment/payment-machine.actors.ts create mode 100644 src/stores/payment/payment-machine.ts create mode 100644 src/stores/payment/payment-state.ts diff --git a/env-example/.env.development.example b/env-example/.env.development.example index 7257f7d8..74aedaa3 100644 --- a/env-example/.env.development.example +++ b/env-example/.env.development.example @@ -29,22 +29,3 @@ AUTH_FACEBOOK_SECRET=004b1a9384b3433e153992e5edcef4ad # Cloudflare CDN(test / production 部署后刷缓存用) CF_ZONE_ID=b1b6bfb7795667609c8e7f418af0156b CF_API_TOKEN=cfut_xyHb70XCw0j8f5Wz5fg5PemUQ2ZHnSpWvmrBvVeK94d3b00f - -# ───────────────────────────── -# Stripe 支付(dev 环境 test keys —— **查** https://dashboard.stripe.com/test/apikeys 拿**到**) -# ───────────────────────────── -# 服务**端** secret key(**仅** Next.js API route **用**,**不**要**曝**露**给**前**端**) -STRIPE_SECRET_KEY=sk_test_replace_with_your_dev_secret_key -# 前**端** publishable key(NEXT_PUBLIC_ 前**缀**才**能**在**前**端**用**) -NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R -# webhook 签**名**密**钥**(**从** `stripe listen` 输**出**拿**到**,**本**地**开**发**用**) -STRIPE_WEBHOOK_SECRET=whsec_replace_with_your_stripe_cli_output - -# 3 **个**订**阅**套餐**的** Product / Price ID(**从** Stripe dashboard **创**建** 3 **个** Product + 3 **个** Price **后**填**) -STRIPE_PRODUCT_MONTHLY=prod_replace_monthly -STRIPE_PRICE_MONTHLY=price_replace_monthly -STRIPE_PRODUCT_QUARTERLY=prod_replace_quarterly -STRIPE_PRICE_QUARTERLY=price_replace_quarterly -STRIPE_PRODUCT_ANNUAL=prod_replace_annual -STRIPE_PRICE_ANNUAL=price_replace_annual - diff --git a/env-example/.env.local.example b/env-example/.env.local.example index ca85ed09..b0596f29 100644 --- a/env-example/.env.local.example +++ b/env-example/.env.local.example @@ -23,18 +23,3 @@ AUTH_FACEBOOK_SECRET=004b1a9384b3433e153992e5edcef4ad # Cloudflare CDN(test / production 部署后刷缓存用) CF_ZONE_ID=b1b6bfb7795667609c8e7f418af0156b CF_API_TOKEN=cfut_xyHb70XCw0j8f5Wz5fg5PemUQ2ZHnSpWvmrBvVeK94d3b00f - -# ───────────────────────────── -# Stripe 支付(test 环境 test keys) -# ───────────────────────────── -STRIPE_SECRET_KEY=sk_test_replace_with_your_test_secret_key -NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R -STRIPE_WEBHOOK_SECRET=whsec_replace_with_your_stripe_cli_output - -STRIPE_PRODUCT_MONTHLY=prod_replace_monthly -STRIPE_PRICE_MONTHLY=price_replace_monthly -STRIPE_PRODUCT_QUARTERLY=prod_replace_quarterly -STRIPE_PRICE_QUARTERLY=price_replace_quarterly -STRIPE_PRODUCT_ANNUAL=prod_replace_annual -STRIPE_PRICE_ANNUAL=price_replace_annual - diff --git a/env-example/.env.production.example b/env-example/.env.production.example index 5e272cb0..a3a41a69 100644 --- a/env-example/.env.production.example +++ b/env-example/.env.production.example @@ -30,20 +30,3 @@ AUTH_FACEBOOK_SECRET=004b1a9384b3433e153992e5edcef4ad # Cloudflare CDN(test / production 部署后刷缓存用) CF_ZONE_ID=b1b6bfb7795667609c8e7f418af0156b CF_API_TOKEN=cfut_xyHb70XCw0j8f5Wz5fg5PemUQ2ZHnSpWvmrBvVeK94d3b00f - -# ───────────────────────────── -# Stripe 支付(**生**产**环**境** live keys —— **从** https://dashboard.stripe.com/apikeys 拿**到**) -# **不**要** commit 真**实** key 到**版**本**控**制** -# ───────────────────────────── -STRIPE_SECRET_KEY=sk_live_replace_with_your_live_secret_key -NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_replace_with_your_dev_publishable_key -# webhook 签**名**密**钥**(**从** Stripe dashboard / Webhooks **端**点**的** "Signing secret" **拿**到**) -STRIPE_WEBHOOK_SECRET=whsec_replace_with_your_live_webhook_signing_secret - -STRIPE_PRODUCT_MONTHLY=prod_replace_monthly -STRIPE_PRICE_MONTHLY=price_replace_monthly -STRIPE_PRODUCT_QUARTERLY=prod_replace_quarterly -STRIPE_PRICE_QUARTERLY=price_replace_quarterly -STRIPE_PRODUCT_ANNUAL=prod_replace_annual -STRIPE_PRICE_ANNUAL=price_replace_annual - diff --git a/package.json b/package.json index e8234cc8..e455ddf5 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ }, "dependencies": { "@fingerprintjs/fingerprintjs": "^5.2.0", - "@stripe/stripe-js": "^9.8.0", "@xstate/react": "^5", "classnames": "^2.5.1", "dexie": "^4.4.3", @@ -32,7 +31,6 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-icons": "^5.6.0", - "stripe": "^22.2.1", "ua-parser-js": "^2.0.10", "unstorage": "^1.17.5", "xstate": "^5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9745eeaa..85e5f096 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: '@fingerprintjs/fingerprintjs': specifier: ^5.2.0 version: 5.2.0 - '@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) @@ -50,9 +47,6 @@ importers: react-icons: specifier: ^5.6.0 version: 5.6.0(react@19.2.4) - stripe: - specifier: ^22.2.1 - version: 22.2.1(@types/node@20.19.41) ua-parser-js: specifier: ^2.0.10 version: 2.0.10 @@ -896,10 +890,6 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@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'} @@ -2944,15 +2934,6 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - stripe@22.2.1: - resolution: {integrity: sha512-ULAtq25USBEx3yeN5zimEkfYVFETVMP5om25Ryr8ol11P62imaCZLBIEW78T7zm7Wg3wnZi+80S72yf3LCpNhw==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -3989,8 +3970,6 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@stripe/stripe-js@9.8.0': {} - '@swc/core-darwin-arm64@1.15.32': optional: true @@ -6236,10 +6215,6 @@ snapshots: strip-json-comments@3.1.1: {} - stripe@22.2.1(@types/node@20.19.41): - optionalDependencies: - '@types/node': 20.19.41 - styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.4): dependencies: client-only: 0.0.1 diff --git a/src/app/api/payment/create-checkout-session/route.ts b/src/app/api/payment/create-checkout-session/route.ts deleted file mode 100644 index 131199a8..00000000 --- a/src/app/api/payment/create-checkout-session/route.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * POST /api/payment/create-checkout-session - * - * 创建 Stripe Checkout Session(订阅模式)→ 返回 URL 给前端 - * 前端接到后 `window.location.href = url` 跳转到 Stripe 托管页 - * - * 业务流程: - * 1. 前端用户点 "Confirm the agreement and Activate" - * 2. 前端 POST `{ planId, successUrl, cancelUrl }` 到本端点 - * 3. 本端调 `stripe.checkout.sessions.create({...})` 创建 session - * 4. 返回 `{ url: session.url }` 给前端 - * 5. 前端 `window.location.href = url` 跳转 - * 6. Stripe 收银台结束后回跳到 success_url / cancel_url - * 7. 同时 Stripe 发送 `checkout.session.completed` webhook → 后端设置 user.isVip + voiceMinutes - */ -import { NextRequest, NextResponse } from "next/server"; - -import { SUBSCRIPTION_PLANS } from "@/data/constants/subscription-plans"; -import type { SubscriptionPlanId } from "@/data/constants/subscription-plans"; -import { getPlanPriceId } from "@/lib/stripe/stripe-products"; -import { getStripeClient } from "@/lib/stripe/stripe-client"; -import { Logger } from "@/utils"; - -const log = new Logger("AppApiPaymentCreateCheckoutSessionRoute"); - -interface CreateCheckoutRequestBody { - planId: SubscriptionPlanId; - successUrl: string; - cancelUrl: string; -} - -export async function POST(req: NextRequest) { - let body: CreateCheckoutRequestBody; - try { - body = (await req.json()) as CreateCheckoutRequestBody; - } catch { - return NextResponse.json( - { error: "Invalid JSON body" }, - { status: 400 }, - ); - } - - // 入参验证 - if (!body.planId || !body.successUrl || !body.cancelUrl) { - return NextResponse.json( - { error: "Missing required fields: planId, successUrl, cancelUrl" }, - { status: 400 }, - ); - } - if (!SUBSCRIPTION_PLANS.find((p) => p.id === body.planId)) { - return NextResponse.json( - { error: `Unknown planId: ${body.planId}` }, - { status: 400 }, - ); - } - - // 验证 successUrl / cancelUrl 来自同源(防 open redirect) - // 注:本轮先不做验证(前端和后端同源)—— 未来如果要开放给第三方请加 `same-origin` 检查 - - // 拿到 Stripe Price ID(从 env) - let priceId: string; - try { - priceId = getPlanPriceId(body.planId); - } catch (e) { - return NextResponse.json( - { error: (e as Error).message }, - { status: 500 }, - ); - } - - // 调 Stripe API 创建 Checkout Session - const stripe = getStripeClient(); - try { - const session = await stripe.checkout.sessions.create({ - mode: "subscription", - line_items: [{ price: priceId, quantity: 1 }], - success_url: body.successUrl, - cancel_url: body.cancelUrl, - // 让 webhook 能识别用户 + 套餐(后端接收 webhook 时从 metadata 取) - metadata: { - planId: body.planId, - // TODO: 加 userId(如果已知)—— 前端可在 body 传 userId - }, - // 允许 promo codes(如果 Stripe dashboard 开了) - allow_promotion_codes: true, - // billing_address_collection: "auto" —— 根据 Stripe dashboard 配置 - }); - - return NextResponse.json({ url: session.url }); - } catch (e) { - const err = e as { message?: string; type?: string; code?: string }; - log.error("[api/create-checkout-session] Stripe error", { - message: err.message, - type: err.type, - code: err.code, - }); - return NextResponse.json( - { - error: err.message ?? "Failed to create checkout session", - type: err.type, - }, - { status: 500 }, - ); - } -} diff --git a/src/app/api/payment/customer-portal/route.ts b/src/app/api/payment/customer-portal/route.ts deleted file mode 100644 index 76925b28..00000000 --- a/src/app/api/payment/customer-portal/route.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * POST /api/payment/customer-portal - * - * 创建 Stripe Customer Portal Session → 返回 URL 给前端 - * - * 为什么需要 Customer Portal: - * - Stripe 托管的"用户自助管理"页(取消 / 换 plan / 查看账单 / 更新卡) - * - 不需要我们自己写 UI - * - 最常用的就是 "取消订阅"(避免客服) - * - * 需要的参数:`customerId`(从 User.stripeCustomerId 取)—— webhook 会设置 - * - 如果为 null:返错("先购买") - */ -import { NextRequest, NextResponse } from "next/server"; - -import { getStripeClient } from "@/lib/stripe/stripe-client"; -import { Logger } from "@/utils"; - -const log = new Logger("AppApiPaymentCustomerPortalRoute"); - -interface CreatePortalRequestBody { - customerId: string | null; - returnUrl: string; -} - -export async function POST(req: NextRequest) { - let body: CreatePortalRequestBody; - try { - body = (await req.json()) as CreatePortalRequestBody; - } catch { - return NextResponse.json( - { error: "Invalid JSON body" }, - { status: 400 }, - ); - } - - // 入参验证 - if (!body.customerId) { - return NextResponse.json( - { error: "Missing customerId. Please subscribe first before managing subscription." }, - { status: 400 }, - ); - } - if (!body.returnUrl) { - return NextResponse.json( - { error: "Missing returnUrl" }, - { status: 400 }, - ); - } - - // 调 Stripe API 创建 Customer Portal Session - const stripe = getStripeClient(); - try { - const session = await stripe.billingPortal.sessions.create({ - customer: body.customerId, - return_url: body.returnUrl, - }); - - return NextResponse.json({ url: session.url }); - } catch (e) { - const err = e as { message?: string; type?: string; code?: string }; - log.error("[api/customer-portal] Stripe error", { - customerId: body.customerId, - message: err.message, - type: err.type, - code: err.code, - }); - return NextResponse.json( - { - error: err.message ?? "Failed to create customer portal session", - type: err.type, - }, - { status: 500 }, - ); - } -} diff --git a/src/app/api/payment/webhook/route.ts b/src/app/api/payment/webhook/route.ts deleted file mode 100644 index 861da91d..00000000 --- a/src/app/api/payment/webhook/route.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * POST /api/payment/webhook - * - * 接收 Stripe webhook → 验签 → 分发到 handler - * - * 关键设计: - * - 必须用 raw body 验签(不能让 Next.js 解析 JSON,所以用 `request.text()` 取原文) - * - 校验 `stripe-signature` 头(用 `STRIPE_WEBHOOK_SECRET`) - * - `stripe.webhooks.constructEvent(rawBody, sigHeader, secret)` 会抛错(如果签名不匹配) - * - 分发到 `dispatchStripeEvent()` → 4 个 handler 之一 - * - * 配置 Stripe webhook 端点: - * - URL:https://cozsweet.com/api/payment/webhook - * - 监听的事件: - * - checkout.session.completed - * - customer.subscription.created - * - customer.subscription.updated - * - customer.subscription.deleted - * - invoice.payment_succeeded - * - * 本地开发用 Stripe CLI 转发: - * stripe listen --forward-to localhost:3000/api/payment/webhook - * 输出的 `whsec_xxx` → 写到 .env.local 的 `STRIPE_WEBHOOK_SECRET` - */ -import { NextRequest, NextResponse } from "next/server"; -import type Stripe from "stripe"; - -import { dispatchStripeEvent } from "@/lib/stripe/stripe-events"; -import { getStripeClient } from "@/lib/stripe/stripe-client"; -import { Logger } from "@/utils"; - -const log = new Logger("AppApiPaymentWebhookRoute"); - -export async function POST(req: NextRequest) { - const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; - if (!webhookSecret) { - log.error("[api/webhook] STRIPE_WEBHOOK_SECRET is not set"); - return NextResponse.json( - { error: "Webhook secret not configured" }, - { status: 500 }, - ); - } - - // 取 raw body(验签用 raw bytes,不能 JSON.parse 后再 stringify —— 会丢空格、字节顺序等) - const rawBody = await req.text(); - const sigHeader = req.headers.get("stripe-signature"); - if (!sigHeader) { - log.error("[api/webhook] Missing stripe-signature header"); - return NextResponse.json( - { error: "Missing stripe-signature header" }, - { status: 400 }, - ); - } - - // 校验签名(不匹配会抛错) - const stripe = getStripeClient(); - let event: Stripe.Event; - try { - event = stripe.webhooks.constructEvent(rawBody, sigHeader, webhookSecret); - } catch (e) { - const err = e as Error; - log.error("[api/webhook] Signature verification failed", { - message: err.message, - }); - return NextResponse.json( - { error: `Webhook signature verification failed: ${err.message}` }, - { status: 400 }, - ); - } - - // 分发到 handler - log.debug("[api/webhook] Received event", { - eventId: event.id, - type: event.type, - created: new Date(event.created * 1000).toISOString(), - }); - - try { - const handled = await dispatchStripeEvent(event); - if (!handled) { - log.debug(`[api/webhook] Ignored event type: ${event.type}`); - } - } catch (e) { - const err = e as Error; - log.error("[api/webhook] Handler error", { - eventId: event.id, - type: event.type, - message: err.message, - stack: err.stack, - }); - // 返 500 让 Stripe 重试(幂等保护:handler 自己用 event.id 去重) - return NextResponse.json( - { error: "Handler error" }, - { status: 500 }, - ); - } - - // 返 200 让 Stripe 标记为已接收(即使 handler 没做什么也返 200 —— 避免无谓重试) - return NextResponse.json({ received: true }); -} - -/** 关闭 body parser 验签 raw body 需要 raw text(不能被 Next.js 的 bodyParser 解析)*/ -export const dynamic = "force-dynamic"; // 关闭静态优化(避免 body 被缓存) diff --git a/src/app/subscription/cancel/page.tsx b/src/app/subscription/cancel/page.tsx index fc6e1b30..802cf980 100644 --- a/src/app/subscription/cancel/page.tsx +++ b/src/app/subscription/cancel/page.tsx @@ -3,8 +3,8 @@ * 订阅取消回跳页 * * 流程: - * 1. 用户在 Stripe 托管页点 "Cancel" / 关闭页面 - * 2. Stripe 重定向到 `/subscription/cancel` + * 1. 用户在支付服务托管页点 "Cancel" / 关闭页面 + * 2. 支付服务重定向到 `/subscription/cancel` * 3. 这个页面显示 "已取消" + 跳回 `/subscription` * * 不需要任何后端逻辑(没付款发生) diff --git a/src/app/subscription/components/index.ts b/src/app/subscription/components/index.ts index 35e8d6c3..a1da90f9 100644 --- a/src/app/subscription/components/index.ts +++ b/src/app/subscription/components/index.ts @@ -7,6 +7,5 @@ export * from "./subscription-banner"; export * from "./subscription-benefits-card"; export * from "./subscription-checkout-button"; export * from "./subscription-cta-button"; -export * from "./subscription-manage-button"; export * from "./subscription-plan-card"; export * from "./subscription-user-row"; diff --git a/src/app/subscription/components/subscription-checkout-button.tsx b/src/app/subscription/components/subscription-checkout-button.tsx index 07370f90..5c33852c 100644 --- a/src/app/subscription/components/subscription-checkout-button.tsx +++ b/src/app/subscription/components/subscription-checkout-button.tsx @@ -2,75 +2,63 @@ /** * 订阅 checkout 触发按钮 * - * 包装 `SubscriptionCtaButton` —— 加上调 `/api/payment/create-checkout-session` 的逻辑 - * - * 流程: - * 1. 用户点 "Confirm the agreement and Activate" - * 2. fetch POST /api/payment/create-checkout-session { planId, successUrl, cancelUrl } - * 3. 拿到 `{ url }` → `window.location.href = url` 跳转到 Stripe 托管页 - * 4. 用户结账 → Stripe 回跳到 successUrl / cancelUrl - * 5. 同时发 webhook → 后端设置 user.isVip + voiceMinutes + * 包装 `SubscriptionCtaButton` —— 通过 payment 状态机创建后端订单并拉起支付。 */ -import { useState } from "react"; +import { useEffect, useRef } from "react"; -import type { SubscriptionPlanId } from "@/data/constants/subscription-plans"; -import { ROUTES } from "@/router/routes"; +import { + usePaymentDispatch, + usePaymentState, +} from "@/stores/payment/payment-context"; import { SubscriptionCtaButton } from "./subscription-cta-button"; import styles from "./subscription-cta-button.module.css"; -import { Logger } from "@/utils"; - -const log = new Logger("AppSubscriptionComponentsSubscriptionCheckoutButton"); export interface SubscriptionCheckoutButtonProps { - planId: SubscriptionPlanId; /** 是否可用(未选套餐 / 未勾选协议 → false) */ disabled?: boolean; } export function SubscriptionCheckoutButton({ - planId, disabled = false, }: SubscriptionCheckoutButtonProps) { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const payment = usePaymentState(); + const paymentDispatch = usePaymentDispatch(); + const launchedNonceRef = useRef(0); - const handleClick = async () => { - if (disabled || isLoading) return; - setIsLoading(true); - setError(null); - - try { - // success / cancel URL —— 走当前 origin 的绝对路径 - const origin = window.location.origin; - const res = await fetch("/api/payment/create-checkout-session", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - planId, - successUrl: `${origin}${ROUTES.subscription}/success?session_id={CHECKOUT_SESSION_ID}`, - cancelUrl: `${origin}${ROUTES.subscription}/cancel`, - }), - }); - - if (!res.ok) { - const data = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(data.error ?? `HTTP ${res.status}`); - } - - const { url } = (await res.json()) as { url: string }; - if (!url) { - throw new Error("No checkout URL returned"); - } - - // 跳转到 Stripe 托管页 - window.location.href = url; - } catch (e) { - const err = e as Error; - log.error("[subscription-checkout-button] error", err); - setError(err.message); - setIsLoading(false); + useEffect(() => { + if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) { + return; } + + launchedNonceRef.current = payment.launchNonce; + const paymentUrl = getPaymentUrl(payment.payParams); + if (!paymentUrl) { + paymentDispatch({ + type: "PaymentLaunchFailed", + errorMessage: "Payment parameters did not include a supported URL.", + }); + return; + } + + window.location.href = paymentUrl; + }, [payment.launchNonce, payment.payParams, paymentDispatch]); + + const isLoading = + payment.isCreatingOrder || + payment.isPollingOrder || + payment.isCheckingVipStatus; + const label = payment.isPaid + ? "Activated" + : payment.isPollingOrder + ? "Processing payment..." + : payment.isCreatingOrder + ? "Creating order..." + : "Confirm the agreement and Activate"; + + const handleClick = () => { + if (disabled || isLoading) return; + paymentDispatch({ type: "PaymentCreateOrderSubmitted" }); }; return ( @@ -82,9 +70,9 @@ export function SubscriptionCheckoutButton({ onClick={handleClick} className={styles.button} > - Confirm the agreement and Activate + {label} - {error ? ( + {payment.errorMessage ? (

- {error} + {payment.errorMessage}

) : null} ); } + +function getPaymentUrl(payParams: Record): string | null { + const keys = [ + "url", + "checkoutUrl", + "checkout_url", + "paymentUrl", + "payment_url", + "redirectUrl", + "redirect_url", + ]; + + for (const key of keys) { + const value = payParams[key]; + if (typeof value === "string" && value.length > 0) return value; + } + + return null; +} diff --git a/src/app/subscription/components/subscription-manage-button.tsx b/src/app/subscription/components/subscription-manage-button.tsx deleted file mode 100644 index 8482fb44..00000000 --- a/src/app/subscription/components/subscription-manage-button.tsx +++ /dev/null @@ -1,101 +0,0 @@ -"use client"; -/** - * "Manage VIP" 按钮组件(取消订阅 / 换 plan / 查看账单) - * - * 点击后调 `/api/payment/customer-portal` 拿到 Stripe Customer Portal 页面的 URL - * - * 业务事实: - * - 不自己写 "取消订阅" UI —— 跳到 Stripe 自己的 Customer Portal - * - 要 `customerId` —— webhook 会设置到 User.stripeCustomerId - * - 如果为 null(用户从未购买)→ 提示 "先购买"("Get VIP" 路径走过来就是这个) - */ -import { useState } from "react"; - -import { ROUTES } from "@/router/routes"; - -import { SubscriptionCtaButton } from "./subscription-cta-button"; -import styles from "./subscription-cta-button.module.css"; -import { Logger } from "@/utils"; - -const log = new Logger("AppSubscriptionComponentsSubscriptionManageButton"); - -export interface SubscriptionManageButtonProps { - /** 从 User.stripeCustomerId 取(webhook 设置) */ - customerId: string | null; -} - -export function SubscriptionManageButton({ customerId }: SubscriptionManageButtonProps) { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const handleClick = async () => { - if (isLoading) return; - - // 没有 customerId → 不该出现这个按钮(sidebar 根据 isVip + stripeCustomerId 控制展示) - // 防御性检查:如果走到这里没有 customerId,提示用户联系客服 - if (!customerId) { - setError("Please subscribe first before managing your subscription."); - return; - } - - setIsLoading(true); - setError(null); - - try { - const origin = window.location.origin; - const res = await fetch("/api/payment/customer-portal", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - customerId, - returnUrl: `${origin}${ROUTES.subscription}`, - }), - }); - - if (!res.ok) { - const data = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(data.error ?? `HTTP ${res.status}`); - } - - const { url } = (await res.json()) as { url: string }; - if (!url) { - throw new Error("No portal URL returned"); - } - - // 跳转到 Stripe Customer Portal - window.location.href = url; - } catch (e) { - const err = e as Error; - log.error("[subscription-manage-button] error", err); - setError(err.message); - setIsLoading(false); - } - }; - - return ( - <> - - Manage VIP - - {error ? ( -

- {error} -

- ) : null} - - ); -} diff --git a/src/app/subscription/components/subscription-plan-card.tsx b/src/app/subscription/components/subscription-plan-card.tsx index 136bba70..27c5cb7f 100644 --- a/src/app/subscription/components/subscription-plan-card.tsx +++ b/src/app/subscription/components/subscription-plan-card.tsx @@ -9,20 +9,31 @@ * - 选中/高亮:2px 粉色边框 + 浅粉阴影 * - 三个卡片底部渐变不同:粉 → 紫红 → 紫 */ -import type { SubscriptionPlan, SubscriptionPlanId } from "@/data/constants/subscription-plans"; import styles from "./subscription-plan-card.module.css"; -const PER_DAY_GRADIENTS: Record = { - monthly: "linear-gradient(90deg, #ff7ab8 0%, #ff5d9c 100%)", - quarterly: "linear-gradient(90deg, #d16bff 0%, #ff5da3 100%)", - annual: "linear-gradient(90deg, #9a4dff 0%, #c44dff 100%)", -}; +const PER_DAY_GRADIENTS = [ + "linear-gradient(90deg, #ff7ab8 0%, #ff5d9c 100%)", + "linear-gradient(90deg, #d16bff 0%, #ff5da3 100%)", + "linear-gradient(90deg, #9a4dff 0%, #c44dff 100%)", + "linear-gradient(90deg, #32c7b5 0%, #2f8fd8 100%)", +] as const; + +export interface SubscriptionPlanView { + id: string; + name: string; + price: string; + originalPrice: string | null; + perDay: string; + highlight: boolean; + currencySymbol: string; +} export interface SubscriptionPlanCardProps { - plan: SubscriptionPlan; + plan: SubscriptionPlanView; selected: boolean; onClick: () => void; + gradientIndex?: number; className?: string; } @@ -30,6 +41,7 @@ export function SubscriptionPlanCard({ plan, selected, onClick, + gradientIndex = 0, className, }: SubscriptionPlanCardProps) { const classes = [ @@ -41,22 +53,26 @@ export function SubscriptionPlanCard({ .filter(Boolean) .join(" "); - const perDayStyle = { background: PER_DAY_GRADIENTS[plan.id] }; + const perDayStyle = { + background: PER_DAY_GRADIENTS[gradientIndex % PER_DAY_GRADIENTS.length], + }; return (