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, + }; +}