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", () => ({
|
||||
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());
|
||||
|
||||
|
||||
@@ -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 {
|
||||
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 {
|
||||
|
||||
@@ -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({
|
||||
<h1 id="tip-title" className={styles.title}>
|
||||
{character.copy.tipTitle}
|
||||
</h1>
|
||||
<p className={styles.subtitle}>
|
||||
❤️ Thank you for being here. If you'd ever like to treat me to a
|
||||
little coffee, I'd really appreciate it. ☕
|
||||
<p
|
||||
className={`${styles.subtitle} ${
|
||||
supportPrompt.isReady ? styles.subtitleReady : ""
|
||||
}`}
|
||||
>
|
||||
{supportPrompt.prompt}
|
||||
</p>
|
||||
</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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user