Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions apps/zoom-app/src/components/Footer.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, lazy, Suspense, memo } from 'react';
import { useEffect, useState, lazy, Suspense, memo } from 'react';
import { MessageSquare, Star, Sparkles } from 'lucide-react';
import { useEntitlement } from '../hooks/useEntitlement';
import { useFlag } from '../hooks/useFlag';
Expand All @@ -7,7 +7,10 @@ import {
REVIEW_PROMPT,
REVIEW_SURVEY_ID,
ZOOM_MARKETPLACE_REVIEW_URL,
isProLaunchBadgeDue,
loadPromptState,
markPromptAnswered,
subscribeToPromptState,
} from '@toastmaster-timer/shared';
import { trackEvent } from '../utils/posthog';
import { openExternalUrl } from '../utils/zoomSdk';
Expand All @@ -21,6 +24,11 @@ export default memo(function Footer() {
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const { isPro, known } = useEntitlement();
const { enabled: proEnabled, known: flagsKnown } = useFlag('pro');
// Prompt history, for the "New" badge: it goes the moment any Upgrade dialog
// marks the Pro announcement answered.
const [promptState, setPromptState] = useState(loadPromptState);
useEffect(() => subscribeToPromptState(setPromptState), []);
const showNewBadge = !isPro && isProLaunchBadgeDue(promptState);

const handleFeedbackClick = () => {
(window.requestIdleCallback || setTimeout)(() => {
Expand Down Expand Up @@ -87,8 +95,21 @@ export default memo(function Footer() {
}`}
aria-label={isPro ? 'Manage your Pro plan' : 'Upgrade to Pro'}
>
<Sparkles className="w-4 h-4 flex-shrink-0" />
{/* A dot, not a "New" pill: a pill pushes "Send Us Feedback" onto
two lines in the narrow Zoom panel. The word stays for screen
readers. */}
<span className="relative flex-shrink-0">
<Sparkles className="w-4 h-4" />
{showNewBadge && (
<span
aria-hidden="true"
data-testid="pro-new-dot"
className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-red-500 ring-2 ring-white"
/>
)}
</span>
<span>{isPro ? 'Pro' : 'Upgrade'}</span>
{showNewBadge && <span className="sr-only">New</span>}
</button>
)}
</footer>
Expand Down
46 changes: 46 additions & 0 deletions apps/zoom-app/src/components/Footer.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { act, render, screen } from '@testing-library/react';
import Footer from './Footer';
import { ToastProvider } from '../context/ToastContext';
import {
PRO_LAUNCH_BADGE_DAYS,
PRO_LAUNCH_PROMPT,
markPromptAnswered,
markPromptShown,
resetEntitlementForTests,
resetFlagsForTests,
setEntitlement,
Expand Down Expand Up @@ -30,6 +34,7 @@ const proButton = () => screen.queryByRole('button', { name: /manage your pro pl
// The real stores, not a mocked hook: what is under test is the gate the
// Footer composes from both of them.
beforeEach(() => {
localStorage.clear();
resetEntitlementForTests();
resetFlagsForTests();
});
Expand Down Expand Up @@ -110,3 +115,44 @@ describe('the Footer\'s Pro button', () => {
expect(screen.getByRole('button', { name: /leave a review/i })).toBeInTheDocument();
});
});

describe('the "New" badge on the Upgrade button', () => {
const badge = () => screen.queryByText('New');
const DAY_MS = 24 * 60 * 60 * 1000;

it('marks the Upgrade button for a free user once Pro is released', () => {
setEntitlement({ plan: 'free' });
setFlags({ pro: true });
renderFooter();
expect(upgradeButton()).toContainElement(badge());
});

it('is not on a Pro user\'s button', () => {
setEntitlement(PRO);
setFlags({ pro: true });
renderFooter();
expect(badge()).toBeNull();
});

it('goes the moment the announcement is answered, without a remount', () => {
setEntitlement({ plan: 'free' });
setFlags({ pro: true });
renderFooter();
expect(badge()).toBeInTheDocument();

act(() => {
markPromptAnswered(PRO_LAUNCH_PROMPT);
});
expect(badge()).toBeNull();
expect(upgradeButton()).toBeInTheDocument();
});

it('is gone once the badge window after the announcement has passed', () => {
markPromptShown(PRO_LAUNCH_PROMPT, Date.now() - (PRO_LAUNCH_BADGE_DAYS + 1) * DAY_MS);
setEntitlement({ plan: 'free' });
setFlags({ pro: true });
renderFooter();
expect(upgradeButton()).toBeInTheDocument();
expect(badge()).toBeNull();
});
});
43 changes: 34 additions & 9 deletions apps/zoom-app/src/components/PeriodicPrompts.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState, lazy, Suspense } from 'react';
import { useEffect, useRef, useState, lazy, Suspense } from 'react';
import {
CLUB_PROMPT,
PRO_LAUNCH_PROMPT,
REVIEW_PROMPT,
loadPromptState,
markPromptAnswered,
Expand All @@ -11,35 +12,52 @@ import {
subscribeToPromptState,
} from '@toastmaster-timer/shared';
import { useTimerTick } from '../context/TimerContext';
import { useEntitlement } from '../hooks/useEntitlement';
import { useFlag } from '../hooks/useFlag';
import { trackEvent } from '../utils/posthog';

const ClubSurveyModal = lazy(() => import('./ClubSurveyModal'));
const ReviewPromptModal = lazy(() => import('./ReviewPromptModal'));
const ProLaunchModal = lazy(() => import('./ProLaunchModal'));

// Prompts this app asks. The review ask belongs here, where the user installed
// the Zoom app in the first place.
const ENABLED_PROMPTS = [CLUB_PROMPT, REVIEW_PROMPT];
// Prompts this app always asks. The review ask belongs here, where the user
// installed the Zoom app in the first place.
const BASE_PROMPTS = [CLUB_PROMPT, REVIEW_PROMPT];
// Plus the Pro announcement, once Pro is released and only to devices that are
// not Pro already: nobody is told about something they have.
const WITH_PRO_LAUNCH = [PRO_LAUNCH_PROMPT, ...BASE_PROMPTS];

// Breathing room after "Finish" before a modal appears: the user has just
// stopped a speech and may be lining up the next speaker. If they start the
// next one within the delay the prompt stays queued and waits for the next gap.
const SHOW_DELAY_MS = 2500;

/**
* Shows an occasional prompt (club question, Zoom review) once the user has
* timed enough speeches. Cadence and history live in the shared prompt
* scheduler; this component only decides when it is polite to interrupt.
* Shows an occasional prompt (club question, Zoom review, the one-time Pro
* announcement) once the user has timed enough speeches. Cadence and history
* live in the shared prompt scheduler; this component only decides which
* prompts apply and when it is polite to interrupt.
*/
export default function PeriodicPrompts() {
const { isRunning } = useTimerTick();
const { isPro, known: planKnown } = useEntitlement();
const { enabled: proReleased, known: flagsKnown } = useFlag('pro');
// Queued: due, but waiting for a safe moment. Visible: actually on screen.
const [queued, setQueued] = useState(null);
const [visible, setVisible] = useState(null);

// Both answers must be in: a Pro device whose plan has not loaded yet looks
// free for a moment, and must not be told about Pro in that moment.
const proLaunchEligible = flagsKnown && proReleased && planKnown && !isPro;
// A ref, so the store subscription and the delayed re-check below read the
// current gate rather than the one they were created with.
const enabledRef = useRef(BASE_PROMPTS);
enabledRef.current = proLaunchEligible ? WITH_PRO_LAUNCH : BASE_PROMPTS;

useEffect(
() =>
subscribeToPromptState((state) => {
setQueued((current) => current ?? selectDuePrompt(state, Date.now(), ENABLED_PROMPTS));
setQueued((current) => current ?? selectDuePrompt(state, Date.now(), enabledRef.current));
}),
[]
);
Expand All @@ -60,7 +78,7 @@ export default function PeriodicPrompts() {
const timer = setTimeout(() => {
// Re-check against stored state: another tab or window may have asked
// since this prompt was queued.
const due = selectDuePrompt(loadPromptState(), Date.now(), ENABLED_PROMPTS);
const due = selectDuePrompt(loadPromptState(), Date.now(), enabledRef.current);
if (!due) {
setQueued(null);
return;
Expand Down Expand Up @@ -117,6 +135,13 @@ export default function PeriodicPrompts() {
onDecline={() => handleDecline(REVIEW_PROMPT)}
/>
)}
{visible === PRO_LAUNCH_PROMPT && (
<ProLaunchModal
isOpen
onAnswered={() => handleAnswered(PRO_LAUNCH_PROMPT)}
onDismiss={() => handleDismiss(PRO_LAUNCH_PROMPT)}
/>
)}
</Suspense>
);
}
161 changes: 161 additions & 0 deletions apps/zoom-app/src/components/PeriodicPrompts.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import '@testing-library/jest-dom';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import PeriodicPrompts from './PeriodicPrompts';
import { trackEvent } from '../utils/posthog';
import {
PRO_LAUNCH_PROMPT,
forcePrompt,
loadPromptState,
recordSpeechFinished,
resetEntitlementForTests,
resetFlagsForTests,
setEntitlement,
setFlags,
} from '@toastmaster-timer/shared';

// Stubbed rather than imported: the real modules pull in @zoom/appssdk, which
// hangs vitest under jsdom, and the timer context needs the whole app.
vi.mock('../utils/zoomSdk', () => ({ openExternalUrl: vi.fn() }));
vi.mock('../utils/posthog', () => ({ trackEvent: vi.fn() }));
vi.mock('../context/TimerContext', () => ({ useTimerTick: () => ({ isRunning: false }) }));
vi.mock('../context/ToastContext', () => ({ useToast: () => ({ showToast: vi.fn() }) }));
// What is under test is the hand-off, not the purchase flow behind it.
vi.mock('./UpgradeModal', () => ({
default: ({ onClose, source }) => (
<div role="dialog" aria-label="Upgrade">
<span>Upgrade from {source}</span>
<button onClick={onClose}>Close upgrade</button>
</div>
),
}));

// Long enough to cover SHOW_DELAY_MS in PeriodicPrompts.
const PAST_DELAY_MS = 5000;
const PRO = { plan: 'pro', status: 'active', entitled: true, source: 'subscription' };

const announcement = () => screen.queryByRole('dialog', { name: /new: pro for your club/i });

function finishSpeeches(count) {
act(() => {
for (let i = 0; i < count; i += 1) recordSpeechFinished();
});
}

function afterDelay() {
act(() => vi.advanceTimersByTime(PAST_DELAY_MS));
}

beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.clearAllMocks();
localStorage.clear();
resetEntitlementForTests();
resetFlagsForTests();
});

afterEach(() => {
vi.useRealTimers();
});

describe('the Pro launch announcement', () => {
it('shows after the first finished speech once Pro is released, for a free device', async () => {
setEntitlement({ plan: 'free' });
setFlags({ pro: true });
render(<PeriodicPrompts />);

finishSpeeches(1);
// Held back until the delay elapses, so it never lands on the FINISH click.
expect(announcement()).not.toBeInTheDocument();

afterDelay();
await waitFor(() => expect(announcement()).toBeInTheDocument());
expect(screen.getByText(/one plan covers every timer in your club/i)).toBeInTheDocument();
expect(loadPromptState().prompts[PRO_LAUNCH_PROMPT].asks).toBe(1);
expect(trackEvent).toHaveBeenCalledWith('periodic_prompt_shown', { prompt: PRO_LAUNCH_PROMPT });
});

it.each([
['Pro is not released', { plan: 'free' }, { pro: false }],
['the device is already Pro', PRO, { pro: true }],
['the plan has not loaded yet', null, { pro: true }],
['the flags have not loaded yet', { plan: 'free' }, undefined],
])('stays away while %s', (_label, plan, flags) => {
if (plan) setEntitlement(plan);
if (flags !== undefined) setFlags(flags);
render(<PeriodicPrompts />);

finishSpeeches(1);
afterDelay();

expect(announcement()).not.toBeInTheDocument();
expect(loadPromptState().prompts[PRO_LAUNCH_PROMPT].asks).toBe(0);
});

it('is shown once: closing it retires it for good', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
setEntitlement({ plan: 'free' });
setFlags({ pro: true });
render(<PeriodicPrompts />);

finishSpeeches(1);
afterDelay();
await waitFor(() => expect(announcement()).toBeInTheDocument());

await user.click(screen.getByRole('button', { name: /not now/i }));
expect(announcement()).not.toBeInTheDocument();
expect(trackEvent).toHaveBeenCalledWith('periodic_prompt_dismissed', { prompt: PRO_LAUNCH_PROMPT });

finishSpeeches(50);
afterDelay();
expect(announcement()).not.toBeInTheDocument();
expect(loadPromptState().prompts[PRO_LAUNCH_PROMPT].asks).toBe(1);
});

it('hands off to the Upgrade dialog and counts that as answered', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
setEntitlement({ plan: 'free' });
setFlags({ pro: true });
render(<PeriodicPrompts />);

finishSpeeches(1);
afterDelay();
await waitFor(() => expect(announcement()).toBeInTheDocument());

await user.click(screen.getByRole('button', { name: /see what's in pro/i }));
expect(trackEvent).toHaveBeenCalledWith('pro_launch_learn_more');
await waitFor(() => expect(screen.getByText('Upgrade from pro_launch')).toBeInTheDocument());
expect(announcement()).not.toBeInTheDocument();

await user.click(screen.getByRole('button', { name: /close upgrade/i }));
expect(screen.queryByText('Upgrade from pro_launch')).not.toBeInTheDocument();
expect(loadPromptState().prompts[PRO_LAUNCH_PROMPT].resolution).toBe('answered');
expect(trackEvent).toHaveBeenCalledWith('periodic_prompt_answered', { prompt: PRO_LAUNCH_PROMPT });
});

it.each([
['lists Sounds once the sounds flag is on', true],
['leaves Sounds out while the sounds flag is off', false],
])('%s', async (_label, sounds) => {
setEntitlement({ plan: 'free' });
setFlags({ pro: true, sounds });
render(<PeriodicPrompts />);

finishSpeeches(1);
afterDelay();
await waitFor(() => expect(announcement()).toBeInTheDocument());

const line = screen.queryByText(/a chime when the card changes, and music during breaks/i);
if (sounds) expect(line).toBeInTheDocument();
else expect(line).not.toBeInTheDocument();
});

it('can be forced from the debug panel whatever the gates say', async () => {
render(<PeriodicPrompts />);

act(() => forcePrompt(PRO_LAUNCH_PROMPT));

await waitFor(() => expect(announcement()).toBeInTheDocument());
expect(loadPromptState().prompts[PRO_LAUNCH_PROMPT].asks).toBe(0);
});
});
Loading
Loading