From 4dae805a88a9d5443442af9a6d03229eb0af7489 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 23 Jul 2026 16:11:20 +0800 Subject: [PATCH] feat(payment): support user-bound seven-discount offers --- e2e/fixtures/api/chat.ts | 16 +++++++- e2e/fixtures/api/payment.ts | 10 +++++ .../chat/commercial-action-navigation.spec.ts | 18 ++++++++ src/app/_hooks/use-payment-route-flow.ts | 5 +++ src/app/chat/chat-screen.tsx | 28 ++++++++++++- .../__tests__/commercial-action-card.test.tsx | 19 +++++++++ src/app/chat/components/chat-area.module.css | 20 +++++++++ .../components/commercial-action-card.tsx | 33 ++++++++++++--- src/app/subscription/page.tsx | 6 +++ src/app/subscription/subscription-screen.tsx | 22 ++++++++++ .../use-subscription-payment-flow.ts | 6 +++ .../__tests__/tip-screen.checkout.test.tsx | 1 + .../interfaces/ipayment_repository.ts | 3 +- src/data/repositories/payment_repository.ts | 8 ++-- .../chat/response/chat_send_response.ts | 4 +- .../payment/__tests__/payment_plan.test.ts | 41 +++++++++++++++++++ src/data/schemas/payment/index.ts | 1 + src/data/schemas/payment/payment_plan.ts | 2 + .../request/create_payment_order_request.ts | 1 + .../response/commercial_offer_response.ts | 18 ++++++++ src/data/schemas/payment/response/index.ts | 1 + .../response/payment_plans_response.ts | 16 ++++++++ .../services/api/__tests__/api_path.test.ts | 8 +++- .../api/__tests__/payment_api.test.ts | 34 +++++++++++++++ src/data/services/api/api_contract.json | 1 + src/data/services/api/api_path.ts | 8 ++++ src/data/services/api/payment_api.ts | 19 ++++++++- .../__tests__/navigation-resolver.test.ts | 13 ++++++ src/router/routes.ts | 6 +++ .../__tests__/payment-machine.test-utils.ts | 2 + .../__tests__/payment-order-flow.test.ts | 24 +++++++++++ src/stores/payment/helper/catalog.ts | 6 ++- src/stores/payment/machine/actors/order.ts | 2 + src/stores/payment/machine/actors/plans.ts | 8 ++-- src/stores/payment/machine/catalog-flow.ts | 25 ++++++++--- src/stores/payment/machine/order-flow.ts | 3 ++ src/stores/payment/payment-context.tsx | 2 + src/stores/payment/payment-events.ts | 1 + src/stores/payment/payment-state.ts | 5 +++ 39 files changed, 418 insertions(+), 28 deletions(-) create mode 100644 src/data/schemas/payment/response/commercial_offer_response.ts diff --git a/e2e/fixtures/api/chat.ts b/e2e/fixtures/api/chat.ts index 38612ed7..1f6e29ca 100644 --- a/e2e/fixtures/api/chat.ts +++ b/e2e/fixtures/api/chat.ts @@ -61,7 +61,21 @@ export async function registerChatMocks(page: Page, options: ChatMockOptions, st await route.fulfill({ status: 401, json: { message: "expired" } }); return; } - const response = message.includes("private album offer test") + const response = message.includes("discount offer test") + ? { + ...chatSendResponse, + reply: "I know you're thinking carefully about the price.", + messageId: "discount-action-message", + commercialAction: { + actionId: "13ec8a10-58d7-4d24-b66b-8db5699a1aa8", + type: "discountOffer", + copy: "Want me to ask for my best private offer?", + ctaLabel: "Yes, ask for me", + target: "discountConsent", + ruleId: "discount_after_price_objection", + }, + } + : message.includes("private album offer test") ? { ...chatSendResponse, reply: "You always know how to make me smile.", diff --git a/e2e/fixtures/api/payment.ts b/e2e/fixtures/api/payment.ts index 70325f75..9bcb2f51 100644 --- a/e2e/fixtures/api/payment.ts +++ b/e2e/fixtures/api/payment.ts @@ -4,6 +4,16 @@ import { apiEnvelope } from "../data/common"; import { createPaymentOrderResponse, paidPaymentOrderStatusResponse, paymentPlansResponse, tipPaymentPlansResponse, vipStatusResponse } from "../data/payment"; export async function registerPaymentMocks(page: Page) { + await page.route("**/api/payment/commercial-offers/*/accept", async (route) => route.fulfill({ json: apiEnvelope({ + commercialOfferId: "13ec8a10-58d7-4d24-b66b-8db5699a1aa8", + planId: "vip_annual", + subscriptionType: "vip", + discountPercent: 30, + pricePercent: 70, + expiresAt: "2026-07-24T08:00:00+00:00", + message: "I got it for you.", + promotionType: "commercial_seven_discount", + }) })); await page.route("**/api/payment/plans**", async (route) => route.fulfill({ json: apiEnvelope(paymentPlansResponse) })); await page.route("**/api/payment/tip-plans**", async (route) => route.fulfill({ json: apiEnvelope(tipPaymentPlansResponse) })); await page.route("**/api/payment/create-order", async (route) => route.fulfill({ json: apiEnvelope(createPaymentOrderResponse) })); diff --git a/e2e/specs/mock/chat/commercial-action-navigation.spec.ts b/e2e/specs/mock/chat/commercial-action-navigation.spec.ts index 28876c6d..97adbca8 100644 --- a/e2e/specs/mock/chat/commercial-action-navigation.spec.ts +++ b/e2e/specs/mock/chat/commercial-action-navigation.spec.ts @@ -29,3 +29,21 @@ test("renders a backend commercial action and opens the private zone", async ({ await expect(page).toHaveURL(/\/characters\/elio\/private-zone$/); }); + +test("asks for consent before opening a user-bound discount plan", async ({ + page, +}) => { + await enterChatFromSplash(page); + + const input = page.getByRole("textbox", { name: "Message" }); + await input.fill("discount offer test"); + await input.press("Enter"); + + const offer = page.getByLabel("Yes, ask for me"); + await expect(offer).toContainText("Want me to ask for my best private offer?"); + await offer.getByRole("button", { name: "Yes, ask for me" }).click(); + + await expect(page).toHaveURL( + /\/subscription\?type=vip.*planId=vip_annual.*commercialOfferId=13ec8a10/, + ); +}); diff --git a/src/app/_hooks/use-payment-route-flow.ts b/src/app/_hooks/use-payment-route-flow.ts index cfa079a0..3172b323 100644 --- a/src/app/_hooks/use-payment-route-flow.ts +++ b/src/app/_hooks/use-payment-route-flow.ts @@ -22,6 +22,7 @@ export interface UsePaymentRouteFlowInput { characterId?: string; initialCategory?: string | null; initialPlanId?: string | null; + commercialOfferId?: string | null; } export interface PaymentRouteFlow { @@ -41,6 +42,7 @@ export function usePaymentRouteFlow({ characterId, initialCategory = null, initialPlanId = null, + commercialOfferId = null, }: UsePaymentRouteFlowInput): PaymentRouteFlow { const payment = usePaymentState(); const paymentDispatch = usePaymentDispatch(); @@ -50,6 +52,7 @@ export function usePaymentRouteFlow({ characterId ?? "", initialCategory ?? "", initialPlanId ?? "", + commercialOfferId ?? "", ].join(":"); usePendingPaymentOrderLifecycle({ @@ -69,11 +72,13 @@ export function usePaymentRouteFlow({ ...(characterId ? { characterId } : {}), ...(initialCategory ? { category: initialCategory } : {}), ...(initialPlanId ? { planId: initialPlanId } : {}), + ...(commercialOfferId ? { commercialOfferId } : {}), }); }, [ catalog, catalogKey, characterId, + commercialOfferId, initialCategory, initialPlanId, initialPayChannel, diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 86ad3cae..8654e3c3 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -17,8 +17,9 @@ import { useCharacterCatalog } from "@/providers/character-catalog-provider"; import { clearPendingChatNavigation } from "@/lib/navigation/chat_unlock_session"; import { buildGlobalPageUrl } from "@/router/global-route-context"; import { resolveAuthenticatedNavigation } from "@/router/navigation-resolver"; -import { getCharacterRoutes, ROUTES } from "@/router/routes"; +import { getCharacterRoutes, ROUTE_BUILDERS, ROUTES } from "@/router/routes"; import type { CommercialAction } from "@/data/schemas/chat"; +import { paymentApi } from "@/data/services/api"; import { behaviorAnalytics } from "@/lib/analytics"; import { MobileShell } from "@/app/_components/core"; @@ -252,7 +253,7 @@ export function ChatScreen() { router.push(characterRoutes.privateZone); } - function handleCommercialAction(action: CommercialAction): void { + async function handleCommercialAction(action: CommercialAction): Promise { behaviorAnalytics.elementClick( "chat.commercial_action.open", action.ctaLabel, @@ -264,6 +265,29 @@ export function ChatScreen() { target: action.target, }, ); + if (action.target === "discountConsent") { + const offer = await paymentApi.acceptCommercialOffer(action.actionId); + behaviorAnalytics.elementClick( + "chat.commercial_action.accepted", + action.ctaLabel, + { + actionId: action.actionId, + actionType: action.type, + characterId: state.characterId, + planId: offer.planId, + discountPercent: offer.discountPercent, + }, + ); + router.push( + ROUTE_BUILDERS.subscription(offer.subscriptionType, { + sourceCharacterSlug: character.slug, + returnTo: "chat", + planId: offer.planId, + commercialOfferId: offer.commercialOfferId, + }), + ); + return; + } router.push( action.target === "giftCatalog" ? characterRoutes.tip diff --git a/src/app/chat/components/__tests__/commercial-action-card.test.tsx b/src/app/chat/components/__tests__/commercial-action-card.test.tsx index 93b06c81..dd8817c7 100644 --- a/src/app/chat/components/__tests__/commercial-action-card.test.tsx +++ b/src/app/chat/components/__tests__/commercial-action-card.test.tsx @@ -23,4 +23,23 @@ describe("CommercialActionCard", () => { expect(html).toContain("Open private zone"); expect(html).toContain('aria-label="Dismiss offer"'); }); + + it("renders a consent-first discount action without a checkout URL", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('data-commercial-action="discountOffer"'); + expect(html).toContain("Yes, ask for me"); + expect(html).not.toContain("/subscription"); + }); }); diff --git a/src/app/chat/components/chat-area.module.css b/src/app/chat/components/chat-area.module.css index 9cbf359a..033bf205 100644 --- a/src/app/chat/components/chat-area.module.css +++ b/src/app/chat/components/chat-area.module.css @@ -189,6 +189,26 @@ overflow-wrap: anywhere; } +.commercialActionButton:disabled { + cursor: wait; + opacity: 0.72; +} + +.commercialActionError { + margin: -4px 0 10px; + color: #ffb4ab; + font-size: 12px; + line-height: 1.4; +} + +.commercialActionSpinner { + animation: commercial-action-spin 0.8s linear infinite; +} + +@keyframes commercial-action-spin { + to { transform: rotate(360deg); } +} + .bubbleAi { max-width: var(--chat-bubble-max-width, 75%); background: var(--color-bubble-ai, rgba(255, 255, 255, 0.08)); diff --git a/src/app/chat/components/commercial-action-card.tsx b/src/app/chat/components/commercial-action-card.tsx index 9519196a..9f3e9431 100644 --- a/src/app/chat/components/commercial-action-card.tsx +++ b/src/app/chat/components/commercial-action-card.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { ArrowRight, Gift, Images, X } from "lucide-react"; +import { ArrowRight, BadgePercent, Gift, Images, LoaderCircle, X } from "lucide-react"; import type { CommercialAction } from "@/data/schemas/chat"; @@ -9,7 +9,7 @@ import styles from "./chat-area.module.css"; export interface CommercialActionCardProps { action: CommercialAction; - onActivate?: (action: CommercialAction) => void; + onActivate?: (action: CommercialAction) => void | Promise; onDismiss?: (action: CommercialAction) => void; } @@ -19,9 +19,16 @@ export function CommercialActionCard({ onDismiss, }: CommercialActionCardProps) { const [dismissed, setDismissed] = useState(false); + const [activating, setActivating] = useState(false); + const [activationError, setActivationError] = useState(null); if (dismissed) return null; - const Icon = action.type === "giftOffer" ? Gift : Images; + const Icon = + action.type === "giftOffer" + ? Gift + : action.type === "discountOffer" + ? BadgePercent + : Images; return ( ); diff --git a/src/app/subscription/page.tsx b/src/app/subscription/page.tsx index 1bb53256..ea68131e 100644 --- a/src/app/subscription/page.tsx +++ b/src/app/subscription/page.tsx @@ -32,6 +32,10 @@ export default async function SubscriptionPage({ const sourceCharacterSlug = getCharacterBySlug(getFirstPaymentSearchParam(query.character))?.slug ?? DEFAULT_CHARACTER_SLUG; + const initialPlanId = getFirstPaymentSearchParam(query.planId); + const commercialOfferId = getFirstPaymentSearchParam( + query.commercialOfferId, + ); const analyticsContext = parsePaymentAnalyticsContext({ entryPoint: getFirstPaymentSearchParam( query[PAYMENT_ANALYTICS_ENTRY_PARAM], @@ -50,6 +54,8 @@ export default async function SubscriptionPage({ initialPayChannel={paymentReturn.initialPayChannel} analyticsContext={analyticsContext} sourceCharacterSlug={sourceCharacterSlug} + initialPlanId={initialPlanId} + commercialOfferId={commercialOfferId} /> ); } diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx index 8b339eb5..47f8598d 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -45,6 +45,8 @@ export interface SubscriptionScreenProps { initialPayChannel?: PayChannel | null; analyticsContext?: PaymentAnalyticsContext; sourceCharacterSlug?: string; + initialPlanId?: string | null; + commercialOfferId?: string | null; } export function SubscriptionScreen({ @@ -54,6 +56,8 @@ export function SubscriptionScreen({ initialPayChannel = null, analyticsContext: providedAnalyticsContext, sourceCharacterSlug = DEFAULT_CHARACTER_SLUG, + initialPlanId = null, + commercialOfferId = null, }: SubscriptionScreenProps) { const userState = useUserState(); const hasHydrated = useHasHydrated(); @@ -77,6 +81,8 @@ export function SubscriptionScreen({ returnTo, sourceCharacterSlug, initialPayChannel: paymentMethodConfig.initialPayChannel, + initialPlanId, + commercialOfferId, }); const canSubscribeVip = subscriptionType === "vip"; const analyticsContext = @@ -214,6 +220,22 @@ export function SubscriptionScreen({ ) : null} + {payment.commercialOffer && !firstRechargeOffer ? ( +
+ Private offer +
+

Elio got this price for you

+

+ {payment.commercialOffer.message || + `Your ${payment.commercialOffer.discountPercent}% discount is already applied below.`} +

+
+
+ ) : null} +
{canSubscribeVip ? ( (null); const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] = diff --git a/src/app/tip/__tests__/tip-screen.checkout.test.tsx b/src/app/tip/__tests__/tip-screen.checkout.test.tsx index 921ac19b..9461a889 100644 --- a/src/app/tip/__tests__/tip-screen.checkout.test.tsx +++ b/src/app/tip/__tests__/tip-screen.checkout.test.tsx @@ -358,6 +358,7 @@ function makePaymentState( giftProducts: [giftProduct], selectedGiftCategory: giftCategory.category, isFirstRecharge: false, + commercialOffer: null, selectedPlanId: giftPlan.planId, payChannel: "stripe", autoRenew: false, diff --git a/src/data/repositories/interfaces/ipayment_repository.ts b/src/data/repositories/interfaces/ipayment_repository.ts index e02ceccd..7f1be4c2 100644 --- a/src/data/repositories/interfaces/ipayment_repository.ts +++ b/src/data/repositories/interfaces/ipayment_repository.ts @@ -13,7 +13,7 @@ import type { Result } from "@/utils/result"; export interface IPaymentRepository { /** 获取套餐列表。 */ - getPlans(): Promise>; + getPlans(commercialOfferId?: string): Promise>; /** 获取本地缓存套餐列表。 */ getCachedPlans(): Promise>; @@ -30,6 +30,7 @@ export interface IPaymentRepository { payChannel: PayChannel, autoRenew: boolean, recipientCharacterId?: string, + commercialOfferId?: string, ): Promise>; /** 查询支付订单状态。 */ diff --git a/src/data/repositories/payment_repository.ts b/src/data/repositories/payment_repository.ts index f56b2287..7c2daf55 100644 --- a/src/data/repositories/payment_repository.ts +++ b/src/data/repositories/payment_repository.ts @@ -24,10 +24,10 @@ export class PaymentRepository implements IPaymentRepository { constructor(private readonly api: PaymentApi) {} /** 获取套餐列表。 */ - async getPlans(): Promise> { + async getPlans(commercialOfferId?: string): Promise> { return Result.wrap(async () => { - const response = await this.api.getPlans(); - await PaymentPlansStorage.setPlans(response); + const response = await this.api.getPlans(commercialOfferId); + if (!commercialOfferId) await PaymentPlansStorage.setPlans(response); return response; }); } @@ -62,12 +62,14 @@ export class PaymentRepository implements IPaymentRepository { payChannel: PayChannel, autoRenew: boolean, recipientCharacterId?: string, + commercialOfferId?: string, ): Promise> { const request = CreatePaymentOrderRequestSchema.parse({ planId, payChannel, autoRenew, ...(recipientCharacterId ? { recipientCharacterId } : {}), + ...(commercialOfferId ? { commercialOfferId } : {}), }); return Result.wrap(() => this.api.createOrder(request)); } diff --git a/src/data/schemas/chat/response/chat_send_response.ts b/src/data/schemas/chat/response/chat_send_response.ts index 44c6b417..48e1b3c6 100644 --- a/src/data/schemas/chat/response/chat_send_response.ts +++ b/src/data/schemas/chat/response/chat_send_response.ts @@ -15,10 +15,10 @@ import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads"; export const CommercialActionSchema = z .object({ actionId: z.string().min(1), - type: z.enum(["giftOffer", "privateAlbumOffer"]), + type: z.enum(["giftOffer", "privateAlbumOffer", "discountOffer"]), copy: z.string().min(1), ctaLabel: z.string().min(1), - target: z.enum(["giftCatalog", "privateZone"]), + target: z.enum(["giftCatalog", "privateZone", "discountConsent"]), ruleId: z.string().min(1), }) .readonly(); diff --git a/src/data/schemas/payment/__tests__/payment_plan.test.ts b/src/data/schemas/payment/__tests__/payment_plan.test.ts index f979585b..81984aec 100644 --- a/src/data/schemas/payment/__tests__/payment_plan.test.ts +++ b/src/data/schemas/payment/__tests__/payment_plan.test.ts @@ -75,6 +75,8 @@ describe("PaymentPlan", () => { mostPopular: true, firstRechargeDiscountPercent: null, promotionType: null, + commercialOfferId: null, + commercialDiscountPercent: null, }); }); @@ -190,6 +192,45 @@ describe("PaymentPlansResponse", () => { discountPercent: 0, }); }); + + it("parses one user-bound commercial offer and its discounted plan", () => { + const response = PaymentPlansResponseSchema.parse({ + commercialOffer: { + enabled: true, + commercialOfferId: "offer-1", + planId: "vip_annual", + discountPercent: 30, + pricePercent: 70, + expiresAt: "2026-07-24T08:00:00+00:00", + triggerReason: "price_objection", + message: "I got this private price for you.", + }, + plans: [ + { + planId: "vip_annual", + planName: "Annual VIP", + orderType: "vip_annual", + vipDays: 365, + dolAmount: null, + creditBalance: 9000, + amountCents: 12593, + originalAmountCents: 17990, + dailyPriceCents: 34, + currency: "USD", + promotionType: "commercial_seven_discount", + commercialOfferId: "offer-1", + commercialDiscountPercent: 30, + }, + ], + }); + + expect(response.commercialOffer?.commercialOfferId).toBe("offer-1"); + expect(response.plans[0]).toMatchObject({ + planId: "vip_annual", + commercialOfferId: "offer-1", + commercialDiscountPercent: 30, + }); + }); }); describe("GiftProductsResponse", () => { diff --git a/src/data/schemas/payment/index.ts b/src/data/schemas/payment/index.ts index c3d58250..5237047c 100644 --- a/src/data/schemas/payment/index.ts +++ b/src/data/schemas/payment/index.ts @@ -8,6 +8,7 @@ export * from "./gift_product"; export * from "./request/create_payment_order_request"; export * from "./request/tip_message_request"; export * from "./response/create_payment_order_response"; +export * from "./response/commercial_offer_response"; export * from "./response/gift_products_response"; export * from "./response/payment_order_status_response"; export * from "./response/payment_plans_response"; diff --git a/src/data/schemas/payment/payment_plan.ts b/src/data/schemas/payment/payment_plan.ts index 07364ad6..f7143418 100644 --- a/src/data/schemas/payment/payment_plan.ts +++ b/src/data/schemas/payment/payment_plan.ts @@ -25,6 +25,8 @@ export const PaymentPlanSchema = z mostPopular: booleanOrFalse, firstRechargeDiscountPercent: numberOrNull, promotionType: stringOrNull, + commercialOfferId: stringOrNull, + commercialDiscountPercent: numberOrNull, }) .readonly(); diff --git a/src/data/schemas/payment/request/create_payment_order_request.ts b/src/data/schemas/payment/request/create_payment_order_request.ts index b10037da..0b8b1b32 100644 --- a/src/data/schemas/payment/request/create_payment_order_request.ts +++ b/src/data/schemas/payment/request/create_payment_order_request.ts @@ -11,6 +11,7 @@ export const CreatePaymentOrderRequestSchema = z payChannel: PayChannelSchema, autoRenew: z.boolean(), recipientCharacterId: z.string().min(1).optional(), + commercialOfferId: z.string().min(1).optional(), }) .readonly(); diff --git a/src/data/schemas/payment/response/commercial_offer_response.ts b/src/data/schemas/payment/response/commercial_offer_response.ts new file mode 100644 index 00000000..c2edd8c3 --- /dev/null +++ b/src/data/schemas/payment/response/commercial_offer_response.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const CommercialOfferResponseSchema = z + .object({ + commercialOfferId: z.string().min(1), + planId: z.string().min(1), + subscriptionType: z.enum(["vip", "topup"]), + discountPercent: z.number().int().min(0).max(100), + pricePercent: z.number().int().min(0).max(100), + expiresAt: z.string().min(1), + message: z.string().min(1), + promotionType: z.string().min(1), + }) + .readonly(); + +export type CommercialOfferResponse = z.output< + typeof CommercialOfferResponseSchema +>; diff --git a/src/data/schemas/payment/response/index.ts b/src/data/schemas/payment/response/index.ts index f8bdcf71..eb9f24e2 100644 --- a/src/data/schemas/payment/response/index.ts +++ b/src/data/schemas/payment/response/index.ts @@ -3,6 +3,7 @@ */ export * from "./create_payment_order_response"; +export * from "./commercial_offer_response"; export * from "./gift_products_response"; export * from "./payment_order_status_response"; export * from "./payment_plans_response"; diff --git a/src/data/schemas/payment/response/payment_plans_response.ts b/src/data/schemas/payment/response/payment_plans_response.ts index a041253d..199084c1 100644 --- a/src/data/schemas/payment/response/payment_plans_response.ts +++ b/src/data/schemas/payment/response/payment_plans_response.ts @@ -20,10 +20,24 @@ export const FirstRechargeOfferSchema = z }) .readonly(); +export const CommercialOfferSummarySchema = z + .object({ + enabled: booleanOrFalse, + commercialOfferId: z.string().min(1), + planId: z.string().min(1), + discountPercent: numberOrZero, + pricePercent: numberOrZero, + expiresAt: z.string().min(1), + triggerReason: stringOrEmpty, + message: stringOrEmpty, + }) + .readonly(); + export const PaymentPlansResponseSchema = z .object({ isFirstRecharge: booleanOrFalse, firstRechargeOffer: schemaOrNull(FirstRechargeOfferSchema), + commercialOffer: schemaOrNull(CommercialOfferSummarySchema), plans: arrayOrEmpty(PaymentPlanSchema), }) .readonly(); @@ -35,7 +49,9 @@ export type PaymentPlansResponseData = z.output< typeof PaymentPlansResponseSchema >; export type FirstRechargeOfferData = z.output; +export type CommercialOfferSummaryData = z.output; export type FirstRechargeOffer = FirstRechargeOfferData; +export type CommercialOfferSummary = CommercialOfferSummaryData; export type PaymentPlansResponse = PaymentPlansResponseData; diff --git a/src/data/services/api/__tests__/api_path.test.ts b/src/data/services/api/__tests__/api_path.test.ts index 22b77d57..ec40e514 100644 --- a/src/data/services/api/__tests__/api_path.test.ts +++ b/src/data/services/api/__tests__/api_path.test.ts @@ -6,7 +6,7 @@ import { ApiPath } from "../api_path"; describe("ApiPath contract source", () => { it("uses the shared contract path for every static operation", () => { const staticOperations = Object.entries(apiContract).filter( - ([operationId]) => operationId !== "privateZoneAlbumUnlock", + ([, operation]) => !operation.path.includes("{"), ); for (const [operationId, operation] of staticOperations) { @@ -21,4 +21,10 @@ describe("ApiPath contract source", () => { "/api/private-zone/albums/album%2Fid%201/unlock", ); }); + + it("fills and encodes the commercial offer path parameter", () => { + expect(ApiPath.paymentCommercialOfferAccept("offer/id 1")).toBe( + "/api/payment/commercial-offers/offer%2Fid%201/accept", + ); + }); }); diff --git a/src/data/services/api/__tests__/payment_api.test.ts b/src/data/services/api/__tests__/payment_api.test.ts index 21f96b90..1c862b96 100644 --- a/src/data/services/api/__tests__/payment_api.test.ts +++ b/src/data/services/api/__tests__/payment_api.test.ts @@ -81,4 +81,38 @@ describe("PaymentApi", () => { }); expect(response.message).toBe("Thank you for the thoughtful gift."); }); + + it("accepts a server-issued offer before opening the subscription page", async () => { + httpClientMock.mockResolvedValue({ + success: true, + data: { + commercialOfferId: "offer-1", + planId: "vip_annual", + subscriptionType: "vip", + discountPercent: 30, + pricePercent: 70, + expiresAt: "2026-07-24T08:00:00+00:00", + message: "I got it for you.", + promotionType: "commercial_seven_discount", + }, + }); + + const response = await new PaymentApi().acceptCommercialOffer("offer-1"); + + expect(httpClientMock).toHaveBeenCalledWith( + "/api/payment/commercial-offers/offer-1/accept", + { method: "POST" }, + ); + expect(response.discountPercent).toBe(30); + }); + + it("requests an offer-scoped plan catalog without changing the public call", async () => { + httpClientMock.mockResolvedValue({ success: true, data: { plans: [] } }); + + await new PaymentApi().getPlans("offer-1"); + + expect(httpClientMock).toHaveBeenCalledWith("/api/payment/plans", { + query: { commercialOfferId: "offer-1" }, + }); + }); }); diff --git a/src/data/services/api/api_contract.json b/src/data/services/api/api_contract.json index e91b17a4..1753b79d 100644 --- a/src/data/services/api/api_contract.json +++ b/src/data/services/api/api_contract.json @@ -17,6 +17,7 @@ "paymentPlans": { "method": "get", "path": "/api/payment/plans" }, "paymentGiftProducts": { "method": "get", "path": "/api/payment/gift-products" }, "paymentTipMessage": { "method": "post", "path": "/api/payment/tip-message" }, + "paymentCommercialOfferAccept": { "method": "post", "path": "/api/payment/commercial-offers/{offerId}/accept" }, "chatSend": { "method": "post", "path": "/api/chat/send" }, "chatOpeningMessage": { "method": "post", "path": "/api/chat/opening-message" }, "chatHistory": { "method": "get", "path": "/api/chat/history" }, diff --git a/src/data/services/api/api_path.ts b/src/data/services/api/api_path.ts index 5e65ca4b..87b14aa2 100644 --- a/src/data/services/api/api_path.ts +++ b/src/data/services/api/api_path.ts @@ -66,6 +66,14 @@ export class ApiPath { /** 获取已支付礼物订单的角色感谢文案 */ static readonly paymentTipMessage = apiContract.paymentTipMessage.path; + /** 接受角色发放的用户专属优惠 */ + static paymentCommercialOfferAccept(offerId: string): string { + return apiContract.paymentCommercialOfferAccept.path.replace( + "{offerId}", + encodeURIComponent(offerId), + ); + } + // ============ 聊天相关 ============ /** 发送消息 */ static readonly chatSend = apiContract.chatSend.path; diff --git a/src/data/services/api/payment_api.ts b/src/data/services/api/payment_api.ts index 71a8df41..2de9238f 100644 --- a/src/data/services/api/payment_api.ts +++ b/src/data/services/api/payment_api.ts @@ -7,6 +7,8 @@ import { CreatePaymentOrderRequest, CreatePaymentOrderResponse, CreatePaymentOrderResponseSchema, + CommercialOfferResponse, + CommercialOfferResponseSchema, GiftProductsResponse, GiftProductsResponseSchema, PaymentOrderStatusResponse, @@ -24,13 +26,26 @@ import { ApiEnvelope, unwrap } from "./response_helper"; export class PaymentApi { /** 获取 VIP 与 Top-up 套餐列表。 */ - async getPlans(): Promise { - const env = await httpClient>(ApiPath.paymentPlans); + async getPlans(commercialOfferId?: string): Promise { + const env = await httpClient>(ApiPath.paymentPlans, { + ...(commercialOfferId ? { query: { commercialOfferId } } : {}), + }); return PaymentPlansResponseSchema.parse( unwrap(env) as Record, ); } + /** 用户确认后激活一份后端签发的专属优惠。 */ + async acceptCommercialOffer(offerId: string): Promise { + const env = await httpClient>( + ApiPath.paymentCommercialOfferAccept(offerId), + { method: "POST" }, + ); + return CommercialOfferResponseSchema.parse( + unwrap(env) as Record, + ); + } + /** 一次获取当前角色的完整礼物目录。 */ async getGiftProducts(characterId: string): Promise { const env = await httpClient>( diff --git a/src/router/__tests__/navigation-resolver.test.ts b/src/router/__tests__/navigation-resolver.test.ts index d8ca03f2..cd638454 100644 --- a/src/router/__tests__/navigation-resolver.test.ts +++ b/src/router/__tests__/navigation-resolver.test.ts @@ -66,6 +66,19 @@ describe("navigation resolver", () => { ); }); + it("keeps the user-bound offer and server-selected plan in the subscription URL", () => { + expect( + ROUTE_BUILDERS.subscription("vip", { + returnTo: "chat", + sourceCharacterSlug: "elio", + planId: "vip_annual", + commercialOfferId: "offer-1", + }), + ).toBe( + "/subscription?type=vip&returnTo=chat&character=elio&planId=vip_annual&commercialOfferId=offer-1", + ); + }); + it("allows not logged in users to enter chat for guest bootstrap", () => { expect( resolveRouteGuardRedirect({ diff --git a/src/router/routes.ts b/src/router/routes.ts index 7e4b19ec..ae43d7ca 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -82,6 +82,8 @@ export const ROUTE_BUILDERS = { returnTo?: Exclude; sourceCharacterSlug?: string; analytics?: PaymentAnalyticsContext; + planId?: string; + commercialOfferId?: string; } = {}, ): `/subscription?${string}` => { const params = new URLSearchParams({ type }); @@ -90,6 +92,10 @@ export const ROUTE_BUILDERS = { if (options.sourceCharacterSlug) { params.set("character", options.sourceCharacterSlug); } + if (options.planId) params.set("planId", options.planId); + if (options.commercialOfferId) { + params.set("commercialOfferId", options.commercialOfferId); + } if (options.analytics) { params.set( PAYMENT_ANALYTICS_ENTRY_PARAM, diff --git a/src/stores/payment/__tests__/payment-machine.test-utils.ts b/src/stores/payment/__tests__/payment-machine.test-utils.ts index 098cb238..113f8ad8 100644 --- a/src/stores/payment/__tests__/payment-machine.test-utils.ts +++ b/src/stores/payment/__tests__/payment-machine.test-utils.ts @@ -19,6 +19,7 @@ import type { PaymentPlanCatalog } from "@/stores/payment/payment-state"; export interface PaymentPlansActorInput { catalog: PaymentPlanCatalog; + commercialOfferId?: string | null; } export interface CreateOrderInput { @@ -26,6 +27,7 @@ export interface CreateOrderInput { payChannel: PayChannel; autoRenew: boolean; recipientCharacterId?: string; + commercialOfferId?: string; } export type CreateOrderSpy = (input: CreateOrderInput) => void; diff --git a/src/stores/payment/__tests__/payment-order-flow.test.ts b/src/stores/payment/__tests__/payment-order-flow.test.ts index c7256c87..7063c18d 100644 --- a/src/stores/payment/__tests__/payment-order-flow.test.ts +++ b/src/stores/payment/__tests__/payment-order-flow.test.ts @@ -81,6 +81,30 @@ describe("payment order flow", () => { actor.stop(); }); + it("carries the server-issued commercial offer into order creation", async () => { + const createOrderSpy = vi.fn(); + const actor = createActor( + createTestPaymentMachine({ createOrderSpy }), + ).start(); + actor.send({ + type: "PaymentInit", + planId: "vip_monthly", + commercialOfferId: "offer-user-bound-1", + }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + + actor.send({ type: "PaymentCreateOrderSubmitted" }); + await waitFor(actor, (snapshot) => snapshot.matches("paid")); + + expect(createOrderSpy).toHaveBeenCalledWith({ + planId: "vip_monthly", + payChannel: "stripe", + autoRenew: true, + commercialOfferId: "offer-user-bound-1", + }); + actor.stop(); + }); + it("loads a stable Tip message after payment and clears it on reset", async () => { const loadTipMessage = vi.fn(); const actor = createActor( diff --git a/src/stores/payment/helper/catalog.ts b/src/stores/payment/helper/catalog.ts index 55dbeed4..ee8dea31 100644 --- a/src/stores/payment/helper/catalog.ts +++ b/src/stores/payment/helper/catalog.ts @@ -52,7 +52,7 @@ export function hydratePlansResponseState( selectedPlanId: string, ): Pick< PaymentState, - "plans" | "isFirstRecharge" | "selectedPlanId" | "autoRenew" | "errorMessage" + "plans" | "isFirstRecharge" | "commercialOffer" | "selectedPlanId" | "autoRenew" | "errorMessage" > { const plans = normalizeFirstRechargePlans( response.plans, @@ -61,6 +61,7 @@ export function hydratePlansResponseState( return { ...hydratePlansState(plans, selectedPlanId), isFirstRecharge: response.isFirstRecharge, + commercialOffer: response.commercialOffer, }; } @@ -89,7 +90,7 @@ export function refreshPlansResponseState( selectedPlanId: string, ): Pick< PaymentState, - "plans" | "isFirstRecharge" | "selectedPlanId" | "autoRenew" | "errorMessage" + "plans" | "isFirstRecharge" | "commercialOffer" | "selectedPlanId" | "autoRenew" | "errorMessage" > { const plans = normalizeFirstRechargePlans( response.plans, @@ -98,6 +99,7 @@ export function refreshPlansResponseState( return { ...refreshPlansState(plans, selectedPlanId), isFirstRecharge: response.isFirstRecharge, + commercialOffer: response.commercialOffer, }; } diff --git a/src/stores/payment/machine/actors/order.ts b/src/stores/payment/machine/actors/order.ts index 1a652c62..ecfc6450 100644 --- a/src/stores/payment/machine/actors/order.ts +++ b/src/stores/payment/machine/actors/order.ts @@ -15,6 +15,7 @@ export const createPaymentOrderActor = fromPromise< payChannel: PayChannel; autoRenew: boolean; recipientCharacterId?: string; + commercialOfferId?: string; } >(async ({ input }) => { const result = await getPaymentRepository().createOrder( @@ -22,6 +23,7 @@ export const createPaymentOrderActor = fromPromise< input.payChannel, input.autoRenew, input.recipientCharacterId, + input.commercialOfferId, ); if (Result.isErr(result)) throw result.error; return result.data; diff --git a/src/stores/payment/machine/actors/plans.ts b/src/stores/payment/machine/actors/plans.ts index 191bd062..ac33594f 100644 --- a/src/stores/payment/machine/actors/plans.ts +++ b/src/stores/payment/machine/actors/plans.ts @@ -8,9 +8,9 @@ import type { PaymentPlanCatalog } from "../../payment-state"; export const loadCachedPaymentPlansActor = fromPromise< PaymentPlansResponse | null, - { catalog: PaymentPlanCatalog } + { catalog: PaymentPlanCatalog; commercialOfferId?: string | null } >(async ({ input }) => { - if (input.catalog === "tip") return null; + if (input.catalog === "tip" || input.commercialOfferId) return null; const result = await getPaymentRepository().getCachedPlans(); if (Result.isErr(result)) throw result.error; return result.data; @@ -18,13 +18,13 @@ export const loadCachedPaymentPlansActor = fromPromise< export const refreshPaymentPlansActor = fromPromise< PaymentPlansResponse, - { catalog: PaymentPlanCatalog } + { catalog: PaymentPlanCatalog; commercialOfferId?: string | null } >(async ({ input }) => { const paymentRepo = getPaymentRepository(); if (input.catalog === "tip") { throw new Error("Gift catalogs must use the gift products actor."); } - const result = await paymentRepo.getPlans(); + const result = await paymentRepo.getPlans(input.commercialOfferId ?? undefined); if (Result.isErr(result)) throw result.error; return result.data; }); diff --git a/src/stores/payment/machine/catalog-flow.ts b/src/stores/payment/machine/catalog-flow.ts index fadbb95c..168218cf 100644 --- a/src/stores/payment/machine/catalog-flow.ts +++ b/src/stores/payment/machine/catalog-flow.ts @@ -35,24 +35,30 @@ function initializeCatalogState( planCatalog === "tip" && ((event.category ?? null) !== context.selectedGiftCategory || (event.planId ?? null) !== (context.selectedPlanId || null)); + const commercialOfferId = + planCatalog === "default" ? (event.commercialOfferId ?? null) : null; + const offerChanged = commercialOfferId !== context.commercialOfferId; return { planCatalog, ...(event.payChannel ? { payChannel: event.payChannel } : {}), giftCharacterId, + commercialOfferId, requestedGiftCategory: planCatalog === "tip" ? (event.category ?? null) : null, requestedGiftPlanId: planCatalog === "tip" ? (event.planId ?? null) : null, - ...(catalogChanged || characterChanged || selectionChanged + ...(catalogChanged || characterChanged || selectionChanged || offerChanged ? { ...resetOrderState(), plans: [], giftCategories: [], giftProducts: [], selectedGiftCategory: null, - selectedPlanId: "", + selectedPlanId: + planCatalog === "default" ? (event.planId ?? "") : "", isFirstRecharge: false, + commercialOffer: null, autoRenew: planCatalog !== "tip", } : {}), @@ -207,7 +213,10 @@ export const loadingCachedPlansState = catalogMachineSetup.createStateConfig({ invoke: { src: "loadCachedPlans", - input: ({ context }) => ({ catalog: context.planCatalog }), + input: ({ context }) => ({ + catalog: context.planCatalog, + commercialOfferId: context.commercialOfferId, + }), onDone: [ { guard: ({ event }) => (event.output?.plans.length ?? 0) > 0, @@ -223,7 +232,10 @@ export const loadingCachedPlansState = export const loadingPlansState = catalogMachineSetup.createStateConfig({ invoke: { src: "refreshPlans", - input: ({ context }) => ({ catalog: context.planCatalog }), + input: ({ context }) => ({ + catalog: context.planCatalog, + commercialOfferId: context.commercialOfferId, + }), onDone: { target: "ready", actions: hydratePlansAction, @@ -238,7 +250,10 @@ export const loadingPlansState = catalogMachineSetup.createStateConfig({ export const refreshingPlansState = catalogMachineSetup.createStateConfig({ invoke: { src: "refreshPlans", - input: ({ context }) => ({ catalog: context.planCatalog }), + input: ({ context }) => ({ + catalog: context.planCatalog, + commercialOfferId: context.commercialOfferId, + }), onDone: { target: "ready", actions: refreshPlansAction, diff --git a/src/stores/payment/machine/order-flow.ts b/src/stores/payment/machine/order-flow.ts index 475e9417..aaedf8d8 100644 --- a/src/stores/payment/machine/order-flow.ts +++ b/src/stores/payment/machine/order-flow.ts @@ -195,6 +195,9 @@ const creatingOrderState = paymentMachineSetup.createStateConfig({ event.recipientCharacterId ? { recipientCharacterId: event.recipientCharacterId } : {}), + ...(context.commercialOfferId + ? { commercialOfferId: context.commercialOfferId } + : {}), }), onDone: { target: "pollingOrder", diff --git a/src/stores/payment/payment-context.tsx b/src/stores/payment/payment-context.tsx index 5c55becb..7b3d29e2 100644 --- a/src/stores/payment/payment-context.tsx +++ b/src/stores/payment/payment-context.tsx @@ -19,6 +19,7 @@ export interface PaymentContextState { giftProducts: MachineContext["giftProducts"]; selectedGiftCategory: string | null; isFirstRecharge: MachineContext["isFirstRecharge"]; + commercialOffer: MachineContext["commercialOffer"]; selectedPlanId: string; payChannel: MachineContext["payChannel"]; autoRenew: boolean; @@ -74,6 +75,7 @@ function selectPaymentState(state: PaymentSnapshot): PaymentContextState { giftProducts: state.context.giftProducts, selectedGiftCategory: state.context.selectedGiftCategory, isFirstRecharge: state.context.isFirstRecharge, + commercialOffer: state.context.commercialOffer, selectedPlanId: state.context.selectedPlanId, payChannel: state.context.payChannel, autoRenew: state.context.autoRenew, diff --git a/src/stores/payment/payment-events.ts b/src/stores/payment/payment-events.ts index 028920d0..f2d30df4 100644 --- a/src/stores/payment/payment-events.ts +++ b/src/stores/payment/payment-events.ts @@ -10,6 +10,7 @@ export type PaymentEvent = characterId?: string; category?: string | null; planId?: string | null; + commercialOfferId?: string | null; } | { type: "PaymentPlanSelected"; planId: string } | { type: "PaymentPayChannelChanged"; payChannel: PayChannel } diff --git a/src/stores/payment/payment-state.ts b/src/stores/payment/payment-state.ts index f2c4f4aa..eec27e06 100644 --- a/src/stores/payment/payment-state.ts +++ b/src/stores/payment/payment-state.ts @@ -5,6 +5,7 @@ import type { PayChannel, PaymentOrderStatus, PaymentPlan, + CommercialOfferSummary, TipMessageResponse, } from "@/data/schemas/payment"; @@ -20,6 +21,8 @@ export interface PaymentState { requestedGiftCategory: string | null; requestedGiftPlanId: string | null; isFirstRecharge: boolean; + commercialOfferId: string | null; + commercialOffer: CommercialOfferSummary | null; selectedPlanId: string; payChannel: PayChannel; autoRenew: boolean; @@ -45,6 +48,8 @@ export const initialState: PaymentState = { requestedGiftCategory: null, requestedGiftPlanId: null, isFirstRecharge: false, + commercialOfferId: null, + commercialOffer: null, selectedPlanId: "", payChannel: "stripe", autoRenew: true,