feat(tip): rotate support prompts
This commit is contained in:
@@ -83,6 +83,12 @@ vi.mock("../tip-checkout-button", () => ({
|
|||||||
vi.mock("../tip-coffee-tier-selector", () => ({
|
vi.mock("../tip-coffee-tier-selector", () => ({
|
||||||
TipCoffeeTierSelector: () => null,
|
TipCoffeeTierSelector: () => null,
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../use-tip-support-prompt", () => ({
|
||||||
|
useTipSupportPrompt: () => ({
|
||||||
|
prompt: "A warm coffee prompt.",
|
||||||
|
isReady: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
import { TipScreen } from "../tip-screen";
|
import { TipScreen } from "../tip-screen";
|
||||||
|
|
||||||
@@ -127,6 +133,7 @@ describe("TipScreen checkout", () => {
|
|||||||
renderScreen();
|
renderScreen();
|
||||||
const checkout = getCheckoutButton();
|
const checkout = getCheckoutButton();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("A warm coffee prompt.");
|
||||||
expect(checkout.disabled).toBe(false);
|
expect(checkout.disabled).toBe(false);
|
||||||
act(() => checkout.click());
|
act(() => checkout.click());
|
||||||
|
|
||||||
|
|||||||
@@ -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(<PromptProbe key={key} marker={marker} />));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<output data-marker={marker} data-ready={state.isReady}>
|
||||||
|
{state.prompt}
|
||||||
|
</output>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -129,12 +129,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
|
min-height: 4.56em;
|
||||||
max-width: 330px;
|
max-width: 330px;
|
||||||
margin: 14px 0 0;
|
margin: 14px 0 0;
|
||||||
color: #7c6062;
|
color: #7c6062;
|
||||||
font-size: clamp(14px, 3.704vw, 18px);
|
font-size: clamp(14px, 3.704vw, 18px);
|
||||||
font-weight: 620;
|
font-weight: 620;
|
||||||
line-height: 1.52;
|
line-height: 1.52;
|
||||||
|
opacity: 0.78;
|
||||||
|
text-wrap: balance;
|
||||||
|
transition: opacity 0.24s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitleReady {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.productCard {
|
.productCard {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
formatTipPrice,
|
formatTipPrice,
|
||||||
} from "./tip-screen.helpers";
|
} from "./tip-screen.helpers";
|
||||||
import { TipSuccessView } from "./tip-success-view";
|
import { TipSuccessView } from "./tip-success-view";
|
||||||
|
import { useTipSupportPrompt } from "./use-tip-support-prompt";
|
||||||
import styles from "./tip-screen.module.css";
|
import styles from "./tip-screen.module.css";
|
||||||
|
|
||||||
const TIP_ANALYTICS_CONTEXT: PaymentAnalyticsContext = {
|
const TIP_ANALYTICS_CONTEXT: PaymentAnalyticsContext = {
|
||||||
@@ -60,6 +61,7 @@ export function TipScreen({
|
|||||||
const character = useActiveCharacter();
|
const character = useActiveCharacter();
|
||||||
const characterRoutes = useActiveCharacterRoutes();
|
const characterRoutes = useActiveCharacterRoutes();
|
||||||
const userState = useUserState();
|
const userState = useUserState();
|
||||||
|
const supportPrompt = useTipSupportPrompt();
|
||||||
const paymentMethodConfig = getPaymentMethodConfig({
|
const paymentMethodConfig = getPaymentMethodConfig({
|
||||||
countryCode: userState.currentUser?.countryCode,
|
countryCode: userState.currentUser?.countryCode,
|
||||||
requestedPayChannel: initialPayChannel,
|
requestedPayChannel: initialPayChannel,
|
||||||
@@ -251,9 +253,12 @@ export function TipScreen({
|
|||||||
<h1 id="tip-title" className={styles.title}>
|
<h1 id="tip-title" className={styles.title}>
|
||||||
{character.copy.tipTitle}
|
{character.copy.tipTitle}
|
||||||
</h1>
|
</h1>
|
||||||
<p className={styles.subtitle}>
|
<p
|
||||||
❤️ Thank you for being here. If you'd ever like to treat me to a
|
className={`${styles.subtitle} ${
|
||||||
little coffee, I'd really appreciate it. ☕
|
supportPrompt.isReady ? styles.subtitleReady : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{supportPrompt.prompt}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -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<ReturnType<typeof selectTipSupportPrompt> | 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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],
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user