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