feat(chat): show first recharge offer in header

This commit is contained in:
2026-07-02 18:45:08 +08:00
parent efd89de0b0
commit 3592c6bb56
14 changed files with 427 additions and 350 deletions
@@ -1,48 +1,59 @@
import { describe, expect, it } from "vitest";
import { shouldShowFirstRechargeOfferDialog } from "../use-first-recharge-offer-dialog";
import { shouldShowFirstRechargeOfferBanner } from "../use-first-recharge-offer-banner";
describe("shouldShowFirstRechargeOfferDialog", () => {
describe("shouldShowFirstRechargeOfferBanner", () => {
it("shows for authenticated users when first recharge offer is available", () => {
expect(
shouldShowFirstRechargeOfferDialog({
shouldShowFirstRechargeOfferBanner({
historyLoaded: true,
loginStatus: "facebook",
isFirstRecharge: true,
hasBlockingDialog: false,
dismissed: false,
}),
).toBe(true);
});
it("does not show for guest users", () => {
expect(
shouldShowFirstRechargeOfferDialog({
shouldShowFirstRechargeOfferBanner({
historyLoaded: true,
loginStatus: "guest",
isFirstRecharge: true,
hasBlockingDialog: false,
dismissed: false,
}),
).toBe(false);
});
it("does not show unless isFirstRecharge is true", () => {
expect(
shouldShowFirstRechargeOfferDialog({
shouldShowFirstRechargeOfferBanner({
historyLoaded: true,
loginStatus: "google",
isFirstRecharge: false,
hasBlockingDialog: false,
dismissed: false,
}),
).toBe(false);
});
it("does not show over a higher priority dialog", () => {
it("does not show after the user dismisses it", () => {
expect(
shouldShowFirstRechargeOfferDialog({
shouldShowFirstRechargeOfferBanner({
historyLoaded: true,
loginStatus: "apple",
isFirstRecharge: true,
hasBlockingDialog: true,
dismissed: true,
}),
).toBe(false);
});
it("waits until chat history has loaded", () => {
expect(
shouldShowFirstRechargeOfferBanner({
historyLoaded: false,
loginStatus: "email",
isFirstRecharge: true,
dismissed: false,
}),
).toBe(false);
});
@@ -0,0 +1,145 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import type { LoginStatus } from "@/data/dto/auth";
import { ROUTE_BUILDERS } from "@/router/routes";
import { usePaymentDispatch, usePaymentState } from "@/stores/payment";
import { useUserState } 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;
}
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 router = useRouter();
const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch();
const userState = useUserState();
const isAuthenticatedUser =
loginStatus !== "notLoggedIn" && loginStatus !== "guest";
const userId = userState.currentUser?.id ?? null;
const hasUserIdentity = !isAuthenticatedUser || Boolean(userId);
const [dismissalState, setDismissalState] = useState<DismissalState>(() => ({
userId,
loaded: false,
dismissed: false,
}));
const discountPercent = payment.firstRechargeOffer?.discountPercent ?? 50;
useEffect(() => {
if (!isAuthenticatedUser) return;
if (payment.status !== "idle") return;
const defaultPayChannel = getDefaultPayChannelForCountryCode(
userState.currentUser?.countryCode,
);
paymentDispatch({ type: "PaymentInit", payChannel: defaultPayChannel });
}, [
payment.status,
paymentDispatch,
isAuthenticatedUser,
userState.currentUser?.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 {
const defaultPayChannel = getDefaultPayChannelForCountryCode(
userState.currentUser?.countryCode,
);
router.push(
ROUTE_BUILDERS.subscription("vip", {
payChannel: defaultPayChannel,
returnTo: "chat",
}),
);
}
return {
visible,
discountPercent,
close,
claim,
};
}
@@ -1,146 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import type { LoginStatus } from "@/data/dto/auth";
import { ROUTE_BUILDERS } from "@/router/routes";
import { usePaymentDispatch, usePaymentState } from "@/stores/payment";
import { useUserState } from "@/stores/user/user-context";
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
import { Result, SpAsyncUtil, todayString } from "@/utils";
export const FIRST_RECHARGE_OFFER_DIALOG_DELAY_MS = 4000;
const FIRST_RECHARGE_OFFER_DIALOG_KEY_PREFIX =
"cozsweet:firstRechargeOfferDialogShown";
export interface FirstRechargeOfferDialogEligibility {
historyLoaded: boolean;
loginStatus: LoginStatus;
isFirstRecharge: boolean;
hasBlockingDialog: boolean;
}
export interface UseFirstRechargeOfferDialogInput {
historyLoaded: boolean;
loginStatus: LoginStatus;
hasBlockingDialog: boolean;
}
export interface UseFirstRechargeOfferDialogOutput {
open: boolean;
discountPercent: number;
close: () => void;
claim: () => void;
}
export function shouldShowFirstRechargeOfferDialog(
input: FirstRechargeOfferDialogEligibility,
): boolean {
if (!input.historyLoaded) return false;
if (input.loginStatus === "notLoggedIn" || input.loginStatus === "guest") {
return false;
}
if (!input.isFirstRecharge) return false;
return !input.hasBlockingDialog;
}
export function useFirstRechargeOfferDialog({
historyLoaded,
loginStatus,
hasBlockingDialog,
}: UseFirstRechargeOfferDialogInput): UseFirstRechargeOfferDialogOutput {
const router = useRouter();
const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch();
const userState = useUserState();
const [open, setOpen] = useState(false);
const [dismissed, setDismissed] = useState(false);
const isAuthenticatedUser =
loginStatus !== "notLoggedIn" && loginStatus !== "guest";
const storageKey = useMemo(
() =>
`${FIRST_RECHARGE_OFFER_DIALOG_KEY_PREFIX}:${
userState.currentUser?.id || "anonymous"
}`,
[userState.currentUser?.id],
);
const discountPercent = payment.firstRechargeOffer?.discountPercent ?? 50;
const canShow = shouldShowFirstRechargeOfferDialog({
historyLoaded,
loginStatus,
isFirstRecharge: payment.isFirstRecharge,
hasBlockingDialog,
});
useEffect(() => {
if (!isAuthenticatedUser) return;
if (payment.status !== "idle") return;
const defaultPayChannel = getDefaultPayChannelForCountryCode(
userState.currentUser?.countryCode,
);
paymentDispatch({ type: "PaymentInit", payChannel: defaultPayChannel });
}, [
payment.status,
paymentDispatch,
isAuthenticatedUser,
userState.currentUser?.countryCode,
]);
useEffect(() => {
if (!canShow || dismissed || open) return;
let cancelled = false;
let timer: number | undefined;
const schedule = async () => {
const shownDateResult = await SpAsyncUtil.getString(storageKey);
if (
cancelled ||
(Result.isOk(shownDateResult) && shownDateResult.data === todayString())
) {
return;
}
timer = window.setTimeout(() => {
if (cancelled) return;
setOpen(true);
}, FIRST_RECHARGE_OFFER_DIALOG_DELAY_MS);
};
void schedule();
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [canShow, dismissed, open, storageKey]);
function markShown(): void {
setDismissed(true);
setOpen(false);
void SpAsyncUtil.setString(storageKey, todayString());
}
function claim(): void {
markShown();
const defaultPayChannel = getDefaultPayChannelForCountryCode(
userState.currentUser?.countryCode,
);
router.push(
ROUTE_BUILDERS.subscription("vip", {
payChannel: defaultPayChannel,
returnTo: "chat",
}),
);
}
return {
open: open && !hasBlockingDialog,
discountPercent,
close: markShown,
claim,
};
}