From 74b7eae18b5f75288190d3da060bbec05e147b57 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
(cherry picked from commit ef9b79bc83be275c96e9b7f89bd63bfa31a16289)
---
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,