feat(payment): show account conversion discounts

This commit is contained in:
Codex
2026-07-30 12:28:52 +08:00
parent ec3e256a8c
commit 4e0551b7ea
21 changed files with 214 additions and 717 deletions
@@ -5,7 +5,7 @@ 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", () => {
it("never invents a first-recharge discount and shows the active account offer", () => {
expect(
deriveChatSupportCta({
isFirstRecharge: true,
@@ -23,10 +23,10 @@ describe("deriveChatSupportCta", () => {
nowMs: now,
}),
).toMatchObject({
kind: "firstRecharge",
label: "Support me · 50% OFF · First recharge",
commercialOfferId: null,
expiresAt: null,
kind: "commercialOffer",
label: "Support me · 30% OFF · 23:59:59",
commercialOfferId: "offer-1",
expiresAt: "2026-07-29T00:00:00.000Z",
});
});
@@ -63,6 +63,14 @@ describe("deriveChatSupportCta", () => {
nowMs: now,
}).label,
).toBe("Support me");
expect(
deriveChatSupportCta({
isFirstRecharge: true,
commercialOffer: null,
nowMs: now,
}).label,
).toBe("Support me");
expect(
deriveChatSupportCta({
isFirstRecharge: false,
@@ -81,4 +89,30 @@ describe("deriveChatSupportCta", () => {
}).label,
).toBe("Support me");
});
it("shows the final 50% account offer with the same countdown", () => {
expect(
deriveChatSupportCta({
isFirstRecharge: false,
commercialOffer: {
enabled: true,
commercialOfferId: "offer-2",
characterId: "elio",
planId: "dol_2500_t3",
discountPercent: 50,
pricePercent: 50,
expiresAt: "2026-07-29T00:00:00.000Z",
triggerReason: "expired_order",
message: "This is the last offer.",
scope: "account",
stage: "halfDiscount",
},
nowMs: now,
}),
).toMatchObject({
kind: "commercialOffer",
label: "Support me · 50% OFF · 23:59:59",
commercialOfferId: "offer-2",
});
});
});
-11
View File
@@ -2,7 +2,6 @@ import type { CommercialOfferSummary } from "@/data/schemas/payment";
export type ChatSupportCtaKind =
| "support"
| "firstRecharge"
| "commercialOffer";
export interface ChatSupportCtaView {
@@ -18,16 +17,6 @@ export function deriveChatSupportCta(input: {
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) {
@@ -1,223 +0,0 @@
.banner {
position: relative;
flex: 0 0 auto;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: stretch;
margin:
clamp(5px, 1.111vw, 6px)
var(--chat-inline-padding, 16px)
clamp(8px, 1.852vw, 10px);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.34);
border-radius: var(--responsive-card-radius-sm, 22px);
background:
radial-gradient(circle at 14% 0%, rgba(255, 255, 255, 0.72), transparent 34%),
linear-gradient(135deg, #fff0b8 0%, #ffd1df 45%, #ff61a7 100%);
box-shadow: 0 14px 34px rgba(114, 21, 63, 0.22);
color: #2c111d;
animation: firstRechargeBannerIn 260ms ease-out both;
}
.banner::after {
position: absolute;
right: clamp(-34px, -6.296vw, -28px);
bottom: clamp(-52px, -9.63vw, -42px);
width: clamp(104px, 23.333vw, 126px);
height: clamp(104px, 23.333vw, 126px);
border-radius: 999px;
background: rgba(255, 255, 255, 0.22);
content: "";
pointer-events: none;
}
.contentButton {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: clamp(8px, 1.852vw, 10px);
align-items: center;
min-width: 0;
padding:
clamp(10px, 2.222vw, 12px)
clamp(7px, 1.481vw, 8px)
clamp(10px, 2.222vw, 12px)
clamp(12px, 2.593vw, 14px);
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
text-align: left;
}
.iconWrap {
display: inline-flex;
width: clamp(30px, 6.296vw, 34px);
height: clamp(30px, 6.296vw, 34px);
align-items: center;
justify-content: center;
border-radius: clamp(12px, 2.593vw, 14px);
background: rgba(255, 255, 255, 0.52);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.42);
}
.icon {
color: #fb2f89;
}
.copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.eyebrow {
color: rgba(44, 17, 29, 0.74);
font-size: var(--responsive-micro, 11px);
font-weight: 800;
letter-spacing: 0.04em;
line-height: 1;
text-transform: uppercase;
}
.title {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: var(--responsive-inline-gap-xs, 5px);
align-items: baseline;
color: #241019;
font-size: var(--responsive-caption, 13px);
font-weight: 750;
line-height: 1.18;
}
.title strong {
color: #f90073;
font-size: var(--responsive-section-title, 20px);
font-weight: 950;
letter-spacing: -0.04em;
line-height: 1;
}
.arrow {
color: rgba(44, 17, 29, 0.58);
}
.closeButton {
position: relative;
z-index: 1;
display: flex;
width: var(--responsive-icon-button-size, 42px);
min-height: 100%;
align-items: center;
justify-content: center;
border: 0;
border-left: 1px solid rgba(255, 255, 255, 0.26);
background: rgba(255, 255, 255, 0.16);
color: rgba(44, 17, 29, 0.68);
cursor: pointer;
}
.compactButton {
display: grid;
width: min(100%, 240px);
min-width: 0;
max-width: 240px;
min-height: 50px;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 7px 10px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.42);
border-radius: 16px;
background:
radial-gradient(circle at 15% 0%, rgba(255, 255, 255, 0.68), transparent 38%),
linear-gradient(135deg, #fff0b8 0%, #ffd0df 50%, #ff6cab 100%);
color: #2c111d;
cursor: pointer;
font: inherit;
text-align: left;
box-shadow: 0 9px 24px rgba(114, 21, 63, 0.2);
animation: firstRechargeBannerIn 260ms ease-out both;
}
.compactIcon {
display: inline-flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
border-radius: 10px;
background: rgba(255, 255, 255, 0.55);
color: #f90073;
}
.compactCopy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
font-size: 11px;
font-weight: 800;
line-height: 1.05;
text-transform: uppercase;
}
.compactCopy span,
.compactCopy strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.compactCopy span {
color: rgba(44, 17, 29, 0.68);
letter-spacing: 0.03em;
}
.compactCopy strong {
color: #ec006d;
font-size: 17px;
font-weight: 950;
letter-spacing: -0.02em;
}
.contentButton:focus-visible,
.closeButton:focus-visible,
.compactButton:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.94);
outline-offset: -3px;
}
@keyframes firstRechargeBannerIn {
from {
opacity: 0;
transform: translateY(-8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (max-width: 360px) {
.title {
flex-direction: column;
gap: 2px;
}
.compactButton {
gap: 5px;
padding-inline: 6px;
}
.compactIcon {
width: 26px;
height: 26px;
}
}
@@ -1,78 +0,0 @@
"use client";
import { ChevronRight, Sparkles, X } from "lucide-react";
import styles from "./first-recharge-offer-banner.module.css";
export interface FirstRechargeOfferBannerProps {
visible: boolean;
discountPercent: number;
onClick: () => void;
onClose: () => void;
variant?: "banner" | "compact";
}
export function FirstRechargeOfferBanner({
visible,
discountPercent,
onClick,
onClose,
variant = "banner",
}: FirstRechargeOfferBannerProps) {
if (!visible) return null;
if (variant === "compact") {
return (
<button
type="button"
data-analytics-key="chat.first_recharge_offer"
data-analytics-label="Open first recharge offer"
className={styles.compactButton}
aria-label={`First recharge offer, ${discountPercent}% off`}
onClick={onClick}
>
<span className={styles.compactIcon} aria-hidden="true">
<Sparkles size={14} strokeWidth={2.4} />
</span>
<span className={styles.compactCopy}>
<span>First recharge</span>
<strong>{discountPercent}% OFF</strong>
</span>
<ChevronRight size={14} strokeWidth={2.4} aria-hidden="true" />
</button>
);
}
return (
<section className={styles.banner} aria-label="First recharge offer">
<button
type="button"
data-analytics-key="chat.first_recharge_offer"
data-analytics-label="Open first recharge offer"
className={styles.contentButton}
onClick={onClick}
>
<span className={styles.iconWrap} aria-hidden="true">
<Sparkles className={styles.icon} size={18} />
</span>
<span className={styles.copy}>
<span className={styles.eyebrow}>First Recharge Offer</span>
<span className={styles.title}>
<strong>{discountPercent}% OFF</strong>
<span>Claim your discount now</span>
</span>
</span>
<ChevronRight className={styles.arrow} size={18} aria-hidden="true" />
</button>
<button
type="button"
className={styles.closeButton}
onClick={onClose}
aria-label="Hide first recharge offer"
>
<X size={16} aria-hidden="true" />
</button>
</section>
);
}
-1
View File
@@ -15,7 +15,6 @@ 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";
export * from "./fullscreen-image-viewer";
export * from "./history-unlock-dialog";
export * from "./image-bubble";
@@ -1,60 +0,0 @@
import { describe, expect, it } from "vitest";
import { shouldShowFirstRechargeOfferBanner } from "../use-first-recharge-offer-banner";
describe("shouldShowFirstRechargeOfferBanner", () => {
it("shows for authenticated users when first recharge offer is available", () => {
expect(
shouldShowFirstRechargeOfferBanner({
historyLoaded: true,
loginStatus: "facebook",
isFirstRecharge: true,
dismissed: false,
}),
).toBe(true);
});
it("does not show for guest users", () => {
expect(
shouldShowFirstRechargeOfferBanner({
historyLoaded: true,
loginStatus: "guest",
isFirstRecharge: true,
dismissed: false,
}),
).toBe(false);
});
it("does not show unless isFirstRecharge is true", () => {
expect(
shouldShowFirstRechargeOfferBanner({
historyLoaded: true,
loginStatus: "google",
isFirstRecharge: false,
dismissed: false,
}),
).toBe(false);
});
it("does not show after the user dismisses it", () => {
expect(
shouldShowFirstRechargeOfferBanner({
historyLoaded: true,
loginStatus: "email",
isFirstRecharge: true,
dismissed: true,
}),
).toBe(false);
});
it("waits until chat history has loaded", () => {
expect(
shouldShowFirstRechargeOfferBanner({
historyLoaded: false,
loginStatus: "email",
isFirstRecharge: true,
dismissed: false,
}),
).toBe(false);
});
});
@@ -6,12 +6,14 @@ 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";
import { usePaymentDispatch } from "@/stores/payment/payment-context";
export function useChatCommercialMessages(input: {
characterId: string;
loginStatus: LoginStatus;
}) {
const chatDispatch = useChatDispatch();
const paymentDispatch = usePaymentDispatch();
useEffect(() => {
if (input.loginStatus === "notLoggedIn" || input.loginStatus === "guest") {
@@ -24,6 +26,10 @@ export function useChatCommercialMessages(input: {
if (disposed || !token) return;
socket = createChatWebSocket(token);
socket.onCommercialMessage = (message) => {
paymentDispatch({
type: "PaymentInit",
characterId: input.characterId,
});
if (message.characterId !== input.characterId) return;
chatDispatch({ type: "ChatCommercialMessageReceived", message });
};
@@ -34,5 +40,5 @@ export function useChatCommercialMessages(input: {
disposed = true;
socket?.disconnect();
};
}, [chatDispatch, input.characterId, input.loginStatus]);
}, [chatDispatch, input.characterId, input.loginStatus, paymentDispatch]);
}
+34 -7
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { shallowEqual } from "@xstate/react";
import type { LoginStatus } from "@/data/schemas/auth";
@@ -36,17 +36,20 @@ export function useChatSupportCta(input: {
);
const isAuthenticated =
input.loginStatus !== "notLoggedIn" && input.loginStatus !== "guest";
useEffect(() => {
if (!isAuthenticated) return;
if (initializedCharacterRef.current === input.characterId) return;
initializedCharacterRef.current = input.characterId;
const refreshCatalog = useCallback(() => {
paymentDispatch({
type: "PaymentInit",
characterId: input.characterId,
payChannel: getDefaultPayChannelForCountryCode(countryCode),
});
}, [countryCode, input.characterId, isAuthenticated, paymentDispatch]);
}, [countryCode, input.characterId, paymentDispatch]);
useEffect(() => {
if (!isAuthenticated) return;
if (initializedCharacterRef.current === input.characterId) return;
initializedCharacterRef.current = input.characterId;
refreshCatalog();
}, [input.characterId, isAuthenticated, refreshCatalog]);
useEffect(() => {
if (!payment.commercialOffer) return;
@@ -54,6 +57,30 @@ export function useChatSupportCta(input: {
return () => window.clearInterval(timer);
}, [payment.commercialOffer]);
useEffect(() => {
if (!isAuthenticated) return;
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") refreshCatalog();
};
window.addEventListener("focus", refreshCatalog);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("focus", refreshCatalog);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [isAuthenticated, refreshCatalog]);
useEffect(() => {
if (!isAuthenticated || !payment.commercialOffer) return;
const expiresAtMs = Date.parse(payment.commercialOffer.expiresAt);
if (!Number.isFinite(expiresAtMs)) return;
const timer = window.setTimeout(() => {
setNowMs(Date.now());
refreshCatalog();
}, Math.max(0, expiresAtMs - Date.now()) + 250);
return () => window.clearTimeout(timer);
}, [isAuthenticated, payment.commercialOffer, refreshCatalog]);
const cta = useMemo(
() =>
deriveChatSupportCta({
@@ -1,161 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { shallowEqual } from "@xstate/react";
import type { LoginStatus } from "@/data/schemas/auth";
import { useAppNavigator } from "@/router/use-app-navigator";
import {
usePaymentDispatch,
usePaymentSelector,
} from "@/stores/payment/payment-context";
import { useUserSelector } from "@/stores/user/user-context";
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
import {
getFirstRechargeOfferBannerDismissed,
recordFirstRechargeOfferBannerDismissed,
} from "@/lib/chat/first_recharge_offer_banner";
export interface FirstRechargeOfferBannerEligibility {
historyLoaded: boolean;
loginStatus: LoginStatus;
isFirstRecharge: boolean;
dismissed: boolean;
}
export interface UseFirstRechargeOfferBannerInput {
historyLoaded: boolean;
loginStatus: LoginStatus;
}
export interface UseFirstRechargeOfferBannerOutput {
visible: boolean;
discountPercent: number;
close: () => void;
claim: () => void;
}
interface DismissalState {
userId: string | null;
loaded: boolean;
dismissed: boolean;
}
const FIRST_RECHARGE_DEFAULT_DISCOUNT_PERCENT = 50;
export function shouldShowFirstRechargeOfferBanner(
input: FirstRechargeOfferBannerEligibility,
): boolean {
if (!input.historyLoaded) return false;
if (input.loginStatus === "notLoggedIn" || input.loginStatus === "guest") {
return false;
}
if (!input.isFirstRecharge) return false;
return !input.dismissed;
}
export function useFirstRechargeOfferBanner({
historyLoaded,
loginStatus,
}: UseFirstRechargeOfferBannerInput): UseFirstRechargeOfferBannerOutput {
const navigator = useAppNavigator();
const payment = usePaymentSelector(
(state) => ({
isFirstRecharge: state.context.isFirstRecharge,
status: String(state.value),
}),
shallowEqual,
);
const paymentDispatch = usePaymentDispatch();
const user = useUserSelector(
(state) => ({
countryCode: state.context.currentUser?.countryCode,
id: state.context.currentUser?.id ?? null,
}),
shallowEqual,
);
const isAuthenticatedUser =
loginStatus !== "notLoggedIn" && loginStatus !== "guest";
const userId = user.id;
const hasUserIdentity = !isAuthenticatedUser || Boolean(userId);
const [dismissalState, setDismissalState] = useState<DismissalState>(() => ({
userId,
loaded: false,
dismissed: false,
}));
const discountPercent = FIRST_RECHARGE_DEFAULT_DISCOUNT_PERCENT;
useEffect(() => {
if (!isAuthenticatedUser) return;
if (payment.status !== "idle") return;
const defaultPayChannel = getDefaultPayChannelForCountryCode(
user.countryCode,
);
paymentDispatch({ type: "PaymentInit", payChannel: defaultPayChannel });
}, [
payment.status,
paymentDispatch,
isAuthenticatedUser,
user.countryCode,
]);
useEffect(() => {
let cancelled = false;
const loadDismissal = async () => {
const dismissed = await getFirstRechargeOfferBannerDismissed(userId);
if (cancelled) return;
setDismissalState({
userId,
loaded: true,
dismissed,
});
};
void loadDismissal();
return () => {
cancelled = true;
};
}, [userId]);
const dismissalLoaded =
dismissalState.userId === userId && dismissalState.loaded;
const visible =
hasUserIdentity &&
dismissalLoaded &&
shouldShowFirstRechargeOfferBanner({
historyLoaded,
loginStatus,
isFirstRecharge: payment.isFirstRecharge,
dismissed: dismissalState.dismissed,
});
function close(): void {
setDismissalState({
userId,
loaded: true,
dismissed: true,
});
void recordFirstRechargeOfferBannerDismissed(userId);
}
function claim(): void {
navigator.openSubscription({
type: "vip",
returnTo: "chat",
analytics: {
entryPoint: "chat_offer_banner",
triggerReason: "vip_cta",
},
});
}
return {
visible,
discountPercent,
close,
claim,
};
}
@@ -1,6 +1,7 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CommercialOfferSummary } from "@/data/schemas/payment";
const mocks = vi.hoisted(() => {
const characters = [
@@ -48,7 +49,7 @@ const mocks = vi.hoisted(() => {
},
],
isFirstRecharge: false,
commercialOffer: null,
commercialOffer: null as CommercialOfferSummary | null,
selectedPlanId: "vip_monthly",
payChannel: "stripe" as const,
agreed: true,
@@ -216,6 +217,7 @@ describe("SubscriptionScreen payment selection flow", () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
it("confirms VIP selection before enabling the separate checkout button", () => {
@@ -325,6 +327,74 @@ describe("SubscriptionScreen payment selection flow", () => {
expect(container.textContent).not.toContain(
"Your first recharge price is already applied",
);
expect(container.textContent).not.toContain("50% OFF");
});
it("refreshes the catalog without a stale offer id when the page regains focus", () => {
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
mocks.paymentDispatch.mockClear();
act(() => window.dispatchEvent(new Event("focus")));
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
type: "PaymentInit",
characterId: "elio",
payChannel: "stripe",
planId: "vip_monthly",
commercialOfferId: null,
});
});
it("describes an account offer without attributing it to the current role", () => {
mocks.payment.commercialOffer = {
enabled: true,
commercialOfferId: "offer-account-1",
characterId: "elio",
planId: "vip_monthly",
discountPercent: 50,
pricePercent: 50,
expiresAt: new Date(Date.now() + 60_000).toISOString(),
triggerReason: "expired_order",
message: "Everything in top-up and VIP is 50% OFF.",
scope: "account",
stage: "halfDiscount",
};
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="maya" />));
expect(container.textContent).toContain("Private offer · 50% OFF");
expect(container.textContent).toContain("Your private price is active");
expect(container.textContent).not.toContain("Maya got this price for you");
});
it("refreshes the catalog when the active offer expires", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-30T08:00:00Z"));
mocks.payment.commercialOffer = {
enabled: true,
commercialOfferId: "offer-expiring-1",
characterId: "elio",
planId: "vip_monthly",
discountPercent: 30,
pricePercent: 70,
expiresAt: "2026-07-30T08:00:01Z",
triggerReason: "expired_order",
message: "30% OFF",
scope: "account",
stage: "sevenDiscount",
};
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
mocks.paymentDispatch.mockClear();
act(() => vi.advanceTimersByTime(1_250));
expect(mocks.paymentDispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: "PaymentInit",
characterId: "elio",
commercialOfferId: null,
}),
);
});
it.each(["elio", "maya", "nayeli"])(
@@ -10,7 +10,6 @@ import {
canCheckoutSubscriptionPlan,
findSelectedSubscriptionPlan,
getDefaultSubscriptionPlanId,
getFirstRechargeOfferView,
requiresVipPlanConfirmation,
toCoinsOfferPlanViews,
toVipOfferPlanViews,
@@ -101,7 +100,7 @@ describe("subscription screen helpers", () => {
]);
});
it("maps first recharge offer fields to plan views", () => {
it("ignores legacy first-recharge flags while retaining quoted and original prices", () => {
const vipViews = toVipOfferPlanViews([
makePlan({
planId: "vip_monthly",
@@ -126,51 +125,18 @@ describe("subscription screen helpers", () => {
}),
]);
expect(vipViews[0]).toMatchObject({
expect(vipViews[0]).toEqual(expect.objectContaining({
price: "9.95",
originalPrice: "19.9",
isFirstRechargeOffer: true,
firstRechargeDiscountPercent: 50,
});
expect(coinViews[0]).toMatchObject({
}));
expect(vipViews[0]).not.toHaveProperty("isFirstRechargeOffer");
expect(vipViews[0]).not.toHaveProperty("firstRechargeDiscountPercent");
expect(coinViews[0]).toEqual(expect.objectContaining({
price: "4.95",
originalPrice: "9.9",
isFirstRechargeOffer: true,
firstRechargeDiscountPercent: 50,
});
expect(
getFirstRechargeOfferView({
isFirstRecharge: true,
subscriptionType: "vip",
vipPlans: vipViews,
coinPlans: coinViews,
}),
).toMatchObject({
badgeText: "50% OFF",
title: "First Recharge Offer",
renewalNotice:
"First month 50% off. Renews at the regular price from the second month.",
});
expect(
getFirstRechargeOfferView({
isFirstRecharge: true,
subscriptionType: "topup",
vipPlans: vipViews,
coinPlans: coinViews,
}),
).toMatchObject({
badgeText: "50% OFF",
title: "First Recharge Offer",
renewalNotice: null,
});
expect(
getFirstRechargeOfferView({
isFirstRecharge: false,
subscriptionType: "vip",
vipPlans: vipViews,
coinPlans: coinViews,
}),
).toBeNull();
}));
expect(coinViews[0]).not.toHaveProperty("isFirstRechargeOffer");
expect(coinViews[0]).not.toHaveProperty("firstRechargeDiscountPercent");
});
it("maps the backend most popular flag to VIP and coin views", () => {
@@ -191,7 +157,7 @@ describe("subscription screen helpers", () => {
expect(popularCoins[0]?.mostPopular).toBe(true);
});
it("maps normal VIP original price outside first recharge activity", () => {
it("maps regular VIP original price without exposing legacy first-recharge state", () => {
expect(
toVipOfferPlanViews([
makePlan({
@@ -206,7 +172,6 @@ describe("subscription screen helpers", () => {
id: "vip_monthly",
price: "19.9",
originalPrice: "24.99",
isFirstRechargeOffer: false,
},
]);
});
@@ -71,8 +71,6 @@ describe("subscription Tailwind components", () => {
currency: "usd",
originalPrice: "19.99",
mostPopular: true,
isFirstRechargeOffer: true,
firstRechargeDiscountPercent: 50,
},
]}
selectedPlanId="vip_monthly"
@@ -96,7 +94,7 @@ describe("subscription Tailwind components", () => {
);
expect(vipHtml).toContain("Most Popular");
expect(vipHtml).toContain("50% OFF");
expect(vipHtml).not.toContain("First Recharge");
expect(coinsHtml).toContain("Most Popular");
});
});
@@ -10,9 +10,7 @@ export interface CoinsOfferPlanView {
price: string;
currency: string;
originalPrice?: string;
isFirstRechargeOffer?: boolean;
mostPopular?: boolean;
firstRechargeDiscountPercent?: number | null;
promotionType?: string | null;
}
@@ -56,11 +54,6 @@ export function SubscriptionCoinsOfferSection({
{plan.mostPopular ? (
<span className={styles.popularBadge}>Most Popular</span>
) : null}
{plan.isFirstRechargeOffer ? (
<span className={styles.offerBadge}>
{plan.firstRechargeDiscountPercent ?? 50}% OFF
</span>
) : null}
</span>
<span className={styles.priceGroup}>
<span className={styles.price}>
@@ -8,9 +8,7 @@ export interface VipOfferPlanView {
price: string;
currency: string;
originalPrice: string;
isFirstRechargeOffer?: boolean;
mostPopular?: boolean;
firstRechargeDiscountPercent?: number | null;
promotionType?: string | null;
}
@@ -43,9 +41,7 @@ export function SubscriptionVipOfferSection({
<div className={styles.planGrid}>
{plans.map((plan) => {
const selected = selectedPlanId === plan.id;
const badgeCount =
Number(plan.mostPopular === true) +
Number(plan.isFirstRechargeOffer === true);
const badgeCount = Number(plan.mostPopular === true);
return (
<button
key={plan.id}
@@ -62,11 +58,6 @@ export function SubscriptionVipOfferSection({
{plan.mostPopular ? (
<span className={styles.popularBadge}>Most Popular</span>
) : null}
{plan.isFirstRechargeOffer ? (
<span className={styles.offerBadge}>
{plan.firstRechargeDiscountPercent ?? 50}% OFF
</span>
) : null}
</span>
) : null}
<span className={styles.planTitle}>{plan.title}</span>
@@ -5,17 +5,9 @@ import type { VipOfferPlanView } from "./components/subscription-vip-offer-secti
export type SubscriptionType = "vip" | "topup";
export interface FirstRechargeOfferView {
badgeText: string;
title: string;
subtitle: string;
renewalNotice: string | null;
}
export function isVipPlan(plan: PaymentPlan): boolean {
return plan.vipDays !== null;
}
export function isCreditPlan(plan: PaymentPlan): boolean {
return plan.dolAmount !== null;
}
@@ -60,9 +52,7 @@ export function toVipOfferPlanView(plan: PaymentPlan): VipOfferPlanView {
price: formatOfferAmount(plan.amountCents),
currency: formatOfferCurrency(plan.currency),
originalPrice: formatOfferAmount(plan.originalAmountCents),
isFirstRechargeOffer: plan.isFirstRechargeOffer,
mostPopular: plan.mostPopular,
firstRechargeDiscountPercent: plan.firstRechargeDiscountPercent,
promotionType: plan.promotionType,
};
}
@@ -74,9 +64,7 @@ export function toCoinsOfferPlanView(plan: PaymentPlan): CoinsOfferPlanView {
price: formatOfferAmount(plan.amountCents),
currency: formatCoinCurrency(plan.currency),
originalPrice: formatOfferAmount(plan.originalAmountCents),
isFirstRechargeOffer: plan.isFirstRechargeOffer,
mostPopular: plan.mostPopular,
firstRechargeDiscountPercent: plan.firstRechargeDiscountPercent,
promotionType: plan.promotionType,
};
}
@@ -162,30 +150,3 @@ export function getDefaultSubscriptionPlanId(input: {
if (input.selectedPlan !== null) return null;
return input.coinPlans[0]?.id ?? null;
}
export function getFirstRechargeOfferView(input: {
isFirstRecharge: boolean;
subscriptionType: SubscriptionType;
vipPlans: readonly VipOfferPlanView[];
coinPlans: readonly CoinsOfferPlanView[];
}): FirstRechargeOfferView | null {
if (!input.isFirstRecharge) return null;
const promotedPlan = [...input.vipPlans, ...input.coinPlans].find(
(plan) => plan.isFirstRechargeOffer,
);
const discountPercent =
promotedPlan?.firstRechargeDiscountPercent ?? 50;
const badgeText = `${discountPercent}% OFF`;
return {
badgeText,
title: "First Recharge Offer",
subtitle:
"Your first recharge price is already applied. No code needed.",
renewalNotice:
input.subscriptionType === "vip"
? "First month 50% off. Renews at the regular price from the second month."
: null,
};
}
+45 -20
View File
@@ -31,7 +31,6 @@ import styles from "./components/subscription-screen.module.css";
import {
canCheckoutSubscriptionPlan,
findSelectedSubscriptionPlan,
getFirstRechargeOfferView,
getDefaultSubscriptionPlanId,
requiresVipPlanConfirmation,
toCoinsOfferPlanViews,
@@ -104,6 +103,44 @@ export function SubscriptionScreen({
resumeOrderId,
chatActionId,
});
useEffect(() => {
if (!sourceCharacter) return;
const refreshCatalog = () => {
paymentDispatch({
type: "PaymentInit",
characterId: sourceCharacter.id,
payChannel: payment.payChannel,
planId: payment.selectedPlanId || null,
commercialOfferId: null,
});
};
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") refreshCatalog();
};
window.addEventListener("focus", refreshCatalog);
document.addEventListener("visibilitychange", handleVisibilityChange);
const expiresAtMs = payment.commercialOffer
? Date.parse(payment.commercialOffer.expiresAt)
: Number.NaN;
const expiryTimer = Number.isFinite(expiresAtMs)
? window.setTimeout(
refreshCatalog,
Math.max(0, expiresAtMs - Date.now()) + 250,
)
: null;
return () => {
window.removeEventListener("focus", refreshCatalog);
document.removeEventListener("visibilitychange", handleVisibilityChange);
if (expiryTimer !== null) window.clearTimeout(expiryTimer);
};
}, [
payment.commercialOffer,
payment.payChannel,
payment.selectedPlanId,
paymentDispatch,
sourceCharacter,
]);
const canSubscribeVip = subscriptionType === "vip";
const analyticsContext =
providedAnalyticsContext ??
@@ -128,22 +165,6 @@ export function SubscriptionScreen({
});
}, [canSubscribeVip, directCoinsPlans, payment.plans, vipOfferPlans]);
usePaymentPlanAnalytics(displayedPlans, analyticsContext);
const hasFirstRechargeOffer = useMemo(
() =>
getFirstRechargeOfferView({
isFirstRecharge: payment.isFirstRecharge,
subscriptionType,
vipPlans: vipOfferPlans,
coinPlans: directCoinsPlans,
}) !== null,
[
directCoinsPlans,
payment.isFirstRecharge,
subscriptionType,
vipOfferPlans,
],
);
const selectedPlan = findSelectedSubscriptionPlan({
canSubscribeVip,
selectedPlanId: payment.selectedPlanId,
@@ -283,15 +304,19 @@ export function SubscriptionScreen({
</section>
) : null}
{payment.commercialOffer && !hasFirstRechargeOffer && sourceCharacter ? (
{payment.commercialOffer && sourceCharacter ? (
<section
className={styles.firstRechargeBanner}
aria-label={`${sourceCharacter.shortName} private offer`}
>
<span className={styles.firstRechargeBadge}>Private offer</span>
<span className={styles.firstRechargeBadge}>
Private offer · {payment.commercialOffer.discountPercent}% OFF
</span>
<div className={styles.firstRechargeCopy}>
<h2 className={styles.firstRechargeTitle}>
{sourceCharacter.shortName} got this price for you
{payment.commercialOffer.scope === "account"
? "Your private price is active"
: `${sourceCharacter.shortName} got this price for you`}
</h2>
<p className={styles.firstRechargeSubtitle}>
{payment.commercialOffer.message ||
@@ -206,6 +206,8 @@ describe("PaymentPlansResponse", () => {
expiresAt: "2026-07-24T08:00:00+00:00",
triggerReason: "price_objection",
message: "I got this private price for you.",
scope: "account",
stage: "sevenDiscount",
},
plans: [
{
@@ -229,6 +231,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.commercialOffer?.scope).toBe("account");
expect(response.commercialOffer?.stage).toBe("sevenDiscount");
expect(response.plans[0]).toMatchObject({
planId: "vip_annual",
commercialOfferId: "offer-1",
@@ -31,6 +31,8 @@ export const CommercialOfferSummarySchema = z
expiresAt: z.string().min(1),
triggerReason: stringOrEmpty,
message: stringOrEmpty,
scope: z.enum(["account", "character"]).optional(),
stage: z.enum(["sevenDiscount", "halfDiscount"]).optional(),
})
.readonly();
-28
View File
@@ -50,27 +50,6 @@ export class AppStorage {
return SpAsyncUtil.setString(StorageKeys.lastAppInfoReported, todayString);
}
// ---- first recharge offer banner ----
static async getFirstRechargeOfferBannerDismissed(
userId: string | null | undefined,
): Promise<ResultT<boolean>> {
const r = await SpAsyncUtil.getBool(
AppStorage.firstRechargeOfferBannerDismissedKey(userId),
);
if (!r.success) return r;
return Result.ok(r.data === true);
}
static recordFirstRechargeOfferBannerDismissed(
userId: string | null | undefined,
): Promise<ResultT<void>> {
return SpAsyncUtil.setBool(
AppStorage.firstRechargeOfferBannerDismissedKey(userId),
true,
);
}
// ---- internal helpers ----
/**
@@ -89,11 +68,4 @@ export class AppStorage {
return Result.ok(r.data === null || r.data !== todayString);
}
private static firstRechargeOfferBannerDismissedKey(
userId: string | null | undefined,
): string {
return `${StorageKeys.firstRechargeOfferBannerDismissed}:${
userId || "anonymous"
}`;
}
}
-1
View File
@@ -33,7 +33,6 @@ export const StorageKeys = {
pwaDialogShown: "pwa_dialog_shown",
lastPwaEventReported: "last_pwa_event_reported",
lastAppInfoReported: "last_app_info_reported",
firstRechargeOfferBannerDismissed: "first_recharge_offer_banner_dismissed",
// payment
pendingPaymentOrder: "pending_payment_order",
@@ -1,16 +0,0 @@
"use client";
import { AppStorage } from "@/data/storage/app/app_storage";
export async function getFirstRechargeOfferBannerDismissed(
userId: string | null | undefined,
): Promise<boolean> {
const result = await AppStorage.getFirstRechargeOfferBannerDismissed(userId);
return result.success ? result.data : false;
}
export function recordFirstRechargeOfferBannerDismissed(
userId: string | null | undefined,
): Promise<unknown> {
return AppStorage.recordFirstRechargeOfferBannerDismissed(userId);
}