From ef9b79bc83be275c96e9b7f89bd63bfa31a16289 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 28 Jul 2026 17:42:53 +0800 Subject: [PATCH] feat(payment): add chat support discounts and gratitude --- e2e/specs/mock/payment/indonesia-qris.spec.ts | 6 ++ .../chat/__tests__/chat-support-cta.test.ts | 84 ++++++++++++++++ src/app/chat/chat-screen.tsx | 26 +++-- src/app/chat/chat-support-cta.ts | 63 ++++++++++++ .../chat/components/chat-support-button.tsx | 32 ++++++ src/app/chat/components/index.ts | 1 + .../hooks/use-chat-commercial-messages.ts | 38 ++++++++ src/app/chat/hooks/use-chat-support-cta.ts | 97 +++++++++++++++++++ .../__tests__/subscription-page.test.tsx | 4 +- .../subscription-screen-flow.test.tsx | 74 ++++++++++++-- .../components/subscription-screen.module.css | 46 +++++++++ src/app/subscription/page.tsx | 4 +- src/app/subscription/subscription-screen.tsx | 54 +++++++++-- .../use-subscription-payment-flow.ts | 15 ++- .../chat-websocket-payment-guidance.test.ts | 21 ++++ src/core/net/chat-websocket.ts | 35 +++++++ .../interfaces/ipayment_repository.ts | 5 +- src/data/repositories/payment_repository.ts | 14 ++- .../payment/__tests__/payment_plan.test.ts | 10 +- .../response/payment_plans_response.ts | 2 + src/data/services/api/payment_api.ts | 14 ++- .../payment_analytics_context.test.ts | 11 +++ .../analytics/payment_analytics_context.ts | 2 + src/router/navigation-types.ts | 2 + src/router/use-global-app-navigator.ts | 4 + .../__tests__/chat-commercial-message.test.ts | 34 +++++++ src/stores/chat/chat-events.ts | 9 ++ src/stores/chat/helper/commercial-message.ts | 30 ++++++ src/stores/chat/machine/session-flow.ts | 16 +++ .../__tests__/payment-order-flow.test.ts | 20 ++++ src/stores/payment/helper/catalog.ts | 10 +- src/stores/payment/machine/actors/plans.ts | 11 ++- src/stores/payment/machine/catalog-flow.ts | 18 +++- src/stores/payment/machine/order-flow.ts | 10 +- src/stores/payment/payment-state.ts | 2 + 35 files changed, 768 insertions(+), 56 deletions(-) create mode 100644 src/app/chat/__tests__/chat-support-cta.test.ts create mode 100644 src/app/chat/chat-support-cta.ts create mode 100644 src/app/chat/components/chat-support-button.tsx create mode 100644 src/app/chat/hooks/use-chat-commercial-messages.ts create mode 100644 src/app/chat/hooks/use-chat-support-cta.ts create mode 100644 src/stores/chat/__tests__/chat-commercial-message.test.ts create mode 100644 src/stores/chat/helper/commercial-message.ts diff --git a/e2e/specs/mock/payment/indonesia-qris.spec.ts b/e2e/specs/mock/payment/indonesia-qris.spec.ts index 41715f90..efb1c06a 100644 --- a/e2e/specs/mock/payment/indonesia-qris.spec.ts +++ b/e2e/specs/mock/payment/indonesia-qris.spec.ts @@ -173,6 +173,7 @@ async function expectQrisOrder( expect(request.postDataJSON()).toMatchObject({ planId, payChannel: "ezpay", + recipientCharacterId: "elio", }); const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" }); @@ -211,6 +212,10 @@ test("Indonesia VIP defaults to QRIS and completes after status polling", async await prepareIndonesiaUser(page); await page.goto("/subscription?type=vip"); + await expect(page.getByText("Choose who you want to support")).toBeVisible(); + await page.getByRole("button", { name: "Elio", exact: true }).click(); + await expect(page.getByText("Supporting Elio")).toBeVisible(); + await expect(page.getByRole("button", { name: "QRIS" })).toHaveAttribute( "aria-pressed", "true", @@ -251,6 +256,7 @@ test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => { const payment = await registerIndonesiaPaymentMocks(page); await prepareIndonesiaUser(page); await page.goto("/subscription?type=topup"); + await page.getByRole("button", { name: "Elio", exact: true }).click(); await expectCheckoutButtonLayout(page); const orderId = await expectQrisOrder( diff --git a/src/app/chat/__tests__/chat-support-cta.test.ts b/src/app/chat/__tests__/chat-support-cta.test.ts new file mode 100644 index 00000000..84276cc3 --- /dev/null +++ b/src/app/chat/__tests__/chat-support-cta.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { deriveChatSupportCta } from "../chat-support-cta"; + +describe("deriveChatSupportCta", () => { + const now = Date.parse("2026-07-28T00:00:00.000Z"); + + it("shows first recharge ahead of a weaker role offer without inventing a timer", () => { + expect( + deriveChatSupportCta({ + isFirstRecharge: true, + commercialOffer: { + enabled: true, + commercialOfferId: "offer-1", + characterId: "elio", + planId: "vip_annual", + discountPercent: 30, + pricePercent: 70, + expiresAt: "2026-07-29T00:00:00.000Z", + triggerReason: "price_objection", + message: "I got this for you.", + }, + nowMs: now, + }), + ).toMatchObject({ + kind: "firstRecharge", + label: "Support me · 50% OFF · First recharge", + commercialOfferId: null, + expiresAt: null, + }); + }); + + it("shows a server-anchored 24-hour role offer countdown", () => { + expect( + deriveChatSupportCta({ + isFirstRecharge: false, + commercialOffer: { + enabled: true, + commercialOfferId: "offer-1", + characterId: "maya-tan", + planId: "vip_annual", + discountPercent: 30, + pricePercent: 70, + expiresAt: "2026-07-29T00:00:00.000Z", + triggerReason: "price_objection", + message: "I got this for you.", + }, + nowMs: now, + }), + ).toMatchObject({ + kind: "commercialOffer", + label: "Support me · 30% OFF · 23:59:59", + commercialOfferId: "offer-1", + expiresAt: "2026-07-29T00:00:00.000Z", + }); + }); + + it("returns to Support me after an offer expires or is absent", () => { + expect( + deriveChatSupportCta({ + isFirstRecharge: false, + commercialOffer: null, + nowMs: now, + }).label, + ).toBe("Support me"); + expect( + deriveChatSupportCta({ + isFirstRecharge: false, + commercialOffer: { + enabled: true, + commercialOfferId: "offer-1", + characterId: "elio", + planId: "vip_annual", + discountPercent: 30, + pricePercent: 70, + expiresAt: "2026-07-27T00:00:00.000Z", + triggerReason: "price_objection", + message: "I got this for you.", + }, + nowMs: now, + }).label, + ).toBe("Support me"); + }); +}); diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index f61caaf3..4f99bc88 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -33,8 +33,8 @@ import { ChatHeader, ChatInputBar, ChatInsufficientCreditsBanner, + ChatSupportButton, ChatUnlockDialogs, - FirstRechargeOfferBanner, FullscreenImageViewer, PwaInstallOverlay, } from "./components"; @@ -46,7 +46,8 @@ import { import { deriveIsGuest, } from "./chat-screen.helpers"; -import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-banner"; +import { useChatSupportCta } from "./hooks/use-chat-support-cta"; +import { useChatCommercialMessages } from "./hooks/use-chat-commercial-messages"; import { useChatUnlockCoordinator } from "./hooks/use-chat-unlock-coordinator"; import { useChatGuestLogin } from "./hooks/use-chat-guest-login"; import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner"; @@ -130,10 +131,15 @@ export function ChatScreen() { upgradeReason: state.upgradeReason, paymentGuidance: state.paymentGuidance, }); - const firstRechargeOfferBanner = useFirstRechargeOfferBanner({ + const supportCta = useChatSupportCta({ + characterId: character.id, historyLoaded: state.historyLoaded, loginStatus: authState.loginStatus, }); + useChatCommercialMessages({ + characterId: character.id, + loginStatus: authState.loginStatus, + }); const shouldShowPwaInstall = state.historyLoaded && state.historyMessages.length >= 10; useChatGuestLogin({ @@ -422,13 +428,13 @@ export function ChatScreen() { + supportCta.visible ? ( + + ) : null } /> diff --git a/src/app/chat/chat-support-cta.ts b/src/app/chat/chat-support-cta.ts new file mode 100644 index 00000000..d262a45e --- /dev/null +++ b/src/app/chat/chat-support-cta.ts @@ -0,0 +1,63 @@ +import type { CommercialOfferSummary } from "@/data/schemas/payment"; + +export type ChatSupportCtaKind = + | "support" + | "firstRecharge" + | "commercialOffer"; + +export interface ChatSupportCtaView { + kind: ChatSupportCtaKind; + label: string; + commercialOfferId: string | null; + planId: string | null; + expiresAt: string | null; +} + +export function deriveChatSupportCta(input: { + isFirstRecharge: boolean; + commercialOffer: CommercialOfferSummary | null; + nowMs: number; +}): ChatSupportCtaView { + if (input.isFirstRecharge) { + return { + kind: "firstRecharge", + label: "Support me · 50% OFF · First recharge", + commercialOfferId: null, + planId: null, + expiresAt: null, + }; + } + + const offer = input.commercialOffer; + const expiresAtMs = offer ? Date.parse(offer.expiresAt) : Number.NaN; + if (offer?.enabled && Number.isFinite(expiresAtMs) && expiresAtMs > input.nowMs) { + return { + kind: "commercialOffer", + label: `Support me · ${offer.discountPercent}% OFF · ${formatOfferCountdown(expiresAtMs, input.nowMs)}`, + commercialOfferId: offer.commercialOfferId, + planId: offer.planId, + expiresAt: offer.expiresAt, + }; + } + + return { + kind: "support", + label: "Support me", + commercialOfferId: null, + planId: null, + expiresAt: null, + }; +} + +export function formatOfferCountdown(expiresAtMs: number, nowMs: number): string { + const totalSeconds = Math.max( + 0, + Math.ceil((expiresAtMs - nowMs) / 1000) - 1, + ); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return [hours, minutes, seconds] + .map((value) => String(value).padStart(2, "0")) + .join(":"); +} diff --git a/src/app/chat/components/chat-support-button.tsx b/src/app/chat/components/chat-support-button.tsx new file mode 100644 index 00000000..8b012bdd --- /dev/null +++ b/src/app/chat/components/chat-support-button.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Heart, Sparkles } from "lucide-react"; + +import type { ChatSupportCtaKind } from "../chat-support-cta"; + +export interface ChatSupportButtonProps { + kind: ChatSupportCtaKind; + label: string; + onClick: () => void; +} + +export function ChatSupportButton({ + kind, + label, + onClick, +}: ChatSupportButtonProps) { + const Icon = kind === "support" ? Heart : Sparkles; + return ( + + ); +} diff --git a/src/app/chat/components/index.ts b/src/app/chat/components/index.ts index 9f4b3c9c..0155561b 100644 --- a/src/app/chat/components/index.ts +++ b/src/app/chat/components/index.ts @@ -12,6 +12,7 @@ export * from "./chat-insufficient-credits-banner"; export * from "./chat-input-bar"; export * from "./chat-input-text-field"; export * from "./chat-send-button"; +export * from "./chat-support-button"; export * from "./chat-unlock-dialogs"; export * from "./date-header"; export * from "./first-recharge-offer-banner"; diff --git a/src/app/chat/hooks/use-chat-commercial-messages.ts b/src/app/chat/hooks/use-chat-commercial-messages.ts new file mode 100644 index 00000000..ec163884 --- /dev/null +++ b/src/app/chat/hooks/use-chat-commercial-messages.ts @@ -0,0 +1,38 @@ +"use client"; + +import { useEffect } from "react"; + +import { createChatWebSocket } from "@/core/net/chat-websocket"; +import type { LoginStatus } from "@/data/schemas/auth"; +import { getSessionAccessToken } from "@/lib/auth/auth_session"; +import { useChatDispatch } from "@/stores/chat/chat-context"; + +export function useChatCommercialMessages(input: { + characterId: string; + loginStatus: LoginStatus; +}) { + const chatDispatch = useChatDispatch(); + + useEffect(() => { + if (input.loginStatus === "notLoggedIn" || input.loginStatus === "guest") { + return; + } + + let disposed = false; + let socket: ReturnType | null = null; + void getSessionAccessToken().then((token) => { + if (disposed || !token) return; + socket = createChatWebSocket(token); + socket.onCommercialMessage = (message) => { + if (message.characterId !== input.characterId) return; + chatDispatch({ type: "ChatCommercialMessageReceived", message }); + }; + socket.connect(); + }); + + return () => { + disposed = true; + socket?.disconnect(); + }; + }, [chatDispatch, input.characterId, input.loginStatus]); +} diff --git a/src/app/chat/hooks/use-chat-support-cta.ts b/src/app/chat/hooks/use-chat-support-cta.ts new file mode 100644 index 00000000..51408c6d --- /dev/null +++ b/src/app/chat/hooks/use-chat-support-cta.ts @@ -0,0 +1,97 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { shallowEqual } from "@xstate/react"; + +import type { LoginStatus } from "@/data/schemas/auth"; +import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel"; +import { useAppNavigator } from "@/router/use-app-navigator"; +import { + usePaymentDispatch, + usePaymentSelector, +} from "@/stores/payment/payment-context"; +import { useUserSelector } from "@/stores/user/user-context"; + +import { deriveChatSupportCta } from "../chat-support-cta"; + +export function useChatSupportCta(input: { + characterId: string; + historyLoaded: boolean; + loginStatus: LoginStatus; +}) { + const navigator = useAppNavigator(); + const paymentDispatch = usePaymentDispatch(); + const initializedCharacterRef = useRef(null); + const [nowMs, setNowMs] = useState(() => Date.now()); + const payment = usePaymentSelector( + (state) => ({ + commercialOffer: state.context.commercialOffer, + isFirstRecharge: state.context.isFirstRecharge, + supportCharacterId: state.context.supportCharacterId, + }), + shallowEqual, + ); + const countryCode = useUserSelector( + (state) => state.context.currentUser?.countryCode, + ); + const isAuthenticated = + input.loginStatus !== "notLoggedIn" && input.loginStatus !== "guest"; + + useEffect(() => { + if (!isAuthenticated) return; + if (initializedCharacterRef.current === input.characterId) return; + initializedCharacterRef.current = input.characterId; + paymentDispatch({ + type: "PaymentInit", + characterId: input.characterId, + payChannel: getDefaultPayChannelForCountryCode(countryCode), + }); + }, [countryCode, input.characterId, isAuthenticated, paymentDispatch]); + + useEffect(() => { + if (!payment.commercialOffer) return; + const timer = window.setInterval(() => setNowMs(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [payment.commercialOffer]); + + const cta = useMemo( + () => + deriveChatSupportCta({ + isFirstRecharge: + isAuthenticated && payment.supportCharacterId === input.characterId + ? payment.isFirstRecharge + : false, + commercialOffer: + isAuthenticated && payment.supportCharacterId === input.characterId + ? payment.commercialOffer + : null, + nowMs, + }), + [ + input.characterId, + isAuthenticated, + nowMs, + payment.commercialOffer, + payment.isFirstRecharge, + payment.supportCharacterId, + ], + ); + + const open = () => { + navigator.openSubscription({ + type: cta.planId?.startsWith("dol_") ? "topup" : "vip", + returnTo: "chat", + ...(cta.planId ? { planId: cta.planId } : {}), + ...(cta.commercialOfferId + ? { commercialOfferId: cta.commercialOfferId } + : {}), + analytics: { + entryPoint: "chat_offer_banner", + triggerReason: + cta.kind === "support" ? "vip_cta" : "commercial_offer", + }, + }); + }; + + return { ...cta, visible: input.historyLoaded, open }; +} diff --git a/src/app/subscription/__tests__/subscription-page.test.tsx b/src/app/subscription/__tests__/subscription-page.test.tsx index 394c2047..5607ec99 100644 --- a/src/app/subscription/__tests__/subscription-page.test.tsx +++ b/src/app/subscription/__tests__/subscription-page.test.tsx @@ -34,14 +34,14 @@ describe("SubscriptionPage", () => { ); }); - it("uses Elio for missing source navigation context", async () => { + it("requires an explicit selection when source navigation has no character", async () => { const page = await SubscriptionPage({ searchParams: Promise.resolve({}), }); renderToStaticMarkup(page); expect(mocks.screenProps).toHaveBeenLastCalledWith( - expect.objectContaining({ sourceCharacterSlug: "elio" }), + expect.objectContaining({ sourceCharacterSlug: null }), ); }); }); diff --git a/src/app/subscription/__tests__/subscription-screen-flow.test.tsx b/src/app/subscription/__tests__/subscription-screen-flow.test.tsx index f7a4bb0f..6e3e1302 100644 --- a/src/app/subscription/__tests__/subscription-screen-flow.test.tsx +++ b/src/app/subscription/__tests__/subscription-screen-flow.test.tsx @@ -3,6 +3,16 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => { + const characters = [ + { id: "elio", slug: "elio", displayName: "Elio Silvestri", shortName: "Elio" }, + { id: "maya-tan", slug: "maya", displayName: "Maya Tan", shortName: "Maya" }, + { + id: "nayeli-cervantes", + slug: "nayeli", + displayName: "Nayeli Cervantes", + shortName: "Nayeli", + }, + ]; const payment = { status: "ready", plans: [ @@ -52,7 +62,17 @@ const mocks = vi.hoisted(() => { payment.selectedPlanId = event.planId; } }); - return { payment, paymentDispatch, planClick: vi.fn() }; + return { + characterCatalog: { + characters, + getBySlug: (slug: string | null) => + characters.find((character) => character.slug === slug) ?? null, + }, + payment, + paymentDispatch, + paymentFlowInput: vi.fn(), + planClick: vi.fn(), + }; }); vi.mock("@/app/_components", () => ({ @@ -83,6 +103,10 @@ vi.mock("@/stores/user/user-context", () => ({ useUserState: () => ({ currentUser: { countryCode: "ID" } }), })); +vi.mock("@/providers/character-catalog-provider", () => ({ + useCharacterCatalog: () => mocks.characterCatalog, +})); + vi.mock("@/lib/payment/payment_method", () => ({ getPaymentMethodConfig: () => ({ initialPayChannel: "stripe", @@ -97,13 +121,16 @@ vi.mock("@/lib/analytics", () => ({ })); vi.mock("../use-subscription-payment-flow", () => ({ - useSubscriptionPaymentFlow: () => ({ - payment: mocks.payment, - paymentDispatch: mocks.paymentDispatch, - showPaymentSuccessDialog: false, - handleBackClick: vi.fn(), - handlePaymentSuccessClose: vi.fn(), - }), + useSubscriptionPaymentFlow: (input: unknown) => { + mocks.paymentFlowInput(input); + return { + payment: mocks.payment, + paymentDispatch: mocks.paymentDispatch, + showPaymentSuccessDialog: false, + handleBackClick: vi.fn(), + handlePaymentSuccessClose: vi.fn(), + }; + }, })); vi.mock("../components", () => ({ @@ -179,6 +206,7 @@ describe("SubscriptionScreen payment selection flow", () => { } mocks.payment.selectedPlanId = "vip_monthly"; mocks.paymentDispatch.mockClear(); + mocks.paymentFlowInput.mockClear(); mocks.planClick.mockClear(); container = document.createElement("div"); document.body.appendChild(container); @@ -298,6 +326,36 @@ describe("SubscriptionScreen payment selection flow", () => { "Your first recharge price is already applied", ); }); + + it("requires a support character selection when opened without a character", () => { + mocks.payment.selectedPlanId = "coin_1000"; + + act(() => + root.render( + , + ), + ); + + const checkout = container.querySelector( + '[data-testid="checkout"]', + ); + expect(container.textContent).toContain("Choose who you want to support"); + expect(checkout?.disabled).toBe(true); + expect(mocks.paymentFlowInput).toHaveBeenLastCalledWith( + expect.objectContaining({ sourceCharacterSlug: null }), + ); + + act(() => clickButton(container, "Maya")); + + expect(container.textContent).toContain("Supporting Maya"); + expect(checkout?.disabled).toBe(false); + expect(mocks.paymentFlowInput).toHaveBeenLastCalledWith( + expect.objectContaining({ sourceCharacterSlug: "maya" }), + ); + }); }); function clickButton(root: ParentNode, label: string): void { diff --git a/src/app/subscription/components/subscription-screen.module.css b/src/app/subscription/components/subscription-screen.module.css index 82e04f0a..d0609881 100644 --- a/src/app/subscription/components/subscription-screen.module.css +++ b/src/app/subscription/components/subscription-screen.module.css @@ -61,6 +61,52 @@ box-shadow: 0 12px 30px rgba(22, 101, 52, 0.12); } +.supportCharacterSelector { + margin-top: var(--page-section-gap, 14px); + padding: 14px 16px; + border: 1px solid rgba(246, 87, 160, 0.2); + border-radius: var(--responsive-card-radius-sm, 22px); + background: rgba(255, 255, 255, 0.9); + color: #4a3340; +} + +.supportCharacterSelector h2 { + margin: 0; + color: #24151d; + font-size: var(--responsive-card-title, 17px); +} + +.supportCharacterSelector p { + margin: 6px 0 10px; + font-size: var(--responsive-caption, 13px); + line-height: 1.4; +} + +.supportCharacterOptions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.supportCharacterOption, +.supportCharacterOptionActive { + border: 1px solid rgba(246, 87, 160, 0.24); + border-radius: 999px; + padding: 7px 12px; + background: #fff; + color: #8e315e; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 800; +} + +.supportCharacterOptionActive { + border-color: #f657a0; + background: #f657a0; + color: #fff; +} + .characterSupportBanner { margin-top: var(--page-section-gap, 14px); padding: 14px 16px; diff --git a/src/app/subscription/page.tsx b/src/app/subscription/page.tsx index 002d5ebb..edc8ea82 100644 --- a/src/app/subscription/page.tsx +++ b/src/app/subscription/page.tsx @@ -11,7 +11,6 @@ import { type PaymentSearchParams, } from "@/lib/payment/payment_search_params"; import { - DEFAULT_CHARACTER_SLUG, getCharacterBySlug, } from "@/data/constants/character"; @@ -31,8 +30,7 @@ export default async function SubscriptionPage({ getFirstPaymentSearchParam(query.type), ); const sourceCharacterSlug = - getCharacterBySlug(getFirstPaymentSearchParam(query.character))?.slug ?? - DEFAULT_CHARACTER_SLUG; + getCharacterBySlug(getFirstPaymentSearchParam(query.character))?.slug ?? null; const initialPlanId = getFirstPaymentSearchParam(query.planId); const commercialOfferId = getFirstPaymentSearchParam( query.commercialOfferId, diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx index ac8f5391..9ef9861d 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -9,10 +9,10 @@ import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics import type { PayChannel } from "@/data/schemas/payment"; import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit"; import { - DEFAULT_CHARACTER, DEFAULT_CHARACTER_SLUG, getCharacterBySlug, } from "@/data/constants/character"; +import { useCharacterCatalog } from "@/providers/character-catalog-provider"; import { behaviorAnalytics, getDefaultPaymentAnalyticsContext, @@ -51,7 +51,7 @@ export interface SubscriptionScreenProps { returnTo?: SubscriptionReturnTo; initialPayChannel?: PayChannel | null; analyticsContext?: PaymentAnalyticsContext; - sourceCharacterSlug?: string; + sourceCharacterSlug?: string | null; initialPlanId?: string | null; initialAutoRenew?: boolean | null; commercialOfferId?: string | null; @@ -80,9 +80,15 @@ export function SubscriptionScreen({ const [paymentIssueNotice, setPaymentIssueNotice] = useState( null, ); + const characterCatalog = useCharacterCatalog(); + const [selectedSupportCharacterSlug, setSelectedSupportCharacterSlug] = + useState(() => + getCharacterBySlug(sourceCharacterSlug)?.slug ?? null, + ); const userState = useUserState(); - const sourceCharacter = - getCharacterBySlug(sourceCharacterSlug) ?? DEFAULT_CHARACTER; + const sourceCharacter = characterCatalog.getBySlug( + selectedSupportCharacterSlug, + ); const hasHydrated = useHasHydrated(); const countryCode = userState.currentUser?.countryCode; const paymentMethodConfig = getPaymentMethodConfig({ @@ -102,7 +108,7 @@ export function SubscriptionScreen({ subscriptionType, shouldResumePendingOrder, returnTo, - sourceCharacterSlug, + sourceCharacterSlug: sourceCharacter?.slug ?? null, initialPayChannel: paymentMethodConfig.initialPayChannel, initialPlanId, initialAutoRenew, @@ -163,6 +169,7 @@ export function SubscriptionScreen({ (plan) => plan.id === payment.selectedPlanId, ); const canActivate = + sourceCharacter !== null && selectedPlan !== null && canCheckoutSubscriptionPlan({ selectedPlanId: payment.selectedPlanId, @@ -271,7 +278,36 @@ export function SubscriptionScreen({

) : null} - {chatActionId ? ( +
+

+ {sourceCharacter + ? `Supporting ${sourceCharacter.shortName}` + : "Choose who you want to support"} +

+

+ Your VIP or credit purchase supports the character you choose, and + they will thank you after the payment and benefits are confirmed. +

+
+ {characterCatalog.characters.map((character) => ( + + ))} +
+
+ + {chatActionId && sourceCharacter ? (
) : null} - {payment.commercialOffer && !hasFirstRechargeOffer ? ( + {payment.commercialOffer && !hasFirstRechargeOffer && sourceCharacter ? (
@@ -364,7 +400,7 @@ export function SubscriptionScreen({ orderId={payment.currentOrderId} payChannel={payment.payChannel} countryCode={countryCode} - characterId={sourceCharacterSlug} + characterId={sourceCharacter?.id ?? ""} onClose={() => setShowPaymentIssueDialog(false)} onSubmitted={setPaymentIssueNotice} /> diff --git a/src/app/subscription/use-subscription-payment-flow.ts b/src/app/subscription/use-subscription-payment-flow.ts index 8ca975cc..fe0c1202 100644 --- a/src/app/subscription/use-subscription-payment-flow.ts +++ b/src/app/subscription/use-subscription-payment-flow.ts @@ -5,7 +5,11 @@ import { useRouter } from "next/navigation"; import { usePaymentRouteFlow } from "@/app/_hooks/use-payment-route-flow"; import type { PayChannel } from "@/data/schemas/payment"; -import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character"; +import { + DEFAULT_CHARACTER, + DEFAULT_CHARACTER_SLUG, + getCharacterBySlug, +} from "@/data/constants/character"; import { consumeSubscriptionExitUrl, resolveSubscriptionSuccessExitUrl, @@ -19,7 +23,7 @@ export interface UseSubscriptionPaymentFlowInput { shouldResumePendingOrder: boolean; returnTo: SubscriptionReturnTo; initialPayChannel: PayChannel; - sourceCharacterSlug?: string; + sourceCharacterSlug?: string | null; initialPlanId?: string | null; initialAutoRenew?: boolean | null; commercialOfferId?: string | null; @@ -40,10 +44,13 @@ export function useSubscriptionPaymentFlow({ chatActionId = null, }: UseSubscriptionPaymentFlowInput) { const router = useRouter(); + const supportCharacter = getCharacterBySlug(sourceCharacterSlug); + const exitCharacterSlug = supportCharacter?.slug ?? DEFAULT_CHARACTER.slug; const { payment, paymentDispatch } = usePaymentRouteFlow({ initialPayChannel, paymentType: subscriptionType, shouldResumePendingOrder, + characterId: supportCharacter?.id, initialPlanId, initialAutoRenew, commercialOfferId, @@ -65,7 +72,7 @@ export function useSubscriptionPaymentFlow({ const handleBackClick = () => { void (async () => { router.replace( - await consumeSubscriptionExitUrl(returnTo, sourceCharacterSlug), + await consumeSubscriptionExitUrl(returnTo, exitCharacterSlug), ); })(); }; @@ -77,7 +84,7 @@ export function useSubscriptionPaymentFlow({ router.replace( await resolveSubscriptionSuccessExitUrl( returnTo, - sourceCharacterSlug, + exitCharacterSlug, ), ); })(); diff --git a/src/core/net/__tests__/chat-websocket-payment-guidance.test.ts b/src/core/net/__tests__/chat-websocket-payment-guidance.test.ts index a902800e..05a8923a 100644 --- a/src/core/net/__tests__/chat-websocket-payment-guidance.test.ts +++ b/src/core/net/__tests__/chat-websocket-payment-guidance.test.ts @@ -45,6 +45,27 @@ describe("ChatWebSocket payment guidance", () => { expect(onPaymentGuidance).not.toHaveBeenCalled(); }); + + it("delivers a persisted commercial thank-you as a live role message", () => { + const socket = new ChatWebSocket("ws://example.test/chat", "token"); + const onCommercialMessage = vi.fn(); + socket.onCommercialMessage = onCommercialMessage; + + receive(socket, { + type: "commercial_message", + messageId: "message-1", + message: "Thank you for supporting me.", + characterId: "maya-tan", + createdAt: "2026-07-28T00:00:00.000Z", + }); + + expect(onCommercialMessage).toHaveBeenCalledWith({ + messageId: "message-1", + message: "Thank you for supporting me.", + characterId: "maya-tan", + createdAt: "2026-07-28T00:00:00.000Z", + }); + }); }); function receive(socket: ChatWebSocket, payload: unknown): void { diff --git a/src/core/net/chat-websocket.ts b/src/core/net/chat-websocket.ts index 755a6c59..9bb409a0 100644 --- a/src/core/net/chat-websocket.ts +++ b/src/core/net/chat-websocket.ts @@ -37,6 +37,13 @@ export interface PaywallStatusPayload { reason: string | null; } +export interface CommercialMessagePayload { + messageId: string; + message: string; + characterId: string; + createdAt: string; +} + export class ChatWebSocket { private ws: WebSocket | null = null; private reconnectTimer: ReturnType | null = null; @@ -52,6 +59,7 @@ export class ChatWebSocket { onCommercialAction: ((action: CommercialAction) => void) | null = null; onChatAction: ((action: ChatAction) => void) | null = null; onPaymentGuidance: ((guidance: PaymentGuidance) => void) | null = null; + onCommercialMessage: ((message: CommercialMessagePayload) => void) | null = null; onError: ((errorMessage: string) => void) | null = null; constructor( @@ -149,6 +157,10 @@ export class ChatWebSocket { done?: boolean; audioUrl?: string; error?: string; + messageId?: string; + message?: string; + characterId?: string; + createdAt?: string; data?: Record & { image?: { type?: string | null; @@ -163,6 +175,10 @@ export class ChatWebSocket { ruleId?: string; kind?: string; orderId?: string | null; + messageId?: string; + message?: string; + characterId?: string; + createdAt?: string; }; }; try { @@ -217,6 +233,25 @@ export class ChatWebSocket { if (guidance.success) this.onPaymentGuidance?.(guidance.data); break; } + case "commercial_message": { + const commercialMessage = payload.data?.messageId + ? payload.data + : payload; + if ( + commercialMessage?.messageId && + commercialMessage.message && + commercialMessage.characterId && + commercialMessage.createdAt + ) { + this.onCommercialMessage?.({ + messageId: commercialMessage.messageId, + message: commercialMessage.message, + characterId: commercialMessage.characterId, + createdAt: commercialMessage.createdAt, + }); + } + break; + } case "error": this.onError?.(payload.error ?? "Unknown error"); break; diff --git a/src/data/repositories/interfaces/ipayment_repository.ts b/src/data/repositories/interfaces/ipayment_repository.ts index f54953ba..c04cd953 100644 --- a/src/data/repositories/interfaces/ipayment_repository.ts +++ b/src/data/repositories/interfaces/ipayment_repository.ts @@ -13,7 +13,10 @@ import type { Result } from "@/utils/result"; export interface IPaymentRepository { /** 获取套餐列表。 */ - getPlans(commercialOfferId?: string): Promise>; + getPlans( + commercialOfferId?: string, + supportCharacterId?: string, + ): Promise>; /** 获取本地缓存套餐列表。 */ getCachedPlans(): Promise>; diff --git a/src/data/repositories/payment_repository.ts b/src/data/repositories/payment_repository.ts index d1fe3415..a9d6be1a 100644 --- a/src/data/repositories/payment_repository.ts +++ b/src/data/repositories/payment_repository.ts @@ -24,10 +24,18 @@ export class PaymentRepository implements IPaymentRepository { constructor(private readonly api: PaymentApi) {} /** 获取套餐列表。 */ - async getPlans(commercialOfferId?: string): Promise> { + async getPlans( + commercialOfferId?: string, + supportCharacterId?: string, + ): Promise> { return Result.wrap(async () => { - const response = await this.api.getPlans(commercialOfferId); - if (!commercialOfferId) await PaymentPlansStorage.setPlans(response); + const response = await this.api.getPlans( + commercialOfferId, + supportCharacterId, + ); + if (!commercialOfferId && !supportCharacterId) { + await PaymentPlansStorage.setPlans(response); + } return response; }); } diff --git a/src/data/schemas/payment/__tests__/payment_plan.test.ts b/src/data/schemas/payment/__tests__/payment_plan.test.ts index 81984aec..fca4d910 100644 --- a/src/data/schemas/payment/__tests__/payment_plan.test.ts +++ b/src/data/schemas/payment/__tests__/payment_plan.test.ts @@ -195,9 +195,11 @@ describe("PaymentPlansResponse", () => { it("parses one user-bound commercial offer and its discounted plan", () => { const response = PaymentPlansResponseSchema.parse({ - commercialOffer: { - enabled: true, - commercialOfferId: "offer-1", + supportCharacterId: "maya-tan", + commercialOffer: { + enabled: true, + commercialOfferId: "offer-1", + characterId: "maya-tan", planId: "vip_annual", discountPercent: 30, pricePercent: 70, @@ -225,6 +227,8 @@ describe("PaymentPlansResponse", () => { }); expect(response.commercialOffer?.commercialOfferId).toBe("offer-1"); + expect(response.supportCharacterId).toBe("maya-tan"); + expect(response.commercialOffer?.characterId).toBe("maya-tan"); expect(response.plans[0]).toMatchObject({ planId: "vip_annual", commercialOfferId: "offer-1", diff --git a/src/data/schemas/payment/response/payment_plans_response.ts b/src/data/schemas/payment/response/payment_plans_response.ts index 199084c1..b5a9cc8e 100644 --- a/src/data/schemas/payment/response/payment_plans_response.ts +++ b/src/data/schemas/payment/response/payment_plans_response.ts @@ -24,6 +24,7 @@ export const CommercialOfferSummarySchema = z .object({ enabled: booleanOrFalse, commercialOfferId: z.string().min(1), + characterId: stringOrEmpty, planId: z.string().min(1), discountPercent: numberOrZero, pricePercent: numberOrZero, @@ -36,6 +37,7 @@ export const CommercialOfferSummarySchema = z export const PaymentPlansResponseSchema = z .object({ isFirstRecharge: booleanOrFalse, + supportCharacterId: stringOrEmpty, firstRechargeOffer: schemaOrNull(FirstRechargeOfferSchema), commercialOffer: schemaOrNull(CommercialOfferSummarySchema), plans: arrayOrEmpty(PaymentPlanSchema), diff --git a/src/data/services/api/payment_api.ts b/src/data/services/api/payment_api.ts index 2de9238f..b6958b10 100644 --- a/src/data/services/api/payment_api.ts +++ b/src/data/services/api/payment_api.ts @@ -26,9 +26,19 @@ import { ApiEnvelope, unwrap } from "./response_helper"; export class PaymentApi { /** 获取 VIP 与 Top-up 套餐列表。 */ - async getPlans(commercialOfferId?: string): Promise { + async getPlans( + commercialOfferId?: string, + supportCharacterId?: string, + ): Promise { const env = await httpClient>(ApiPath.paymentPlans, { - ...(commercialOfferId ? { query: { commercialOfferId } } : {}), + ...(commercialOfferId || supportCharacterId + ? { + query: { + ...(commercialOfferId ? { commercialOfferId } : {}), + ...(supportCharacterId ? { supportCharacterId } : {}), + }, + } + : {}), }); return PaymentPlansResponseSchema.parse( unwrap(env) as Record, diff --git a/src/lib/analytics/__tests__/payment_analytics_context.test.ts b/src/lib/analytics/__tests__/payment_analytics_context.test.ts index 4064e7a0..11f8bcd4 100644 --- a/src/lib/analytics/__tests__/payment_analytics_context.test.ts +++ b/src/lib/analytics/__tests__/payment_analytics_context.test.ts @@ -17,6 +17,17 @@ describe("payment analytics context", () => { entryPoint: "chat_unlock", triggerReason: "ad_landing", }); + + expect( + parsePaymentAnalyticsContext({ + entryPoint: "chat_offer_banner", + triggerReason: "commercial_offer", + subscriptionType: "vip", + }), + ).toEqual({ + entryPoint: "chat_offer_banner", + triggerReason: "commercial_offer", + }); }); it("falls back independently for invalid query values", () => { diff --git a/src/lib/analytics/payment_analytics_context.ts b/src/lib/analytics/payment_analytics_context.ts index e1460dfc..7ddba66b 100644 --- a/src/lib/analytics/payment_analytics_context.ts +++ b/src/lib/analytics/payment_analytics_context.ts @@ -7,6 +7,7 @@ export type PaymentAnalyticsTriggerReason = | "insufficient_credits" | "profile_recharge" | "vip_cta" + | "commercial_offer" | "ad_landing" | "manual_recharge" | "unknown"; @@ -47,6 +48,7 @@ const TRIGGER_REASONS = new Set([ "insufficient_credits", "profile_recharge", "vip_cta", + "commercial_offer", "ad_landing", "manual_recharge", "unknown", diff --git a/src/router/navigation-types.ts b/src/router/navigation-types.ts index 7b4c6b30..97331de5 100644 --- a/src/router/navigation-types.ts +++ b/src/router/navigation-types.ts @@ -20,6 +20,8 @@ export interface OpenSubscriptionInput { replace?: boolean; analytics?: PaymentAnalyticsContext; chatActionId?: string; + planId?: string; + commercialOfferId?: string; } export interface StartMessageUnlockInput { diff --git a/src/router/use-global-app-navigator.ts b/src/router/use-global-app-navigator.ts index 8054b7e6..2f46c055 100644 --- a/src/router/use-global-app-navigator.ts +++ b/src/router/use-global-app-navigator.ts @@ -80,6 +80,8 @@ export function useGlobalAppNavigator(): GlobalAppNavigator { replace: shouldReplace = false, analytics = getDefaultPaymentAnalyticsContext(type), chatActionId, + planId, + commercialOfferId, }: OpenGlobalSubscriptionInput): void => { const target = ROUTE_BUILDERS.subscription(type, { payChannel, @@ -87,6 +89,8 @@ export function useGlobalAppNavigator(): GlobalAppNavigator { sourceCharacterSlug, analytics, chatActionId, + planId, + commercialOfferId, }); behaviorAnalytics.rechargeModalOpen(analytics); diff --git a/src/stores/chat/__tests__/chat-commercial-message.test.ts b/src/stores/chat/__tests__/chat-commercial-message.test.ts new file mode 100644 index 00000000..d03d8141 --- /dev/null +++ b/src/stores/chat/__tests__/chat-commercial-message.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { appendCommercialMessage } from "../helper/commercial-message"; + +describe("chat commercial message", () => { + const incoming = { + messageId: "payment-thanks-1", + message: "Thank you for supporting me.", + characterId: "maya-tan", + createdAt: "2026-07-28T08:30:00.000Z", + }; + + it("appends one persisted payment thank-you to the matching character chat", () => { + const messages = appendCommercialMessage([], "maya-tan", incoming); + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + remoteId: "payment-thanks-1", + content: "Thank you for supporting me.", + isFromAI: true, + }); + }); + + it("ignores duplicate delivery and a message for another character", () => { + const messages = appendCommercialMessage([], "maya-tan", incoming); + + expect(appendCommercialMessage(messages, "maya-tan", incoming)).toEqual( + messages, + ); + expect(appendCommercialMessage(messages, "elio", incoming)).toEqual( + messages, + ); + }); +}); diff --git a/src/stores/chat/chat-events.ts b/src/stores/chat/chat-events.ts index 02d67a87..7fce99be 100644 --- a/src/stores/chat/chat-events.ts +++ b/src/stores/chat/chat-events.ts @@ -28,6 +28,15 @@ export type ChatEvent = } | { type: "ChatOlderHistoryLoadFailed"; error: unknown } | { type: "ChatHistoryRefreshRequested" } + | { + type: "ChatCommercialMessageReceived"; + message: { + messageId: string; + message: string; + characterId: string; + createdAt: string; + }; + } // Chat domain events. | { type: "ChatSendMessage"; content: string } | { type: "ChatSendImage"; imageBase64: string } diff --git a/src/stores/chat/helper/commercial-message.ts b/src/stores/chat/helper/commercial-message.ts new file mode 100644 index 00000000..52573ae8 --- /dev/null +++ b/src/stores/chat/helper/commercial-message.ts @@ -0,0 +1,30 @@ +import { createRemoteUiMessageIdentity, type UiMessage } from "../ui-message"; +import { todayString } from "@/utils/date"; + +export interface IncomingCommercialMessage { + messageId: string; + message: string; + characterId: string; + createdAt: string; +} + +export function appendCommercialMessage( + messages: readonly UiMessage[], + activeCharacterId: string, + incoming: IncomingCommercialMessage, +): UiMessage[] { + if (incoming.characterId !== activeCharacterId) return [...messages]; + if (messages.some((message) => message.remoteId === incoming.messageId)) { + return [...messages]; + } + const createdAt = new Date(incoming.createdAt); + return [ + ...messages, + { + ...createRemoteUiMessageIdentity(incoming.messageId, true), + content: incoming.message, + isFromAI: true, + date: todayString(Number.isNaN(createdAt.getTime()) ? new Date() : createdAt), + }, + ]; +} diff --git a/src/stores/chat/machine/session-flow.ts b/src/stores/chat/machine/session-flow.ts index f21efdd0..83a37b56 100644 --- a/src/stores/chat/machine/session-flow.ts +++ b/src/stores/chat/machine/session-flow.ts @@ -1,4 +1,5 @@ import { createChatPromotionState } from "../helper/promotion"; +import { appendCommercialMessage } from "../helper/commercial-message"; import { shouldPromptUnlockHistory } from "../helper/unlock"; import { createInitialChatState } from "../chat-state"; import { @@ -53,6 +54,19 @@ const injectPromotionAction = unlockMachineSetup.assign(({ event }) => { const clearPromotionAction = unlockMachineSetup.assign({ promotion: null }); +const appendCommercialMessageAction = unlockMachineSetup.assign( + ({ context, event }) => { + if (event.type !== "ChatCommercialMessageReceived") return {}; + return { + messages: appendCommercialMessage( + context.messages, + context.characterId, + event.message, + ), + }; + }, +); + export const chatMachineSetup = unlockMachineSetup.extend({ actions: { startGuestSession: startGuestSessionAction, @@ -60,6 +74,7 @@ export const chatMachineSetup = unlockMachineSetup.extend({ clearChatSession: clearChatSessionAction, injectPromotion: injectPromotionAction, clearPromotion: clearPromotionAction, + appendCommercialMessage: appendCommercialMessageAction, }, }); @@ -258,6 +273,7 @@ export const chatRootStateConfig = chatMachineSetup.createStateConfig({ on: { ChatPromotionInjected: { actions: "injectPromotion" }, ChatPromotionCleared: { actions: "clearPromotion" }, + ChatCommercialMessageReceived: { actions: "appendCommercialMessage" }, }, states: { idle: idleState, diff --git a/src/stores/payment/__tests__/payment-order-flow.test.ts b/src/stores/payment/__tests__/payment-order-flow.test.ts index c83e0e6e..acf31de3 100644 --- a/src/stores/payment/__tests__/payment-order-flow.test.ts +++ b/src/stores/payment/__tests__/payment-order-flow.test.ts @@ -105,6 +105,26 @@ describe("payment order flow", () => { actor.stop(); }); + it("binds a default VIP or credit order to the selected support character", async () => { + const createOrderSpy = vi.fn(); + const actor = createActor( + createTestPaymentMachine({ createOrderSpy }), + ).start(); + actor.send({ type: "PaymentInit", characterId: "maya-tan" }); + 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, + recipientCharacterId: "maya-tan", + }); + actor.stop(); + }); + it("carries the originating chat action into order creation", async () => { const createOrderSpy = vi.fn(); const actor = createActor( diff --git a/src/stores/payment/helper/catalog.ts b/src/stores/payment/helper/catalog.ts index ee8dea31..73646267 100644 --- a/src/stores/payment/helper/catalog.ts +++ b/src/stores/payment/helper/catalog.ts @@ -136,12 +136,20 @@ export function consumeFirstRechargeState( context: PaymentState, ): Pick< PaymentState, - "plans" | "isFirstRecharge" | "selectedPlanId" | "autoRenew" | "errorMessage" + | "plans" + | "isFirstRecharge" + | "commercialOffer" + | "commercialOfferId" + | "selectedPlanId" + | "autoRenew" + | "errorMessage" > { const plans = context.plans.map(consumeFirstRechargePlan); return { ...refreshPlansState(plans, context.selectedPlanId), isFirstRecharge: false, + commercialOffer: null, + commercialOfferId: null, }; } diff --git a/src/stores/payment/machine/actors/plans.ts b/src/stores/payment/machine/actors/plans.ts index ac33594f..d8cc39c7 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; commercialOfferId?: string | null } + { catalog: PaymentPlanCatalog; commercialOfferId?: string | null; supportCharacterId?: string | null } >(async ({ input }) => { - if (input.catalog === "tip" || input.commercialOfferId) return null; + if (input.catalog === "tip" || input.commercialOfferId || input.supportCharacterId) return null; const result = await getPaymentRepository().getCachedPlans(); if (Result.isErr(result)) throw result.error; return result.data; @@ -18,13 +18,16 @@ export const loadCachedPaymentPlansActor = fromPromise< export const refreshPaymentPlansActor = fromPromise< PaymentPlansResponse, - { catalog: PaymentPlanCatalog; commercialOfferId?: string | null } + { catalog: PaymentPlanCatalog; commercialOfferId?: string | null; supportCharacterId?: 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(input.commercialOfferId ?? undefined); + const result = await paymentRepo.getPlans( + input.commercialOfferId ?? undefined, + input.supportCharacterId ?? 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 32a99fbb..bd4a1c4e 100644 --- a/src/stores/payment/machine/catalog-flow.ts +++ b/src/stores/payment/machine/catalog-flow.ts @@ -29,8 +29,14 @@ function initializeCatalogState( planCatalog === "tip" ? (event.characterId ?? context.giftCharacterId) : null; + const supportCharacterId = + planCatalog === "default" + ? (event.characterId ?? context.supportCharacterId) + : null; const catalogChanged = planCatalog !== context.planCatalog; - const characterChanged = giftCharacterId !== context.giftCharacterId; + const characterChanged = + giftCharacterId !== context.giftCharacterId || + supportCharacterId !== context.supportCharacterId; const selectionChanged = planCatalog === "tip" ? (event.category ?? null) !== context.selectedGiftCategory || @@ -49,6 +55,7 @@ function initializeCatalogState( planCatalog, ...(event.payChannel ? { payChannel: event.payChannel } : {}), giftCharacterId, + supportCharacterId, commercialOfferId, chatActionId, requestedGiftCategory: @@ -228,6 +235,7 @@ export const loadingCachedPlansState = input: ({ context }) => ({ catalog: context.planCatalog, commercialOfferId: context.commercialOfferId, + supportCharacterId: context.supportCharacterId, }), onDone: [ { @@ -244,9 +252,10 @@ export const loadingCachedPlansState = export const loadingPlansState = catalogMachineSetup.createStateConfig({ invoke: { src: "refreshPlans", - input: ({ context }) => ({ - catalog: context.planCatalog, - commercialOfferId: context.commercialOfferId, + input: ({ context }) => ({ + catalog: context.planCatalog, + commercialOfferId: context.commercialOfferId, + supportCharacterId: context.supportCharacterId, }), onDone: { target: "ready", @@ -265,6 +274,7 @@ export const refreshingPlansState = catalogMachineSetup.createStateConfig({ input: ({ context }) => ({ catalog: context.planCatalog, commercialOfferId: context.commercialOfferId, + supportCharacterId: context.supportCharacterId, }), onDone: { target: "ready", diff --git a/src/stores/payment/machine/order-flow.ts b/src/stores/payment/machine/order-flow.ts index 891f9dad..4b2efa25 100644 --- a/src/stores/payment/machine/order-flow.ts +++ b/src/stores/payment/machine/order-flow.ts @@ -192,8 +192,14 @@ const creatingOrderState = paymentMachineSetup.createStateConfig({ payChannel: context.payChannel, autoRenew: context.autoRenew, ...(event.type === "PaymentCreateOrderSubmitted" && - event.recipientCharacterId - ? { recipientCharacterId: event.recipientCharacterId } + (event.recipientCharacterId || context.supportCharacterId || context.giftCharacterId) + ? { + recipientCharacterId: + event.recipientCharacterId || + context.supportCharacterId || + context.giftCharacterId || + undefined, + } : {}), ...(context.commercialOfferId ? { commercialOfferId: context.commercialOfferId } diff --git a/src/stores/payment/payment-state.ts b/src/stores/payment/payment-state.ts index 6fcb3bed..fdf598b7 100644 --- a/src/stores/payment/payment-state.ts +++ b/src/stores/payment/payment-state.ts @@ -17,6 +17,7 @@ export interface PaymentState { giftCategories: readonly GiftCategory[]; giftProducts: readonly GiftProduct[]; giftCharacterId: string | null; + supportCharacterId: string | null; selectedGiftCategory: string | null; requestedGiftCategory: string | null; requestedGiftPlanId: string | null; @@ -45,6 +46,7 @@ export const initialState: PaymentState = { giftCategories: [], giftProducts: [], giftCharacterId: null, + supportCharacterId: null, selectedGiftCategory: null, requestedGiftCategory: null, requestedGiftPlanId: null,