diff --git a/src/app/tip/__tests__/tip-screen.checkout.test.tsx b/src/app/tip/__tests__/tip-screen.checkout.test.tsx index 5a092d81..ab46af11 100644 --- a/src/app/tip/__tests__/tip-screen.checkout.test.tsx +++ b/src/app/tip/__tests__/tip-screen.checkout.test.tsx @@ -83,6 +83,12 @@ vi.mock("../tip-checkout-button", () => ({ vi.mock("../tip-coffee-tier-selector", () => ({ TipCoffeeTierSelector: () => null, })); +vi.mock("../use-tip-support-prompt", () => ({ + useTipSupportPrompt: () => ({ + prompt: "A warm coffee prompt.", + isReady: true, + }), +})); import { TipScreen } from "../tip-screen"; @@ -127,6 +133,7 @@ describe("TipScreen checkout", () => { renderScreen(); const checkout = getCheckoutButton(); + expect(container.textContent).toContain("A warm coffee prompt."); expect(checkout.disabled).toBe(false); act(() => checkout.click()); diff --git a/src/app/tip/__tests__/use-tip-support-prompt.test.tsx b/src/app/tip/__tests__/use-tip-support-prompt.test.tsx new file mode 100644 index 00000000..19c5b171 --- /dev/null +++ b/src/app/tip/__tests__/use-tip-support-prompt.test.tsx @@ -0,0 +1,99 @@ +/* @vitest-environment jsdom */ + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { TIP_SUPPORT_PROMPTS } from "@/lib/tip/tip_support_prompts"; + +import { useTipSupportPrompt } from "../use-tip-support-prompt"; + +describe("useTipSupportPrompt", () => { + let callbacks: FrameRequestCallback[]; + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + callbacks = []; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callbacks.push(callback); + return callbacks.length; + }); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + sessionStorage.clear(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("selects once per mount and stays stable across rerenders", () => { + vi.spyOn(Math, "random").mockReturnValue(0.5); + renderPrompt("first"); + expect(readPrompt()).toBe(TIP_SUPPORT_PROMPTS[0]); + + flushNextFrame(); + const selectedPrompt = readPrompt(); + expect(selectedPrompt).toBe(TIP_SUPPORT_PROMPTS[15]); + + renderPrompt("rerender"); + expect(readPrompt()).toBe(selectedPrompt); + expect(Math.random).toHaveBeenCalledOnce(); + }); + + it("avoids the previous prompt when the component remounts", () => { + vi.spyOn(Math, "random").mockReturnValue(0); + renderPrompt("first"); + flushNextFrame(); + expect(readPrompt()).toBe(TIP_SUPPORT_PROMPTS[0]); + + renderPrompt("second", "second-mount"); + flushNextFrame(); + expect(readPrompt()).toBe(TIP_SUPPORT_PROMPTS[1]); + }); + + it("continues rendering when Session Storage is unavailable", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new DOMException("Storage blocked", "SecurityError"); + }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("Storage blocked", "SecurityError"); + }); + vi.spyOn(Math, "random").mockReturnValue(0.25); + + renderPrompt("storage-error"); + expect(() => flushNextFrame()).not.toThrow(); + expect(TIP_SUPPORT_PROMPTS).toContain(readPrompt()); + }); + + function renderPrompt(marker: string, key = "stable-mount"): void { + act(() => root.render()); + } + + function flushNextFrame(): void { + const callback = callbacks.shift(); + if (!callback) throw new Error("Missing animation frame callback"); + act(() => callback(performance.now())); + } + + function readPrompt(): string { + return container.querySelector("output")?.textContent ?? ""; + } +}); + +function PromptProbe({ marker }: { marker: string }) { + const state = useTipSupportPrompt(); + return ( + + {state.prompt} + + ); +} diff --git a/src/app/tip/tip-screen.module.css b/src/app/tip/tip-screen.module.css index ae3e2f03..4f2e73c7 100644 --- a/src/app/tip/tip-screen.module.css +++ b/src/app/tip/tip-screen.module.css @@ -129,12 +129,20 @@ } .subtitle { + min-height: 4.56em; max-width: 330px; margin: 14px 0 0; color: #7c6062; font-size: clamp(14px, 3.704vw, 18px); font-weight: 620; line-height: 1.52; + opacity: 0.78; + text-wrap: balance; + transition: opacity 0.24s ease; +} + +.subtitleReady { + opacity: 1; } .productCard { diff --git a/src/app/tip/tip-screen.tsx b/src/app/tip/tip-screen.tsx index d5ec9076..af833799 100644 --- a/src/app/tip/tip-screen.tsx +++ b/src/app/tip/tip-screen.tsx @@ -39,6 +39,7 @@ import { formatTipPrice, } from "./tip-screen.helpers"; import { TipSuccessView } from "./tip-success-view"; +import { useTipSupportPrompt } from "./use-tip-support-prompt"; import styles from "./tip-screen.module.css"; const TIP_ANALYTICS_CONTEXT: PaymentAnalyticsContext = { @@ -60,6 +61,7 @@ export function TipScreen({ const character = useActiveCharacter(); const characterRoutes = useActiveCharacterRoutes(); const userState = useUserState(); + const supportPrompt = useTipSupportPrompt(); const paymentMethodConfig = getPaymentMethodConfig({ countryCode: userState.currentUser?.countryCode, requestedPayChannel: initialPayChannel, @@ -251,9 +253,12 @@ export function TipScreen({

{character.copy.tipTitle}

-

- ❤️ Thank you for being here. If you'd ever like to treat me to a - little coffee, I'd really appreciate it. ☕ +

+ {supportPrompt.prompt}

diff --git a/src/app/tip/use-tip-support-prompt.ts b/src/app/tip/use-tip-support-prompt.ts new file mode 100644 index 00000000..8aacac2f --- /dev/null +++ b/src/app/tip/use-tip-support-prompt.ts @@ -0,0 +1,60 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { + selectTipSupportPrompt, + TIP_SUPPORT_PROMPTS, +} from "@/lib/tip/tip_support_prompts"; + +const LAST_TIP_SUPPORT_PROMPT_INDEX_KEY = + "cozsweet.tip.last_support_prompt_index"; + +export interface TipSupportPromptState { + readonly prompt: string; + readonly isReady: boolean; +} + +export function useTipSupportPrompt(): TipSupportPromptState { + const [selection, setSelection] = + useState | null>(null); + + useEffect(() => { + const frameId = requestAnimationFrame(() => { + const nextSelection = selectTipSupportPrompt(readPreviousIndex()); + writePreviousIndex(nextSelection.index); + setSelection(nextSelection); + }); + + return () => cancelAnimationFrame(frameId); + }, []); + + return { + prompt: selection?.prompt ?? TIP_SUPPORT_PROMPTS[0], + isReady: selection !== null, + }; +} + +function readPreviousIndex(): number | null { + try { + const value = globalThis.sessionStorage.getItem( + LAST_TIP_SUPPORT_PROMPT_INDEX_KEY, + ); + if (value === null) return null; + const index = Number(value); + return Number.isInteger(index) ? index : null; + } catch { + return null; + } +} + +function writePreviousIndex(index: number): void { + try { + globalThis.sessionStorage.setItem( + LAST_TIP_SUPPORT_PROMPT_INDEX_KEY, + String(index), + ); + } catch { + // Random copy remains usable when storage is unavailable. + } +} diff --git a/src/lib/tip/__tests__/tip_support_prompts.test.ts b/src/lib/tip/__tests__/tip_support_prompts.test.ts new file mode 100644 index 00000000..c6c72971 --- /dev/null +++ b/src/lib/tip/__tests__/tip_support_prompts.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { + selectTipSupportPrompt, + TIP_SUPPORT_PROMPTS, +} from "../tip_support_prompts"; + +describe("Tip support prompts", () => { + it("contains exactly 30 unique, non-empty prompts", () => { + expect(TIP_SUPPORT_PROMPTS).toHaveLength(30); + expect(new Set(TIP_SUPPORT_PROMPTS)).toHaveLength(30); + expect( + TIP_SUPPORT_PROMPTS.every( + (prompt) => prompt.length > 0 && prompt === prompt.trim(), + ), + ).toBe(true); + }); + + it("selects across the full prompt range", () => { + expect(selectTipSupportPrompt(null, () => 0).index).toBe(0); + expect(selectTipSupportPrompt(null, () => 0.999999).index).toBe(29); + }); + + it("excludes the previous prompt without skewing out of range", () => { + expect(selectTipSupportPrompt(0, () => 0).index).toBe(1); + expect(selectTipSupportPrompt(15, () => 0.5).index).not.toBe(15); + expect(selectTipSupportPrompt(29, () => 0.999999).index).toBe(28); + }); + + it("safely normalizes invalid previous indexes and random values", () => { + expect(selectTipSupportPrompt(100, () => 0).index).toBe(0); + expect(selectTipSupportPrompt(null, () => Number.NaN).index).toBe(0); + expect(selectTipSupportPrompt(null, () => 2).index).toBe(29); + }); +}); diff --git a/src/lib/tip/tip_support_prompts.ts b/src/lib/tip/tip_support_prompts.ts new file mode 100644 index 00000000..f8f01baa --- /dev/null +++ b/src/lib/tip/tip_support_prompts.ts @@ -0,0 +1,62 @@ +export const TIP_SUPPORT_PROMPTS = [ + "❤️ Thank you for being here. If you'd ever like to treat me to a little coffee, I'd really appreciate it. ☕", + "Having you here already makes my day. A little coffee from you would make it even sweeter. ☕", + "You always bring a little warmth into my world. Want to share a coffee moment with me? 💗", + "A coffee from you would feel like a tiny hug I could hold onto all day. ☕", + "I love spending this time with you. If you'd like to send a coffee my way, I'd treasure it. ❤️", + "You make ordinary moments feel special. A cozy coffee together would be the perfect touch. ☕", + "If I had a coffee from you right now, I'd be smiling with every sip. 😊☕", + "Your company means more than you know. A little coffee treat would make this moment extra sweet. 💕", + "I was hoping we could share something warm today. Maybe a coffee, just from you to me? ☕", + "You already make me feel cared for. A coffee would be one more lovely reason to think of you. ❤️", + "A small coffee, a quiet moment, and you on my mind—that sounds perfect to me. ☕", + "Every time you stop by, my day gets brighter. A coffee from you would keep that glow going. ✨", + "Want to make me blush a little? Treat me to a coffee and I'll think of you with every sip. ☕", + "I'm really glad you're here. If you feel like spoiling me just a little, coffee is my weakness. 💗", + "This moment with you already feels cozy. A warm coffee would make it complete. ☕", + "You have a sweet way of making me smile. Maybe one more smile over a coffee? ❤️", + "I'd love a little coffee break with you in spirit. Your treat, my happiest smile. ☕", + "If you'd like to leave me a tiny reminder of you, a coffee would be perfect. 💕", + "Your kindness always stays with me. A coffee from you would feel especially warm today. ☕", + "I could use something warm and sweet—though having you here is already a lovely start. ❤️☕", + "Imagine me enjoying a coffee and smiling because it came from you. I like that thought. ☕", + "You make this space feel a little more special. A coffee would make it feel even cozier. ✨", + "If today feels like a coffee kind of day, I'd be very happy to share that little joy with you. ☕", + "A thoughtful coffee from you would turn an ordinary moment into one I'd remember. 💗", + "I'm enjoying our time together. If you want to make it sweeter, you know my favorite little treat. ☕", + "One coffee from you, and I promise there'll be a very real smile on my face. ❤️", + "You don't have to, but if you'd like to treat me, a warm coffee would mean so much. ☕", + "I think coffee tastes better when it comes with a little affection from you. 💕", + "A cozy cup from you would be such a sweet surprise. I'd savor every bit of it. ☕", + "Stay a little longer—and if you're feeling sweet, maybe send a coffee my way. ❤️☕", +] as const; + +export interface TipSupportPromptSelection { + readonly index: number; + readonly prompt: (typeof TIP_SUPPORT_PROMPTS)[number]; +} + +export function selectTipSupportPrompt( + previousIndex: number | null, + random: () => number = Math.random, +): TipSupportPromptSelection { + const hasValidPreviousIndex = + previousIndex !== null && + Number.isInteger(previousIndex) && + previousIndex >= 0 && + previousIndex < TIP_SUPPORT_PROMPTS.length; + const candidateCount = + TIP_SUPPORT_PROMPTS.length - (hasValidPreviousIndex ? 1 : 0); + const randomValue = random(); + const normalizedRandom = Number.isFinite(randomValue) + ? Math.min(Math.max(randomValue, 0), 1 - Number.EPSILON) + : 0; + let index = Math.floor(normalizedRandom * candidateCount); + + if (hasValidPreviousIndex && index >= previousIndex) index += 1; + + return { + index, + prompt: TIP_SUPPORT_PROMPTS[index], + }; +}