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

(cherry picked from commit ef9b79bc83)
This commit is contained in:
Codex
2026-07-28 17:42:53 +08:00
parent e43912eebf
commit 9121b50e02
35 changed files with 768 additions and 56 deletions
@@ -173,6 +173,7 @@ async function expectQrisOrder(
expect(request.postDataJSON()).toMatchObject({ expect(request.postDataJSON()).toMatchObject({
planId, planId,
payChannel: "ezpay", payChannel: "ezpay",
recipientCharacterId: "elio",
}); });
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" }); 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 prepareIndonesiaUser(page);
await page.goto("/subscription?type=vip"); 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( await expect(page.getByRole("button", { name: "QRIS" })).toHaveAttribute(
"aria-pressed", "aria-pressed",
"true", "true",
@@ -251,6 +256,7 @@ test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
const payment = await registerIndonesiaPaymentMocks(page); const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page); await prepareIndonesiaUser(page);
await page.goto("/subscription?type=topup"); await page.goto("/subscription?type=topup");
await page.getByRole("button", { name: "Elio", exact: true }).click();
await expectCheckoutButtonLayout(page); await expectCheckoutButtonLayout(page);
const orderId = await expectQrisOrder( 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, ChatHeader,
ChatInputBar, ChatInputBar,
ChatInsufficientCreditsBanner, ChatInsufficientCreditsBanner,
ChatSupportButton,
ChatUnlockDialogs, ChatUnlockDialogs,
FirstRechargeOfferBanner,
FullscreenImageViewer, FullscreenImageViewer,
PwaInstallOverlay, PwaInstallOverlay,
} from "./components"; } from "./components";
@@ -46,7 +46,8 @@ import {
import { import {
deriveIsGuest, deriveIsGuest,
} from "./chat-screen.helpers"; } 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 { useChatUnlockCoordinator } from "./hooks/use-chat-unlock-coordinator";
import { useChatGuestLogin } from "./hooks/use-chat-guest-login"; import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner"; import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
@@ -130,10 +131,15 @@ export function ChatScreen() {
upgradeReason: state.upgradeReason, upgradeReason: state.upgradeReason,
paymentGuidance: state.paymentGuidance, paymentGuidance: state.paymentGuidance,
}); });
const firstRechargeOfferBanner = useFirstRechargeOfferBanner({ const supportCta = useChatSupportCta({
characterId: character.id,
historyLoaded: state.historyLoaded, historyLoaded: state.historyLoaded,
loginStatus: authState.loginStatus, loginStatus: authState.loginStatus,
}); });
useChatCommercialMessages({
characterId: character.id,
loginStatus: authState.loginStatus,
});
const shouldShowPwaInstall = const shouldShowPwaInstall =
state.historyLoaded && state.historyMessages.length >= 10; state.historyLoaded && state.historyMessages.length >= 10;
useChatGuestLogin({ useChatGuestLogin({
@@ -422,13 +428,13 @@ export function ChatScreen() {
<ChatHeader <ChatHeader
isGuest={isGuest} isGuest={isGuest}
offerBanner={ offerBanner={
<FirstRechargeOfferBanner supportCta.visible ? (
visible={firstRechargeOfferBanner.visible} <ChatSupportButton
discountPercent={firstRechargeOfferBanner.discountPercent} kind={supportCta.kind}
onClick={firstRechargeOfferBanner.claim} label={supportCta.label}
onClose={firstRechargeOfferBanner.close} onClick={supportCta.open}
variant="compact"
/> />
) : 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-bar";
export * from "./chat-input-text-field"; export * from "./chat-input-text-field";
export * from "./chat-send-button"; export * from "./chat-send-button";
export * from "./chat-support-button";
export * from "./chat-unlock-dialogs"; export * from "./chat-unlock-dialogs";
export * from "./date-header"; export * from "./date-header";
export * from "./first-recharge-offer-banner"; 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({ const page = await SubscriptionPage({
searchParams: Promise.resolve({}), searchParams: Promise.resolve({}),
}); });
renderToStaticMarkup(page); renderToStaticMarkup(page);
expect(mocks.screenProps).toHaveBeenLastCalledWith( 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"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => { 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 = { const payment = {
status: "ready", status: "ready",
plans: [ plans: [
@@ -52,7 +62,17 @@ const mocks = vi.hoisted(() => {
payment.selectedPlanId = event.planId; 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", () => ({ vi.mock("@/app/_components", () => ({
@@ -83,6 +103,10 @@ vi.mock("@/stores/user/user-context", () => ({
useUserState: () => ({ currentUser: { countryCode: "ID" } }), useUserState: () => ({ currentUser: { countryCode: "ID" } }),
})); }));
vi.mock("@/providers/character-catalog-provider", () => ({
useCharacterCatalog: () => mocks.characterCatalog,
}));
vi.mock("@/lib/payment/payment_method", () => ({ vi.mock("@/lib/payment/payment_method", () => ({
getPaymentMethodConfig: () => ({ getPaymentMethodConfig: () => ({
initialPayChannel: "stripe", initialPayChannel: "stripe",
@@ -97,13 +121,16 @@ vi.mock("@/lib/analytics", () => ({
})); }));
vi.mock("../use-subscription-payment-flow", () => ({ vi.mock("../use-subscription-payment-flow", () => ({
useSubscriptionPaymentFlow: () => ({ useSubscriptionPaymentFlow: (input: unknown) => {
mocks.paymentFlowInput(input);
return {
payment: mocks.payment, payment: mocks.payment,
paymentDispatch: mocks.paymentDispatch, paymentDispatch: mocks.paymentDispatch,
showPaymentSuccessDialog: false, showPaymentSuccessDialog: false,
handleBackClick: vi.fn(), handleBackClick: vi.fn(),
handlePaymentSuccessClose: vi.fn(), handlePaymentSuccessClose: vi.fn(),
}), };
},
})); }));
vi.mock("../components", () => ({ vi.mock("../components", () => ({
@@ -179,6 +206,7 @@ describe("SubscriptionScreen payment selection flow", () => {
} }
mocks.payment.selectedPlanId = "vip_monthly"; mocks.payment.selectedPlanId = "vip_monthly";
mocks.paymentDispatch.mockClear(); mocks.paymentDispatch.mockClear();
mocks.paymentFlowInput.mockClear();
mocks.planClick.mockClear(); mocks.planClick.mockClear();
container = document.createElement("div"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
@@ -298,6 +326,36 @@ describe("SubscriptionScreen payment selection flow", () => {
"Your first recharge price is already applied", "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 { function clickButton(root: ParentNode, label: string): void {
@@ -61,6 +61,52 @@
box-shadow: 0 12px 30px rgba(22, 101, 52, 0.12); 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 { .characterSupportBanner {
margin-top: var(--page-section-gap, 14px); margin-top: var(--page-section-gap, 14px);
padding: 14px 16px; padding: 14px 16px;
+1 -3
View File
@@ -11,7 +11,6 @@ import {
type PaymentSearchParams, type PaymentSearchParams,
} from "@/lib/payment/payment_search_params"; } from "@/lib/payment/payment_search_params";
import { import {
DEFAULT_CHARACTER_SLUG,
getCharacterBySlug, getCharacterBySlug,
} from "@/data/constants/character"; } from "@/data/constants/character";
@@ -31,8 +30,7 @@ export default async function SubscriptionPage({
getFirstPaymentSearchParam(query.type), getFirstPaymentSearchParam(query.type),
); );
const sourceCharacterSlug = const sourceCharacterSlug =
getCharacterBySlug(getFirstPaymentSearchParam(query.character))?.slug ?? getCharacterBySlug(getFirstPaymentSearchParam(query.character))?.slug ?? null;
DEFAULT_CHARACTER_SLUG;
const initialPlanId = getFirstPaymentSearchParam(query.planId); const initialPlanId = getFirstPaymentSearchParam(query.planId);
const commercialOfferId = getFirstPaymentSearchParam( const commercialOfferId = getFirstPaymentSearchParam(
query.commercialOfferId, 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 { PayChannel } from "@/data/schemas/payment";
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit"; import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
import { import {
DEFAULT_CHARACTER,
DEFAULT_CHARACTER_SLUG, DEFAULT_CHARACTER_SLUG,
getCharacterBySlug, getCharacterBySlug,
} from "@/data/constants/character"; } from "@/data/constants/character";
import { useCharacterCatalog } from "@/providers/character-catalog-provider";
import { import {
behaviorAnalytics, behaviorAnalytics,
getDefaultPaymentAnalyticsContext, getDefaultPaymentAnalyticsContext,
@@ -51,7 +51,7 @@ export interface SubscriptionScreenProps {
returnTo?: SubscriptionReturnTo; returnTo?: SubscriptionReturnTo;
initialPayChannel?: PayChannel | null; initialPayChannel?: PayChannel | null;
analyticsContext?: PaymentAnalyticsContext; analyticsContext?: PaymentAnalyticsContext;
sourceCharacterSlug?: string; sourceCharacterSlug?: string | null;
initialPlanId?: string | null; initialPlanId?: string | null;
initialAutoRenew?: boolean | null; initialAutoRenew?: boolean | null;
commercialOfferId?: string | null; commercialOfferId?: string | null;
@@ -80,9 +80,15 @@ export function SubscriptionScreen({
const [paymentIssueNotice, setPaymentIssueNotice] = useState<string | null>( const [paymentIssueNotice, setPaymentIssueNotice] = useState<string | null>(
null, null,
); );
const characterCatalog = useCharacterCatalog();
const [selectedSupportCharacterSlug, setSelectedSupportCharacterSlug] =
useState<string | null>(() =>
getCharacterBySlug(sourceCharacterSlug)?.slug ?? null,
);
const userState = useUserState(); const userState = useUserState();
const sourceCharacter = const sourceCharacter = characterCatalog.getBySlug(
getCharacterBySlug(sourceCharacterSlug) ?? DEFAULT_CHARACTER; selectedSupportCharacterSlug,
);
const hasHydrated = useHasHydrated(); const hasHydrated = useHasHydrated();
const countryCode = userState.currentUser?.countryCode; const countryCode = userState.currentUser?.countryCode;
const paymentMethodConfig = getPaymentMethodConfig({ const paymentMethodConfig = getPaymentMethodConfig({
@@ -102,7 +108,7 @@ export function SubscriptionScreen({
subscriptionType, subscriptionType,
shouldResumePendingOrder, shouldResumePendingOrder,
returnTo, returnTo,
sourceCharacterSlug, sourceCharacterSlug: sourceCharacter?.slug ?? null,
initialPayChannel: paymentMethodConfig.initialPayChannel, initialPayChannel: paymentMethodConfig.initialPayChannel,
initialPlanId, initialPlanId,
initialAutoRenew, initialAutoRenew,
@@ -163,6 +169,7 @@ export function SubscriptionScreen({
(plan) => plan.id === payment.selectedPlanId, (plan) => plan.id === payment.selectedPlanId,
); );
const canActivate = const canActivate =
sourceCharacter !== null &&
selectedPlan !== null && selectedPlan !== null &&
canCheckoutSubscriptionPlan({ canCheckoutSubscriptionPlan({
selectedPlanId: payment.selectedPlanId, selectedPlanId: payment.selectedPlanId,
@@ -271,7 +278,36 @@ export function SubscriptionScreen({
</p> </p>
) : null} ) : 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 <section
className={styles.characterSupportBanner} className={styles.characterSupportBanner}
aria-label={`Support ${sourceCharacter.displayName}`} aria-label={`Support ${sourceCharacter.displayName}`}
@@ -287,7 +323,7 @@ export function SubscriptionScreen({
</section> </section>
) : null} ) : null}
{payment.commercialOffer && !hasFirstRechargeOffer ? ( {payment.commercialOffer && !hasFirstRechargeOffer && sourceCharacter ? (
<section <section
className={styles.firstRechargeBanner} className={styles.firstRechargeBanner}
aria-label={`${sourceCharacter.shortName} private offer`} aria-label={`${sourceCharacter.shortName} private offer`}
@@ -339,7 +375,7 @@ export function SubscriptionScreen({
disabled={!canActivate} disabled={!canActivate}
subscriptionType={subscriptionType} subscriptionType={subscriptionType}
returnTo={returnTo} returnTo={returnTo}
sourceCharacterSlug={sourceCharacterSlug} sourceCharacterSlug={sourceCharacter?.slug ?? DEFAULT_CHARACTER_SLUG}
/> />
</div> </div>
@@ -364,7 +400,7 @@ export function SubscriptionScreen({
orderId={payment.currentOrderId} orderId={payment.currentOrderId}
payChannel={payment.payChannel} payChannel={payment.payChannel}
countryCode={countryCode} countryCode={countryCode}
characterId={sourceCharacterSlug} characterId={sourceCharacter?.id ?? ""}
onClose={() => setShowPaymentIssueDialog(false)} onClose={() => setShowPaymentIssueDialog(false)}
onSubmitted={setPaymentIssueNotice} onSubmitted={setPaymentIssueNotice}
/> />
@@ -5,7 +5,11 @@ import { useRouter } from "next/navigation";
import { usePaymentRouteFlow } from "@/app/_hooks/use-payment-route-flow"; import { usePaymentRouteFlow } from "@/app/_hooks/use-payment-route-flow";
import type { PayChannel } from "@/data/schemas/payment"; 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 { import {
consumeSubscriptionExitUrl, consumeSubscriptionExitUrl,
resolveSubscriptionSuccessExitUrl, resolveSubscriptionSuccessExitUrl,
@@ -19,7 +23,7 @@ export interface UseSubscriptionPaymentFlowInput {
shouldResumePendingOrder: boolean; shouldResumePendingOrder: boolean;
returnTo: SubscriptionReturnTo; returnTo: SubscriptionReturnTo;
initialPayChannel: PayChannel; initialPayChannel: PayChannel;
sourceCharacterSlug?: string; sourceCharacterSlug?: string | null;
initialPlanId?: string | null; initialPlanId?: string | null;
initialAutoRenew?: boolean | null; initialAutoRenew?: boolean | null;
commercialOfferId?: string | null; commercialOfferId?: string | null;
@@ -40,10 +44,13 @@ export function useSubscriptionPaymentFlow({
chatActionId = null, chatActionId = null,
}: UseSubscriptionPaymentFlowInput) { }: UseSubscriptionPaymentFlowInput) {
const router = useRouter(); const router = useRouter();
const supportCharacter = getCharacterBySlug(sourceCharacterSlug);
const exitCharacterSlug = supportCharacter?.slug ?? DEFAULT_CHARACTER.slug;
const { payment, paymentDispatch } = usePaymentRouteFlow({ const { payment, paymentDispatch } = usePaymentRouteFlow({
initialPayChannel, initialPayChannel,
paymentType: subscriptionType, paymentType: subscriptionType,
shouldResumePendingOrder, shouldResumePendingOrder,
characterId: supportCharacter?.id,
initialPlanId, initialPlanId,
initialAutoRenew, initialAutoRenew,
commercialOfferId, commercialOfferId,
@@ -65,7 +72,7 @@ export function useSubscriptionPaymentFlow({
const handleBackClick = () => { const handleBackClick = () => {
void (async () => { void (async () => {
router.replace( router.replace(
await consumeSubscriptionExitUrl(returnTo, sourceCharacterSlug), await consumeSubscriptionExitUrl(returnTo, exitCharacterSlug),
); );
})(); })();
}; };
@@ -77,7 +84,7 @@ export function useSubscriptionPaymentFlow({
router.replace( router.replace(
await resolveSubscriptionSuccessExitUrl( await resolveSubscriptionSuccessExitUrl(
returnTo, returnTo,
sourceCharacterSlug, exitCharacterSlug,
), ),
); );
})(); })();
@@ -45,6 +45,27 @@ describe("ChatWebSocket payment guidance", () => {
expect(onPaymentGuidance).not.toHaveBeenCalled(); 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 { function receive(socket: ChatWebSocket, payload: unknown): void {
+35
View File
@@ -37,6 +37,13 @@ export interface PaywallStatusPayload {
reason: string | null; reason: string | null;
} }
export interface CommercialMessagePayload {
messageId: string;
message: string;
characterId: string;
createdAt: string;
}
export class ChatWebSocket { export class ChatWebSocket {
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null; private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -52,6 +59,7 @@ export class ChatWebSocket {
onCommercialAction: ((action: CommercialAction) => void) | null = null; onCommercialAction: ((action: CommercialAction) => void) | null = null;
onChatAction: ((action: ChatAction) => void) | null = null; onChatAction: ((action: ChatAction) => void) | null = null;
onPaymentGuidance: ((guidance: PaymentGuidance) => void) | null = null; onPaymentGuidance: ((guidance: PaymentGuidance) => void) | null = null;
onCommercialMessage: ((message: CommercialMessagePayload) => void) | null = null;
onError: ((errorMessage: string) => void) | null = null; onError: ((errorMessage: string) => void) | null = null;
constructor( constructor(
@@ -149,6 +157,10 @@ export class ChatWebSocket {
done?: boolean; done?: boolean;
audioUrl?: string; audioUrl?: string;
error?: string; error?: string;
messageId?: string;
message?: string;
characterId?: string;
createdAt?: string;
data?: Record<string, unknown> & { data?: Record<string, unknown> & {
image?: { image?: {
type?: string | null; type?: string | null;
@@ -163,6 +175,10 @@ export class ChatWebSocket {
ruleId?: string; ruleId?: string;
kind?: string; kind?: string;
orderId?: string | null; orderId?: string | null;
messageId?: string;
message?: string;
characterId?: string;
createdAt?: string;
}; };
}; };
try { try {
@@ -217,6 +233,25 @@ export class ChatWebSocket {
if (guidance.success) this.onPaymentGuidance?.(guidance.data); if (guidance.success) this.onPaymentGuidance?.(guidance.data);
break; 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": case "error":
this.onError?.(payload.error ?? "Unknown error"); this.onError?.(payload.error ?? "Unknown error");
break; break;
@@ -13,7 +13,10 @@ import type { Result } from "@/utils/result";
export interface IPaymentRepository { export interface IPaymentRepository {
/** 获取套餐列表。 */ /** 获取套餐列表。 */
getPlans(commercialOfferId?: string): Promise<Result<PaymentPlansResponse>>; getPlans(
commercialOfferId?: string,
supportCharacterId?: string,
): Promise<Result<PaymentPlansResponse>>;
/** 获取本地缓存套餐列表。 */ /** 获取本地缓存套餐列表。 */
getCachedPlans(): Promise<Result<PaymentPlansResponse | null>>; getCachedPlans(): Promise<Result<PaymentPlansResponse | null>>;
+11 -3
View File
@@ -24,10 +24,18 @@ export class PaymentRepository implements IPaymentRepository {
constructor(private readonly api: PaymentApi) {} 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 () => { return Result.wrap(async () => {
const response = await this.api.getPlans(commercialOfferId); const response = await this.api.getPlans(
if (!commercialOfferId) await PaymentPlansStorage.setPlans(response); commercialOfferId,
supportCharacterId,
);
if (!commercialOfferId && !supportCharacterId) {
await PaymentPlansStorage.setPlans(response);
}
return response; return response;
}); });
} }
@@ -195,9 +195,11 @@ describe("PaymentPlansResponse", () => {
it("parses one user-bound commercial offer and its discounted plan", () => { it("parses one user-bound commercial offer and its discounted plan", () => {
const response = PaymentPlansResponseSchema.parse({ const response = PaymentPlansResponseSchema.parse({
supportCharacterId: "maya-tan",
commercialOffer: { commercialOffer: {
enabled: true, enabled: true,
commercialOfferId: "offer-1", commercialOfferId: "offer-1",
characterId: "maya-tan",
planId: "vip_annual", planId: "vip_annual",
discountPercent: 30, discountPercent: 30,
pricePercent: 70, pricePercent: 70,
@@ -225,6 +227,8 @@ describe("PaymentPlansResponse", () => {
}); });
expect(response.commercialOffer?.commercialOfferId).toBe("offer-1"); 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({ expect(response.plans[0]).toMatchObject({
planId: "vip_annual", planId: "vip_annual",
commercialOfferId: "offer-1", commercialOfferId: "offer-1",
@@ -24,6 +24,7 @@ export const CommercialOfferSummarySchema = z
.object({ .object({
enabled: booleanOrFalse, enabled: booleanOrFalse,
commercialOfferId: z.string().min(1), commercialOfferId: z.string().min(1),
characterId: stringOrEmpty,
planId: z.string().min(1), planId: z.string().min(1),
discountPercent: numberOrZero, discountPercent: numberOrZero,
pricePercent: numberOrZero, pricePercent: numberOrZero,
@@ -36,6 +37,7 @@ export const CommercialOfferSummarySchema = z
export const PaymentPlansResponseSchema = z export const PaymentPlansResponseSchema = z
.object({ .object({
isFirstRecharge: booleanOrFalse, isFirstRecharge: booleanOrFalse,
supportCharacterId: stringOrEmpty,
firstRechargeOffer: schemaOrNull(FirstRechargeOfferSchema), firstRechargeOffer: schemaOrNull(FirstRechargeOfferSchema),
commercialOffer: schemaOrNull(CommercialOfferSummarySchema), commercialOffer: schemaOrNull(CommercialOfferSummarySchema),
plans: arrayOrEmpty(PaymentPlanSchema), plans: arrayOrEmpty(PaymentPlanSchema),
+12 -2
View File
@@ -26,9 +26,19 @@ import { ApiEnvelope, unwrap } from "./response_helper";
export class PaymentApi { export class PaymentApi {
/** 获取 VIP 与 Top-up 套餐列表。 */ /** 获取 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, { const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentPlans, {
...(commercialOfferId ? { query: { commercialOfferId } } : {}), ...(commercialOfferId || supportCharacterId
? {
query: {
...(commercialOfferId ? { commercialOfferId } : {}),
...(supportCharacterId ? { supportCharacterId } : {}),
},
}
: {}),
}); });
return PaymentPlansResponseSchema.parse( return PaymentPlansResponseSchema.parse(
unwrap(env) as Record<string, unknown>, unwrap(env) as Record<string, unknown>,
@@ -17,6 +17,17 @@ describe("payment analytics context", () => {
entryPoint: "chat_unlock", entryPoint: "chat_unlock",
triggerReason: "ad_landing", 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", () => { it("falls back independently for invalid query values", () => {
@@ -7,6 +7,7 @@ export type PaymentAnalyticsTriggerReason =
| "insufficient_credits" | "insufficient_credits"
| "profile_recharge" | "profile_recharge"
| "vip_cta" | "vip_cta"
| "commercial_offer"
| "ad_landing" | "ad_landing"
| "manual_recharge" | "manual_recharge"
| "unknown"; | "unknown";
@@ -47,6 +48,7 @@ const TRIGGER_REASONS = new Set<PaymentAnalyticsTriggerReason>([
"insufficient_credits", "insufficient_credits",
"profile_recharge", "profile_recharge",
"vip_cta", "vip_cta",
"commercial_offer",
"ad_landing", "ad_landing",
"manual_recharge", "manual_recharge",
"unknown", "unknown",
+2
View File
@@ -20,6 +20,8 @@ export interface OpenSubscriptionInput {
replace?: boolean; replace?: boolean;
analytics?: PaymentAnalyticsContext; analytics?: PaymentAnalyticsContext;
chatActionId?: string; chatActionId?: string;
planId?: string;
commercialOfferId?: string;
} }
export interface StartMessageUnlockInput { export interface StartMessageUnlockInput {
+4
View File
@@ -80,6 +80,8 @@ export function useGlobalAppNavigator(): GlobalAppNavigator {
replace: shouldReplace = false, replace: shouldReplace = false,
analytics = getDefaultPaymentAnalyticsContext(type), analytics = getDefaultPaymentAnalyticsContext(type),
chatActionId, chatActionId,
planId,
commercialOfferId,
}: OpenGlobalSubscriptionInput): void => { }: OpenGlobalSubscriptionInput): void => {
const target = ROUTE_BUILDERS.subscription(type, { const target = ROUTE_BUILDERS.subscription(type, {
payChannel, payChannel,
@@ -87,6 +89,8 @@ export function useGlobalAppNavigator(): GlobalAppNavigator {
sourceCharacterSlug, sourceCharacterSlug,
analytics, analytics,
chatActionId, chatActionId,
planId,
commercialOfferId,
}); });
behaviorAnalytics.rechargeModalOpen(analytics); 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: "ChatOlderHistoryLoadFailed"; error: unknown }
| { type: "ChatHistoryRefreshRequested" } | { type: "ChatHistoryRefreshRequested" }
| {
type: "ChatCommercialMessageReceived";
message: {
messageId: string;
message: string;
characterId: string;
createdAt: string;
};
}
// Chat domain events. // Chat domain events.
| { type: "ChatSendMessage"; content: string } | { type: "ChatSendMessage"; content: string }
| { type: "ChatSendImage"; imageBase64: 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 { createChatPromotionState } from "../helper/promotion";
import { appendCommercialMessage } from "../helper/commercial-message";
import { shouldPromptUnlockHistory } from "../helper/unlock"; import { shouldPromptUnlockHistory } from "../helper/unlock";
import { createInitialChatState } from "../chat-state"; import { createInitialChatState } from "../chat-state";
import { import {
@@ -53,6 +54,19 @@ const injectPromotionAction = unlockMachineSetup.assign(({ event }) => {
const clearPromotionAction = unlockMachineSetup.assign({ promotion: null }); 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({ export const chatMachineSetup = unlockMachineSetup.extend({
actions: { actions: {
startGuestSession: startGuestSessionAction, startGuestSession: startGuestSessionAction,
@@ -60,6 +74,7 @@ export const chatMachineSetup = unlockMachineSetup.extend({
clearChatSession: clearChatSessionAction, clearChatSession: clearChatSessionAction,
injectPromotion: injectPromotionAction, injectPromotion: injectPromotionAction,
clearPromotion: clearPromotionAction, clearPromotion: clearPromotionAction,
appendCommercialMessage: appendCommercialMessageAction,
}, },
}); });
@@ -258,6 +273,7 @@ export const chatRootStateConfig = chatMachineSetup.createStateConfig({
on: { on: {
ChatPromotionInjected: { actions: "injectPromotion" }, ChatPromotionInjected: { actions: "injectPromotion" },
ChatPromotionCleared: { actions: "clearPromotion" }, ChatPromotionCleared: { actions: "clearPromotion" },
ChatCommercialMessageReceived: { actions: "appendCommercialMessage" },
}, },
states: { states: {
idle: idleState, idle: idleState,
@@ -105,6 +105,26 @@ describe("payment order flow", () => {
actor.stop(); 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 () => { it("carries the originating chat action into order creation", async () => {
const createOrderSpy = vi.fn<CreateOrderSpy>(); const createOrderSpy = vi.fn<CreateOrderSpy>();
const actor = createActor( const actor = createActor(
+9 -1
View File
@@ -136,12 +136,20 @@ export function consumeFirstRechargeState(
context: PaymentState, context: PaymentState,
): Pick< ): Pick<
PaymentState, PaymentState,
"plans" | "isFirstRecharge" | "selectedPlanId" | "autoRenew" | "errorMessage" | "plans"
| "isFirstRecharge"
| "commercialOffer"
| "commercialOfferId"
| "selectedPlanId"
| "autoRenew"
| "errorMessage"
> { > {
const plans = context.plans.map(consumeFirstRechargePlan); const plans = context.plans.map(consumeFirstRechargePlan);
return { return {
...refreshPlansState(plans, context.selectedPlanId), ...refreshPlansState(plans, context.selectedPlanId),
isFirstRecharge: false, isFirstRecharge: false,
commercialOffer: null,
commercialOfferId: null,
}; };
} }
+7 -4
View File
@@ -8,9 +8,9 @@ import type { PaymentPlanCatalog } from "../../payment-state";
export const loadCachedPaymentPlansActor = fromPromise< export const loadCachedPaymentPlansActor = fromPromise<
PaymentPlansResponse | null, PaymentPlansResponse | null,
{ catalog: PaymentPlanCatalog; commercialOfferId?: string | null } { catalog: PaymentPlanCatalog; commercialOfferId?: string | null; supportCharacterId?: string | null }
>(async ({ input }) => { >(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(); const result = await getPaymentRepository().getCachedPlans();
if (Result.isErr(result)) throw result.error; if (Result.isErr(result)) throw result.error;
return result.data; return result.data;
@@ -18,13 +18,16 @@ export const loadCachedPaymentPlansActor = fromPromise<
export const refreshPaymentPlansActor = fromPromise< export const refreshPaymentPlansActor = fromPromise<
PaymentPlansResponse, PaymentPlansResponse,
{ catalog: PaymentPlanCatalog; commercialOfferId?: string | null } { catalog: PaymentPlanCatalog; commercialOfferId?: string | null; supportCharacterId?: string | null }
>(async ({ input }) => { >(async ({ input }) => {
const paymentRepo = getPaymentRepository(); const paymentRepo = getPaymentRepository();
if (input.catalog === "tip") { if (input.catalog === "tip") {
throw new Error("Gift catalogs must use the gift products actor."); 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; if (Result.isErr(result)) throw result.error;
return result.data; return result.data;
}); });
+11 -1
View File
@@ -29,8 +29,14 @@ function initializeCatalogState(
planCatalog === "tip" planCatalog === "tip"
? (event.characterId ?? context.giftCharacterId) ? (event.characterId ?? context.giftCharacterId)
: null; : null;
const supportCharacterId =
planCatalog === "default"
? (event.characterId ?? context.supportCharacterId)
: null;
const catalogChanged = planCatalog !== context.planCatalog; const catalogChanged = planCatalog !== context.planCatalog;
const characterChanged = giftCharacterId !== context.giftCharacterId; const characterChanged =
giftCharacterId !== context.giftCharacterId ||
supportCharacterId !== context.supportCharacterId;
const selectionChanged = const selectionChanged =
planCatalog === "tip" planCatalog === "tip"
? (event.category ?? null) !== context.selectedGiftCategory || ? (event.category ?? null) !== context.selectedGiftCategory ||
@@ -49,6 +55,7 @@ function initializeCatalogState(
planCatalog, planCatalog,
...(event.payChannel ? { payChannel: event.payChannel } : {}), ...(event.payChannel ? { payChannel: event.payChannel } : {}),
giftCharacterId, giftCharacterId,
supportCharacterId,
commercialOfferId, commercialOfferId,
chatActionId, chatActionId,
requestedGiftCategory: requestedGiftCategory:
@@ -228,6 +235,7 @@ export const loadingCachedPlansState =
input: ({ context }) => ({ input: ({ context }) => ({
catalog: context.planCatalog, catalog: context.planCatalog,
commercialOfferId: context.commercialOfferId, commercialOfferId: context.commercialOfferId,
supportCharacterId: context.supportCharacterId,
}), }),
onDone: [ onDone: [
{ {
@@ -247,6 +255,7 @@ export const loadingPlansState = catalogMachineSetup.createStateConfig({
input: ({ context }) => ({ input: ({ context }) => ({
catalog: context.planCatalog, catalog: context.planCatalog,
commercialOfferId: context.commercialOfferId, commercialOfferId: context.commercialOfferId,
supportCharacterId: context.supportCharacterId,
}), }),
onDone: { onDone: {
target: "ready", target: "ready",
@@ -265,6 +274,7 @@ export const refreshingPlansState = catalogMachineSetup.createStateConfig({
input: ({ context }) => ({ input: ({ context }) => ({
catalog: context.planCatalog, catalog: context.planCatalog,
commercialOfferId: context.commercialOfferId, commercialOfferId: context.commercialOfferId,
supportCharacterId: context.supportCharacterId,
}), }),
onDone: { onDone: {
target: "ready", target: "ready",
+8 -2
View File
@@ -192,8 +192,14 @@ const creatingOrderState = paymentMachineSetup.createStateConfig({
payChannel: context.payChannel, payChannel: context.payChannel,
autoRenew: context.autoRenew, autoRenew: context.autoRenew,
...(event.type === "PaymentCreateOrderSubmitted" && ...(event.type === "PaymentCreateOrderSubmitted" &&
event.recipientCharacterId (event.recipientCharacterId || context.supportCharacterId || context.giftCharacterId)
? { recipientCharacterId: event.recipientCharacterId } ? {
recipientCharacterId:
event.recipientCharacterId ||
context.supportCharacterId ||
context.giftCharacterId ||
undefined,
}
: {}), : {}),
...(context.commercialOfferId ...(context.commercialOfferId
? { commercialOfferId: context.commercialOfferId } ? { commercialOfferId: context.commercialOfferId }
+2
View File
@@ -17,6 +17,7 @@ export interface PaymentState {
giftCategories: readonly GiftCategory[]; giftCategories: readonly GiftCategory[];
giftProducts: readonly GiftProduct[]; giftProducts: readonly GiftProduct[];
giftCharacterId: string | null; giftCharacterId: string | null;
supportCharacterId: string | null;
selectedGiftCategory: string | null; selectedGiftCategory: string | null;
requestedGiftCategory: string | null; requestedGiftCategory: string | null;
requestedGiftPlanId: string | null; requestedGiftPlanId: string | null;
@@ -45,6 +46,7 @@ export const initialState: PaymentState = {
giftCategories: [], giftCategories: [],
giftProducts: [], giftProducts: [],
giftCharacterId: null, giftCharacterId: null,
supportCharacterId: null,
selectedGiftCategory: null, selectedGiftCategory: null,
requestedGiftCategory: null, requestedGiftCategory: null,
requestedGiftPlanId: null, requestedGiftPlanId: null,