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

+ + New: Pro for your club +

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

+ One plan covers every timer in your club: +

+
    +
  • Your club's brand kit on every timer card and report
  • +
  • Shared timing presets, set once for every timer
  • +
  • Every meeting saved to your club's archive
  • + {soundsReleased &&
  • A chime when the card changes, and music during breaks
  • } +
+

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

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