feat(payment): show account conversion discounts
This commit is contained in:
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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"
|
||||
}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user