diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx
index e45be419..cd37a89e 100644
--- a/src/app/chat/chat-screen.tsx
+++ b/src/app/chat/chat-screen.tsx
@@ -24,6 +24,7 @@ import {
ChatInputBar,
ChatInsufficientCreditsBanner,
ExternalBrowserDialog,
+ FirstRechargeOfferDialog,
HistoryUnlockDialog,
InsufficientCreditsDialog,
PwaInstallOverlay,
@@ -35,6 +36,7 @@ import {
isChatDevelopmentEnvironment,
shouldStartExternalBrowserPrompt,
} from "./chat-screen.helpers";
+import { useFirstRechargeOfferDialog } from "./hooks/use-first-recharge-offer-dialog";
import { useChatUnlockNavigationFlow } from "./hooks/use-chat-unlock-navigation-flow";
import styles from "./components/chat-screen.module.css";
@@ -63,6 +65,16 @@ export function ChatScreen() {
const messageLimitTitle =
"Insufficient credits\nTop up to continue chatting";
const messageLimitCtaLabel = "Top up credits to continue";
+ const hasBlockingDialog =
+ showExternalBrowserDialog ||
+ showMessageLimitBanner ||
+ state.unlockHistoryPromptVisible ||
+ unlockPaywallRequest !== null;
+ const firstRechargeOfferDialog = useFirstRechargeOfferDialog({
+ historyLoaded: state.historyLoaded,
+ loginStatus: authState.loginStatus,
+ hasBlockingDialog,
+ });
const externalBrowserPromptShownRef = useRef(false);
@@ -199,6 +211,13 @@ export function ChatScreen() {
onClose={closeInsufficientCreditsDialog}
onConfirm={confirmInsufficientCreditsDialog}
/>
+
+
);
diff --git a/src/app/chat/components/first-recharge-offer-dialog.module.css b/src/app/chat/components/first-recharge-offer-dialog.module.css
new file mode 100644
index 00000000..bcdadec1
--- /dev/null
+++ b/src/app/chat/components/first-recharge-offer-dialog.module.css
@@ -0,0 +1,116 @@
+.overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 215;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+ background:
+ radial-gradient(circle at 50% 28%, rgba(255, 103, 224, 0.26), transparent 34%),
+ rgba(0, 0, 0, 0.52);
+}
+
+.dialog {
+ position: relative;
+ overflow: hidden;
+ width: 100%;
+ max-width: var(--pwa-install-dialog-max-width, 360px);
+ padding: 24px 18px 18px;
+ border: 1px solid rgba(255, 103, 224, 0.3);
+ border-radius: 34px;
+ background:
+ linear-gradient(180deg, rgba(255, 250, 253, 0.98) 0%, rgba(255, 238, 247, 0.98) 100%),
+ #ffffff;
+ box-shadow: 0 20px 52px rgba(39, 14, 30, 0.26);
+ text-align: center;
+}
+
+.dialog::before {
+ position: absolute;
+ top: -58px;
+ right: -42px;
+ width: 138px;
+ height: 138px;
+ border-radius: 999px;
+ background: rgba(255, 138, 52, 0.18);
+ content: "";
+}
+
+.badge {
+ position: relative;
+ display: inline-flex;
+ padding: 6px 10px;
+ border-radius: 999px;
+ background: rgba(255, 95, 174, 0.12);
+ color: #f657a0;
+ font-size: 12px;
+ font-weight: 900;
+ letter-spacing: 0.02em;
+ line-height: 1;
+ text-transform: uppercase;
+}
+
+.discount {
+ position: relative;
+ margin-top: 12px;
+ color: #181014;
+ font-size: 46px;
+ font-weight: 900;
+ letter-spacing: -1.5px;
+ line-height: 1;
+}
+
+.title {
+ position: relative;
+ margin: 10px 0 0;
+ color: #181014;
+ font-size: 22px;
+ font-weight: 900;
+ line-height: 1.18;
+}
+
+.content {
+ position: relative;
+ margin: 10px 12px 20px;
+ color: #5f4d56;
+ font-size: 15px;
+ font-weight: 600;
+ line-height: 1.45;
+}
+
+.actions {
+ position: relative;
+ display: flex;
+ gap: var(--spacing-md, 12px);
+ width: 100%;
+}
+
+.button {
+ display: flex;
+ flex: 1 1 auto;
+ height: var(--pwa-button-height, 44px);
+ align-items: center;
+ justify-content: center;
+ border: 0;
+ border-radius: var(--radius-bottom-sheet, 28px);
+ cursor: pointer;
+ font-size: var(--font-size-lg, 16px);
+ font-weight: 800;
+}
+
+.secondary {
+ background: rgba(24, 16, 20, 0.1);
+ color: #5f4d56;
+}
+
+.primary {
+ background: linear-gradient(90deg, #ff67e0 0%, #ff52a2 100%);
+ color: #ffffff;
+ box-shadow: 0 8px 18px rgba(246, 87, 160, 0.28);
+}
+
+.button:focus-visible {
+ outline: 2px solid #f657a0;
+ outline-offset: 3px;
+}
diff --git a/src/app/chat/components/first-recharge-offer-dialog.tsx b/src/app/chat/components/first-recharge-offer-dialog.tsx
new file mode 100644
index 00000000..8fd74496
--- /dev/null
+++ b/src/app/chat/components/first-recharge-offer-dialog.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import styles from "./first-recharge-offer-dialog.module.css";
+
+export interface FirstRechargeOfferDialogProps {
+ open: boolean;
+ discountPercent: number;
+ onClose: () => void;
+ onClaim: () => void;
+}
+
+export function FirstRechargeOfferDialog({
+ open,
+ discountPercent,
+ onClose,
+ onClaim,
+}: FirstRechargeOfferDialogProps) {
+ if (!open) return null;
+
+ return (
+
+
+
Limited offer
+
{discountPercent}% OFF
+
+ First Recharge Offer
+
+
+ Your first recharge discount is ready. Claim it now and keep the
+ conversation flowing.
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/chat/components/index.ts b/src/app/chat/components/index.ts
index 7974ab5c..f89c2253 100644
--- a/src/app/chat/components/index.ts
+++ b/src/app/chat/components/index.ts
@@ -12,6 +12,7 @@ export * from "./chat-input-text-field";
export * from "./chat-send-button";
export * from "./date-header";
export * from "./external-browser-dialog";
+export * from "./first-recharge-offer-dialog";
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-dialog.test.ts b/src/app/chat/hooks/__tests__/use-first-recharge-offer-dialog.test.ts
new file mode 100644
index 00000000..9bbb62d2
--- /dev/null
+++ b/src/app/chat/hooks/__tests__/use-first-recharge-offer-dialog.test.ts
@@ -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);
+ });
+});
diff --git a/src/app/chat/hooks/use-first-recharge-offer-dialog.ts b/src/app/chat/hooks/use-first-recharge-offer-dialog.ts
new file mode 100644
index 00000000..484f3383
--- /dev/null
+++ b/src/app/chat/hooks/use-first-recharge-offer-dialog.ts
@@ -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,
+ };
+}