feat(payment): add chat support discounts and gratitude
Docker Image / Build and Push Docker Image (push) Successful in 2m14s

(cherry picked from commit ef9b79bc83)
This commit is contained in:
Codex
2026-07-28 17:42:53 +08:00
parent 59e4eac736
commit 74b7eae18b
35 changed files with 768 additions and 56 deletions
@@ -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(
@@ -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");
});
});
+15 -9
View File
@@ -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() {
<ChatHeader
isGuest={isGuest}
offerBanner={
<FirstRechargeOfferBanner
visible={firstRechargeOfferBanner.visible}
discountPercent={firstRechargeOfferBanner.discountPercent}
onClick={firstRechargeOfferBanner.claim}
onClose={firstRechargeOfferBanner.close}
variant="compact"
supportCta.visible ? (
<ChatSupportButton
kind={supportCta.kind}
label={supportCta.label}
onClick={supportCta.open}
/>
) : null
}
/>
+63
View File
@@ -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(":");
}
@@ -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 (
<button
type="button"
data-analytics-key="chat.support_character"
data-support-state={kind}
className="inline-flex min-h-9 max-w-full items-center justify-center gap-1.5 rounded-full border border-[rgba(255,255,255,0.22)] bg-[linear-gradient(135deg,rgba(246,87,160,0.96),rgba(255,119,84,0.94))] px-3 py-1.5 text-center text-[12px] font-extrabold leading-tight text-white shadow-[0_8px_24px_rgba(246,87,160,0.3)] backdrop-blur-md transition active:scale-95"
aria-label={label}
onClick={onClick}
>
<Icon className="shrink-0" size={14} strokeWidth={2.5} aria-hidden="true" />
<span className="whitespace-normal text-balance">{label}</span>
</button>
);
}
+1
View File
@@ -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";
@@ -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<typeof createChatWebSocket> | 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]);
}
@@ -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<string | null>(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 };
}
@@ -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 }),
);
});
});
@@ -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: () => ({
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(
<SubscriptionScreen
subscriptionType="topup"
sourceCharacterSlug={null}
/>,
),
);
const checkout = container.querySelector<HTMLButtonElement>(
'[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 {
@@ -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;
+1 -3
View File
@@ -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,
+45 -9
View File
@@ -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<string | null>(
null,
);
const characterCatalog = useCharacterCatalog();
const [selectedSupportCharacterSlug, setSelectedSupportCharacterSlug] =
useState<string | null>(() =>
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({
</p>
) : null}
{chatActionId ? (
<section className={styles.supportCharacterSelector}>
<h2>
{sourceCharacter
? `Supporting ${sourceCharacter.shortName}`
: "Choose who you want to support"}
</h2>
<p>
Your VIP or credit purchase supports the character you choose, and
they will thank you after the payment and benefits are confirmed.
</p>
<div className={styles.supportCharacterOptions}>
{characterCatalog.characters.map((character) => (
<button
key={character.id}
type="button"
className={
sourceCharacter?.id === character.id
? styles.supportCharacterOptionActive
: styles.supportCharacterOption
}
aria-pressed={sourceCharacter?.id === character.id}
onClick={() => setSelectedSupportCharacterSlug(character.slug)}
>
{character.shortName}
</button>
))}
</div>
</section>
{chatActionId && sourceCharacter ? (
<section
className={styles.characterSupportBanner}
aria-label={`Support ${sourceCharacter.displayName}`}
@@ -287,7 +323,7 @@ export function SubscriptionScreen({
</section>
) : null}
{payment.commercialOffer && !hasFirstRechargeOffer ? (
{payment.commercialOffer && !hasFirstRechargeOffer && sourceCharacter ? (
<section
className={styles.firstRechargeBanner}
aria-label={`${sourceCharacter.shortName} private offer`}
@@ -339,7 +375,7 @@ export function SubscriptionScreen({
disabled={!canActivate}
subscriptionType={subscriptionType}
returnTo={returnTo}
sourceCharacterSlug={sourceCharacterSlug}
sourceCharacterSlug={sourceCharacter?.slug ?? DEFAULT_CHARACTER_SLUG}
/>
</div>
@@ -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}
/>
@@ -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,
),
);
})();
@@ -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 {
+35
View File
@@ -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<typeof setTimeout> | 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<string, unknown> & {
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;
@@ -13,7 +13,10 @@ import type { Result } from "@/utils/result";
export interface IPaymentRepository {
/** 获取套餐列表。 */
getPlans(commercialOfferId?: string): Promise<Result<PaymentPlansResponse>>;
getPlans(
commercialOfferId?: string,
supportCharacterId?: string,
): Promise<Result<PaymentPlansResponse>>;
/** 获取本地缓存套餐列表。 */
getCachedPlans(): Promise<Result<PaymentPlansResponse | null>>;
+11 -3
View File
@@ -24,10 +24,18 @@ export class PaymentRepository implements IPaymentRepository {
constructor(private readonly api: PaymentApi) {}
/** 获取套餐列表。 */
async getPlans(commercialOfferId?: string): Promise<Result<PaymentPlansResponse>> {
async getPlans(
commercialOfferId?: string,
supportCharacterId?: string,
): Promise<Result<PaymentPlansResponse>> {
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;
});
}
@@ -195,9 +195,11 @@ describe("PaymentPlansResponse", () => {
it("parses one user-bound commercial offer and its discounted plan", () => {
const response = PaymentPlansResponseSchema.parse({
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",
@@ -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),
+12 -2
View File
@@ -26,9 +26,19 @@ import { ApiEnvelope, unwrap } from "./response_helper";
export class PaymentApi {
/** 获取 VIP 与 Top-up 套餐列表。 */
async getPlans(commercialOfferId?: string): Promise<PaymentPlansResponse> {
async getPlans(
commercialOfferId?: string,
supportCharacterId?: string,
): Promise<PaymentPlansResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentPlans, {
...(commercialOfferId ? { query: { commercialOfferId } } : {}),
...(commercialOfferId || supportCharacterId
? {
query: {
...(commercialOfferId ? { commercialOfferId } : {}),
...(supportCharacterId ? { supportCharacterId } : {}),
},
}
: {}),
});
return PaymentPlansResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
@@ -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", () => {
@@ -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<PaymentAnalyticsTriggerReason>([
"insufficient_credits",
"profile_recharge",
"vip_cta",
"commercial_offer",
"ad_landing",
"manual_recharge",
"unknown",
+2
View File
@@ -20,6 +20,8 @@ export interface OpenSubscriptionInput {
replace?: boolean;
analytics?: PaymentAnalyticsContext;
chatActionId?: string;
planId?: string;
commercialOfferId?: string;
}
export interface StartMessageUnlockInput {
+4
View File
@@ -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);
@@ -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,
);
});
});
+9
View File
@@ -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 }
@@ -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),
},
];
}
+16
View File
@@ -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,
@@ -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<CreateOrderSpy>();
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<CreateOrderSpy>();
const actor = createActor(
+9 -1
View File
@@ -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,
};
}
+7 -4
View File
@@ -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;
});
+11 -1
View File
@@ -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: [
{
@@ -247,6 +255,7 @@ export const loadingPlansState = catalogMachineSetup.createStateConfig({
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",
+8 -2
View File
@@ -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 }
+2
View File
@@ -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,