From eeaa840eca6f6ba3bd77a995c9937f1c05432280 Mon Sep 17 00:00:00 2001 From: Shuhao Date: Fri, 13 Nov 2026 21:22:17 -0500 Subject: [PATCH 1/3] feat(pro): one-time Pro launch announcement in the Zoom app Once the pro flag is on, a free device gets a 'New: Pro for your club' window after a speech ends (PeriodicPrompts timing, shown once), handing off to the Upgrade dialog, and the footer's Upgrade button carries a red dot until any Upgrade dialog has been opened or 28 days after the window showed. Pro devices see neither. New zoom-client spec pro-launch. --- apps/zoom-app/src/components/Footer.jsx | 25 ++- apps/zoom-app/src/components/Footer.test.jsx | 46 ++++++ .../src/components/PeriodicPrompts.jsx | 43 ++++-- .../src/components/PeriodicPrompts.test.jsx | 144 ++++++++++++++++++ .../src/components/ProLaunchModal.jsx | 88 +++++++++++ .../src/components/PromptDebugControls.jsx | 6 +- apps/zoom-app/src/components/UpgradeModal.jsx | 6 + .../src/components/UpgradeModal.test.jsx | 18 +++ e2e/zoom-client/INDEX.md | 3 +- e2e/zoom-client/pro-launch.md | 79 ++++++++++ e2e/zoom.pro-launch.spec.js | 87 +++++++++++ .../shared/__tests__/promptScheduler.test.js | 100 +++++++++++- packages/shared/promptScheduler.js | 49 +++++- 13 files changed, 673 insertions(+), 21 deletions(-) create mode 100644 apps/zoom-app/src/components/PeriodicPrompts.test.jsx create mode 100644 apps/zoom-app/src/components/ProLaunchModal.jsx create mode 100644 e2e/zoom-client/pro-launch.md create mode 100644 e2e/zoom.pro-launch.spec.js diff --git a/apps/zoom-app/src/components/Footer.jsx b/apps/zoom-app/src/components/Footer.jsx index 0a2a3b7..6701b06 100644 --- a/apps/zoom-app/src/components/Footer.jsx +++ b/apps/zoom-app/src/components/Footer.jsx @@ -1,4 +1,4 @@ -import { useState, lazy, Suspense, memo } from 'react'; +import { useEffect, useState, lazy, Suspense, memo } from 'react'; import { MessageSquare, Star, Sparkles } from 'lucide-react'; import { useEntitlement } from '../hooks/useEntitlement'; import { useFlag } from '../hooks/useFlag'; @@ -7,7 +7,10 @@ import { REVIEW_PROMPT, REVIEW_SURVEY_ID, ZOOM_MARKETPLACE_REVIEW_URL, + isProLaunchBadgeDue, + loadPromptState, markPromptAnswered, + subscribeToPromptState, } from '@toastmaster-timer/shared'; import { trackEvent } from '../utils/posthog'; import { openExternalUrl } from '../utils/zoomSdk'; @@ -21,6 +24,11 @@ export default memo(function Footer() { const [showUpgradeModal, setShowUpgradeModal] = useState(false); const { isPro, known } = useEntitlement(); const { enabled: proEnabled, known: flagsKnown } = useFlag('pro'); + // Prompt history, for the "New" badge: it goes the moment any Upgrade dialog + // marks the Pro announcement answered. + const [promptState, setPromptState] = useState(loadPromptState); + useEffect(() => subscribeToPromptState(setPromptState), []); + const showNewBadge = !isPro && isProLaunchBadgeDue(promptState); const handleFeedbackClick = () => { (window.requestIdleCallback || setTimeout)(() => { @@ -87,8 +95,21 @@ export default memo(function Footer() { }`} aria-label={isPro ? 'Manage your Pro plan' : 'Upgrade to Pro'} > - + {/* A dot, not a "New" pill: a pill pushes "Send Us Feedback" onto + two lines in the narrow Zoom panel. The word stays for screen + readers. */} + + + {showNewBadge && ( + {isPro ? 'Pro' : 'Upgrade'} + {showNewBadge && New} )} diff --git a/apps/zoom-app/src/components/Footer.test.jsx b/apps/zoom-app/src/components/Footer.test.jsx index edf437b..01d1cf4 100644 --- a/apps/zoom-app/src/components/Footer.test.jsx +++ b/apps/zoom-app/src/components/Footer.test.jsx @@ -3,6 +3,10 @@ import { act, render, screen } from '@testing-library/react'; import Footer from './Footer'; import { ToastProvider } from '../context/ToastContext'; import { + PRO_LAUNCH_BADGE_DAYS, + PRO_LAUNCH_PROMPT, + markPromptAnswered, + markPromptShown, resetEntitlementForTests, resetFlagsForTests, setEntitlement, @@ -30,6 +34,7 @@ const proButton = () => screen.queryByRole('button', { name: /manage your pro pl // The real stores, not a mocked hook: what is under test is the gate the // Footer composes from both of them. beforeEach(() => { + localStorage.clear(); resetEntitlementForTests(); resetFlagsForTests(); }); @@ -110,3 +115,44 @@ describe('the Footer\'s Pro button', () => { expect(screen.getByRole('button', { name: /leave a review/i })).toBeInTheDocument(); }); }); + +describe('the "New" badge on the Upgrade button', () => { + const badge = () => screen.queryByText('New'); + const DAY_MS = 24 * 60 * 60 * 1000; + + it('marks the Upgrade button for a free user once Pro is released', () => { + setEntitlement({ plan: 'free' }); + setFlags({ pro: true }); + renderFooter(); + expect(upgradeButton()).toContainElement(badge()); + }); + + it('is not on a Pro user\'s button', () => { + setEntitlement(PRO); + setFlags({ pro: true }); + renderFooter(); + expect(badge()).toBeNull(); + }); + + it('goes the moment the announcement is answered, without a remount', () => { + setEntitlement({ plan: 'free' }); + setFlags({ pro: true }); + renderFooter(); + expect(badge()).toBeInTheDocument(); + + act(() => { + markPromptAnswered(PRO_LAUNCH_PROMPT); + }); + expect(badge()).toBeNull(); + expect(upgradeButton()).toBeInTheDocument(); + }); + + it('is gone once the badge window after the announcement has passed', () => { + markPromptShown(PRO_LAUNCH_PROMPT, Date.now() - (PRO_LAUNCH_BADGE_DAYS + 1) * DAY_MS); + setEntitlement({ plan: 'free' }); + setFlags({ pro: true }); + renderFooter(); + expect(upgradeButton()).toBeInTheDocument(); + expect(badge()).toBeNull(); + }); +}); diff --git a/apps/zoom-app/src/components/PeriodicPrompts.jsx b/apps/zoom-app/src/components/PeriodicPrompts.jsx index 4d56166..62fac2e 100644 --- a/apps/zoom-app/src/components/PeriodicPrompts.jsx +++ b/apps/zoom-app/src/components/PeriodicPrompts.jsx @@ -1,6 +1,7 @@ -import { useEffect, useState, lazy, Suspense } from 'react'; +import { useEffect, useRef, useState, lazy, Suspense } from 'react'; import { CLUB_PROMPT, + PRO_LAUNCH_PROMPT, REVIEW_PROMPT, loadPromptState, markPromptAnswered, @@ -11,14 +12,20 @@ import { subscribeToPromptState, } from '@toastmaster-timer/shared'; import { useTimerTick } from '../context/TimerContext'; +import { useEntitlement } from '../hooks/useEntitlement'; +import { useFlag } from '../hooks/useFlag'; import { trackEvent } from '../utils/posthog'; const ClubSurveyModal = lazy(() => import('./ClubSurveyModal')); const ReviewPromptModal = lazy(() => import('./ReviewPromptModal')); +const ProLaunchModal = lazy(() => import('./ProLaunchModal')); -// Prompts this app asks. The review ask belongs here, where the user installed -// the Zoom app in the first place. -const ENABLED_PROMPTS = [CLUB_PROMPT, REVIEW_PROMPT]; +// Prompts this app always asks. The review ask belongs here, where the user +// installed the Zoom app in the first place. +const BASE_PROMPTS = [CLUB_PROMPT, REVIEW_PROMPT]; +// Plus the Pro announcement, once Pro is released and only to devices that are +// not Pro already: nobody is told about something they have. +const WITH_PRO_LAUNCH = [PRO_LAUNCH_PROMPT, ...BASE_PROMPTS]; // Breathing room after "Finish" before a modal appears: the user has just // stopped a speech and may be lining up the next speaker. If they start the @@ -26,20 +33,31 @@ const ENABLED_PROMPTS = [CLUB_PROMPT, REVIEW_PROMPT]; const SHOW_DELAY_MS = 2500; /** - * Shows an occasional prompt (club question, Zoom review) once the user has - * timed enough speeches. Cadence and history live in the shared prompt - * scheduler; this component only decides when it is polite to interrupt. + * Shows an occasional prompt (club question, Zoom review, the one-time Pro + * announcement) once the user has timed enough speeches. Cadence and history + * live in the shared prompt scheduler; this component only decides which + * prompts apply and when it is polite to interrupt. */ export default function PeriodicPrompts() { const { isRunning } = useTimerTick(); + const { isPro, known: planKnown } = useEntitlement(); + const { enabled: proReleased, known: flagsKnown } = useFlag('pro'); // Queued: due, but waiting for a safe moment. Visible: actually on screen. const [queued, setQueued] = useState(null); const [visible, setVisible] = useState(null); + // Both answers must be in: a Pro device whose plan has not loaded yet looks + // free for a moment, and must not be told about Pro in that moment. + const proLaunchEligible = flagsKnown && proReleased && planKnown && !isPro; + // A ref, so the store subscription and the delayed re-check below read the + // current gate rather than the one they were created with. + const enabledRef = useRef(BASE_PROMPTS); + enabledRef.current = proLaunchEligible ? WITH_PRO_LAUNCH : BASE_PROMPTS; + useEffect( () => subscribeToPromptState((state) => { - setQueued((current) => current ?? selectDuePrompt(state, Date.now(), ENABLED_PROMPTS)); + setQueued((current) => current ?? selectDuePrompt(state, Date.now(), enabledRef.current)); }), [] ); @@ -60,7 +78,7 @@ export default function PeriodicPrompts() { const timer = setTimeout(() => { // Re-check against stored state: another tab or window may have asked // since this prompt was queued. - const due = selectDuePrompt(loadPromptState(), Date.now(), ENABLED_PROMPTS); + const due = selectDuePrompt(loadPromptState(), Date.now(), enabledRef.current); if (!due) { setQueued(null); return; @@ -117,6 +135,13 @@ export default function PeriodicPrompts() { onDecline={() => handleDecline(REVIEW_PROMPT)} /> )} + {visible === PRO_LAUNCH_PROMPT && ( + handleAnswered(PRO_LAUNCH_PROMPT)} + onDismiss={() => handleDismiss(PRO_LAUNCH_PROMPT)} + /> + )} ); } diff --git a/apps/zoom-app/src/components/PeriodicPrompts.test.jsx b/apps/zoom-app/src/components/PeriodicPrompts.test.jsx new file mode 100644 index 0000000..936a6d1 --- /dev/null +++ b/apps/zoom-app/src/components/PeriodicPrompts.test.jsx @@ -0,0 +1,144 @@ +import '@testing-library/jest-dom'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import PeriodicPrompts from './PeriodicPrompts'; +import { trackEvent } from '../utils/posthog'; +import { + PRO_LAUNCH_PROMPT, + forcePrompt, + loadPromptState, + recordSpeechFinished, + resetEntitlementForTests, + resetFlagsForTests, + setEntitlement, + setFlags, +} from '@toastmaster-timer/shared'; + +// Stubbed rather than imported: the real modules pull in @zoom/appssdk, which +// hangs vitest under jsdom, and the timer context needs the whole app. +vi.mock('../utils/zoomSdk', () => ({ openExternalUrl: vi.fn() })); +vi.mock('../utils/posthog', () => ({ trackEvent: vi.fn() })); +vi.mock('../context/TimerContext', () => ({ useTimerTick: () => ({ isRunning: false }) })); +vi.mock('../context/ToastContext', () => ({ useToast: () => ({ showToast: vi.fn() }) })); +// What is under test is the hand-off, not the purchase flow behind it. +vi.mock('./UpgradeModal', () => ({ + default: ({ onClose, source }) => ( +
+ Upgrade from {source} + +
+ ), +})); + +// Long enough to cover SHOW_DELAY_MS in PeriodicPrompts. +const PAST_DELAY_MS = 5000; +const PRO = { plan: 'pro', status: 'active', entitled: true, source: 'subscription' }; + +const announcement = () => screen.queryByRole('dialog', { name: /new: pro for your club/i }); + +function finishSpeeches(count) { + act(() => { + for (let i = 0; i < count; i += 1) recordSpeechFinished(); + }); +} + +function afterDelay() { + act(() => vi.advanceTimersByTime(PAST_DELAY_MS)); +} + +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.clearAllMocks(); + localStorage.clear(); + resetEntitlementForTests(); + resetFlagsForTests(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('the Pro launch announcement', () => { + it('shows after the first finished speech once Pro is released, for a free device', async () => { + setEntitlement({ plan: 'free' }); + setFlags({ pro: true }); + render(); + + finishSpeeches(1); + // Held back until the delay elapses, so it never lands on the FINISH click. + expect(announcement()).not.toBeInTheDocument(); + + afterDelay(); + await waitFor(() => expect(announcement()).toBeInTheDocument()); + expect(screen.getByText(/one plan covers every timer in your club/i)).toBeInTheDocument(); + expect(loadPromptState().prompts[PRO_LAUNCH_PROMPT].asks).toBe(1); + expect(trackEvent).toHaveBeenCalledWith('periodic_prompt_shown', { prompt: PRO_LAUNCH_PROMPT }); + }); + + it.each([ + ['Pro is not released', { plan: 'free' }, { pro: false }], + ['the device is already Pro', PRO, { pro: true }], + ['the plan has not loaded yet', null, { pro: true }], + ['the flags have not loaded yet', { plan: 'free' }, undefined], + ])('stays away while %s', (_label, plan, flags) => { + if (plan) setEntitlement(plan); + if (flags !== undefined) setFlags(flags); + render(); + + finishSpeeches(1); + afterDelay(); + + expect(announcement()).not.toBeInTheDocument(); + expect(loadPromptState().prompts[PRO_LAUNCH_PROMPT].asks).toBe(0); + }); + + it('is shown once: closing it retires it for good', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + setEntitlement({ plan: 'free' }); + setFlags({ pro: true }); + render(); + + finishSpeeches(1); + afterDelay(); + await waitFor(() => expect(announcement()).toBeInTheDocument()); + + await user.click(screen.getByRole('button', { name: /not now/i })); + expect(announcement()).not.toBeInTheDocument(); + expect(trackEvent).toHaveBeenCalledWith('periodic_prompt_dismissed', { prompt: PRO_LAUNCH_PROMPT }); + + finishSpeeches(50); + afterDelay(); + expect(announcement()).not.toBeInTheDocument(); + expect(loadPromptState().prompts[PRO_LAUNCH_PROMPT].asks).toBe(1); + }); + + it('hands off to the Upgrade dialog and counts that as answered', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + setEntitlement({ plan: 'free' }); + setFlags({ pro: true }); + render(); + + finishSpeeches(1); + afterDelay(); + await waitFor(() => expect(announcement()).toBeInTheDocument()); + + await user.click(screen.getByRole('button', { name: /see what's in pro/i })); + expect(trackEvent).toHaveBeenCalledWith('pro_launch_learn_more'); + await waitFor(() => expect(screen.getByText('Upgrade from pro_launch')).toBeInTheDocument()); + expect(announcement()).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /close upgrade/i })); + expect(screen.queryByText('Upgrade from pro_launch')).not.toBeInTheDocument(); + expect(loadPromptState().prompts[PRO_LAUNCH_PROMPT].resolution).toBe('answered'); + expect(trackEvent).toHaveBeenCalledWith('periodic_prompt_answered', { prompt: PRO_LAUNCH_PROMPT }); + }); + + it('can be forced from the debug panel whatever the gates say', async () => { + render(); + + act(() => forcePrompt(PRO_LAUNCH_PROMPT)); + + await waitFor(() => expect(announcement()).toBeInTheDocument()); + expect(loadPromptState().prompts[PRO_LAUNCH_PROMPT].asks).toBe(0); + }); +}); diff --git a/apps/zoom-app/src/components/ProLaunchModal.jsx b/apps/zoom-app/src/components/ProLaunchModal.jsx new file mode 100644 index 0000000..ea5437d --- /dev/null +++ b/apps/zoom-app/src/components/ProLaunchModal.jsx @@ -0,0 +1,88 @@ +import { lazy, Suspense, useState } from 'react'; +import { X, Sparkles } from 'lucide-react'; +import { trackEvent } from '../utils/posthog'; + +const UpgradeModal = lazy(() => import('./UpgradeModal')); + +/** + * The one-time "Pro is here" announcement, shown by PeriodicPrompts after a + * speech ends, never on its own and never before START: the Upgrade dialog's + * rule that nothing stands between a timer and the START button still holds. + * + * It only says what Pro is for and hands off. Buying, and joining a club with a + * code, stay in UpgradeModal, which opens in its place; opening it marks the + * announcement answered there, so this modal has no answered path of its own. + * Closing it (X or "Not now") counts as the one ask it gets. + */ +export default function ProLaunchModal({ isOpen, onAnswered, onDismiss }) { + const [showUpgrade, setShowUpgrade] = useState(false); + + if (!isOpen) return null; + + if (showUpgrade) { + return ( + + + + ); + } + + const handleLearnMore = () => { + trackEvent('pro_launch_learn_more'); + setShowUpgrade(true); + }; + + return ( +
+
+
+

+ + New: Pro for your club +

+ +
+ + {/* The club is the buyer, and the timer role rotates weekly: the copy + speaks to the whole club so whoever is timing can pass it on. */} +

+ One plan covers every timer in your club: +

+
    +
  • Your club's brand kit on every timer card and report
  • +
  • Shared timing presets, set once for every timer
  • +
  • Every meeting saved to your club's archive
  • +
+

+ The timer itself stays free. Not the one who decides? Tell your club officers. +

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

    The timer itself stays free. Not the one who decides? Tell your club officers. diff --git a/apps/zoom-app/src/components/UpgradeModal.jsx b/apps/zoom-app/src/components/UpgradeModal.jsx index 71c2726..25acc6b 100644 --- a/apps/zoom-app/src/components/UpgradeModal.jsx +++ b/apps/zoom-app/src/components/UpgradeModal.jsx @@ -18,6 +18,7 @@ import { trackEvent } from '../utils/posthog'; import { openExternalUrl } from '../utils/zoomSdk'; import { getSessionToken, resolveZoomIdentity } from '../utils/zoomIdentity'; import { useEntitlement } from '../hooks/useEntitlement'; +import { useFlag } from '../hooks/useFlag'; /** * Buying Pro from inside Zoom, and joining a club that already bought it. @@ -93,6 +94,9 @@ async function openPortal() { export default function UpgradeModal({ isOpen, onClose, source = 'unknown', onUpgraded }) { const { entitlement, isPro } = useEntitlement(); + // Sounds join the pitch only once released; while `sounds` is off they are + // locked as "coming soon" even for Pro. + const { enabled: soundsReleased } = useFlag('sounds'); const club = useClub(); const [identity, setIdentity] = useState(null); const [phase, setPhase] = useState('choose'); // choose | opening | waiting | done | error @@ -487,6 +491,9 @@ export default function UpgradeModal({ isOpen, onClose, source = 'unknown', onUp

  • Your club's brand kit on every timer card and report
  • Shared timing presets — set once, every timer gets them
  • Every meeting saved to your club's archive
  • + {soundsReleased && ( +
  • A chime when the card changes, and music during breaks
  • + )}
  • Your settings backed up and synced across devices
  • The timer itself stays free, always
  • diff --git a/apps/zoom-app/src/components/UpgradeModal.test.jsx b/apps/zoom-app/src/components/UpgradeModal.test.jsx index c186b6b..e4b6f0a 100644 --- a/apps/zoom-app/src/components/UpgradeModal.test.jsx +++ b/apps/zoom-app/src/components/UpgradeModal.test.jsx @@ -165,6 +165,19 @@ describe('the club-framed pitch', () => { expect(JSON.parse(init.body)).toEqual({ interval: 'monthly', clubName: 'Downtown Speakers' }); }); + it.each([ + ['lists Sounds once the sounds flag is on', true], + ['leaves Sounds out while the sounds flag is off', false], + ])('%s', async (_label, sounds) => { + setFlags({ pro: true, sounds }); + renderModal(); + + expect(await screen.findByText(/One Pro account for your whole club/i)).toBeInTheDocument(); + const line = screen.queryByText(/a chime when the card changes, and music during breaks/i); + if (sounds) expect(line).toBeInTheDocument(); + else expect(line).not.toBeInTheDocument(); + }); + // A buyer stopped at a required field is a sale lost; the club is minted with // a placeholder instead. it('checks out with the field left empty and sends no key at all', async () => { diff --git a/e2e/zoom-client/pro-launch.md b/e2e/zoom-client/pro-launch.md index 5a7e47c..ce53978 100644 --- a/e2e/zoom-client/pro-launch.md +++ b/e2e/zoom-client/pro-launch.md @@ -44,7 +44,8 @@ Evidence: screenshot of the footer; for a Pro device, which plan the panel shows Do: debug panel → Periodic Prompts → "Pro launch" → Show. Then "See what's in Pro", and close the Upgrade dialog. Expect: the window "New: Pro for your club" fits the panel with no horizontal scroll: three -bullet lines, "The timer itself stays free. Not the one who decides? Tell your club officers.", +bullet lines, four with "A chime when the card changes, and music during breaks" while the +`sounds` flag is on (it is, under FLAGS_FORCE "1"), "The timer itself stays free. Not the one who decides? Tell your club officers.", and the buttons "Not now" and "See what's in Pro". "See what's in Pro" replaces it with the Upgrade dialog (its benefit list starts "Your club's brand kit on every timer card and report" on a free device; a Pro device sees its own plan). After closing, the debug panel reads diff --git a/e2e/zoom.pro-launch.spec.js b/e2e/zoom.pro-launch.spec.js index 8b2c931..4214d81 100644 --- a/e2e/zoom.pro-launch.spec.js +++ b/e2e/zoom.pro-launch.spec.js @@ -3,7 +3,7 @@ import { test, expect } from '@playwright/test'; // The one-time "Pro is here" announcement. It appears after a speech ends, only // while the `pro` flag is on and only to a device that is not Pro already. -function session(page, { plan = 'free', pro = true } = {}) { +function session(page, { plan = 'free', pro = true, sounds = false } = {}) { return page.route('**/api/zoom/session', (route) => route.fulfill({ json: { @@ -13,7 +13,7 @@ function session(page, { plan = 'free', pro = true } = {}) { plan === 'pro' ? { plan: 'pro', status: 'active', entitled: true, source: 'subscription' } : { plan: 'free', status: null, entitled: false, source: null }, - flags: { pro }, + flags: { pro, sounds }, }, }) ); @@ -54,14 +54,17 @@ test('a free user sees it once, after FINISH, and the footer marks Upgrade as ne }); test('"See what\'s in Pro" opens the Upgrade dialog and retires the badge', async ({ page }) => { - await session(page); + await session(page, { sounds: true }); await page.goto('/zoom/'); await timeOneSpeech(page); await expect(announcement(page)).toBeVisible({ timeout: 6000 }); + // Sounds are pitched once released, in the announcement and behind it. + await expect(announcement(page)).toContainText('A chime when the card changes, and music during breaks'); await announcement(page).getByRole('button', { name: /see what's in pro/i }).click(); await expect(announcement(page)).toBeHidden(); await expect(page.getByText("Every meeting saved to your club's archive")).toBeVisible(); + await expect(page.getByText('A chime when the card changes, and music during breaks')).toBeVisible(); await page.getByRole('button', { name: 'Close' }).first().click(); await expect(upgradeButton(page)).toBeVisible();