feat(chat): show first recharge offer dialog

This commit is contained in:
2026-07-02 15:50:11 +08:00
parent f6932357c6
commit b5ca2d301f
6 changed files with 387 additions and 0 deletions
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { shouldShowFirstRechargeOfferDialog } from "../use-first-recharge-offer-dialog";
describe("shouldShowFirstRechargeOfferDialog", () => {
it("shows for authenticated users when first recharge offer is available", () => {
expect(
shouldShowFirstRechargeOfferDialog({
historyLoaded: true,
loginStatus: "facebook",
isFirstRecharge: true,
hasBlockingDialog: false,
}),
).toBe(true);
});
it("does not show for guest users", () => {
expect(
shouldShowFirstRechargeOfferDialog({
historyLoaded: true,
loginStatus: "guest",
isFirstRecharge: true,
hasBlockingDialog: false,
}),
).toBe(false);
});
it("does not show unless isFirstRecharge is true", () => {
expect(
shouldShowFirstRechargeOfferDialog({
historyLoaded: true,
loginStatus: "google",
isFirstRecharge: false,
hasBlockingDialog: false,
}),
).toBe(false);
});
it("does not show over a higher priority dialog", () => {
expect(
shouldShowFirstRechargeOfferDialog({
historyLoaded: true,
loginStatus: "apple",
isFirstRecharge: true,
hasBlockingDialog: true,
}),
).toBe(false);
});
});
@@ -0,0 +1,146 @@
"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,
};
}