diff --git a/apps/zoom-app/src/components/Footer.jsx b/apps/zoom-app/src/components/Footer.jsx
index 0a2a3b7..6701b06 100644
--- a/apps/zoom-app/src/components/Footer.jsx
+++ b/apps/zoom-app/src/components/Footer.jsx
@@ -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';
@@ -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';
@@ -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)(() => {
@@ -87,8 +95,21 @@ export default memo(function Footer() {
}`}
aria-label={isPro ? 'Manage your Pro plan' : 'Upgrade to Pro'}
>
-
+ {/* 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. */}
+
+
+ {showNewBadge && (
+
+ )}
+ {isPro ? 'Pro' : 'Upgrade'}
+ {showNewBadge && New}
)}
diff --git a/apps/zoom-app/src/components/Footer.test.jsx b/apps/zoom-app/src/components/Footer.test.jsx
index edf437b..01d1cf4 100644
--- a/apps/zoom-app/src/components/Footer.test.jsx
+++ b/apps/zoom-app/src/components/Footer.test.jsx
@@ -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,
@@ -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();
});
@@ -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();
+ });
+});
diff --git a/apps/zoom-app/src/components/PeriodicPrompts.jsx b/apps/zoom-app/src/components/PeriodicPrompts.jsx
index 4d56166..62fac2e 100644
--- a/apps/zoom-app/src/components/PeriodicPrompts.jsx
+++ b/apps/zoom-app/src/components/PeriodicPrompts.jsx
@@ -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,
@@ -11,14 +12,20 @@ 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
@@ -26,20 +33,31 @@ const ENABLED_PROMPTS = [CLUB_PROMPT, REVIEW_PROMPT];
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));
}),
[]
);
@@ -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;
@@ -117,6 +135,13 @@ export default function PeriodicPrompts() {
onDecline={() => handleDecline(REVIEW_PROMPT)}
/>
)}
+ {visible === PRO_LAUNCH_PROMPT && (
+ handleAnswered(PRO_LAUNCH_PROMPT)}
+ onDismiss={() => handleDismiss(PRO_LAUNCH_PROMPT)}
+ />
+ )}
);
}
diff --git a/apps/zoom-app/src/components/PeriodicPrompts.test.jsx b/apps/zoom-app/src/components/PeriodicPrompts.test.jsx
new file mode 100644
index 0000000..fcd7fe7
--- /dev/null
+++ b/apps/zoom-app/src/components/PeriodicPrompts.test.jsx
@@ -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 }) => (
+
+ Upgrade from {source}
+
+
+ ),
+}));
+
+// 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ act(() => forcePrompt(PRO_LAUNCH_PROMPT));
+
+ await waitFor(() => expect(announcement()).toBeInTheDocument());
+ expect(loadPromptState().prompts[PRO_LAUNCH_PROMPT].asks).toBe(0);
+ });
+});
diff --git a/apps/zoom-app/src/components/ProLaunchModal.jsx b/apps/zoom-app/src/components/ProLaunchModal.jsx
new file mode 100644
index 0000000..d9d0a39
--- /dev/null
+++ b/apps/zoom-app/src/components/ProLaunchModal.jsx
@@ -0,0 +1,93 @@
+import { lazy, Suspense, useState } from 'react';
+import { X, Sparkles } from 'lucide-react';
+import { useFlag } from '../hooks/useFlag';
+import { trackEvent } from '../utils/posthog';
+
+const UpgradeModal = lazy(() => import('./UpgradeModal'));
+
+/**
+ * The one-time "Pro is here" announcement, shown by PeriodicPrompts after a
+ * speech ends, never on its own and never before START: the Upgrade dialog's
+ * rule that nothing stands between a timer and the START button still holds.
+ *
+ * It only says what Pro is for and hands off. Buying, and joining a club with a
+ * code, stay in UpgradeModal, which opens in its place; opening it marks the
+ * announcement answered there, so this modal has no answered path of its own.
+ * Closing it (X or "Not now") counts as the one ask it gets.
+ */
+export default function ProLaunchModal({ isOpen, onAnswered, onDismiss }) {
+ const [showUpgrade, setShowUpgrade] = useState(false);
+ // Sounds are only pitched once they are released: while `sounds` is off a
+ // Pro user would find them locked as "coming soon".
+ const { enabled: soundsReleased } = useFlag('sounds');
+
+ if (!isOpen) return null;
+
+ if (showUpgrade) {
+ return (
+
+
+
+ );
+ }
+
+ const handleLearnMore = () => {
+ trackEvent('pro_launch_learn_more');
+ setShowUpgrade(true);
+ };
+
+ return (
+
+
+
+
+
+ New: Pro for your club
+
+
+
+
+ {/* The club is the buyer, and the timer role rotates weekly: the copy
+ speaks to the whole club so whoever is timing can pass it on. */}
+
+ One plan covers every timer in your club:
+
+
+
Your club's brand kit on every timer card and report
+
Shared timing presets, set once for every timer
+
Every meeting saved to your club's archive
+ {soundsReleased &&
A chime when the card changes, and music during breaks
}
+
+
+ The timer itself stays free. Not the one who decides? Tell your club officers.
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/zoom-app/src/components/PromptDebugControls.jsx b/apps/zoom-app/src/components/PromptDebugControls.jsx
index b3babd0..2ff0dfe 100644
--- a/apps/zoom-app/src/components/PromptDebugControls.jsx
+++ b/apps/zoom-app/src/components/PromptDebugControls.jsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import {
CLUB_PROMPT,
+ PRO_LAUNCH_PROMPT,
PROMPT_ORDER,
PROMPT_RULES,
REVIEW_PROMPT,
@@ -14,6 +15,7 @@ import {
} from '@toastmaster-timer/shared';
const LABELS = {
+ [PRO_LAUNCH_PROMPT]: 'Pro launch',
[CLUB_PROMPT]: 'Club survey',
[REVIEW_PROMPT]: 'Review ask',
};
@@ -31,7 +33,9 @@ export default function PromptDebugControls() {
useEffect(() => subscribeToPromptState(setState), []);
- const duePrompt = selectDuePrompt(state);
+ // Every prompt, the Pro announcement included, whatever the release gate says:
+ // this panel shows the cadence, not who is eligible.
+ const duePrompt = selectDuePrompt(state, Date.now(), PROMPT_ORDER);
return (
diff --git a/apps/zoom-app/src/components/UpgradeModal.jsx b/apps/zoom-app/src/components/UpgradeModal.jsx
index d2d8c58..25acc6b 100644
--- a/apps/zoom-app/src/components/UpgradeModal.jsx
+++ b/apps/zoom-app/src/components/UpgradeModal.jsx
@@ -10,12 +10,15 @@ import {
leaveClub,
subscribeClub,
lapsedClubName,
+ markPromptAnswered,
+ PRO_LAUNCH_PROMPT,
} from '@toastmaster-timer/shared';
import ClubSetupCard from '@toastmaster-timer/ui/ClubSetupCard';
import { trackEvent } from '../utils/posthog';
import { openExternalUrl } from '../utils/zoomSdk';
import { getSessionToken, resolveZoomIdentity } from '../utils/zoomIdentity';
import { useEntitlement } from '../hooks/useEntitlement';
+import { useFlag } from '../hooks/useFlag';
/**
* Buying Pro from inside Zoom, and joining a club that already bought it.
@@ -91,6 +94,9 @@ async function openPortal() {
export default function UpgradeModal({ isOpen, onClose, source = 'unknown', onUpgraded }) {
const { entitlement, isPro } = useEntitlement();
+ // Sounds join the pitch only once released; while `sounds` is off they are
+ // locked as "coming soon" even for Pro.
+ const { enabled: soundsReleased } = useFlag('sounds');
const club = useClub();
const [identity, setIdentity] = useState(null);
const [phase, setPhase] = useState('choose'); // choose | opening | waiting | done | error
@@ -113,6 +119,10 @@ export default function UpgradeModal({ isOpen, onClose, source = 'unknown', onUp
// plan_source separates buyers from timers who activated a club code, which
// is what the upgrade-funnel read needs to mean anything.
trackEvent('upgrade_prompt_shown', { source, plan: entitlement.plan, plan_source: entitlement.source });
+ // Whichever door they came through (footer, Sounds, Card Images, the
+ // announcement itself), this user has now seen Pro: retire the one-time
+ // announcement and the footer's "New" badge.
+ markPromptAnswered(PRO_LAUNCH_PROMPT);
// The renewal funnel's other end: a device that came looking after the
// club's Pro stopped, which is the moment a renewal is most likely.
const lapsed = lapsedClubName();
@@ -481,6 +491,9 @@ export default function UpgradeModal({ isOpen, onClose, source = 'unknown', onUp
Your club's brand kit on every timer card and report
Shared timing presets — set once, every timer gets them
Every meeting saved to your club's archive
+ {soundsReleased && (
+
A chime when the card changes, and music during breaks
+ )}
Your settings backed up and synced across devices
The timer itself stays free, always
diff --git a/apps/zoom-app/src/components/UpgradeModal.test.jsx b/apps/zoom-app/src/components/UpgradeModal.test.jsx
index 5e60559..e4b6f0a 100644
--- a/apps/zoom-app/src/components/UpgradeModal.test.jsx
+++ b/apps/zoom-app/src/components/UpgradeModal.test.jsx
@@ -12,6 +12,8 @@ import {
setFlags,
initClubFromCache,
getEntitlement,
+ loadPromptState,
+ PRO_LAUNCH_PROMPT,
} from '@toastmaster-timer/shared';
// Stubbed rather than imported: the real module pulls in @zoom/appssdk, which
@@ -55,6 +57,22 @@ beforeEach(() => {
global.fetch = vi.fn();
});
+describe('the Pro announcement', () => {
+ it.each(['footer', 'sounds', 'card_images', 'pro_launch'])(
+ 'is retired when the dialog opens from %s',
+ (source) => {
+ expect(loadPromptState().prompts[PRO_LAUNCH_PROMPT].resolution).toBeNull();
+ renderModal({ source });
+ expect(loadPromptState().prompts[PRO_LAUNCH_PROMPT].resolution).toBe('answered');
+ }
+ );
+
+ it('is left alone while the dialog is closed', () => {
+ renderModal({ isOpen: false });
+ expect(loadPromptState().prompts[PRO_LAUNCH_PROMPT].resolution).toBeNull();
+ });
+});
+
describe('club code entry', () => {
it('turns the device Pro and tells analytics which club', async () => {
const user = userEvent.setup();
@@ -147,6 +165,19 @@ describe('the club-framed pitch', () => {
expect(JSON.parse(init.body)).toEqual({ interval: 'monthly', clubName: 'Downtown Speakers' });
});
+ 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) => {
+ setFlags({ pro: true, sounds });
+ renderModal();
+
+ expect(await screen.findByText(/One Pro account for your whole club/i)).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();
+ });
+
// A buyer stopped at a required field is a sale lost; the club is minted with
// a placeholder instead.
it('checks out with the field left empty and sends no key at all', async () => {
diff --git a/e2e/zoom-client/INDEX.md b/e2e/zoom-client/INDEX.md
index 14fea04..8a3e41b 100644
--- a/e2e/zoom-client/INDEX.md
+++ b/e2e/zoom-client/INDEX.md
@@ -12,6 +12,7 @@ Read this first. Update it in the same PR as any spec change.
| [reauth-landing](reauth-landing.md) | stale | never | install landing: `tt_approved` mark, `?welcome=new` / `?welcome=back`, "You're reconnected" and Open in Zoom; a marked browser's bare `/` redirects to `/reconnect` (`/?home` and the `zoom.` host do not); where Zoom lands a guest after approving (#96) |
| [contact-capture](contact-capture.md) | draft | never | Zoom email/name capture: authorize, "Stay in touch" card, browser and web sign-in doors, purge on uninstall (#83) |
| [timer-sounds](timer-sounds.md) | draft | never | Pro sounds: card-change chime (1/2/3 notes), break music during Take a Break, locked for free plans, audible through Share sound (#105) |
+| [pro-launch](pro-launch.md) | current | eeaa840, 2026-11-13 | one-time "New: Pro for your club" window after FINISH for free devices once `pro` is on; hand-off to the Upgrade dialog; red dot on the footer's Upgrade button until Pro has been looked at. PL-01's free-device half and PL-04 (Free account) not run yet |
| [club-card-sets](club-card-sets.md) | current | 905d8d1, 2026-11-13 | an admin or editor shares a custom card set with the club; another club device downloads it and shows it on real video; own choice kept; members can't publish; console removal; lapse; republish prunes the old image (plan 10 Phase 1). CC-07 and CC-08 not run on 905d8d1, and CC-06's member 403 never yet (a guest got 401) |
| pro-club | **not migrated** | — | source: `docs/ZOOM_TEST_PLAN.md` P1–P7, plus the internal Club Pro plan (`06-zoom-client-test-plan-club-pro.md`); reference run `test-runs/2026-09-28-club-pro/run.md` |
| webhooks-lifecycle | **not migrated** | — | source: `docs/ZOOM_TEST_PLAN.md` Steps 4, 5, 6 |
@@ -21,5 +22,5 @@ yet. Migrate one at a time, running each once so `verified_at` is real.
## Run order for a full pass
Dependencies between specs, so the skill can batch human steps: `auth-identity` →
-`connection-notice` → `reauth-landing` → `contact-capture` → `timer-core` → `timer-sounds` → `agenda-import` → `pro-club` → `club-card-sets` → `webhooks-lifecycle` (deauth
+`connection-notice` → `reauth-landing` → `contact-capture` → `timer-core` → `timer-sounds` → `agenda-import` → `pro-launch` → `pro-club` → `club-card-sets` → `webhooks-lifecycle` (deauth
last, it removes the app).
diff --git a/e2e/zoom-client/pro-launch.md b/e2e/zoom-client/pro-launch.md
new file mode 100644
index 0000000..ce53978
--- /dev/null
+++ b/e2e/zoom-client/pro-launch.md
@@ -0,0 +1,84 @@
+---
+id: pro-launch
+title: Pro launch announcement
+status: current
+covers:
+ - apps/zoom-app/src/components/ProLaunchModal.jsx
+ - apps/zoom-app/src/components/PeriodicPrompts.jsx
+ - apps/zoom-app/src/components/Footer.jsx
+ - packages/shared/promptScheduler.js
+automated:
+ - e2e/zoom.pro-launch.spec.js
+ - apps/zoom-app/src/components/PeriodicPrompts.test.jsx
+ - apps/zoom-app/src/components/Footer.test.jsx
+ - packages/shared/__tests__/promptScheduler.test.js
+needs:
+ - dev deployed with `VITE_ENABLE_DEBUG_PANEL=true` (PL-02, PL-03 use the debug panel's prompt controls)
+ - FLAGS_FORCE "1" on dev (the announcement is behind `pro`)
+ - camera on, in a meeting
+ - PL-04 only, a Zoom account on the Free plan with no club on the device
+verified_at: eeaa840
+verified_on: 2026-11-13 (Zoom 7.2.5, macOS 26.7.2, dev c8774483)
+---
+
+# Pro launch announcement
+
+## Purpose
+Once the `pro` flag is on, a free device gets a one-time "New: Pro for your club" window
+after a speech ends, and the footer's Upgrade button carries a red dot until the user has
+looked at Pro. Playwright proves the gates and the once-only rule with the SDK mocked. Only
+the real client shows that the window fits the narrow Zoom panel, lands after FINISH rather
+than over a running speech or Zoom's own background prompts, and hands off to the real
+Upgrade dialog in Zoom's webview.
+
+## Steps
+
+### PL-01 · auto · Footer dot in the Zoom panel
+Do: open the app on a device that is not Pro, with the `pro` flag on.
+Expect: the footer reads "Send Us Feedback", "Review", "Upgrade" on one line where the panel is
+wide enough, and the sparkle icon on Upgrade carries a small red dot. On a Pro device the
+button reads "Pro" with no dot.
+Evidence: screenshot of the footer; for a Pro device, which plan the panel shows.
+
+### PL-02 · auto · The window in the Zoom panel, and the hand-off to Upgrade
+Do: debug panel → Periodic Prompts → "Pro launch" → Show. Then "See what's in Pro", and close
+the Upgrade dialog.
+Expect: the window "New: Pro for your club" fits the panel with no horizontal scroll: three
+bullet lines, four with "A chime when the card changes, and music during breaks" while the
+`sounds` flag is on (it is, under FLAGS_FORCE "1"), "The timer itself stays free. Not the one who decides? Tell your club officers.",
+and the buttons "Not now" and "See what's in Pro". "See what's in Pro" replaces it with the
+Upgrade dialog (its benefit list starts "Your club's brand kit on every timer card and report"
+on a free device; a Pro device sees its own plan). After closing, the debug panel reads
+"Pro launch: 0/1 asks, answered" (a forced show records no ask) and the footer dot is gone.
+Evidence: screenshots; PostHog from this device: `pro_launch_learn_more`, then
+`upgrade_prompt_shown` with `source: "pro_launch"`.
+
+### PL-03 · auto · A Pro device is not told about Pro
+Do: on a device that is Pro (a club code or a subscription), debug panel → Reset history, then
+time one short speech and press FINISH. Wait 5 s.
+Expect: no announcement. The debug panel reads "Pro launch: 0/1 asks". No other prompt is due
+after a single speech.
+Evidence: screenshot after the wait; PostHog has no `periodic_prompt_shown` with
+`prompt: "pro_launch"` from this device for the run.
+
+### PL-04 · human · A free device sees it once, after FINISH
+Do: sign in to Zoom with a Free account that has no club on the device. With prompt history
+reset (debug panel), START a speech, wait for green, FINISH. Answer Zoom's background prompt if
+it shows. Wait 3 s. Close the window with "Not now". Time and finish another speech.
+Expect: nothing appears while the timer runs or on the FINISH click itself; about 2.5 s after
+FINISH the window appears. After "Not now" the footer dot stays. The second speech brings no
+window back; the debug panel reads "Pro launch: 1/1 asks".
+Evidence: screenshots; PostHog `periodic_prompt_shown` and `periodic_prompt_dismissed` with
+`prompt: "pro_launch"`, once each.
+Human because it needs a second, Free Zoom account signed in on the test computer.
+
+Run 2026-11-13 (eeaa840, Zoom on macOS, device Pro through its own subscription): PL-02 and
+PL-03 passed; PL-01 only its Pro half (no dot on "Pro"); PL-04 not run (needs a Free account).
+Log: test-runs/2026-11-13-pro-launch/run.md.
+
+## Not covered
+- The purchase and club-code flows inside the Upgrade dialog (the pro-club area is not
+ migrated to a spec yet; see `INDEX.md`).
+- The web timer: it has no Pro announcement. Its prompts are unchanged.
+- The 28-day end of the footer dot (`PRO_LAUNCH_BADGE_DAYS`): unit-tested with a pinned clock.
+- A link for club officers: there is no page about Pro on the website to point at yet.
diff --git a/e2e/zoom.pro-launch.spec.js b/e2e/zoom.pro-launch.spec.js
new file mode 100644
index 0000000..4214d81
--- /dev/null
+++ b/e2e/zoom.pro-launch.spec.js
@@ -0,0 +1,90 @@
+import { test, expect } from '@playwright/test';
+
+// The one-time "Pro is here" announcement. It appears after a speech ends, only
+// while the `pro` flag is on and only to a device that is not Pro already.
+
+function session(page, { plan = 'free', pro = true, sounds = false } = {}) {
+ return page.route('**/api/zoom/session', (route) =>
+ route.fulfill({
+ json: {
+ identified: true,
+ uid: 'e2e-pro-launch',
+ entitlement:
+ plan === 'pro'
+ ? { plan: 'pro', status: 'active', entitled: true, source: 'subscription' }
+ : { plan: 'free', status: null, entitled: false, source: null },
+ flags: { pro, sounds },
+ },
+ })
+ );
+}
+
+const announcement = (page) => page.getByRole('dialog', { name: /new: pro for your club/i });
+// The footer's: the locked Sounds button's label mentions upgrading too.
+const upgradeButton = (page) => page.locator('footer').getByRole('button', { name: /upgrade to pro/i });
+
+async function timeOneSpeech(page) {
+ await page.getByRole('button', { name: /START/i }).click();
+ await page.waitForTimeout(1200);
+ await page.getByRole('button', { name: /FINISH/i }).click();
+}
+
+test('a free user sees it once, after FINISH, and the footer marks Upgrade as new', async ({ page }) => {
+ await session(page);
+ await page.goto('/zoom/');
+
+ await expect(upgradeButton(page)).toContainText('New');
+ await timeOneSpeech(page);
+
+ // Not on the FINISH click itself: the prompt waits for a quiet moment.
+ await expect(announcement(page)).toBeHidden();
+ await expect(announcement(page)).toBeVisible({ timeout: 6000 });
+ await expect(announcement(page)).toContainText('One plan covers every timer in your club');
+
+ await announcement(page).getByRole('button', { name: /not now/i }).click();
+ await expect(announcement(page)).toBeHidden();
+ // Closing the announcement leaves the quiet reminder in place.
+ await expect(upgradeButton(page)).toContainText('New');
+
+ // Shown once: another speech, after a reload, brings nothing back.
+ await page.reload();
+ await timeOneSpeech(page);
+ await page.waitForTimeout(4000);
+ await expect(announcement(page)).toBeHidden();
+});
+
+test('"See what\'s in Pro" opens the Upgrade dialog and retires the badge', async ({ page }) => {
+ await session(page, { sounds: true });
+ await page.goto('/zoom/');
+ await timeOneSpeech(page);
+ await expect(announcement(page)).toBeVisible({ timeout: 6000 });
+ // Sounds are pitched once released, in the announcement and behind it.
+ await expect(announcement(page)).toContainText('A chime when the card changes, and music during breaks');
+
+ await announcement(page).getByRole('button', { name: /see what's in pro/i }).click();
+ await expect(announcement(page)).toBeHidden();
+ await expect(page.getByText("Every meeting saved to your club's archive")).toBeVisible();
+ await expect(page.getByText('A chime when the card changes, and music during breaks')).toBeVisible();
+
+ await page.getByRole('button', { name: 'Close' }).first().click();
+ await expect(upgradeButton(page)).toBeVisible();
+ await expect(upgradeButton(page)).not.toContainText('New');
+});
+
+test('nothing while Pro is not released', async ({ page }) => {
+ await session(page, { pro: false });
+ await page.goto('/zoom/');
+ await timeOneSpeech(page);
+ await page.waitForTimeout(4000);
+ await expect(announcement(page)).toBeHidden();
+ await expect(upgradeButton(page)).toHaveCount(0);
+});
+
+test('nothing for a device that is already Pro', async ({ page }) => {
+ await session(page, { plan: 'pro' });
+ await page.goto('/zoom/');
+ await timeOneSpeech(page);
+ await page.waitForTimeout(4000);
+ await expect(announcement(page)).toBeHidden();
+ await expect(page.locator('footer').getByRole('button', { name: /manage your pro plan/i })).not.toContainText('New');
+});
diff --git a/packages/shared/__tests__/promptScheduler.test.js b/packages/shared/__tests__/promptScheduler.test.js
index 61f92d0..f7cecea 100644
--- a/packages/shared/__tests__/promptScheduler.test.js
+++ b/packages/shared/__tests__/promptScheduler.test.js
@@ -2,9 +2,13 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
CLUB_PROMPT,
REVIEW_PROMPT,
+ PRO_LAUNCH_PROMPT,
+ DEFAULT_PROMPTS,
+ PRO_LAUNCH_BADGE_DAYS,
GLOBAL_COOLDOWN_DAYS,
PROMPT_ORDER,
PROMPT_RULES,
+ isProLaunchBadgeDue,
forcePrompt,
isPromptDebugMode,
isPromptDue,
@@ -91,9 +95,9 @@ describe('global cooldown', () => {
markPromptAnswered(CLUB_PROMPT);
// Review is otherwise due, but the club ask just happened.
- expect(selectDuePrompt(loadPromptState(), NOW + DAY_MS, PROMPT_ORDER, STRICT)).toBeNull();
+ expect(selectDuePrompt(loadPromptState(), NOW + DAY_MS, DEFAULT_PROMPTS, STRICT)).toBeNull();
expect(
- selectDuePrompt(loadPromptState(), NOW + GLOBAL_COOLDOWN_DAYS * DAY_MS, PROMPT_ORDER, STRICT)
+ selectDuePrompt(loadPromptState(), NOW + GLOBAL_COOLDOWN_DAYS * DAY_MS, DEFAULT_PROMPTS, STRICT)
).toBe(REVIEW_PROMPT);
});
});
@@ -182,8 +186,8 @@ describe('dev mode (VITE_ENABLE_DEBUG_PANEL=true)', () => {
markPromptAnswered(CLUB_PROMPT);
const soon = NOW + 60 * 1000;
- expect(selectDuePrompt(loadPromptState(), soon, PROMPT_ORDER, STRICT)).toBeNull();
- expect(selectDuePrompt(loadPromptState(), soon, PROMPT_ORDER, { ignoreTimeGates: true })).toBe(
+ expect(selectDuePrompt(loadPromptState(), soon, DEFAULT_PROMPTS, STRICT)).toBeNull();
+ expect(selectDuePrompt(loadPromptState(), soon, DEFAULT_PROMPTS, { ignoreTimeGates: true })).toBe(
REVIEW_PROMPT
);
});
@@ -299,3 +303,91 @@ describe('forcePrompt (debug panel)', () => {
expect(listener).not.toHaveBeenCalled();
});
});
+
+describe('the Pro launch announcement', () => {
+ const ALL = PROMPT_ORDER;
+
+ it('is never picked unless the app enables it', () => {
+ finishSpeeches(PROMPT_RULES[REVIEW_PROMPT].firstAskAfterSpeeches);
+ const state = loadPromptState();
+
+ expect(isPromptDue(PRO_LAUNCH_PROMPT, state, NOW)).toBe(true);
+ expect(DEFAULT_PROMPTS).not.toContain(PRO_LAUNCH_PROMPT);
+ expect(selectDuePrompt(state, NOW)).toBe(CLUB_PROMPT);
+ });
+
+ it('is due after the first finished speech, and goes ahead of the others', () => {
+ expect(selectDuePrompt(loadPromptState(), NOW, ALL)).toBeNull();
+ finishSpeeches(1);
+ expect(selectDuePrompt(loadPromptState(), NOW, ALL)).toBe(PRO_LAUNCH_PROMPT);
+
+ finishSpeeches(PROMPT_RULES[REVIEW_PROMPT].firstAskAfterSpeeches);
+ expect(selectDuePrompt(loadPromptState(), NOW, ALL)).toBe(PRO_LAUNCH_PROMPT);
+ });
+
+ it('is shown once: a dismissal retires it, however much the timer is used after', () => {
+ finishSpeeches(1);
+ markPromptShown(PRO_LAUNCH_PROMPT, NOW);
+
+ finishSpeeches(100);
+ const muchLater = NOW + 365 * DAY_MS;
+ expect(isPromptDue(PRO_LAUNCH_PROMPT, loadPromptState(), muchLater, STRICT)).toBe(false);
+ expect(isPromptDue(PRO_LAUNCH_PROMPT, loadPromptState(), muchLater, { ignoreTimeGates: true })).toBe(false);
+ });
+
+ it('holds the club question back for the global cooldown after it shows', () => {
+ finishSpeeches(PROMPT_RULES[CLUB_PROMPT].firstAskAfterSpeeches);
+ markPromptShown(PRO_LAUNCH_PROMPT, NOW);
+
+ expect(selectDuePrompt(loadPromptState(), NOW + DAY_MS, ALL, STRICT)).toBeNull();
+ expect(
+ selectDuePrompt(loadPromptState(), NOW + GLOBAL_COOLDOWN_DAYS * DAY_MS, ALL, STRICT)
+ ).toBe(CLUB_PROMPT);
+ });
+
+ it('is not due once answered (any Upgrade dialog marks it)', () => {
+ finishSpeeches(1);
+ markPromptAnswered(PRO_LAUNCH_PROMPT);
+ expect(selectDuePrompt(loadPromptState(), NOW, ALL)).toBeNull();
+ });
+
+ it('keeps an old browser\'s stored prompts and adds the new record', () => {
+ savePromptState({
+ speechesFinished: 40,
+ lastPromptAt: NOW - 30 * DAY_MS,
+ prompts: { [CLUB_PROMPT]: { asks: 1, resolution: 'answered' } },
+ });
+ const state = loadPromptState();
+ expect(state.prompts[CLUB_PROMPT].resolution).toBe('answered');
+ expect(state.prompts[PRO_LAUNCH_PROMPT]).toEqual({
+ asks: 0,
+ lastAskAt: 0,
+ lastAskAtSpeeches: 0,
+ resolution: null,
+ });
+ expect(selectDuePrompt(state, NOW, ALL, STRICT)).toBe(PRO_LAUNCH_PROMPT);
+ });
+});
+
+describe('isProLaunchBadgeDue', () => {
+ it('shows before the announcement has had its chance', () => {
+ expect(isProLaunchBadgeDue(loadPromptState(), NOW)).toBe(true);
+ });
+
+ it('stays for the badge window after the announcement was dismissed, then goes', () => {
+ markPromptShown(PRO_LAUNCH_PROMPT, NOW);
+ const state = loadPromptState();
+ expect(isProLaunchBadgeDue(state, NOW + (PRO_LAUNCH_BADGE_DAYS - 1) * DAY_MS)).toBe(true);
+ expect(isProLaunchBadgeDue(state, NOW + PRO_LAUNCH_BADGE_DAYS * DAY_MS)).toBe(false);
+ });
+
+ it('goes as soon as the user has looked at Pro', () => {
+ markPromptAnswered(PRO_LAUNCH_PROMPT);
+ expect(isProLaunchBadgeDue(loadPromptState(), NOW)).toBe(false);
+ });
+
+ it('is false for a missing or malformed state', () => {
+ expect(isProLaunchBadgeDue(null, NOW)).toBe(false);
+ expect(isProLaunchBadgeDue({ prompts: {} }, NOW)).toBe(false);
+ });
+});
diff --git a/packages/shared/promptScheduler.js b/packages/shared/promptScheduler.js
index 491dc8a..d7c1169 100644
--- a/packages/shared/promptScheduler.js
+++ b/packages/shared/promptScheduler.js
@@ -14,6 +14,9 @@ const DAY_MS = 24 * 60 * 60 * 1000;
export const CLUB_PROMPT = 'club';
export const REVIEW_PROMPT = 'review';
+// The one-time "Pro is here" announcement. Opt-in: an app enables it only while
+// the `pro` flag is on and the device is not already Pro (see DEFAULT_PROMPTS).
+export const PRO_LAUNCH_PROMPT = 'pro_launch';
/**
* Cadence per prompt. The thresholds are deliberately conservative: a prompt
@@ -38,10 +41,28 @@ export const PROMPT_RULES = {
daysBetweenAsks: 30,
maxAsks: 3,
},
+ // An announcement, not a question: shown once, after the first speech the
+ // device finishes while it is eligible, and never again.
+ [PRO_LAUNCH_PROMPT]: {
+ firstAskAfterSpeeches: 1,
+ speechesBetweenAsks: 0,
+ daysBetweenAsks: 0,
+ maxAsks: 1,
+ },
};
-// The club question takes one line to answer, so it goes first when both are due.
-export const PROMPT_ORDER = [CLUB_PROMPT, REVIEW_PROMPT];
+// The Pro announcement is time-bound news, so it goes first when it is enabled.
+// Among the rest, the club question takes one line to answer, so it goes before
+// the review ask.
+export const PROMPT_ORDER = [PRO_LAUNCH_PROMPT, CLUB_PROMPT, REVIEW_PROMPT];
+
+// What selectDuePrompt considers when the caller names no list: the prompts
+// that need no release gate. The Pro announcement is never in here; an app
+// adds it while Pro is released.
+export const DEFAULT_PROMPTS = [CLUB_PROMPT, REVIEW_PROMPT];
+
+// How long the footer keeps its "New" badge after the announcement was shown.
+export const PRO_LAUNCH_BADGE_DAYS = 28;
// Two prompts never land closer together than this, whichever prompts they are.
export const GLOBAL_COOLDOWN_DAYS = 3;
@@ -279,11 +300,11 @@ export function isPromptDue(key, state, now = Date.now(), options = {}) {
* Pick the prompt to show, or null if none is due.
* @param {Object} state - Prompt state
* @param {number} [now] - Current epoch ms
- * @param {string[]} [enabled] - Prompts this app supports
+ * @param {string[]} [enabled] - Prompts this app supports (DEFAULT_PROMPTS if omitted)
* @param {Object} [options] - Forwarded to isPromptDue
* @returns {string|null} Prompt key
*/
-export function selectDuePrompt(state, now = Date.now(), enabled = PROMPT_ORDER, options = {}) {
+export function selectDuePrompt(state, now = Date.now(), enabled = DEFAULT_PROMPTS, options = {}) {
return (
PROMPT_ORDER.find((key) => enabled.includes(key) && isPromptDue(key, state, now, options)) ||
null
@@ -338,3 +359,23 @@ export function markPromptAnswered(key) {
export function markPromptDeclined(key) {
return resolve(key, RESOLUTION_DECLINED);
}
+
+/**
+ * Whether the footer's Upgrade button should carry a "New" badge: the quiet
+ * half of the Pro announcement, for someone who closed the modal or has not
+ * finished a speech since Pro was released. It goes once the user has looked at
+ * Pro (any Upgrade dialog marks the announcement answered) or
+ * PRO_LAUNCH_BADGE_DAYS after the announcement was shown.
+ *
+ * Release and plan gates are the caller's: this only reads the history.
+ *
+ * @param {Object} state - Prompt state
+ * @param {number} [now] - Current epoch ms
+ * @returns {boolean}
+ */
+export function isProLaunchBadgeDue(state, now = Date.now()) {
+ const record = state?.prompts?.[PRO_LAUNCH_PROMPT];
+ if (!record || record.resolution) return false;
+ if (record.asks === 0) return true;
+ return now - record.lastAskAt < PRO_LAUNCH_BADGE_DAYS * DAY_MS;
+}