From 60e1b6e5e449bf6b4e7deffabb9a18d525d97d4f Mon Sep 17 00:00:00 2001 From: Shuhao Date: Sat, 14 Nov 2026 14:58:50 -0500 Subject: [PATCH 1/8] feat(pro): pre-launch banner, and Sounds say "coming soon" until Pro can be bought Sounds stay visible and locked. The lock says "Hold tight! Sounds are coming soon." whenever the sounds or pro flag is off, and "Sounds are a Pro feature" only once buying Pro would unlock them. Same rule in the web app. A dismissible banner above the tabs in the Zoom app says a new look and Pro are coming. It shows only while the pro flag is known and off, so it goes away by itself at launch. --- apps/web/src/hooks/useSounds.js | 18 +++-- apps/zoom-app/src/App.jsx | 2 + .../src/components/LaunchTeaserBanner.jsx | 67 +++++++++++++++++++ .../components/LaunchTeaserBanner.test.jsx | 49 ++++++++++++++ .../src/components/TimerSounds.test.jsx | 24 +++++-- apps/zoom-app/src/hooks/useSounds.js | 18 +++-- e2e/web.timer-sounds.spec.js | 27 ++++++-- e2e/zoom-client/INDEX.md | 5 +- e2e/zoom-client/launch-teaser.md | 61 +++++++++++++++++ e2e/zoom-client/timer-sounds.md | 28 +++++--- e2e/zoom.launch-teaser.spec.js | 46 +++++++++++++ 11 files changed, 310 insertions(+), 35 deletions(-) create mode 100644 apps/zoom-app/src/components/LaunchTeaserBanner.jsx create mode 100644 apps/zoom-app/src/components/LaunchTeaserBanner.test.jsx create mode 100644 e2e/zoom-client/launch-teaser.md create mode 100644 e2e/zoom.launch-teaser.spec.js 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..377f27c 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 LaunchTeaserBanner from './components/LaunchTeaserBanner'; 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. */} + { + if (visible) trackEvent('launch_teaser_banner_shown', { surface: 'zoom' }); + }, [visible]); + + if (!visible) return null; + + const handleDismiss = () => { + try { + localStorage.setItem(LAUNCH_TEASER_DISMISSED_KEY, '1'); + } catch { + // Storage refused: it still goes away until the app is reopened. + } + trackEvent('launch_teaser_banner_dismissed', { surface: 'zoom' }); + setDismissed(true); + }; + + return ( +
+
+ ); +} diff --git a/apps/zoom-app/src/components/LaunchTeaserBanner.test.jsx b/apps/zoom-app/src/components/LaunchTeaserBanner.test.jsx new file mode 100644 index 0000000..69b680a --- /dev/null +++ b/apps/zoom-app/src/components/LaunchTeaserBanner.test.jsx @@ -0,0 +1,49 @@ +import '@testing-library/jest-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { resetFlagsForTests, setFlags } from '@toastmaster-timer/shared'; +import LaunchTeaserBanner, { LAUNCH_TEASER_DISMISSED_KEY } from './LaunchTeaserBanner'; +import { trackEvent } from '../utils/posthog'; + +vi.mock('../utils/posthog', () => ({ trackEvent: vi.fn() })); + +beforeEach(() => { + localStorage.clear(); + resetFlagsForTests(); + trackEvent.mockClear(); +}); + +describe('LaunchTeaserBanner', () => { + it('says a new look and Pro are coming while pro is off', () => { + setFlags({ pro: false, sounds: true }); + render(); + expect(screen.getByTestId('launch-teaser-banner')).toHaveTextContent( + 'Coming soon: a fresh new look for the timer, and Pro features for your club.', + ); + expect(trackEvent).toHaveBeenCalledWith('launch_teaser_banner_shown', { surface: 'zoom' }); + }); + + it('stays hidden until the flags are known, so it never flashes', () => { + render(); + expect(screen.queryByTestId('launch-teaser-banner')).toBeNull(); + expect(trackEvent).not.toHaveBeenCalled(); + }); + + it('retires itself once pro is released', () => { + setFlags({ pro: true, sounds: true }); + render(); + expect(screen.queryByTestId('launch-teaser-banner')).toBeNull(); + }); + + it('stays dismissed on this device', () => { + setFlags({ pro: false }); + const first = render(); + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + expect(screen.queryByTestId('launch-teaser-banner')).toBeNull(); + expect(localStorage.getItem(LAUNCH_TEASER_DISMISSED_KEY)).toBe('1'); + expect(trackEvent).toHaveBeenCalledWith('launch_teaser_banner_dismissed', { surface: 'zoom' }); + first.unmount(); + + render(); + expect(screen.queryByTestId('launch-teaser-banner')).toBeNull(); + }); +}); 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..727a5a8 100644 --- a/e2e/zoom-client/INDEX.md +++ b/e2e/zoom-client/INDEX.md @@ -11,7 +11,8 @@ 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) | draft | 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) | +| [launch-teaser](launch-teaser.md) | draft | never | pre-launch banner above the tabs while `pro` is off ("Coming soon: a fresh new look… and Pro features"), dismissal kept per device, gone once `pro` is on | | [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` → `launch-teaser` → `timer-core` → `timer-sounds` → `agenda-import` → `pro-club` → `club-card-sets` → `webhooks-lifecycle` (deauth last, it removes the app). diff --git a/e2e/zoom-client/launch-teaser.md b/e2e/zoom-client/launch-teaser.md new file mode 100644 index 0000000..b071ad8 --- /dev/null +++ b/e2e/zoom-client/launch-teaser.md @@ -0,0 +1,61 @@ +--- +id: launch-teaser +title: Pre-launch banner (a new look and Pro, coming soon) +status: draft +covers: + - apps/zoom-app/src/components/LaunchTeaserBanner.jsx + - apps/zoom-app/src/App.jsx +automated: + - apps/zoom-app/src/components/LaunchTeaserBanner.test.jsx + - e2e/zoom.launch-teaser.spec.js +needs: + - dev deployed + - FLAGS_FORCE "0" on dev (`pro` off) for LT-01 to LT-03; "1" for LT-04 +verified_at: never +verified_on: never +--- + +# Pre-launch banner (a new look and Pro, coming soon) + +A one-line heads-up above the tabs while Pro is unreleased: the app's new look and Pro are +on the way. It shows only while the `pro` flag is known and off, and a dismissal hides it +on that device for good. + +## Purpose +Catches a banner that crowds the Zoom side panel (wraps badly or pushes the tabs off +screen at the panel's real width), a dismissal that does not survive closing the app, and +a banner that is still up after Pro launches. + +## Steps + +### LT-01 · auto · The banner shows above the tabs while `pro` is off +Do: with FLAGS_FORCE "0" on dev, open the app from Apps (on a device where the banner was +never dismissed: `localStorage.toastmaster_launch_teaser_dismissed` absent). +Expect: a light-blue line between Zoom's header and the Agenda / Live / Report tabs, with a +sparkle icon and "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 the timer below are still fully visible. +Evidence: screenshot; PostHog `launch_teaser_banner_shown` with `surface: "zoom"`. + +### LT-02 · 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. + +### LT-03 · auto · Dismissed stays dismissed after reopening +Do: click the × on the banner. Close the app and reopen it from Apps. +Expect: the banner disappears at once and is not back after reopening. +Evidence: `localStorage.toastmaster_launch_teaser_dismissed` is `"1"`; PostHog +`launch_teaser_banner_dismissed`. + +### LT-04 · auto · No banner once Pro is released +Do: remove `toastmaster_launch_teaser_dismissed` from localStorage, set FLAGS_FORCE "1" on +dev, and reopen the app. +Expect: no banner. The tabs sit directly under Zoom's header, as before this change. +Evidence: screenshot; `GET /api/me?flags=1` shows `"pro":true`. + +## Not covered +- The web app (`apps/web`). The banner is only in the Zoom app, where the new look is + coming. +- What replaces the banner at launch: the one-time Pro announcement, which is on its own + branch (`feat/pro-launch-announcement`) with its own spec. diff --git a/e2e/zoom-client/timer-sounds.md b/e2e/zoom-client/timer-sounds.md index cb0f21a..7fddc2d 100644 --- a/e2e/zoom-client/timer-sounds.md +++ b/e2e/zoom-client/timer-sounds.md @@ -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.launch-teaser.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.launch-teaser.spec.js b/e2e/zoom.launch-teaser.spec.js new file mode 100644 index 0000000..6c9892a --- /dev/null +++ b/e2e/zoom.launch-teaser.spec.js @@ -0,0 +1,46 @@ +import { test, expect } from '@playwright/test'; + +// The pre-launch heads-up above the tabs. Production until Pro launches: +// `sounds` released, `pro` not. +function session(page, flags) { + return page.route('**/api/zoom/session', (route) => + route.fulfill({ + json: { identified: true, uid: 'e2e-teaser', 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('launch-teaser-banner'); + 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 dismissed banner stays gone after reopening', async ({ page }) => { + await session(page, { sounds: true, pro: false }); + await page.goto('/zoom/'); + const banner = page.getByTestId('launch-teaser-banner'); + await banner.getByRole('button', { name: 'Dismiss' }).click(); + await expect(banner).toHaveCount(0); + + await page.reload(); + await expect(page.getByRole('button', { name: /START/i })).toBeVisible(); + await expect(page.getByTestId('launch-teaser-banner')).toHaveCount(0); +}); + +test('no banner 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('launch-teaser-banner')).toHaveCount(0); +}); From 4c024b4e1ed254d60dbcc585dc9bf39531e4655f Mon Sep 17 00:00:00 2001 From: Shuhao Date: Sat, 14 Nov 2026 15:11:30 -0500 Subject: [PATCH 2/8] test(zoom-client): run launch-teaser and timer-sounds TS-09 on 60e1b6e --- e2e/zoom-client/INDEX.md | 4 ++-- e2e/zoom-client/launch-teaser.md | 11 ++++++----- e2e/zoom-client/timer-sounds.md | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/e2e/zoom-client/INDEX.md b/e2e/zoom-client/INDEX.md index 727a5a8..92cf6cd 100644 --- a/e2e/zoom-client/INDEX.md +++ b/e2e/zoom-client/INDEX.md @@ -11,8 +11,8 @@ 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 ("Hold tight! Sounds are coming soon." while `pro` is off), audible through Share sound (#105) | -| [launch-teaser](launch-teaser.md) | draft | never | pre-launch banner above the tabs while `pro` is off ("Coming soon: a fresh new look… and Pro features"), dismissal kept per device, gone once `pro` is on | +| [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 | +| [launch-teaser](launch-teaser.md) | current | 60e1b6e, 2026-11-14 | pre-launch banner above the tabs while `pro` is off ("Coming soon: a fresh new look… and Pro features"), dismissal kept per device, gone once `pro` is on. LT-04 ran after LT-03's dismissal, so it did not isolate the flag (unit + e2e do) | | [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 | diff --git a/e2e/zoom-client/launch-teaser.md b/e2e/zoom-client/launch-teaser.md index b071ad8..694d9ba 100644 --- a/e2e/zoom-client/launch-teaser.md +++ b/e2e/zoom-client/launch-teaser.md @@ -1,7 +1,7 @@ --- id: launch-teaser title: Pre-launch banner (a new look and Pro, coming soon) -status: draft +status: current covers: - apps/zoom-app/src/components/LaunchTeaserBanner.jsx - apps/zoom-app/src/App.jsx @@ -11,8 +11,8 @@ automated: needs: - dev deployed - FLAGS_FORCE "0" on dev (`pro` off) for LT-01 to LT-03; "1" for LT-04 -verified_at: never -verified_on: never +verified_at: 60e1b6e +verified_on: 2026-11-14 (Zoom 7.2.5, macOS 26.7.2, dev 87c1f866 and 400420a4) --- # Pre-launch banner (a new look and Pro, coming soon) @@ -49,8 +49,9 @@ Evidence: `localStorage.toastmaster_launch_teaser_dismissed` is `"1"`; PostHog `launch_teaser_banner_dismissed`. ### LT-04 · auto · No banner once Pro is released -Do: remove `toastmaster_launch_teaser_dismissed` from localStorage, set FLAGS_FORCE "1" on -dev, and reopen the app. +Do: run this before LT-03, whose dismissal also hides the banner; otherwise first remove +`toastmaster_launch_teaser_dismissed` from localStorage (Zoom's webview inspector). Set +FLAGS_FORCE "1" on dev and reopen the app. Expect: no banner. The tabs sit directly under Zoom's header, as before this change. Evidence: screenshot; `GET /api/me?flags=1` shows `"pro":true`. diff --git a/e2e/zoom-client/timer-sounds.md b/e2e/zoom-client/timer-sounds.md index 7fddc2d..6472e2e 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 From cb4917686b72c5aa1bf00a0d8c25e987b4e39de8 Mon Sep 17 00:00:00 2001 From: Shuhao Date: Sat, 14 Nov 2026 15:18:25 -0500 Subject: [PATCH 3/8] feat(announcements): one reusable banner for any announcement The pre-launch banner becomes the first entry in ANNOUNCEMENTS (packages/shared/announcements.js). To announce something, add an entry with a new id: lead, text, tone, surfaces, optional dates, an optional flag condition and an optional link. The Zoom app shows the first entry that applies; a close is remembered per announcement on that device. The banner itself is presentational and shared (packages/ui), so the web app can render it later. Events become announcement_shown, announcement_dismissed and announcement_link_opened, each with announcement_id. --- apps/zoom-app/src/App.jsx | 4 +- .../src/components/AnnouncementBanner.jsx | 70 +++++++++++ .../components/AnnouncementBanner.test.jsx | 101 +++++++++++++++ .../src/components/LaunchTeaserBanner.jsx | 67 ---------- .../components/LaunchTeaserBanner.test.jsx | 49 -------- e2e/zoom-client/INDEX.md | 4 +- e2e/zoom-client/announcement-banner.md | 73 +++++++++++ e2e/zoom-client/launch-teaser.md | 62 ---------- e2e/zoom-client/timer-sounds.md | 2 +- ...ec.js => zoom.announcement-banner.spec.js} | 23 ++-- .../shared/__tests__/announcements.test.js | 108 ++++++++++++++++ packages/shared/announcements.js | 109 ++++++++++++++++ packages/shared/index.js | 1 + packages/ui/AnnouncementBanner.jsx | 116 ++++++++++++++++++ packages/ui/index.js | 1 + packages/ui/package.json | 1 + 16 files changed, 599 insertions(+), 192 deletions(-) create mode 100644 apps/zoom-app/src/components/AnnouncementBanner.jsx create mode 100644 apps/zoom-app/src/components/AnnouncementBanner.test.jsx delete mode 100644 apps/zoom-app/src/components/LaunchTeaserBanner.jsx delete mode 100644 apps/zoom-app/src/components/LaunchTeaserBanner.test.jsx create mode 100644 e2e/zoom-client/announcement-banner.md delete mode 100644 e2e/zoom-client/launch-teaser.md rename e2e/{zoom.launch-teaser.spec.js => zoom.announcement-banner.spec.js} (59%) create mode 100644 packages/shared/__tests__/announcements.test.js create mode 100644 packages/shared/announcements.js create mode 100644 packages/ui/AnnouncementBanner.jsx diff --git a/apps/zoom-app/src/App.jsx b/apps/zoom-app/src/App.jsx index 377f27c..1b7da4a 100644 --- a/apps/zoom-app/src/App.jsx +++ b/apps/zoom-app/src/App.jsx @@ -8,7 +8,7 @@ import TimerSounds from './components/TimerSounds'; import ContactCapture from './components/ContactCapture'; import ZoomConnectionNotice from './components/ZoomConnectionNotice'; import ClubStatusBanner from './components/ClubStatusBanner'; -import LaunchTeaserBanner from './components/LaunchTeaserBanner'; +import AnnouncementBanner from './components/AnnouncementBanner'; import './App.css'; const LiveTab = lazy(() => import('./components/LiveTab')); @@ -27,7 +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/LaunchTeaserBanner.jsx b/apps/zoom-app/src/components/LaunchTeaserBanner.jsx deleted file mode 100644 index ae0026e..0000000 --- a/apps/zoom-app/src/components/LaunchTeaserBanner.jsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Sparkles, X } from 'lucide-react'; -import { useFlag } from '../hooks/useFlag'; -import { trackEvent } from '../utils/posthog'; - -export const LAUNCH_TEASER_DISMISSED_KEY = 'toastmaster_launch_teaser_dismissed'; - -function wasDismissed() { - try { - return localStorage.getItem(LAUNCH_TEASER_DISMISSED_KEY) === '1'; - } catch { - return false; - } -} - -/** - * A heads-up before the launch: a new look for the app and Pro are on the way. - * - * Shown only once the flags are known and `pro` is off, so it never flashes on - * load and it retires itself the moment Pro is released: from then on the - * launch announcement speaks instead. One dismissal hides it on this device for - * good. A plain line with no button to act on, because nothing can be bought - * or tried yet. - */ -export default function LaunchTeaserBanner() { - const { enabled: proReleased, known: flagsKnown } = useFlag('pro'); - const [dismissed, setDismissed] = useState(wasDismissed); - const visible = flagsKnown && !proReleased && !dismissed; - - useEffect(() => { - if (visible) trackEvent('launch_teaser_banner_shown', { surface: 'zoom' }); - }, [visible]); - - if (!visible) return null; - - const handleDismiss = () => { - try { - localStorage.setItem(LAUNCH_TEASER_DISMISSED_KEY, '1'); - } catch { - // Storage refused: it still goes away until the app is reopened. - } - trackEvent('launch_teaser_banner_dismissed', { surface: 'zoom' }); - setDismissed(true); - }; - - return ( -
-
- ); -} diff --git a/apps/zoom-app/src/components/LaunchTeaserBanner.test.jsx b/apps/zoom-app/src/components/LaunchTeaserBanner.test.jsx deleted file mode 100644 index 69b680a..0000000 --- a/apps/zoom-app/src/components/LaunchTeaserBanner.test.jsx +++ /dev/null @@ -1,49 +0,0 @@ -import '@testing-library/jest-dom'; -import { fireEvent, render, screen } from '@testing-library/react'; -import { resetFlagsForTests, setFlags } from '@toastmaster-timer/shared'; -import LaunchTeaserBanner, { LAUNCH_TEASER_DISMISSED_KEY } from './LaunchTeaserBanner'; -import { trackEvent } from '../utils/posthog'; - -vi.mock('../utils/posthog', () => ({ trackEvent: vi.fn() })); - -beforeEach(() => { - localStorage.clear(); - resetFlagsForTests(); - trackEvent.mockClear(); -}); - -describe('LaunchTeaserBanner', () => { - it('says a new look and Pro are coming while pro is off', () => { - setFlags({ pro: false, sounds: true }); - render(); - expect(screen.getByTestId('launch-teaser-banner')).toHaveTextContent( - 'Coming soon: a fresh new look for the timer, and Pro features for your club.', - ); - expect(trackEvent).toHaveBeenCalledWith('launch_teaser_banner_shown', { surface: 'zoom' }); - }); - - it('stays hidden until the flags are known, so it never flashes', () => { - render(); - expect(screen.queryByTestId('launch-teaser-banner')).toBeNull(); - expect(trackEvent).not.toHaveBeenCalled(); - }); - - it('retires itself once pro is released', () => { - setFlags({ pro: true, sounds: true }); - render(); - expect(screen.queryByTestId('launch-teaser-banner')).toBeNull(); - }); - - it('stays dismissed on this device', () => { - setFlags({ pro: false }); - const first = render(); - fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); - expect(screen.queryByTestId('launch-teaser-banner')).toBeNull(); - expect(localStorage.getItem(LAUNCH_TEASER_DISMISSED_KEY)).toBe('1'); - expect(trackEvent).toHaveBeenCalledWith('launch_teaser_banner_dismissed', { surface: 'zoom' }); - first.unmount(); - - render(); - expect(screen.queryByTestId('launch-teaser-banner')).toBeNull(); - }); -}); diff --git a/e2e/zoom-client/INDEX.md b/e2e/zoom-client/INDEX.md index 92cf6cd..60be4b7 100644 --- a/e2e/zoom-client/INDEX.md +++ b/e2e/zoom-client/INDEX.md @@ -12,7 +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) | 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 | -| [launch-teaser](launch-teaser.md) | current | 60e1b6e, 2026-11-14 | pre-launch banner above the tabs while `pro` is off ("Coming soon: a fresh new look… and Pro features"), dismissal kept per device, gone once `pro` is on. LT-04 ran after LT-03's dismissal, so it did not isolate the flag (unit + e2e do) | +| [announcement-banner](announcement-banner.md) | draft | never | 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) | | 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 | @@ -22,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` → `launch-teaser` → `timer-core` → `timer-sounds` → `agenda-import` → `pro-club` → `club-card-sets` → `webhooks-lifecycle` (deauth +`connection-notice` → `reauth-landing` → `contact-capture` → `announcement-banner` → `timer-core` → `timer-sounds` → `agenda-import` → `pro-club` → `club-card-sets` → `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..db023e5 --- /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: draft +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: never +verified_on: never +--- + +# 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/launch-teaser.md b/e2e/zoom-client/launch-teaser.md deleted file mode 100644 index 694d9ba..0000000 --- a/e2e/zoom-client/launch-teaser.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -id: launch-teaser -title: Pre-launch banner (a new look and Pro, coming soon) -status: current -covers: - - apps/zoom-app/src/components/LaunchTeaserBanner.jsx - - apps/zoom-app/src/App.jsx -automated: - - apps/zoom-app/src/components/LaunchTeaserBanner.test.jsx - - e2e/zoom.launch-teaser.spec.js -needs: - - dev deployed - - FLAGS_FORCE "0" on dev (`pro` off) for LT-01 to LT-03; "1" for LT-04 -verified_at: 60e1b6e -verified_on: 2026-11-14 (Zoom 7.2.5, macOS 26.7.2, dev 87c1f866 and 400420a4) ---- - -# Pre-launch banner (a new look and Pro, coming soon) - -A one-line heads-up above the tabs while Pro is unreleased: the app's new look and Pro are -on the way. It shows only while the `pro` flag is known and off, and a dismissal hides it -on that device for good. - -## Purpose -Catches a banner that crowds the Zoom side panel (wraps badly or pushes the tabs off -screen at the panel's real width), a dismissal that does not survive closing the app, and -a banner that is still up after Pro launches. - -## Steps - -### LT-01 · auto · The banner shows above the tabs while `pro` is off -Do: with FLAGS_FORCE "0" on dev, open the app from Apps (on a device where the banner was -never dismissed: `localStorage.toastmaster_launch_teaser_dismissed` absent). -Expect: a light-blue line between Zoom's header and the Agenda / Live / Report tabs, with a -sparkle icon and "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 the timer below are still fully visible. -Evidence: screenshot; PostHog `launch_teaser_banner_shown` with `surface: "zoom"`. - -### LT-02 · 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. - -### LT-03 · auto · Dismissed stays dismissed after reopening -Do: click the × on the banner. Close the app and reopen it from Apps. -Expect: the banner disappears at once and is not back after reopening. -Evidence: `localStorage.toastmaster_launch_teaser_dismissed` is `"1"`; PostHog -`launch_teaser_banner_dismissed`. - -### LT-04 · auto · No banner once Pro is released -Do: run this before LT-03, whose dismissal also hides the banner; otherwise first remove -`toastmaster_launch_teaser_dismissed` from localStorage (Zoom's webview inspector). Set -FLAGS_FORCE "1" on dev and reopen the app. -Expect: no banner. The tabs sit directly under Zoom's header, as before this change. -Evidence: screenshot; `GET /api/me?flags=1` shows `"pro":true`. - -## Not covered -- The web app (`apps/web`). The banner is only in the Zoom app, where the new look is - coming. -- What replaces the banner at launch: the one-time Pro announcement, which is on its own - branch (`feat/pro-launch-announcement`) with its own spec. diff --git a/e2e/zoom-client/timer-sounds.md b/e2e/zoom-client/timer-sounds.md index 6472e2e..a5b3bfc 100644 --- a/e2e/zoom-client/timer-sounds.md +++ b/e2e/zoom-client/timer-sounds.md @@ -112,7 +112,7 @@ 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.launch-teaser.spec.js` and the unit tests cover that pair. + 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.launch-teaser.spec.js b/e2e/zoom.announcement-banner.spec.js similarity index 59% rename from e2e/zoom.launch-teaser.spec.js rename to e2e/zoom.announcement-banner.spec.js index 6c9892a..8d793d7 100644 --- a/e2e/zoom.launch-teaser.spec.js +++ b/e2e/zoom.announcement-banner.spec.js @@ -1,11 +1,12 @@ import { test, expect } from '@playwright/test'; -// The pre-launch heads-up above the tabs. Production until Pro launches: -// `sounds` released, `pro` not. +// 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-teaser', entitlement: { plan: 'free' }, flags }, + json: { identified: true, uid: 'e2e-announcement', entitlement: { plan: 'free' }, flags }, }) ); } @@ -14,7 +15,8 @@ test('before the launch, the banner says a new look and Pro are coming, and Soun await session(page, { sounds: true, pro: false }); await page.goto('/zoom/'); - const banner = page.getByTestId('launch-teaser-banner'); + 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.' ); @@ -26,21 +28,24 @@ test('before the launch, the banner says a new look and Pro are coming, and Soun await expect(page.getByText(/sounds are a pro feature/i)).toHaveCount(0); }); -test('a dismissed banner stays gone after reopening', async ({ page }) => { +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('launch-teaser-banner'); + 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('launch-teaser-banner')).toHaveCount(0); + await expect(page.getByTestId('announcement-banner')).toHaveCount(0); }); -test('no banner once Pro is released', async ({ page }) => { +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('launch-teaser-banner')).toHaveCount(0); + await expect(page.getByTestId('announcement-banner')).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/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} [options.announcements] + */ +export function pickAnnouncement({ + surface, + flags = {}, + flagsKnown = false, + dismissed = loadDismissedAnnouncements(), + now = Date.now(), + announcements = ANNOUNCEMENTS, +} = {}) { + for (const announcement of announcements) { + if (!announcement?.id || !announcement.text) continue; + if (!(announcement.surfaces ?? ['zoom']).includes(surface)) continue; + if (dismissed.includes(announcement.id)) continue; + if (!inWindow(announcement, now)) continue; + if (announcement.when) { + if (!flagsKnown) continue; + let applies = false; + try { + applies = announcement.when({ flags }) === true; + } catch { + // A broken condition hides its notice rather than the app. + } + if (!applies) continue; + } + return announcement; + } + return null; +} diff --git a/packages/shared/index.js b/packages/shared/index.js index eacd07d..bb47b1d 100644 --- a/packages/shared/index.js +++ b/packages/shared/index.js @@ -20,3 +20,4 @@ export * from './clubCards.js'; export * from './clubArchive.js'; export * from './reportImage.js'; export * from './timerSounds.js'; +export * from './announcements.js'; diff --git a/packages/ui/AnnouncementBanner.jsx b/packages/ui/AnnouncementBanner.jsx new file mode 100644 index 0000000..9aaa0b3 --- /dev/null +++ b/packages/ui/AnnouncementBanner.jsx @@ -0,0 +1,116 @@ +/** + * A one-line announcement under the app's header: an icon, an optional bold + * lead, the message, an optional link, and an × to close it. + * + * Presentational, like everything in this package: which announcement to show, + * remembering that it was closed and opening the link are the app's job (see + * `pickAnnouncement` in `@toastmaster-timer/shared`). + */ + +const STROKE = { + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 2, + strokeLinecap: 'round', + strokeLinejoin: 'round', + 'aria-hidden': 'true', +}; + +// Whole class strings, so Tailwind's scan of this file keeps every one of them. +const TONES = { + info: { + box: 'border-blue-100 bg-blue-50', + icon: 'text-blue-600', + text: 'text-blue-900', + link: 'text-blue-800 hover:text-blue-950', + close: 'text-blue-700 hover:bg-blue-100 hover:text-blue-900', + }, + success: { + box: 'border-green-200 bg-green-50', + icon: 'text-green-600', + text: 'text-green-900', + link: 'text-green-800 hover:text-green-950', + close: 'text-green-700 hover:bg-green-100 hover:text-green-900', + }, + warning: { + box: 'border-amber-200 bg-amber-50', + icon: 'text-amber-600', + text: 'text-amber-900', + link: 'text-amber-800 hover:text-amber-950', + close: 'text-amber-700 hover:bg-amber-100 hover:text-amber-900', + }, +}; + +function ToneIcon({ tone, className }) { + if (tone === 'warning') { + return ( + + + + + + ); + } + return ( + + + + + + + + ); +} + +/** + * @param {Object} props + * @param {string} props.id - recorded on the element, for tests and analytics + * @param {string} [props.lead] - bold opener, e.g. "Coming soon:" + * @param {string} props.text + * @param {'info'|'success'|'warning'} [props.tone] + * @param {string} [props.linkLabel] + * @param {() => void} [props.onLink] - the link shows only with a label and this + * @param {() => void} [props.onDismiss] - the × shows only with this + */ +export default function AnnouncementBanner({ id, lead, text, tone = 'info', linkLabel, onLink, onDismiss }) { + if (!text) return null; + const style = TONES[tone] ?? TONES.info; + + return ( +
+ +

+ {lead && {lead}} + {lead && ' '} + {text} + {linkLabel && onLink && ( + <> + {' '} + + + )} +

+ {onDismiss && ( + + )} +
+ ); +} diff --git a/packages/ui/index.js b/packages/ui/index.js index 3e323e6..13af09f 100644 --- a/packages/ui/index.js +++ b/packages/ui/index.js @@ -1,3 +1,4 @@ +export { default as AnnouncementBanner } from './AnnouncementBanner.jsx'; export { default as BrandedReportHeader } from './BrandedReportHeader.jsx'; export { default as ClubBadge, ClubBadgeLayer } from './ClubBadge.jsx'; export { default as ClubGraceBanner } from './ClubGraceBanner.jsx'; diff --git a/packages/ui/package.json b/packages/ui/package.json index 28e8214..2d747f6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -6,6 +6,7 @@ "main": "index.js", "exports": { ".": "./index.js", + "./AnnouncementBanner": "./AnnouncementBanner.jsx", "./BrandedReportHeader": "./BrandedReportHeader.jsx", "./ClubBadge": "./ClubBadge.jsx", "./ClubGraceBanner": "./ClubGraceBanner.jsx", From 9e19d192645b770b739b906c1f333c3fd857bae5 Mon Sep 17 00:00:00 2001 From: Shuhao Date: Sat, 14 Nov 2026 15:25:27 -0500 Subject: [PATCH 4/8] test(zoom-client): run announcement-banner on cb49176 --- e2e/zoom-client/INDEX.md | 2 +- e2e/zoom-client/announcement-banner.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/e2e/zoom-client/INDEX.md b/e2e/zoom-client/INDEX.md index 60be4b7..2fe2b4c 100644 --- a/e2e/zoom-client/INDEX.md +++ b/e2e/zoom-client/INDEX.md @@ -12,7 +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) | 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) | draft | never | 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 | +| [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) | | 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 | diff --git a/e2e/zoom-client/announcement-banner.md b/e2e/zoom-client/announcement-banner.md index db023e5..327f635 100644 --- a/e2e/zoom-client/announcement-banner.md +++ b/e2e/zoom-client/announcement-banner.md @@ -1,7 +1,7 @@ --- id: announcement-banner title: Announcement banner (currently the pre-launch notice) -status: draft +status: current covers: - packages/shared/announcements.js - packages/ui/AnnouncementBanner.jsx @@ -14,8 +14,8 @@ automated: needs: - dev deployed - FLAGS_FORCE "1" on dev (`pro` on) for AB-01; "0" (`pro` off) for AB-02 to AB-04 -verified_at: never -verified_on: never +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) From 5305bc9f3e445820476534e45d4e5e266440f1f0 Mon Sep 17 00:00:00 2001 From: Shuhao Date: Sat, 14 Nov 2026 15:37:29 -0500 Subject: [PATCH 5/8] feat(pro): locked Pro button that says what is coming; renewal reminder only in the last 60 days Before the launch (pro off) the footer keeps a grey Pro button with a lock. Pointing at it or clicking it lists what Pro brings: sounds, club card sets on every club timer, voting for Best Table Topics and Best Evaluator, and more. Once pro is on it is the real Pro / Upgrade button again. The club renewal reminder now shows for a club that will not renew only in its last 60 days, and only with a known end date. A failed payment is still warned about at once. --- apps/zoom-app/src/components/Footer.jsx | 4 + apps/zoom-app/src/components/Footer.test.jsx | 30 +++++ .../zoom-app/src/components/ProComingSoon.jsx | 105 ++++++++++++++++++ .../src/components/ProComingSoon.test.jsx | 67 +++++++++++ e2e/zoom-client/INDEX.md | 4 +- e2e/zoom-client/club-card-sets.md | 3 + e2e/zoom-client/club-renewal-reminder.md | 61 ++++++++++ e2e/zoom-client/pro-coming-soon.md | 61 ++++++++++ e2e/zoom.pro-coming-soon.spec.js | 44 ++++++++ packages/shared/__tests__/club.test.js | 41 +++++++ packages/shared/club.js | 24 +++- 11 files changed, 442 insertions(+), 2 deletions(-) create mode 100644 apps/zoom-app/src/components/ProComingSoon.jsx create mode 100644 apps/zoom-app/src/components/ProComingSoon.test.jsx create mode 100644 e2e/zoom-client/club-renewal-reminder.md create mode 100644 e2e/zoom-client/pro-coming-soon.md create mode 100644 e2e/zoom.pro-coming-soon.spec.js 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( + +