diff --git a/apps/web/src/hooks/useSounds.js b/apps/web/src/hooks/useSounds.js
index 4925bdd..6f4b4ac 100644
--- a/apps/web/src/hooks/useSounds.js
+++ b/apps/web/src/hooks/useSounds.js
@@ -15,23 +15,29 @@ export function useSoundSettings() {
// The lock tooltip on the Sounds button, its modal, and the modal's controls.
export const SOUNDS_LOCK_MESSAGES = Object.freeze({
pro: 'Sounds are a Pro feature. Upgrade to Pro to use them.',
- unreleased: 'Sounds are coming soon.',
+ unreleased: 'Hold tight! Sounds are coming soon.',
})
/**
* Whether this user may turn sounds on: the release flag and Pro both. The
- * button and modal always show, so free users can see what Pro adds; anyone
- * who cannot use them gets them locked, with lockReason saying why. Until both
+ * button and modal always show, so everyone can see what is on the way; anyone
+ * who cannot use them gets them locked, with lockReason saying why. 'pro' only
+ * when buying Pro would unlock them, which needs both `sounds` and `pro`
+ * released. While either is off, Pro cannot get them, so the lock says they are
+ * coming rather than selling a plan that cannot be bought yet. Until both
* answers are in, sounds are unusable but not yet locked, so a Pro user never
* sees a lock flash on load.
*
* @returns {{usable: boolean, lockReason: 'pro'|'unreleased'|null}}
*/
export function useSoundsAccess() {
- const { enabled, known: flagsKnown } = useFlag('sounds')
+ const { enabled: soundsReleased, known: flagsKnown } = useFlag('sounds')
+ const { enabled: proReleased } = useFlag('pro')
const { isPro, known: entitlementKnown } = useEntitlement()
- const usable = flagsKnown && enabled && isPro
+ const usable = flagsKnown && soundsReleased && isPro
let lockReason = null
- if (flagsKnown && entitlementKnown && !usable) lockReason = isPro ? 'unreleased' : 'pro'
+ if (flagsKnown && entitlementKnown && !usable) {
+ lockReason = soundsReleased && proReleased ? 'pro' : 'unreleased'
+ }
return { usable, lockReason }
}
diff --git a/apps/zoom-app/src/App.jsx b/apps/zoom-app/src/App.jsx
index a0c34ae..1b7da4a 100644
--- a/apps/zoom-app/src/App.jsx
+++ b/apps/zoom-app/src/App.jsx
@@ -8,6 +8,7 @@ import TimerSounds from './components/TimerSounds';
import ContactCapture from './components/ContactCapture';
import ZoomConnectionNotice from './components/ZoomConnectionNotice';
import ClubStatusBanner from './components/ClubStatusBanner';
+import AnnouncementBanner from './components/AnnouncementBanner';
import './App.css';
const LiveTab = lazy(() => import('./components/LiveTab'));
@@ -26,6 +27,7 @@ function App() {
gets before it loses Pro has to be seen by whoever is timing, who
is rarely whoever pays. */}
+ }} props - a list for tests;
+ * the app uses the shipped one
+ */
+export default function AnnouncementBanner({ announcements } = {}) {
+ const flags = useSyncExternalStore(subscribeFlags, getFlags, getFlags);
+ const flagsKnown = useSyncExternalStore(subscribeFlags, areFlagsKnown, areFlagsKnown);
+ // Bumped by a close so the memo below asks again; the answer itself lives
+ // in localStorage.
+ const [dismissals, setDismissals] = useState(0);
+ const [closedHere, setClosedHere] = useState(null);
+
+ const announcement = useMemo(
+ () => pickAnnouncement({ surface: 'zoom', flags, flagsKnown, announcements }),
+ [flags, flagsKnown, announcements, dismissals],
+ );
+ const id = announcement?.id ?? null;
+
+ useEffect(() => {
+ if (id) trackEvent('announcement_shown', { surface: 'zoom', announcement_id: id });
+ }, [id]);
+
+ // Storage that refuses the write still hides it until the app reopens.
+ if (!announcement || announcement.id === closedHere) return null;
+
+ const handleDismiss = () => {
+ dismissAnnouncement(announcement.id);
+ trackEvent('announcement_dismissed', { surface: 'zoom', announcement_id: announcement.id });
+ setClosedHere(announcement.id);
+ setDismissals((count) => count + 1);
+ };
+
+ const handleLink = announcement.link?.url
+ ? () => {
+ trackEvent('announcement_link_opened', { surface: 'zoom', announcement_id: announcement.id });
+ openExternalUrl(announcement.link.url);
+ }
+ : undefined;
+
+ return (
+
+ );
+}
diff --git a/apps/zoom-app/src/components/AnnouncementBanner.test.jsx b/apps/zoom-app/src/components/AnnouncementBanner.test.jsx
new file mode 100644
index 0000000..84e5116
--- /dev/null
+++ b/apps/zoom-app/src/components/AnnouncementBanner.test.jsx
@@ -0,0 +1,101 @@
+import '@testing-library/jest-dom';
+import { fireEvent, render, screen } from '@testing-library/react';
+import {
+ ANNOUNCEMENTS_DISMISSED_KEY,
+ resetFlagsForTests,
+ setFlags,
+} from '@toastmaster-timer/shared';
+import AnnouncementBanner from './AnnouncementBanner';
+import { trackEvent } from '../utils/posthog';
+import { openExternalUrl } from '../utils/zoomSdk';
+
+vi.mock('../utils/posthog', () => ({ trackEvent: vi.fn() }));
+// Stubbed: the real module pulls in @zoom/appssdk, which hangs vitest under jsdom.
+vi.mock('../utils/zoomSdk', () => ({ openExternalUrl: vi.fn() }));
+
+const banner = () => screen.queryByTestId('announcement-banner');
+
+beforeEach(() => {
+ localStorage.clear();
+ resetFlagsForTests();
+ trackEvent.mockClear();
+ openExternalUrl.mockClear();
+});
+
+describe('AnnouncementBanner with the shipped announcements', () => {
+ it('says a new look and Pro are coming while pro is off', () => {
+ setFlags({ pro: false, sounds: true });
+ render();
+ expect(banner()).toHaveAttribute('data-announcement-id', 'pre-launch-2026-11');
+ expect(banner()).toHaveTextContent(
+ 'Coming soon: a fresh new look for the timer, and Pro features for your club.',
+ );
+ expect(trackEvent).toHaveBeenCalledWith('announcement_shown', {
+ surface: 'zoom',
+ announcement_id: 'pre-launch-2026-11',
+ });
+ });
+
+ it('stays hidden until the flags are known, so it never flashes', () => {
+ render();
+ expect(banner()).toBeNull();
+ expect(trackEvent).not.toHaveBeenCalled();
+ });
+
+ it('retires the pre-launch notice once pro is released', () => {
+ setFlags({ pro: true, sounds: true });
+ render();
+ expect(banner()).toBeNull();
+ });
+
+ it('stays closed on this device', () => {
+ setFlags({ pro: false });
+ const first = render();
+ fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
+ expect(banner()).toBeNull();
+ expect(JSON.parse(localStorage.getItem(ANNOUNCEMENTS_DISMISSED_KEY))).toEqual(['pre-launch-2026-11']);
+ expect(trackEvent).toHaveBeenCalledWith('announcement_dismissed', {
+ surface: 'zoom',
+ announcement_id: 'pre-launch-2026-11',
+ });
+ first.unmount();
+
+ render();
+ expect(banner()).toBeNull();
+ });
+});
+
+describe('AnnouncementBanner with any announcement', () => {
+ const news = {
+ id: 'news-1',
+ lead: 'New:',
+ text: 'club card sets.',
+ tone: 'success',
+ link: { label: 'How it works', url: 'https://www.timer.simple-tech.app/help' },
+ };
+
+ it('shows the lead, the text and the link, and opens the link outside Zoom', () => {
+ render();
+ expect(banner()).toHaveTextContent('New: club card sets. How it works');
+ expect(banner()).toHaveClass('bg-green-50');
+ fireEvent.click(screen.getByRole('button', { name: 'How it works' }));
+ expect(openExternalUrl).toHaveBeenCalledWith('https://www.timer.simple-tech.app/help');
+ expect(trackEvent).toHaveBeenCalledWith('announcement_link_opened', {
+ surface: 'zoom',
+ announcement_id: 'news-1',
+ });
+ });
+
+ it('moves on to the next announcement after one is closed', () => {
+ const later = { id: 'news-2', text: 'Something else.' };
+ render();
+ fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
+ expect(banner()).toHaveAttribute('data-announcement-id', 'news-2');
+ expect(banner()).toHaveTextContent('Something else.');
+ });
+
+ it('skips one meant only for the web app', () => {
+ render();
+ expect(banner()).toBeNull();
+ });
+});
diff --git a/apps/zoom-app/src/components/Footer.jsx b/apps/zoom-app/src/components/Footer.jsx
index 0a2a3b7..7c5cd72 100644
--- a/apps/zoom-app/src/components/Footer.jsx
+++ b/apps/zoom-app/src/components/Footer.jsx
@@ -12,6 +12,7 @@ import {
import { trackEvent } from '../utils/posthog';
import { openExternalUrl } from '../utils/zoomSdk';
import { useToast } from '../context/ToastContext';
+import ProComingSoon from './ProComingSoon';
const FeedbackModal = lazy(() => import('./FeedbackModal'));
const UpgradeModal = lazy(() => import('./UpgradeModal'));
@@ -91,6 +92,9 @@ export default memo(function Footer() {
{isPro ? 'Pro' : 'Upgrade'}
)}
+ {/* Before Pro is released: the same place, locked, saying what is
+ coming. Nothing can be bought yet, so there is no upgrade here. */}
+ {flagsKnown && !proEnabled && }
{showUpgradeModal && (
diff --git a/apps/zoom-app/src/components/Footer.test.jsx b/apps/zoom-app/src/components/Footer.test.jsx
index edf437b..cbac5c1 100644
--- a/apps/zoom-app/src/components/Footer.test.jsx
+++ b/apps/zoom-app/src/components/Footer.test.jsx
@@ -102,6 +102,36 @@ describe('the Footer\'s Pro button', () => {
expect(upgradeButton()).toBeInTheDocument();
});
+ // Before the launch the button stays in its place, locked, and says what is
+ // coming instead of selling a plan nobody can buy yet.
+ it('shows a locked Pro button while pro is off, for every plan', () => {
+ setEntitlement({ plan: 'free' });
+ setFlags({ pro: false });
+ const free = renderFooter();
+ expect(screen.getByRole('button', { name: 'Pro (coming soon)' })).toBeInTheDocument();
+ free.unmount();
+
+ setEntitlement(PRO);
+ renderFooter();
+ expect(screen.getByRole('button', { name: 'Pro (coming soon)' })).toBeInTheDocument();
+ expect(proButton()).toBeNull();
+ });
+
+ it('shows no locked Pro button before the flags land, or once pro is on', () => {
+ setEntitlement({ plan: 'free' });
+ const view = renderFooter();
+ expect(screen.queryByRole('button', { name: 'Pro (coming soon)' })).toBeNull();
+
+ act(() => setFlags({ pro: true }));
+ view.rerender(
+
+
+
+ );
+ expect(screen.queryByRole('button', { name: 'Pro (coming soon)' })).toBeNull();
+ expect(upgradeButton()).toBeInTheDocument();
+ });
+
it('leaves the other footer buttons alone whatever the flag says', () => {
setEntitlement({ plan: 'free' });
setFlags({ pro: false });
diff --git a/apps/zoom-app/src/components/ProComingSoon.jsx b/apps/zoom-app/src/components/ProComingSoon.jsx
new file mode 100644
index 0000000..87a2caa
--- /dev/null
+++ b/apps/zoom-app/src/components/ProComingSoon.jsx
@@ -0,0 +1,106 @@
+import { useEffect, useId, useRef, useState } from 'react';
+import { Check, Lock, Sparkles } from 'lucide-react';
+import { trackEvent } from '../utils/posthog';
+
+// What Pro will bring, in a few words each. Keep it short: it opens in the
+// footer of a 400px side panel.
+export const PRO_COMING_SOON_ITEMS = Object.freeze([
+ 'Sounds: a chime at each colour, music for breaks',
+ "Your club's card sets on every club timer",
+ 'Voting for Best Table Topics and Best Evaluator',
+]);
+export const PRO_COMING_SOON_MORE = "And more: your club's logo, shared timings, meeting history.";
+
+/**
+ * The footer's Pro button before Pro is released: greyed, with a lock. Pointing
+ * at it or clicking it opens a short list of what is coming. A click keeps the
+ * list open until the next click, Escape or a click elsewhere, so it works
+ * without a mouse too.
+ */
+export default function ProComingSoon() {
+ const [hovered, setHovered] = useState(false);
+ const [pinned, setPinned] = useState(false);
+ const open = hovered || pinned;
+ const wrapperRef = useRef(null);
+ const trackedHover = useRef(false);
+ const panelId = useId();
+
+ useEffect(() => {
+ if (!pinned) return undefined;
+ const onKey = (event) => {
+ if (event.key === 'Escape') {
+ setPinned(false);
+ setHovered(false);
+ }
+ };
+ const onPointer = (event) => {
+ if (!wrapperRef.current?.contains(event.target)) setPinned(false);
+ };
+ document.addEventListener('keydown', onKey);
+ document.addEventListener('mousedown', onPointer);
+ return () => {
+ document.removeEventListener('keydown', onKey);
+ document.removeEventListener('mousedown', onPointer);
+ };
+ }, [pinned]);
+
+ const handleEnter = () => {
+ setHovered(true);
+ if (!trackedHover.current) {
+ trackedHover.current = true;
+ trackEvent('pro_coming_soon_opened', { trigger: 'hover' });
+ }
+ };
+
+ const handleClick = () => {
+ if (!pinned) trackEvent('pro_coming_soon_opened', { trigger: 'click' });
+ setPinned(!pinned);
+ };
+
+ return (
+
setHovered(false)}
+ >
+
+ {open && (
+
+
+
+ Pro is coming soon
+
+
+ {PRO_COMING_SOON_ITEMS.map((item) => (
+
+
+ {item}
+
+ ))}
+
+
{PRO_COMING_SOON_MORE}
+
The timer itself stays free.
+
+ )}
+
+ );
+}
diff --git a/apps/zoom-app/src/components/ProComingSoon.test.jsx b/apps/zoom-app/src/components/ProComingSoon.test.jsx
new file mode 100644
index 0000000..93a96c8
--- /dev/null
+++ b/apps/zoom-app/src/components/ProComingSoon.test.jsx
@@ -0,0 +1,67 @@
+import '@testing-library/jest-dom';
+import { fireEvent, render, screen } from '@testing-library/react';
+import ProComingSoon from './ProComingSoon';
+import { trackEvent } from '../utils/posthog';
+
+vi.mock('../utils/posthog', () => ({ trackEvent: vi.fn() }));
+
+const button = () => screen.getByRole('button', { name: 'Pro (coming soon)' });
+const panel = () => screen.queryByRole('dialog', { name: 'Pro is coming soon' });
+
+beforeEach(() => trackEvent.mockClear());
+
+describe('ProComingSoon', () => {
+ it('is a greyed, locked Pro button with nothing open', () => {
+ render();
+ expect(button()).toHaveClass('text-gray-400');
+ expect(button()).toHaveAttribute('aria-expanded', 'false');
+ expect(panel()).toBeNull();
+ });
+
+ it('lists what is coming while pointed at, and closes when the pointer leaves', () => {
+ render();
+ fireEvent.mouseEnter(button().parentElement);
+ expect(panel()).toBeInTheDocument();
+ expect(panel()).toHaveTextContent('Pro is coming soon');
+ expect(panel()).toHaveTextContent('Sounds: a chime at each colour, music for breaks');
+ expect(panel()).toHaveTextContent("Your club's card sets on every club timer");
+ expect(panel()).toHaveTextContent('Voting for Best Table Topics and Best Evaluator');
+ expect(panel()).toHaveTextContent("And more: your club's logo, shared timings, meeting history.");
+ expect(panel()).toHaveTextContent('The timer itself stays free.');
+ expect(button()).toHaveAttribute('aria-expanded', 'true');
+ expect(trackEvent).toHaveBeenCalledWith('pro_coming_soon_opened', { trigger: 'hover' });
+
+ fireEvent.mouseLeave(button().parentElement);
+ expect(panel()).toBeNull();
+ });
+
+ it('stays open after a click until clicked again, Escape, or a click elsewhere', () => {
+ render(
+
+
elsewhere
+
+
+ );
+ fireEvent.click(button());
+ expect(panel()).toBeInTheDocument();
+ expect(trackEvent).toHaveBeenCalledWith('pro_coming_soon_opened', { trigger: 'click' });
+ fireEvent.click(button());
+ expect(panel()).toBeNull();
+
+ fireEvent.click(button());
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(panel()).toBeNull();
+
+ fireEvent.click(button());
+ fireEvent.mouseDown(screen.getByText('elsewhere'));
+ expect(panel()).toBeNull();
+ });
+
+ it('records a hover once per mount, not on every pass of the pointer', () => {
+ render();
+ fireEvent.mouseEnter(button().parentElement);
+ fireEvent.mouseLeave(button().parentElement);
+ fireEvent.mouseEnter(button().parentElement);
+ expect(trackEvent).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/zoom-app/src/components/TimerSounds.test.jsx b/apps/zoom-app/src/components/TimerSounds.test.jsx
index 5405ccb..b65687a 100644
--- a/apps/zoom-app/src/components/TimerSounds.test.jsx
+++ b/apps/zoom-app/src/components/TimerSounds.test.jsx
@@ -173,20 +173,36 @@ describe('the Sounds modal', () => {
setEntitlement(PRO);
render( {}} />);
expect(screen.getByRole('checkbox', { name: /chime when the card changes/i })).toBeDisabled();
- expect(screen.getByText(/sounds are coming soon/i)).toBeInTheDocument();
+ expect(screen.getByText('Hold tight! Sounds are coming soon.')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /upgrade to pro/i })).toBeNull();
});
- it('locks a Pro plan as a Pro feature while pro itself is unreleased', () => {
+ it('tells a Pro plan sounds are coming while pro itself is unreleased', () => {
setFlags({ sounds: false, pro: false });
setEntitlement(PRO);
render( {}} />);
expect(screen.getByRole('checkbox', { name: /chime when the card changes/i })).toBeDisabled();
- expect(screen.getByText(/sounds are a pro feature/i)).toBeInTheDocument();
- expect(screen.queryByText(/coming soon/i)).toBeNull();
+ expect(screen.getByText('Hold tight! Sounds are coming soon.')).toBeInTheDocument();
+ expect(screen.queryByText(/sounds are a pro feature/i)).toBeNull();
expect(screen.queryByRole('button', { name: /upgrade to pro/i })).toBeNull();
});
+ // Production before the launch: `sounds` released, `pro` not. Nobody can buy
+ // Pro yet, so nobody is told it is a Pro feature.
+ it('tells a free user sounds are coming while sounds are on and pro is off', () => {
+ setFlags({ sounds: true, pro: false });
+ setEntitlement({ plan: 'free' });
+ render( {}} />);
+ expect(screen.getByRole('checkbox', { name: /chime when the card changes/i })).toBeDisabled();
+ expect(screen.getByText('Hold tight! Sounds are coming soon.')).toBeInTheDocument();
+ expect(screen.queryByText(/sounds are a pro feature/i)).toBeNull();
+ expect(screen.getByLabelText('Locked')).toBeInTheDocument();
+ expect(document.querySelector('[data-tooltip]')).toHaveAttribute(
+ 'data-tooltip',
+ 'Hold tight! Sounds are coming soon.',
+ );
+ });
+
it('shows no lock to a Pro user once sounds are released', () => {
setFlags({ sounds: true, pro: true });
setEntitlement(PRO);
diff --git a/apps/zoom-app/src/hooks/useSounds.js b/apps/zoom-app/src/hooks/useSounds.js
index 04f48e1..6a5e863 100644
--- a/apps/zoom-app/src/hooks/useSounds.js
+++ b/apps/zoom-app/src/hooks/useSounds.js
@@ -17,23 +17,29 @@ export function useSoundSettings() {
// The lock tooltip on the Sounds button, its modal, and the modal's controls.
export const SOUNDS_LOCK_MESSAGES = Object.freeze({
pro: 'Sounds are a Pro feature. Upgrade to Pro to use them.',
- unreleased: 'Sounds are coming soon.',
+ unreleased: 'Hold tight! Sounds are coming soon.',
});
/**
* Whether this user may turn sounds on: the release flag and Pro both. The
- * button and modal always show, so free users can see what Pro adds; anyone
- * who cannot use them gets them locked, with lockReason saying why. Until both
+ * button and modal always show, so everyone can see what is on the way; anyone
+ * who cannot use them gets them locked, with lockReason saying why. 'pro' only
+ * when buying Pro would unlock them, which needs both `sounds` and `pro`
+ * released. While either is off, Pro cannot get them, so the lock says they are
+ * coming rather than selling a plan that cannot be bought yet. Until both
* answers are in, sounds are unusable but not yet locked, so a Pro user never
* sees a lock flash on load.
*
* @returns {{usable: boolean, lockReason: 'pro'|'unreleased'|null}}
*/
export function useSoundsAccess() {
- const { enabled, known: flagsKnown } = useFlag('sounds');
+ const { enabled: soundsReleased, known: flagsKnown } = useFlag('sounds');
+ const { enabled: proReleased } = useFlag('pro');
const { isPro, known: entitlementKnown } = useEntitlement();
- const usable = flagsKnown && enabled && isPro;
+ const usable = flagsKnown && soundsReleased && isPro;
let lockReason = null;
- if (flagsKnown && entitlementKnown && !usable) lockReason = isPro ? 'unreleased' : 'pro';
+ if (flagsKnown && entitlementKnown && !usable) {
+ lockReason = soundsReleased && proReleased ? 'pro' : 'unreleased';
+ }
return { usable, lockReason };
}
diff --git a/e2e/web.timer-sounds.spec.js b/e2e/web.timer-sounds.spec.js
index b3b0178..d0a5f7f 100644
--- a/e2e/web.timer-sounds.spec.js
+++ b/e2e/web.timer-sounds.spec.js
@@ -70,7 +70,7 @@ test('the Sounds modal turns the chime off', async ({ page }) => {
test('a free user sees Sounds locked, with every control disabled', async ({ page }) => {
// Registered after beforeEach's route, so it answers /api/me first.
await page.route('**/api/me*', (route) =>
- route.fulfill({ json: { uid: 'e2e-free', entitlement: { plan: 'free' }, flags: { sounds: false, pro: false } } })
+ route.fulfill({ json: { uid: 'e2e-free', entitlement: { plan: 'free' }, flags: { sounds: true, pro: true } } })
);
await page.goto('/timer/app');
const button = page.getByRole('button', { name: /^Sounds \(locked\)/ });
@@ -82,9 +82,9 @@ test('a free user sees Sounds locked, with every control disabled', async ({ pag
await expect(page.getByRole('slider', { name: 'Volume' })).toBeDisabled();
});
-test('a Pro plan sees Sounds locked as Pro while pro is unreleased', async ({ page }) => {
+test('a Pro plan is told Sounds are coming while pro is unreleased', async ({ page }) => {
// The pro flag is the source of truth for Pro in the UI: with it off, a
- // paying user is shown the same lock as a free one, not "coming soon".
+ // paying user is locked like a free one, and nobody is sold Pro yet.
await page.route('**/api/me*', (route) =>
route.fulfill({
json: {
@@ -96,10 +96,25 @@ test('a Pro plan sees Sounds locked as Pro while pro is unreleased', async ({ pa
);
await page.goto('/timer/app');
const button = page.getByRole('button', { name: /^Sounds \(locked\)/ });
- await expect(button).toHaveAttribute('title', 'Sounds are a Pro feature. Upgrade to Pro to use them.');
+ await expect(button).toHaveAttribute('title', 'Hold tight! Sounds are coming soon.');
await button.click();
- await expect(page.getByText('Sounds are a Pro feature.')).toBeVisible();
- await expect(page.getByText(/coming soon/i)).toHaveCount(0);
+ await expect(page.getByText('Hold tight! Sounds are coming soon.')).toBeVisible();
+ await expect(page.getByText(/sounds are a pro feature/i)).toHaveCount(0);
await expect(page.getByRole('checkbox', { name: /chime when the card changes/i })).toBeDisabled();
});
+
+test('before the launch, a free user is told Sounds are coming', async ({ page }) => {
+ // Production until Pro launches: `sounds` released, `pro` not.
+ await page.route('**/api/me*', (route) =>
+ route.fulfill({ json: { uid: 'e2e-prelaunch', entitlement: { plan: 'free' }, flags: { sounds: true, pro: false } } })
+ );
+ await page.goto('/timer/app');
+ const button = page.getByRole('button', { name: /^Sounds \(locked\)/ });
+ await expect(button).toHaveAttribute('title', 'Hold tight! Sounds are coming soon.');
+
+ await button.click();
+ await expect(page.getByText('Hold tight! Sounds are coming soon.')).toBeVisible();
+ await expect(page.getByText(/sounds are a pro feature/i)).toHaveCount(0);
+ await expect(page.getByRole('button', { name: /upgrade to pro/i })).toHaveCount(0);
+});
diff --git a/e2e/zoom-client/INDEX.md b/e2e/zoom-client/INDEX.md
index 14fea04..0db87cf 100644
--- a/e2e/zoom-client/INDEX.md
+++ b/e2e/zoom-client/INDEX.md
@@ -11,8 +11,11 @@ Read this first. Update it in the same PR as any spec change.
| [connection-notice](connection-notice.md) | draft | never | guest-mode banner and "Approve Toastmusters Timer in Zoom" modal at open; raised mid-session when Zoom drops the grant, modal held until the timer stops (#85) |
| [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) |
+| [timer-sounds](timer-sounds.md) | stale | never | Pro sounds: card-change chime (1/2/3 notes), break music during Take a Break, locked for free plans ("Hold tight! Sounds are coming soon." while `pro` is off), audible through Share sound (#105). TS-09 ran green on 60e1b6e (2026-11-14); TS-01 needs a free device (the test device is on club Pro), TS-08 needs per-uid PostHog flags; audio steps never run |
+| [announcement-banner](announcement-banner.md) | current | cb49176, 2026-11-14 | one-line announcement above the tabs, picked from `ANNOUNCEMENTS` (packages/shared/announcements.js); today the pre-launch notice ("Coming soon: a fresh new look… and Pro features") while `pro` is off; a close is kept per device per announcement |
| [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) |
+| [club-renewal-reminder](club-renewal-reminder.md) | current | 51534f9, 2026-11-14 | amber "'s Pro ends in N days" line: only in the last 60 days of a club that will not renew; a failed payment at once. CR-01 in Zoom; CR-02/CR-03 on the web app as a second club device (the Zoom client can't be made to refresh its club) |
+| [pro-coming-soon](pro-coming-soon.md) | current | 4d95d3d, 2026-11-14 | footer Pro button while `pro` is off: grey with a lock; hover or click lists what is coming (sounds, club card sets, voting, and more); the real Pro / Upgrade button once `pro` is on |
| 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 +24,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` → `announcement-banner` → `pro-coming-soon` → `timer-core` → `timer-sounds` → `agenda-import` → `pro-club` → `club-card-sets` → `club-renewal-reminder` → `webhooks-lifecycle` (deauth
last, it removes the app).
diff --git a/e2e/zoom-client/announcement-banner.md b/e2e/zoom-client/announcement-banner.md
new file mode 100644
index 0000000..327f635
--- /dev/null
+++ b/e2e/zoom-client/announcement-banner.md
@@ -0,0 +1,73 @@
+---
+id: announcement-banner
+title: Announcement banner (currently the pre-launch notice)
+status: current
+covers:
+ - packages/shared/announcements.js
+ - packages/ui/AnnouncementBanner.jsx
+ - apps/zoom-app/src/components/AnnouncementBanner.jsx
+ - apps/zoom-app/src/App.jsx
+automated:
+ - packages/shared/__tests__/announcements.test.js
+ - apps/zoom-app/src/components/AnnouncementBanner.test.jsx
+ - e2e/zoom.announcement-banner.spec.js
+needs:
+ - dev deployed
+ - FLAGS_FORCE "1" on dev (`pro` on) for AB-01; "0" (`pro` off) for AB-02 to AB-04
+verified_at: cb49176
+verified_on: 2026-11-14 (Zoom 7.2.5, macOS 26.7.2, dev b5efb7d2 and fcf1ed01)
+---
+
+# Announcement banner (currently the pre-launch notice)
+
+A one-line notice above the tabs, picked from `ANNOUNCEMENTS` in
+`packages/shared/announcements.js`: the first entry that applies to the Zoom app, has not
+been closed on this device, is inside its dates and passes its flag condition. Today the
+list holds one entry, `pre-launch-2026-11`: "Coming soon: a fresh new look for the timer,
+and Pro features for your club.", shown only while `pro` is off.
+
+Adding or changing an entry changes `announcements.js`, which this spec covers. Rerun
+AB-02 to AB-04 with the new entry's text and id, so its wording is seen at the panel's
+real width.
+
+## Purpose
+Catches a notice that crowds the Zoom side panel (wraps badly or pushes the tabs off screen
+at the panel's real width), a close that does not survive reopening the app, and a notice
+still up after its condition has ended.
+
+## Steps
+
+### AB-01 · auto · No pre-launch notice once Pro is released
+Do: with FLAGS_FORCE "1" on dev, open the app from Apps. Run this before AB-04, whose close
+also hides the notice.
+Expect: no announcement banner. The tabs sit directly under Zoom's header (or under the club
+renewal banner, on a club device whose Pro is ending).
+Evidence: screenshot; the footer shows the Pro chip, which proves `pro` reached the app.
+
+### AB-02 · auto · The current announcement shows above the tabs
+Do: with FLAGS_FORCE "0" on dev, open the app from Apps on a device where it was never
+closed (`localStorage.toastmaster_announcements_dismissed` absent or without its id).
+Expect: a light-blue line between Zoom's header and the Agenda / Live / Report tabs, with a
+sparkle icon, the bold lead and the text ("Coming soon: a fresh new look for the timer, and
+Pro features for your club."), and an × at the right. At the panel's default width it wraps
+to two lines at most, and the tabs and timer below are fully visible.
+Evidence: screenshot; PostHog `announcement_shown` with `announcement_id`.
+
+### AB-03 · auto · The banner sits alongside the timer without getting in the way
+Do: start a speech on the Live tab, let it reach green, then stop and reset.
+Expect: the banner stays put, nothing jumps, and every Live tab control still works.
+Evidence: screenshot at green.
+
+### AB-04 · auto · A closed announcement stays closed after reopening
+Do: click the × on the banner. Close the app (Apps → Close all) and reopen it from Apps.
+Expect: the banner disappears at once and is not back after reopening.
+Evidence: `localStorage.toastmaster_announcements_dismissed` holds its id; PostHog
+`announcement_dismissed`.
+
+## Not covered
+- The web app (`apps/web`). The shared banner and the list support `surfaces: ['web']`, but
+ only the Zoom app renders it so far.
+- An announcement's `link`, `starts` / `ends` and `tone`. The list has no entry using them
+ yet; unit and e2e tests cover them. Run AB-02 against the first entry that does.
+- What replaces the pre-launch notice at launch: the one-time Pro announcement, on its own
+ branch (`feat/pro-launch-announcement`) with its own spec.
diff --git a/e2e/zoom-client/club-card-sets.md b/e2e/zoom-client/club-card-sets.md
index a029f5b..eb70b64 100644
--- a/e2e/zoom-client/club-card-sets.md
+++ b/e2e/zoom-client/club-card-sets.md
@@ -108,6 +108,9 @@ Evidence: `PUT /api/club/cards` → 200; KV `club-cards:` names only the
- Sharing from the web timer: same shared module and endpoints, proven by unit tests.
- Two publishers racing: the prune grace window and the client's retry are unit-tested.
- Card sets are one per club for now (open question 2 in the plan).
+- The renewal reminder's 60-day window (`clubGraceReminder` in `club.js`, PR #147). Reviewed
+ 2026-11-14: it changes when the amber renewal line shows, not card sets; see
+ [club-renewal-reminder](club-renewal-reminder.md). Not rerun for it.
- The Live tab refusing an agenda row that needs a role (#91, `LiveTab.jsx`). Reviewed
2026-11-13: it changes which speaker loads, not the card artwork or its push; no step here
loads an agenda row. See [timer-core](timer-core.md) TC-13.
diff --git a/e2e/zoom-client/club-renewal-reminder.md b/e2e/zoom-client/club-renewal-reminder.md
new file mode 100644
index 0000000..7d3549a
--- /dev/null
+++ b/e2e/zoom-client/club-renewal-reminder.md
@@ -0,0 +1,65 @@
+---
+id: club-renewal-reminder
+title: Club renewal reminder (last 60 days of a club that will not renew)
+status: current
+covers:
+ - packages/shared/club.js
+ - packages/ui/ClubGraceBanner.jsx
+ - apps/zoom-app/src/components/ClubStatusBanner.jsx
+automated:
+ - packages/shared/__tests__/club.test.js
+ - apps/zoom-app/src/components/ClubStatusBanner.test.jsx
+needs:
+ - dev deployed
+ - a club device (Claude Test Club on the test Mac)
+ - for CR-02 and CR-03, the club record's `currentPeriodEnd` / `status` edited in dev KV
+ (save a backup first, restore after; ask the user once, as a list), and a club device
+ that can be made to refresh: a device re-reads its club only once its cache is 24 h old,
+ so use the web app in a browser (set `toastmaster_club.lastRefreshAt` to 0, reload)
+ rather than the Zoom client, whose localStorage cannot be reached
+verified_at: 51534f9
+verified_on: 2026-11-14 (CR-01 in Zoom 7.2.5 on 4d95d3d; CR-02/CR-03 on the web app as a second club device, dev dd1b9ab7 restore)
+---
+
+# Club renewal reminder (last 60 days of a club that will not renew)
+
+The amber "'s Pro ends in N days" line above the tabs. It shows for a club that will
+not renew (set to cancel at period end, or cancelled) only in its last 60 days, and only
+when its end date is known. A failed payment is warned about at once, whatever the date.
+
+## Purpose
+Catches a reminder that nags a club months before anything is due (as on 2026-11-14:
+"Pro ends in 316 days"), and one that stays silent when a club is about to lose Pro.
+
+## Steps
+
+### CR-01 · auto · No reminder with more than 60 days to go
+Do: on a club device whose club is set not to renew and ends more than 60 days from now
+(Claude Test Club on dev, 2026-11-14: `cancelAtPeriodEnd` true, about 316 days left), open
+the app from Apps.
+Expect: no amber renewal line above the tabs.
+Evidence: screenshot; the device refresh (`GET /api/club`) returns `cancelAtPeriodEnd: true` and a
+`currentPeriodEnd` more than 60 days out.
+
+### CR-02 · auto · The reminder appears inside the last 60 days
+Do: back up the club record, set its `currentPeriodEnd` to 29.5 days from now (dev KV), wait
+about 60 s for the KV cache, then make the device refresh (see needs) and reload.
+Expect: "Claude Test Club's Pro ends in 30 days." with "Renew to keep your presets, branding
+and archive." for an admin, or "Ask your club admin to renew." otherwise, and an ×. The ×
+hides it for the rest of the day. Restore the record afterwards.
+Evidence: screenshot; PostHog `club_grace_banner_shown` with `days_left: 30`.
+
+### CR-03 · auto · A failed payment is warned about at once
+Do: with the backup from CR-02, set the club's `status` to `past_due`, `cancelAtPeriodEnd`
+to false and `currentPeriodEnd` to 2 days ago, then make the device refresh and reload.
+Expect: the amber line shows straight away ("Pro ends in N days" counting down a week from
+the period end, or "payment did not go through" without a date). Restore the record
+afterwards.
+Evidence: screenshot.
+
+## Not covered
+- CR-02 and CR-03 inside the Zoom client itself. Run 2026-11-14 on the web app instead (see
+ needs); the banner component and `clubGraceReminder` are shared, and the Zoom client showed
+ the same banner earlier the same day.
+- Other club behaviour in `club.js` (joining, refresh, lapse, card sets). See
+ [club-card-sets](club-card-sets.md); `pro-club` is not migrated yet.
diff --git a/e2e/zoom-client/pro-coming-soon.md b/e2e/zoom-client/pro-coming-soon.md
new file mode 100644
index 0000000..cbd7d1b
--- /dev/null
+++ b/e2e/zoom-client/pro-coming-soon.md
@@ -0,0 +1,61 @@
+---
+id: pro-coming-soon
+title: Footer Pro button before the launch (locked, "Pro is coming soon")
+status: current
+covers:
+ - apps/zoom-app/src/components/ProComingSoon.jsx
+ - apps/zoom-app/src/components/Footer.jsx
+automated:
+ - apps/zoom-app/src/components/ProComingSoon.test.jsx
+ - apps/zoom-app/src/components/Footer.test.jsx
+ - e2e/zoom.pro-coming-soon.spec.js
+needs:
+ - dev deployed
+ - FLAGS_FORCE "0" on dev (`pro` off) for PC-01 to PC-03; "1" for PC-04
+verified_at: 4d95d3d
+verified_on: 2026-11-14 (Zoom 7.2.5, macOS 26.7.2, dev 3e3f2561 and 2fcb1b39)
+---
+
+# Footer Pro button before the launch (locked, "Pro is coming soon")
+
+While `pro` is off, the footer keeps a Pro button in its usual place, greyed with a small
+lock. Pointing at it, or clicking it, opens a short list of what Pro will bring. Once `pro`
+is on, it is the real Pro / Upgrade button again.
+
+## Purpose
+Catches a footer that no longer fits on one line in the 400px panel, a list that opens off
+screen or under other content in the Zoom webview, a hover that never closes, and a locked
+button that survives the launch.
+
+## Steps
+
+### PC-01 · auto · A locked Pro button sits in the footer while `pro` is off
+Do: with FLAGS_FORCE "0" on dev, open the app from Apps and look at the footer.
+Expect: "Send Us Feedback", "Review" and a grey "Pro" with a lock in place of the sparkle, all
+on one line. No "Upgrade" anywhere in the footer.
+Evidence: screenshot of the footer.
+
+### PC-02 · auto · Pointing at it lists what is coming
+Do: point at the locked Pro button, then move the pointer away.
+Expect: a white card opens above the button, inside the panel: "Pro is coming soon", then
+three ticked lines (sounds; club card sets on every club timer; voting for Best Table
+Topics and Best Evaluator), "And more: your club's logo, shared timings, meeting history."
+and "The timer itself stays free." It closes when the pointer leaves.
+Evidence: screenshot with the card open; PostHog `pro_coming_soon_opened` with
+`trigger: "hover"`.
+
+### PC-03 · auto · A click keeps the list open
+Do: click the locked Pro button, move the pointer away, then click on the Live tab's
+"Timing Role" label.
+Expect: the card stays open after the pointer leaves, and closes on the click elsewhere.
+Nothing else happens on that click.
+Evidence: screenshots; PostHog `pro_coming_soon_opened` with `trigger: "click"`.
+
+### PC-04 · auto · The real Pro button is back once `pro` is on
+Do: set FLAGS_FORCE "1" on dev and reopen the app.
+Expect: no lock. A Pro device shows the amber "Pro" button; a free device shows "Upgrade".
+Evidence: screenshot of the footer.
+
+## Not covered
+- The web app, whose header has no Pro button of this kind.
+- Touch: the Zoom desktop client is mouse-driven, and a click works the same as a tap.
diff --git a/e2e/zoom-client/timer-sounds.md b/e2e/zoom-client/timer-sounds.md
index cb0f21a..a5b3bfc 100644
--- a/e2e/zoom-client/timer-sounds.md
+++ b/e2e/zoom-client/timer-sounds.md
@@ -1,7 +1,7 @@
---
id: timer-sounds
title: Timer sounds (card-change chime, break music)
-status: draft
+status: stale
covers:
- packages/shared/timerSounds.js
- apps/zoom-app/src/components/TimerSounds.jsx
@@ -35,7 +35,8 @@ and only a second participant proves that "Share sound" carries it.
## Purpose
Catches a webview that blocks Web Audio (autoplay policy), so the chime is silent in Zoom
while it works in a browser. Also catches music that keeps playing after a break ends, a
-free user who can switch sounds on, a Sounds button that hides instead of locking, and a chime that stops when the timer switches tabs.
+free user who can switch sounds on, a Sounds button that hides instead of locking, a lock
+that sells Pro before Pro can be bought, and a chime that stops when the timer switches tabs.
## Steps
@@ -53,18 +54,20 @@ Evidence: screenshot; `localStorage.toastmaster_sound_settings` is absent or has
Do: on a Pro account, with `pro` on and `sounds` off (FLAGS_FORCE cleared on dev and the
flags targeted at your uid in PostHog; FLAGS_FORCE "0" turns `pro` off too, which is TS-09),
open the Live tab and click the speaker icon.
-Expect: the icon is shown, grey with a lock. The modal opens with "Sounds are coming
-soon." and no Upgrade link, and its controls cannot be used.
+Expect: the icon is shown, grey with a lock. The modal opens with "Hold tight! Sounds are
+coming soon." and no Upgrade link, and its controls cannot be used.
Evidence: screenshot.
-### TS-09 · auto · A Pro plan is locked as free while the `pro` flag is off
-Do: on the Pro account from TS-08 (a subscription, a grant, or a club on this device), with
-FLAGS_FORCE "0" on dev, open the Live tab and click the speaker icon.
-Expect: the same lock as TS-01: "Sounds are a Pro feature. Upgrade to Pro to use them." on
-hover and "Sounds are a Pro feature." in the modal, never "coming soon", and no Upgrade
-link. The footer shows no Pro chip. The `pro` flag is the source of truth for Pro in the
-app, so the plan alone unlocks nothing while it is off.
-Evidence: screenshot; `GET /api/me?flags=1` shows `"plan":"pro"` and `"pro":false`.
+### TS-09 · auto · Sounds say "coming soon" to everyone while the `pro` flag is off
+Do: with FLAGS_FORCE "0" on dev, open the Live tab and click the speaker icon, on any
+account: free, or the Pro account from TS-08 (a subscription, a grant, or a club on this
+device).
+Expect: the icon is grey with a lock, and hovering it shows "Hold tight! Sounds are coming
+soon." The modal says the same, never "Sounds are a Pro feature.", has no Upgrade link, and
+its controls cannot be used. The footer shows no Pro chip. Pro cannot be bought while the
+`pro` flag is off, so nobody is told sounds are a Pro feature; the plan alone unlocks
+nothing. Production stays in this state (with `sounds` on) until Pro launches.
+Evidence: screenshot; `GET /api/me?flags=1` shows `"pro":false`.
### TS-02 · human · Test chimes are audible in the Zoom webview
Do: on a Pro account, open Sounds and click Test: Green, Yellow, Red.
@@ -107,6 +110,9 @@ Evidence: the second participant confirms; note the result for each share path i
log.
## Not covered
+- Production's exact pre-launch state, `sounds` on with `pro` off, for a free account.
+ FLAGS_FORCE sets both flags together, so TS-09 runs with both off; the lock text is the
+ same either way. `e2e/zoom.announcement-banner.spec.js` and the unit tests cover that pair.
- Break music from audio files. Only the synthesized "Music box" track ships so far; licensed
tracks under `apps/zoom-app/public/music/` will add file playback, which needs its own
step.
diff --git a/e2e/zoom.announcement-banner.spec.js b/e2e/zoom.announcement-banner.spec.js
new file mode 100644
index 0000000..8d793d7
--- /dev/null
+++ b/e2e/zoom.announcement-banner.spec.js
@@ -0,0 +1,51 @@
+import { test, expect } from '@playwright/test';
+
+// The announcement banner above the tabs, with the shipped announcements.
+// Production until Pro launches: `sounds` released, `pro` not, so the
+// pre-launch notice is the one showing.
+function session(page, flags) {
+ return page.route('**/api/zoom/session', (route) =>
+ route.fulfill({
+ json: { identified: true, uid: 'e2e-announcement', entitlement: { plan: 'free' }, flags },
+ })
+ );
+}
+
+test('before the launch, the banner says a new look and Pro are coming, and Sounds is locked as coming soon', async ({ page }) => {
+ await session(page, { sounds: true, pro: false });
+ await page.goto('/zoom/');
+
+ const banner = page.getByTestId('announcement-banner');
+ await expect(banner).toHaveAttribute('data-announcement-id', 'pre-launch-2026-11');
+ await expect(banner).toHaveText(
+ 'Coming soon: a fresh new look for the timer, and Pro features for your club.'
+ );
+
+ const sounds = page.getByRole('button', { name: /^Sounds \(locked\)/ });
+ await expect(sounds).toHaveAccessibleName('Sounds (locked): Hold tight! Sounds are coming soon.');
+ await sounds.click();
+ await expect(page.getByText('Hold tight! Sounds are coming soon.')).toBeVisible();
+ await expect(page.getByText(/sounds are a pro feature/i)).toHaveCount(0);
+});
+
+test('a closed announcement stays gone after reopening', async ({ page }) => {
+ await session(page, { sounds: true, pro: false });
+ await page.goto('/zoom/');
+ const banner = page.getByTestId('announcement-banner');
+ await banner.getByRole('button', { name: 'Dismiss' }).click();
+ await expect(banner).toHaveCount(0);
+ expect(await page.evaluate(() => localStorage.getItem('toastmaster_announcements_dismissed'))).toBe(
+ '["pre-launch-2026-11"]'
+ );
+
+ await page.reload();
+ await expect(page.getByRole('button', { name: /START/i })).toBeVisible();
+ await expect(page.getByTestId('announcement-banner')).toHaveCount(0);
+});
+
+test('no pre-launch notice once Pro is released', async ({ page }) => {
+ await session(page, { sounds: true, pro: true });
+ await page.goto('/zoom/');
+ await expect(page.getByRole('button', { name: /START/i })).toBeVisible();
+ await expect(page.getByTestId('announcement-banner')).toHaveCount(0);
+});
diff --git a/e2e/zoom.pro-coming-soon.spec.js b/e2e/zoom.pro-coming-soon.spec.js
new file mode 100644
index 0000000..4404f1e
--- /dev/null
+++ b/e2e/zoom.pro-coming-soon.spec.js
@@ -0,0 +1,44 @@
+import { test, expect } from '@playwright/test';
+
+// The footer's Pro button before and after the launch.
+function session(page, flags, entitlement = { plan: 'free' }) {
+ return page.route('**/api/zoom/session', (route) =>
+ route.fulfill({ json: { identified: true, uid: 'e2e-pro-soon', entitlement, flags } })
+ );
+}
+
+test('before the launch, the Pro button is locked and lists what is coming', async ({ page }) => {
+ await session(page, { sounds: true, pro: false });
+ await page.goto('/zoom/');
+
+ const pro = page.getByRole('button', { name: 'Pro (coming soon)' });
+ await expect(pro).toBeVisible();
+ await expect(page.locator('footer').getByRole('button', { name: /upgrade to pro/i })).toHaveCount(0);
+
+ // Pointing at it opens the list; moving away closes it.
+ await pro.hover();
+ const panel = page.getByRole('dialog', { name: 'Pro is coming soon' });
+ await expect(panel).toBeVisible();
+ await expect(panel).toContainText('Sounds: a chime at each colour, music for breaks');
+ await expect(panel).toContainText("Your club's card sets on every club timer");
+ await expect(panel).toContainText('Voting for Best Table Topics and Best Evaluator');
+ await expect(panel).toContainText('And more');
+ await page.mouse.move(5, 5);
+ await expect(panel).toHaveCount(0);
+
+ // A click keeps it open until a click elsewhere.
+ await pro.click();
+ await page.mouse.move(5, 5);
+ await expect(panel).toBeVisible();
+ await page.getByText('Timing Role').click();
+ await expect(panel).toHaveCount(0);
+});
+
+test('once Pro is released, the Pro button is the real one again', async ({ page }) => {
+ await session(page, { sounds: true, pro: true });
+ await page.goto('/zoom/');
+ // Scoped to the footer: the locked Sounds button's label also says "Upgrade to Pro".
+ const footer = page.locator('footer');
+ await expect(footer.getByRole('button', { name: /upgrade to pro/i })).toBeVisible();
+ await expect(footer.getByRole('button', { name: 'Pro (coming soon)' })).toHaveCount(0);
+});
diff --git a/packages/shared/__tests__/announcements.test.js b/packages/shared/__tests__/announcements.test.js
new file mode 100644
index 0000000..ebecee7
--- /dev/null
+++ b/packages/shared/__tests__/announcements.test.js
@@ -0,0 +1,108 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import {
+ ANNOUNCEMENTS,
+ ANNOUNCEMENTS_DISMISSED_KEY,
+ dismissAnnouncement,
+ loadDismissedAnnouncements,
+ pickAnnouncement,
+} from '../announcements.js';
+
+const NOW = Date.UTC(2026, 10, 14, 12);
+
+const plain = { id: 'plain', text: 'Hello.' };
+const gated = { id: 'gated', text: 'Only with beta.', when: ({ flags }) => flags.beta === true };
+const webOnly = { id: 'web-only', text: 'Web.', surfaces: ['web'] };
+
+beforeEach(() => localStorage.clear());
+
+describe('pickAnnouncement', () => {
+ it('returns the first announcement that applies to this surface', () => {
+ const picked = pickAnnouncement({ surface: 'zoom', announcements: [webOnly, plain, gated], now: NOW, dismissed: [] });
+ expect(picked.id).toBe('plain');
+ expect(pickAnnouncement({ surface: 'web', announcements: [webOnly, plain], now: NOW, dismissed: [] }).id).toBe('web-only');
+ });
+
+ it('defaults to the Zoom app only', () => {
+ expect(pickAnnouncement({ surface: 'web', announcements: [plain], now: NOW, dismissed: [] })).toBeNull();
+ });
+
+ it('skips one this device closed, and moves on to the next', () => {
+ const list = [plain, { id: 'second', text: 'Next.' }];
+ expect(pickAnnouncement({ surface: 'zoom', announcements: list, now: NOW, dismissed: ['plain'] }).id).toBe('second');
+ expect(pickAnnouncement({ surface: 'zoom', announcements: list, now: NOW, dismissed: ['plain', 'second'] })).toBeNull();
+ });
+
+ it('waits for the flags before a gated one, so it never flashes', () => {
+ expect(pickAnnouncement({ surface: 'zoom', announcements: [gated], now: NOW, dismissed: [], flagsKnown: false, flags: { beta: true } })).toBeNull();
+ expect(pickAnnouncement({ surface: 'zoom', announcements: [gated], now: NOW, dismissed: [], flagsKnown: true, flags: { beta: true } }).id).toBe('gated');
+ expect(pickAnnouncement({ surface: 'zoom', announcements: [gated], now: NOW, dismissed: [], flagsKnown: true, flags: {} })).toBeNull();
+ });
+
+ it('shows an ungated one before the flags arrive', () => {
+ expect(pickAnnouncement({ surface: 'zoom', announcements: [plain], now: NOW, dismissed: [], flagsKnown: false }).id).toBe('plain');
+ });
+
+ it('keeps to its dates: from starts, until ends', () => {
+ const dated = { id: 'dated', text: 'Soon.', starts: '2026-11-14T00:00:00Z', ends: '2026-11-21T00:00:00Z' };
+ const at = (iso) => pickAnnouncement({ surface: 'zoom', announcements: [dated], now: Date.parse(iso), dismissed: [] });
+ expect(at('2026-11-13T23:59:59Z')).toBeNull();
+ expect(at('2026-11-14T00:00:00Z').id).toBe('dated');
+ expect(at('2026-11-20T23:59:59Z').id).toBe('dated');
+ expect(at('2026-11-21T00:00:00Z')).toBeNull();
+ });
+
+ it('hides one whose condition throws, and shows the next', () => {
+ const broken = { id: 'broken', text: 'x', when: () => { throw new Error('bad'); } };
+ expect(pickAnnouncement({ surface: 'zoom', announcements: [broken, plain], now: NOW, dismissed: [], flagsKnown: true }).id).toBe('plain');
+ });
+
+ it('ignores entries without an id or text', () => {
+ expect(pickAnnouncement({ surface: 'zoom', announcements: [{ text: 'no id' }, { id: 'no-text' }], now: NOW, dismissed: [] })).toBeNull();
+ });
+
+ it('reads this device\'s dismissals by default', () => {
+ dismissAnnouncement('plain');
+ expect(pickAnnouncement({ surface: 'zoom', announcements: [plain], now: NOW })).toBeNull();
+ });
+});
+
+describe('the shipped pre-launch announcement', () => {
+ const preLaunch = ANNOUNCEMENTS.find((a) => a.id === 'pre-launch-2026-11');
+
+ it('says a new look and Pro are coming', () => {
+ expect(`${preLaunch.lead} ${preLaunch.text}`).toBe(
+ 'Coming soon: a fresh new look for the timer, and Pro features for your club.',
+ );
+ });
+
+ it('shows in the Zoom app while pro is off, and retires at launch', () => {
+ const pick = (flags) => pickAnnouncement({ surface: 'zoom', flags, flagsKnown: true, dismissed: [], now: NOW });
+ expect(pick({ pro: false, sounds: true })?.id).toBe('pre-launch-2026-11');
+ expect(pick({ pro: true, sounds: true })).toBeNull();
+ });
+
+ it('has unique ids across the list', () => {
+ const ids = ANNOUNCEMENTS.map((a) => a.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+});
+
+describe('dismissals', () => {
+ it('remember each id once, per device', () => {
+ expect(loadDismissedAnnouncements()).toEqual([]);
+ expect(dismissAnnouncement('a')).toBe(true);
+ expect(dismissAnnouncement('a')).toBe(true);
+ dismissAnnouncement('b');
+ expect(loadDismissedAnnouncements()).toEqual(['a', 'b']);
+ expect(JSON.parse(localStorage.getItem(ANNOUNCEMENTS_DISMISSED_KEY))).toEqual(['a', 'b']);
+ });
+
+ it('read garbage as nothing closed', () => {
+ localStorage.setItem(ANNOUNCEMENTS_DISMISSED_KEY, '{not json');
+ expect(loadDismissedAnnouncements()).toEqual([]);
+ localStorage.setItem(ANNOUNCEMENTS_DISMISSED_KEY, JSON.stringify({ a: 1 }));
+ expect(loadDismissedAnnouncements()).toEqual([]);
+ localStorage.setItem(ANNOUNCEMENTS_DISMISSED_KEY, JSON.stringify(['a', 3, null]));
+ expect(loadDismissedAnnouncements()).toEqual(['a']);
+ });
+});
diff --git a/packages/shared/__tests__/club.test.js b/packages/shared/__tests__/club.test.js
index 56a5452..c3d4bbe 100644
--- a/packages/shared/__tests__/club.test.js
+++ b/packages/shared/__tests__/club.test.js
@@ -8,6 +8,7 @@ import {
clubHeaders,
clubLifecycle,
clubGraceReminder,
+ GRACE_REMINDER_WINDOW_DAYS,
dismissGraceReminder,
graceReminderDismissedToday,
lapsedClubName,
@@ -339,6 +340,46 @@ describe('grace and lapse', () => {
expect(clubLifecycle(NOW)).toMatchObject({ state: 'grace', daysLeft: 3, endsAt: NOW + 3 * DAY });
});
+ // A yearly plan set not to renew sits in grace for most of a year. Saying so
+ // 316 days out is noise; the reminder waits for the last 60.
+ it('reminds a club that will not renew only in its last 60 days', () => {
+ const endingIn = (days) => {
+ resetClubForTests();
+ localStorage.setItem(
+ CLUB_STORAGE_KEY,
+ JSON.stringify(cached({ status: 'active', cancelAtPeriodEnd: true, currentPeriodEnd: NOW + days * DAY }))
+ );
+ return clubGraceReminder(NOW);
+ };
+
+ expect(GRACE_REMINDER_WINDOW_DAYS).toBe(60);
+ expect(endingIn(316)).toBeNull();
+ expect(endingIn(61)).toBeNull();
+ expect(endingIn(60)).toBeNull();
+ expect(endingIn(59.5)).toMatchObject({ state: 'grace', reason: 'not_renewing', daysLeft: 60 });
+ expect(endingIn(3)).toMatchObject({ reason: 'not_renewing', daysLeft: 3 });
+ // The lifecycle itself still says grace: only the reminder waits.
+ endingIn(316);
+ expect(clubLifecycle(NOW)).toMatchObject({ state: 'grace', reason: 'not_renewing' });
+ });
+
+ it('says nothing about a cancelled club with no end date to count down to', () => {
+ localStorage.setItem(CLUB_STORAGE_KEY, JSON.stringify(cached({ status: 'canceled', currentPeriodEnd: null })));
+ expect(clubLifecycle(NOW)).toMatchObject({ state: 'grace', reason: 'not_renewing', endsAt: null });
+ expect(clubGraceReminder(NOW)).toBeNull();
+ });
+
+ // Still set to renew, but the card failed: Pro ends a week after, so it is
+ // said at once, with or without a date.
+ it('warns about a failed payment straight away', () => {
+ localStorage.setItem(CLUB_STORAGE_KEY, JSON.stringify(cached({ status: 'past_due', currentPeriodEnd: NOW })));
+ expect(clubGraceReminder(NOW)).toMatchObject({ reason: 'payment_failed', daysLeft: 7 });
+
+ resetClubForTests();
+ localStorage.setItem(CLUB_STORAGE_KEY, JSON.stringify(cached({ status: 'past_due', currentPeriodEnd: null })));
+ expect(clubGraceReminder(NOW)).toMatchObject({ reason: 'payment_failed', daysLeft: null });
+ });
+
it('says nothing at all about a healthy club, or about no club', () => {
expect(clubLifecycle(NOW)).toBeNull();
diff --git a/packages/shared/announcements.js b/packages/shared/announcements.js
new file mode 100644
index 0000000..19cd054
--- /dev/null
+++ b/packages/shared/announcements.js
@@ -0,0 +1,109 @@
+/**
+ * Announcements: a one-line notice above the tabs, one at a time.
+ *
+ * To announce something, add an entry to ANNOUNCEMENTS with a new `id`. The
+ * first entry that applies is the one shown, so put the most important first.
+ * Take an entry out once it is over, or give it an `ends` date.
+ *
+ * An `id` is what a dismissal remembers, so never reuse one: a new id shows
+ * the notice to everyone again, including people who closed an earlier one.
+ *
+ * Fields:
+ * - `id` unique and never reused.
+ * - `lead` optional bold opener, e.g. "Coming soon:".
+ * - `text` the message. Keep it to two lines in the Zoom side panel.
+ * - `tone` 'info' (blue, the default), 'success' (green) or 'warning' (amber).
+ * - `surfaces` which apps show it: 'zoom', 'web'. Defaults to ['zoom'].
+ * - `starts`, `ends` optional ISO dates: shown from `starts` until `ends`.
+ * - `when` optional ({ flags }) => boolean. It waits for the flags to be
+ * known, so a gated notice never flashes on load. Tie it to a
+ * release flag (declared in worker/flags.js) to switch a notice
+ * on and off from PostHog without a deploy.
+ * - `link` optional { label, url }, opened outside the app.
+ */
+export const ANNOUNCEMENTS = Object.freeze([
+ Object.freeze({
+ id: 'pre-launch-2026-11',
+ lead: 'Coming soon:',
+ text: 'a fresh new look for the timer, and Pro features for your club.',
+ tone: 'info',
+ surfaces: ['zoom'],
+ // Retires itself at launch, when the Pro launch announcement takes over.
+ when: ({ flags }) => !flags.pro,
+ }),
+]);
+
+// Per device on purpose: closing a notice in Zoom says nothing about the web.
+export const ANNOUNCEMENTS_DISMISSED_KEY = 'toastmaster_announcements_dismissed';
+
+/** The ids this device has closed. Never throws; bad storage reads as none. */
+export function loadDismissedAnnouncements() {
+ try {
+ const parsed = JSON.parse(localStorage.getItem(ANNOUNCEMENTS_DISMISSED_KEY) ?? '[]');
+ return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : [];
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Remember that this device closed an announcement. Returns false when storage
+ * refused it, in which case the notice is back on the next load.
+ */
+export function dismissAnnouncement(id) {
+ const dismissed = loadDismissedAnnouncements();
+ if (dismissed.includes(id)) return true;
+ try {
+ localStorage.setItem(ANNOUNCEMENTS_DISMISSED_KEY, JSON.stringify([...dismissed, id]));
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function inWindow(announcement, now) {
+ const starts = announcement.starts ? Date.parse(announcement.starts) : null;
+ const ends = announcement.ends ? Date.parse(announcement.ends) : null;
+ if (starts !== null && !Number.isNaN(starts) && now < starts) return false;
+ if (ends !== null && !Number.isNaN(ends) && now >= ends) return false;
+ return true;
+}
+
+/**
+ * The announcement to show now, or null.
+ *
+ * @param {Object} options
+ * @param {'zoom'|'web'} options.surface - the app asking
+ * @param {Object} [options.flags] - release flags, as `getFlags()` returns them
+ * @param {boolean} [options.flagsKnown] - whether the server has answered yet
+ * @param {string[]} [options.dismissed] - ids this device closed
+ * @param {number} [options.now]
+ * @param {ReadonlyArray