From cdf225bfab0857e1bc1a5e5d1ea6e447c7dd262d Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 30 Jul 2026 12:28:52 +0800 Subject: [PATCH] feat(payment): show account conversion discounts --- .../chat/__tests__/chat-support-cta.test.ts | 44 +++- src/app/chat/chat-support-cta.ts | 11 - .../first-recharge-offer-banner.module.css | 223 ------------------ .../first-recharge-offer-banner.tsx | 78 ------ src/app/chat/components/index.ts | 1 - .../use-first-recharge-offer-banner.test.ts | 60 ----- .../hooks/use-chat-commercial-messages.ts | 8 +- src/app/chat/hooks/use-chat-support-cta.ts | 41 +++- .../hooks/use-first-recharge-offer-banner.ts | 161 ------------- .../subscription-screen-flow.test.tsx | 72 +++++- .../subscription-screen.helpers.test.ts | 55 +---- .../__tests__/tailwind-components.test.tsx | 4 +- .../subscription-coins-offer-section.tsx | 7 - .../subscription-vip-offer-section.tsx | 11 +- .../subscription-screen.helpers.ts | 39 --- src/app/subscription/subscription-screen.tsx | 65 +++-- .../payment/__tests__/payment_plan.test.ts | 4 + .../response/payment_plans_response.ts | 2 + src/data/storage/app/app_storage.ts | 28 --- src/data/storage/storage_keys.ts | 1 - src/lib/chat/first_recharge_offer_banner.ts | 16 -- 21 files changed, 214 insertions(+), 717 deletions(-) delete mode 100644 src/app/chat/components/first-recharge-offer-banner.module.css delete mode 100644 src/app/chat/components/first-recharge-offer-banner.tsx delete mode 100644 src/app/chat/hooks/__tests__/use-first-recharge-offer-banner.test.ts delete mode 100644 src/app/chat/hooks/use-first-recharge-offer-banner.ts delete mode 100644 src/lib/chat/first_recharge_offer_banner.ts diff --git a/src/app/chat/__tests__/chat-support-cta.test.ts b/src/app/chat/__tests__/chat-support-cta.test.ts index 84276cc3..516ac8ee 100644 --- a/src/app/chat/__tests__/chat-support-cta.test.ts +++ b/src/app/chat/__tests__/chat-support-cta.test.ts @@ -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", + }); + }); }); diff --git a/src/app/chat/chat-support-cta.ts b/src/app/chat/chat-support-cta.ts index d262a45e..3ec577ef 100644 --- a/src/app/chat/chat-support-cta.ts +++ b/src/app/chat/chat-support-cta.ts @@ -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) { diff --git a/src/app/chat/components/first-recharge-offer-banner.module.css b/src/app/chat/components/first-recharge-offer-banner.module.css deleted file mode 100644 index 16c71b6c..00000000 --- a/src/app/chat/components/first-recharge-offer-banner.module.css +++ /dev/null @@ -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; - } -} diff --git a/src/app/chat/components/first-recharge-offer-banner.tsx b/src/app/chat/components/first-recharge-offer-banner.tsx deleted file mode 100644 index 298de0ab..00000000 --- a/src/app/chat/components/first-recharge-offer-banner.tsx +++ /dev/null @@ -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 ( - - ); - } - - return ( -
- - - -
- ); -} diff --git a/src/app/chat/components/index.ts b/src/app/chat/components/index.ts index 0155561b..169e9e73 100644 --- a/src/app/chat/components/index.ts +++ b/src/app/chat/components/index.ts @@ -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"; diff --git a/src/app/chat/hooks/__tests__/use-first-recharge-offer-banner.test.ts b/src/app/chat/hooks/__tests__/use-first-recharge-offer-banner.test.ts deleted file mode 100644 index 9a17d6f0..00000000 --- a/src/app/chat/hooks/__tests__/use-first-recharge-offer-banner.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/app/chat/hooks/use-chat-commercial-messages.ts b/src/app/chat/hooks/use-chat-commercial-messages.ts index ec163884..302bc5a1 100644 --- a/src/app/chat/hooks/use-chat-commercial-messages.ts +++ b/src/app/chat/hooks/use-chat-commercial-messages.ts @@ -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]); } diff --git a/src/app/chat/hooks/use-chat-support-cta.ts b/src/app/chat/hooks/use-chat-support-cta.ts index 51408c6d..f237dfcd 100644 --- a/src/app/chat/hooks/use-chat-support-cta.ts +++ b/src/app/chat/hooks/use-chat-support-cta.ts @@ -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({ diff --git a/src/app/chat/hooks/use-first-recharge-offer-banner.ts b/src/app/chat/hooks/use-first-recharge-offer-banner.ts deleted file mode 100644 index 6447db9a..00000000 --- a/src/app/chat/hooks/use-first-recharge-offer-banner.ts +++ /dev/null @@ -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(() => ({ - 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, - }; -} diff --git a/src/app/subscription/__tests__/subscription-screen-flow.test.tsx b/src/app/subscription/__tests__/subscription-screen-flow.test.tsx index 0da25150..3b96f5f7 100644 --- a/src/app/subscription/__tests__/subscription-screen-flow.test.tsx +++ b/src/app/subscription/__tests__/subscription-screen-flow.test.tsx @@ -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()); + 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()); + + 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()); + 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"])( diff --git a/src/app/subscription/__tests__/subscription-screen.helpers.test.ts b/src/app/subscription/__tests__/subscription-screen.helpers.test.ts index ae3f3b46..f5271463 100644 --- a/src/app/subscription/__tests__/subscription-screen.helpers.test.ts +++ b/src/app/subscription/__tests__/subscription-screen.helpers.test.ts @@ -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, }, ]); }); diff --git a/src/app/subscription/components/__tests__/tailwind-components.test.tsx b/src/app/subscription/components/__tests__/tailwind-components.test.tsx index c70976d1..e83468c6 100644 --- a/src/app/subscription/components/__tests__/tailwind-components.test.tsx +++ b/src/app/subscription/components/__tests__/tailwind-components.test.tsx @@ -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"); }); }); diff --git a/src/app/subscription/components/subscription-coins-offer-section.tsx b/src/app/subscription/components/subscription-coins-offer-section.tsx index 14dc0596..cb111e88 100644 --- a/src/app/subscription/components/subscription-coins-offer-section.tsx +++ b/src/app/subscription/components/subscription-coins-offer-section.tsx @@ -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 ? ( Most Popular ) : null} - {plan.isFirstRechargeOffer ? ( - - {plan.firstRechargeDiscountPercent ?? 50}% OFF - - ) : null} diff --git a/src/app/subscription/components/subscription-vip-offer-section.tsx b/src/app/subscription/components/subscription-vip-offer-section.tsx index 9393f13b..1cfe9701 100644 --- a/src/app/subscription/components/subscription-vip-offer-section.tsx +++ b/src/app/subscription/components/subscription-vip-offer-section.tsx @@ -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({
{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 (