diff --git a/apps/web/src/components/CardImagesModal.jsx b/apps/web/src/components/CardImagesModal.jsx index 75a112e..af50d4c 100644 --- a/apps/web/src/components/CardImagesModal.jsx +++ b/apps/web/src/components/CardImagesModal.jsx @@ -348,7 +348,7 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {

Custom images are stored only in this browser. - {club?.entitled && canPublishCards() ? ' Share a set to put it on every club device.' : ''} Please + {canPublishCards() ? ' Share a set to put it on every club device.' : ''} Please upload only images you have the right to use — for example your club's own materials. This app is independent and cannot grant permission for third-party logos or artwork.

diff --git a/apps/web/src/components/ClubStatusBanner.jsx b/apps/web/src/components/ClubStatusBanner.jsx index d38defe..e898fc2 100644 --- a/apps/web/src/components/ClubStatusBanner.jsx +++ b/apps/web/src/components/ClubStatusBanner.jsx @@ -25,7 +25,9 @@ export default function ClubStatusBanner() { const { enabled: proEnabled, known: flagsKnown } = useFlag('pro') const canOpenPortal = flagsKnown && proEnabled - const reminder = useMemo(() => clubGraceReminder(), [club, dismissals]) + // The pro flag is part of the answer (club features are off while it is), so + // it is a dependency too. + const reminder = useMemo(() => clubGraceReminder(), [club, dismissals, proEnabled, flagsKnown]) useEffect(() => { if (!reminder) return diff --git a/apps/web/src/components/ClubStatusBanner.test.jsx b/apps/web/src/components/ClubStatusBanner.test.jsx index 38cbbb9..157b3e4 100644 --- a/apps/web/src/components/ClubStatusBanner.test.jsx +++ b/apps/web/src/components/ClubStatusBanner.test.jsx @@ -39,6 +39,8 @@ beforeEach(() => { localStorage.clear(); resetClubForTests(); resetFlagsForTests(); + // Club features need Pro released; the cases below that turn it off say so. + setFlags({ pro: true }); }); describe('ClubStatusBanner', () => { @@ -86,12 +88,11 @@ describe('ClubStatusBanner', () => { expect(screen.getByRole('button', { name: /manage billing/i })).toBeInTheDocument(); }); - // The portal answers 404 while pro is off, so the button would do - // nothing. The warning itself still reaches the admin. + // Club features, the reminder among them, are off until Pro is released. it.each([ - ['the flags are still unknown', () => {}], + ['the flags are still unknown', () => resetFlagsForTests()], ['pro is off', () => setFlags({ pro: false })], - ])('warns an admin but offers no billing while %s', (_, seed) => { + ])('shows nothing at all while %s', (_, seed) => { seed(); localStorage.setItem( CLUB_STORAGE_KEY, @@ -99,7 +100,7 @@ describe('ClubStatusBanner', () => { ); render(); - expect(screen.getByTestId('club-grace-banner')).toHaveTextContent('Renew to keep your presets'); + expect(screen.queryByTestId('club-grace-banner')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /manage billing/i })).not.toBeInTheDocument(); }); diff --git a/apps/web/src/components/ReportTab.jsx b/apps/web/src/components/ReportTab.jsx index d33f16f..7aa4e68 100644 --- a/apps/web/src/components/ReportTab.jsx +++ b/apps/web/src/components/ReportTab.jsx @@ -6,7 +6,7 @@ import { useState } from 'react'; import BrandedReportHeader from '@toastmaster-timer/ui/BrandedReportHeader'; import ReportHistoryList from '@toastmaster-timer/ui/ReportHistoryList'; import ShareStep from '@toastmaster-timer/ui/ShareStep'; -import { fetchHistory, fetchMeeting, endMeetingAndShare, copyReportImage } from '@toastmaster-timer/shared'; +import { clubFeaturesOn, fetchHistory, fetchMeeting, endMeetingAndShare, copyReportImage } from '@toastmaster-timer/shared'; import ConfirmModal from './ConfirmModal'; import { useClub, useOutboxPending } from '../hooks/useClub'; import { trackEvent } from '../utils/posthog'; @@ -37,8 +37,9 @@ export default memo(function ReportTab() { const [share, setShare] = useState(null); // A lapsed club keeps its cached record (the device still knows which club to - // re-check) but stops archiving, so the strip and History go with it. - const archiving = Boolean(club?.entitled); + // re-check) but stops archiving, so the strip and History go with it. So + // does a club while Pro is not released (clubFeaturesOn). + const archiving = clubFeaturesOn(club); const archiveName = clubName || club?.club?.name || 'your club'; const endMeeting = async () => { diff --git a/apps/web/src/components/ReportTab.test.jsx b/apps/web/src/components/ReportTab.test.jsx index dd6cc64..67e944a 100644 --- a/apps/web/src/components/ReportTab.test.jsx +++ b/apps/web/src/components/ReportTab.test.jsx @@ -8,6 +8,7 @@ import { resetClubForTests, resetClubArchiveForTests, resetEntitlementForTests, + setFlags, } from '@toastmaster-timer/shared'; import { TimerProvider, useTimer } from '../context/TimerContext'; import { ToastProvider } from '../context/ToastContext'; @@ -47,6 +48,8 @@ beforeEach(() => { resetClubForTests(); resetClubArchiveForTests(); resetEntitlementForTests(); + // Club features need Pro released (clubFeaturesOn). + setFlags({ pro: true }); global.fetch = vi.fn(); }); @@ -243,6 +246,15 @@ describe('ReportTab', () => { expect(screen.getByTestId('club-archive-indicator')).toHaveTextContent('Saved to Downtown Speakers'); }); + // Club features are off until Pro is released: no strip, no History. + it('says nothing about the club while pro is off', () => { + setFlags({ pro: false }); + joinClub(); + renderWithProviders(); + expect(screen.queryByTestId('club-archive-indicator')).toBeNull(); + expect(screen.queryByRole('button', { name: /history/i })).toBeNull(); + }); + // The indicator reads outbox emptiness, so a device that lost the network // mid-meeting shows work pending rather than claiming success. it('shows work pending while speeches are still queued', () => { diff --git a/apps/web/src/hooks/useClub.js b/apps/web/src/hooks/useClub.js index 5025039..3297efc 100644 --- a/apps/web/src/hooks/useClub.js +++ b/apps/web/src/hooks/useClub.js @@ -1,5 +1,5 @@ import { useSyncExternalStore } from 'react'; -import { loadClub, subscribeClub, clubKit, subscribeOutbox, outboxCount } from '@toastmaster-timer/shared'; +import { loadClub, subscribeClub, clubKit, subscribeOutbox, outboxCount, getFlags, subscribeFlags } from '@toastmaster-timer/shared'; /** * The club this device joined, live. @@ -13,6 +13,9 @@ import { loadClub, subscribeClub, clubKit, subscribeOutbox, outboxCount } from ' */ export function useClub() { const club = useSyncExternalStore(subscribeClub, loadClub, loadClub); + // Subscribed only to re-render: whether club features are on also depends on + // the pro flag (clubFeaturesOn), which arrives after the cached club. + useSyncExternalStore(subscribeFlags, getFlags, getFlags); // Derived rather than stored: a lapsed club still has a cached record — the // device keeps knowing which club to check — but it has no kit to render. const kit = club ? clubKit() : null; diff --git a/apps/web/src/pages/TimerApp.test.jsx b/apps/web/src/pages/TimerApp.test.jsx index 0dbe794..c871dc5 100644 --- a/apps/web/src/pages/TimerApp.test.jsx +++ b/apps/web/src/pages/TimerApp.test.jsx @@ -1,7 +1,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom'; -import { CLUB_STORAGE_KEY, resetClubForTests } from '@toastmaster-timer/shared'; +import { CLUB_STORAGE_KEY, resetClubForTests, setFlags } from '@toastmaster-timer/shared'; import TimerApp from './TimerApp'; /** @@ -50,6 +50,9 @@ const renderApp = () => const minimize = async () => userEvent.click(await screen.findByTitle('Minimize panel')); describe('TimerApp — the full-screen card', () => { + // The badge is a club feature, shown only once Pro is released. + beforeEach(() => setFlags({ pro: true })); + it('keeps the club badge on the card the meeting is looking at', async () => { joinClub(); renderApp(); diff --git a/apps/zoom-app/src/components/CardImagesModal.jsx b/apps/zoom-app/src/components/CardImagesModal.jsx index 4e6c803..f398f98 100644 --- a/apps/zoom-app/src/components/CardImagesModal.jsx +++ b/apps/zoom-app/src/components/CardImagesModal.jsx @@ -9,6 +9,7 @@ import { getCardImageSettings, activeCardSetId, clubCardSetAvailable, + clubFeaturesOn, getClubCardImage, selectCardSet, addCustomCardSet, @@ -368,7 +369,7 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) { />

- {club?.entitled ? ( + {clubFeaturesOn(club) ? ( <> Custom images are stored only in this browser. {canPublishCards() ? ' Share a set to put it on every club device. ' : ' '} diff --git a/apps/zoom-app/src/components/CardImagesModal.test.jsx b/apps/zoom-app/src/components/CardImagesModal.test.jsx index 7e55e02..3009ff4 100644 --- a/apps/zoom-app/src/components/CardImagesModal.test.jsx +++ b/apps/zoom-app/src/components/CardImagesModal.test.jsx @@ -73,6 +73,9 @@ function joinClub({ role = null, cards = { label: null, images: { blue: 'a'.repe * by default on a device that never chose cards of its own. */ describe('the club card set', () => { + // Club features need Pro released (clubFeaturesOn). + beforeEach(() => setFlags({ pro: true })); + it('is listed, and selected, on a device with no cards of its own', async () => { joinClub(); await renderModal(); @@ -81,6 +84,15 @@ describe('the club card set', () => { expect(screen.getByRole('radio', { name: /Classic/ })).toHaveAttribute('aria-checked', 'false'); }); + // Club features are off until Pro is released; the device's own cards show. + it('is not listed while pro is off', async () => { + setFlags({ pro: false }); + joinClub(); + await renderModal(); + expect(screen.queryByRole('radio', { name: /Downtown Speakers cards/ })).toBeNull(); + expect(screen.getByRole('radio', { name: /Classic/ })).toHaveAttribute('aria-checked', 'true'); + }); + it('is not listed when the club has not published one', async () => { joinClub({ cards: null }); await renderModal(); diff --git a/apps/zoom-app/src/components/ClubStatusBanner.jsx b/apps/zoom-app/src/components/ClubStatusBanner.jsx index 70b0e22..e5829e2 100644 --- a/apps/zoom-app/src/components/ClubStatusBanner.jsx +++ b/apps/zoom-app/src/components/ClubStatusBanner.jsx @@ -28,7 +28,9 @@ export default function ClubStatusBanner() { const { enabled: proEnabled, known: flagsKnown } = useFlag('pro'); const canOpenPortal = flagsKnown && proEnabled; - const reminder = useMemo(() => clubGraceReminder(), [club, dismissals]); + // The pro flag is part of the answer (club features are off while it is), so + // it is a dependency too. + const reminder = useMemo(() => clubGraceReminder(), [club, dismissals, proEnabled, flagsKnown]); useEffect(() => { if (!reminder) return; diff --git a/apps/zoom-app/src/components/ClubStatusBanner.test.jsx b/apps/zoom-app/src/components/ClubStatusBanner.test.jsx index 82ce958..5eac379 100644 --- a/apps/zoom-app/src/components/ClubStatusBanner.test.jsx +++ b/apps/zoom-app/src/components/ClubStatusBanner.test.jsx @@ -72,15 +72,15 @@ describe('ClubStatusBanner — Manage billing', () => { await waitFor(() => expect(openExternalUrl).toHaveBeenCalledWith('https://billing.stripe.com/p/1')); }); + // Club features, the reminder among them, are off until Pro is released. it.each([ ['the flags are still unknown', () => {}], ['pro is off', () => setFlags({ pro: false })], - ])('still warns the admin, but offers no billing, while %s', (_, seed) => { + ])('shows nothing at all while %s', (_, seed) => { seed(); render(); - expect(screen.getByTestId('club-grace-banner')).toHaveTextContent("Downtown Speakers's Pro ends tomorrow"); - expect(screen.getByTestId('club-grace-banner')).toHaveTextContent('Renew to keep your presets'); + expect(screen.queryByTestId('club-grace-banner')).toBeNull(); expect(manageBilling()).toBeNull(); }); }); diff --git a/apps/zoom-app/src/components/EditRulesModal.test.jsx b/apps/zoom-app/src/components/EditRulesModal.test.jsx index f60e076..6b1ea7b 100644 --- a/apps/zoom-app/src/components/EditRulesModal.test.jsx +++ b/apps/zoom-app/src/components/EditRulesModal.test.jsx @@ -13,6 +13,7 @@ import { loadClubPresets, resetClubForTests, resetEntitlementForTests, + setFlags, } from '@toastmaster-timer/shared'; /** @@ -85,6 +86,8 @@ beforeEach(() => { localStorage.clear(); resetClubForTests(); resetEntitlementForTests(); + // Club features need Pro released (clubFeaturesOn). + setFlags({ pro: true }); global.fetch = vi.fn(); }); diff --git a/apps/zoom-app/src/components/ReportTab.jsx b/apps/zoom-app/src/components/ReportTab.jsx index 563ddbf..1100c0a 100644 --- a/apps/zoom-app/src/components/ReportTab.jsx +++ b/apps/zoom-app/src/components/ReportTab.jsx @@ -6,7 +6,7 @@ import { useState } from 'react'; import BrandedReportHeader from '@toastmaster-timer/ui/BrandedReportHeader'; import ReportHistoryList from '@toastmaster-timer/ui/ReportHistoryList'; import ShareStep from '@toastmaster-timer/ui/ShareStep'; -import { fetchHistory, fetchMeeting, endMeetingAndShare, copyReportImage } from '@toastmaster-timer/shared'; +import { clubFeaturesOn, fetchHistory, fetchMeeting, endMeetingAndShare, copyReportImage } from '@toastmaster-timer/shared'; import ConfirmModal from './ConfirmModal'; import { useClub, useOutboxPending } from '../hooks/useClub'; import { getSessionToken } from '../utils/zoomIdentity'; @@ -46,8 +46,9 @@ export default memo(function ReportTab() { const [share, setShare] = useState(null); // A lapsed club keeps its cached record (the device still knows which club to - // re-check) but stops archiving, so the strip and History go with it. - const archiving = Boolean(club?.entitled); + // re-check) but stops archiving, so the strip and History go with it. So + // does a club while Pro is not released (clubFeaturesOn). + const archiving = clubFeaturesOn(club); const archiveName = clubName || club?.club?.name || 'your club'; const endMeeting = async () => { diff --git a/apps/zoom-app/src/components/UpgradeModal.test.jsx b/apps/zoom-app/src/components/UpgradeModal.test.jsx index 5e60559..38d179a 100644 --- a/apps/zoom-app/src/components/UpgradeModal.test.jsx +++ b/apps/zoom-app/src/components/UpgradeModal.test.jsx @@ -163,6 +163,7 @@ describe('the club-framed pitch', () => { describe('a club whose Pro has ended', () => { beforeEach(() => { + setFlags({ pro: true }); localStorage.setItem( CLUB_STORAGE_KEY, JSON.stringify({ diff --git a/apps/zoom-app/src/hooks/useClub.js b/apps/zoom-app/src/hooks/useClub.js index 5025039..3297efc 100644 --- a/apps/zoom-app/src/hooks/useClub.js +++ b/apps/zoom-app/src/hooks/useClub.js @@ -1,5 +1,5 @@ import { useSyncExternalStore } from 'react'; -import { loadClub, subscribeClub, clubKit, subscribeOutbox, outboxCount } from '@toastmaster-timer/shared'; +import { loadClub, subscribeClub, clubKit, subscribeOutbox, outboxCount, getFlags, subscribeFlags } from '@toastmaster-timer/shared'; /** * The club this device joined, live. @@ -13,6 +13,9 @@ import { loadClub, subscribeClub, clubKit, subscribeOutbox, outboxCount } from ' */ export function useClub() { const club = useSyncExternalStore(subscribeClub, loadClub, loadClub); + // Subscribed only to re-render: whether club features are on also depends on + // the pro flag (clubFeaturesOn), which arrives after the cached club. + useSyncExternalStore(subscribeFlags, getFlags, getFlags); // Derived rather than stored: a lapsed club still has a cached record — the // device keeps knowing which club to check — but it has no kit to render. const kit = club ? clubKit() : null; diff --git a/apps/zoom-app/src/utils/zoomSdk.test.js b/apps/zoom-app/src/utils/zoomSdk.test.js index fbf69f8..14c51b4 100644 --- a/apps/zoom-app/src/utils/zoomSdk.test.js +++ b/apps/zoom-app/src/utils/zoomSdk.test.js @@ -4044,6 +4044,8 @@ describe("the club's badge", () => { const loaded = await loadModule(); const shared = await import('@toastmaster-timer/shared'); shared.resetClubForTests(); + // The badge is a club feature, drawn only once Pro is released. + shared.setFlags({ pro: true }); return loaded; } @@ -4668,7 +4670,9 @@ describe('one card push per color change', () => { saveOverlayMode('camera'); localStorage.setItem('toastmaster_club', JSON.stringify(CLUB)); const mod = await loadModule(); - (await import('@toastmaster-timer/shared')).resetClubForTests(); + const shared = await import('@toastmaster-timer/shared'); + shared.resetClubForTests(); + shared.setFlags({ pro: true }); await mod.initializeZoomSdk(); const label = (s) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; diff --git a/e2e/zoom-client/INDEX.md b/e2e/zoom-client/INDEX.md index 0db87cf..fca4eca 100644 --- a/e2e/zoom-client/INDEX.md +++ b/e2e/zoom-client/INDEX.md @@ -13,8 +13,9 @@ Read this first. Update it in the same PR as any spec change. | [contact-capture](contact-capture.md) | draft | never | Zoom email/name capture: authorize, "Stay in touch" card, browser and web sign-in doors, purge on uninstall (#83) | | [timer-sounds](timer-sounds.md) | stale | never | Pro sounds: card-change chime (1/2/3 notes), break music during Take a Break, locked for free plans ("Hold tight! Sounds are coming soon." while `pro` is off), audible through Share sound (#105). TS-09 ran green on 60e1b6e (2026-11-14); TS-01 needs a free device (the test device is on club Pro), TS-08 needs per-uid PostHog flags; audio steps never run | | [announcement-banner](announcement-banner.md) | current | cb49176, 2026-11-14 | one-line announcement above the tabs, picked from `ANNOUNCEMENTS` (packages/shared/announcements.js); today the pre-launch notice ("Coming soon: a fresh new look… and Pro features") while `pro` is off; a close is kept per device per announcement | -| [club-card-sets](club-card-sets.md) | current | 905d8d1, 2026-11-13 | an admin or editor shares a custom card set with the club; another club device downloads it and shows it on real video; own choice kept; members can't publish; console removal; lapse; republish prunes the old image (plan 10 Phase 1). CC-07 and CC-08 not run on 905d8d1, and CC-06's member 403 never yet (a guest got 401) | -| [club-renewal-reminder](club-renewal-reminder.md) | current | 51534f9, 2026-11-14 | amber "'s Pro ends in N days" line: only in the last 60 days of a club that will not renew; a failed payment at once. CR-01 in Zoom; CR-02/CR-03 on the web app as a second club device (the Zoom client can't be made to refresh its club) | +| [club-card-sets](club-card-sets.md) | stale | 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). Stale since 2026-11-14: covered files changed for the 60-day reminder (#147) and the pro gate (club-pro-gate); rerun before the next release | +| [club-renewal-reminder](club-renewal-reminder.md) | stale | 51534f9, 2026-11-14 | amber "'s Pro ends in N days" line: only in the last 60 days of a club that will not renew; a failed payment at once. CR-01 in Zoom; CR-02/CR-03 on the web app as a second club device (the Zoom client can't be made to refresh its club). CR-04 (nothing while `pro` is off) added 2026-11-14, not run yet (needs the club inside the 60-day window in dev KV) | +| [club-pro-gate](club-pro-gate.md) | current | 32ebeb7, 2026-11-14 | every club feature (badge, club card set, club timings, archive strip / History / share, report branding, renewal line) only while the club is active **and** `pro` is on; nothing deleted while off | | [pro-coming-soon](pro-coming-soon.md) | current | 4d95d3d, 2026-11-14 | footer Pro button while `pro` is off: grey with a lock; hover or click lists what is coming (sounds, club card sets, voting, and more); the real Pro / Upgrade button once `pro` is on | | pro-club | **not migrated** | — | source: `docs/ZOOM_TEST_PLAN.md` P1–P7, plus the internal Club Pro plan (`06-zoom-client-test-plan-club-pro.md`); reference run `test-runs/2026-09-28-club-pro/run.md` | | webhooks-lifecycle | **not migrated** | — | source: `docs/ZOOM_TEST_PLAN.md` Steps 4, 5, 6 | @@ -24,5 +25,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` → `announcement-banner` → `pro-coming-soon` → `timer-core` → `timer-sounds` → `agenda-import` → `pro-club` → `club-card-sets` → `club-renewal-reminder` → `webhooks-lifecycle` (deauth +`connection-notice` → `reauth-landing` → `contact-capture` → `announcement-banner` → `pro-coming-soon` → `timer-core` → `timer-sounds` → `agenda-import` → `pro-club` → `club-card-sets` → `club-renewal-reminder` → `club-pro-gate` → `webhooks-lifecycle` (deauth last, it removes the app). diff --git a/e2e/zoom-client/club-card-sets.md b/e2e/zoom-client/club-card-sets.md index eb70b64..a622ac6 100644 --- a/e2e/zoom-client/club-card-sets.md +++ b/e2e/zoom-client/club-card-sets.md @@ -1,7 +1,7 @@ --- id: club-card-sets title: Club card sets (share card artwork with the club) -status: current +status: stale covers: - worker/club-cards.js - packages/shared/clubCards.js @@ -16,7 +16,8 @@ automated: - apps/zoom-app/src/components/CardImagesModal.test.jsx needs: - dev deployed - - FLAGS_FORCE "1" on dev (the club doors are behind `pro`) + - FLAGS_FORCE "1" on dev (the club doors are behind `pro`, and since 2026-11-14 every club + feature is too: with `pro` off the club set is not listed; see club-pro-gate) - a dev club with a code, made with `node scripts/club-cli.mjs create --env dev` - Zoom account A, an admin or editor of that club (club-member record) - a second computer or browser profile, B, with no cards of its own @@ -108,6 +109,9 @@ Evidence: `PUT /api/club/cards` → 200; KV `club-cards:` names only the - Sharing from the web timer: same shared module and endpoints, proven by unit tests. - Two publishers racing: the prune grace window and the client's retry are unit-tested. - Card sets are one per club for now (open question 2 in the plan). +- The club set while `pro` is off (`clubFeaturesOn` in `club.js`, used by `cardImages.js`, + `clubCards.js` and `CardImagesModal.jsx`): it is not listed and the device shows its own + cards. See [club-pro-gate](club-pro-gate.md). - The renewal reminder's 60-day window (`clubGraceReminder` in `club.js`, PR #147). Reviewed 2026-11-14: it changes when the amber renewal line shows, not card sets; see [club-renewal-reminder](club-renewal-reminder.md). Not rerun for it. diff --git a/e2e/zoom-client/club-pro-gate.md b/e2e/zoom-client/club-pro-gate.md new file mode 100644 index 0000000..a0b4bf8 --- /dev/null +++ b/e2e/zoom-client/club-pro-gate.md @@ -0,0 +1,66 @@ +--- +id: club-pro-gate +title: Club features only while Pro is released +status: current +covers: + - packages/shared/club.js + - packages/shared/clubPresets.js + - packages/shared/clubArchive.js + - apps/zoom-app/src/hooks/useClub.js + - apps/zoom-app/src/components/ReportTab.jsx +automated: + - packages/shared/__tests__/club.test.js + - apps/zoom-app/src/components/CardImagesModal.test.jsx + - e2e/zoom.club-pro-gate.spec.js +needs: + - dev deployed + - FLAGS_FORCE "0" on dev (`pro` off) for GP-01 and GP-02; "1" for GP-03 + - a club device whose club is active (Claude Test Club on the test Mac), with a club card + set, club timings and the badge shown on cards +verified_at: 32ebeb7 +verified_on: 2026-11-14 (Zoom 7.2.5, macOS 26.7.2, dev with FLAGS_FORCE "0" then "1") +--- + +# Club features only while Pro is released + +Every club feature reads one gate, `clubFeaturesOn` in `packages/shared/club.js`: the club +is active (the server says entitled) **and** the `pro` flag is known and on. While `pro` is +off, a club device behaves as a lapsed one: no badge, no club card set, no club timings, no +archive strip, History or End meeting & share, no club branding on reports, no renewal line. +Nothing is deleted. The device stays in its club and everything is back once `pro` is on. + +## Purpose +Catches a club surface that still shows (or still uploads) while Pro is unreleased, and one +that does not come back when it is released, or comes back only after a reload. + +## Steps + +### GP-01 · auto · A club device shows no club features while `pro` is off +Do: with FLAGS_FORCE "0" on dev, open the app on the club device. Look at the Live tab +preview and your video, open Cards, open Edit timing rules, open the Report tab. +Expect: no club badge on the preview or on the video; Cards lists Classic, Modern and the +device's own sets but not " cards", and the device's own set is the one in use; Edit +timing rules shows the device's own timings with no club banner or "Share with my club"; +the Report tab has no "Saved to " strip, no History and no End meeting & share; no +amber renewal line; the footer shows the locked Pro button. +Evidence: screenshots. + +### GP-02 · auto · Nothing is sent to the club's archive while `pro` is off +Do: still with `pro` off, time a short speech and press FINISH. +Expect: the report lists it on this device, and no speech upload reaches the club +(`POST /api/club/meetings/…` absent from the Worker log). +Evidence: `reqs.sh` after FINISH. + +### GP-03 · auto · Everything is back once `pro` is on +Do: set FLAGS_FORCE "1" on dev and refresh the app (⋯ → Refresh app). +Expect: the badge is back on the preview and the video where it was; Cards has " cards" +selected again (unless the device chose its own); the club timings and banner are back; +the Report tab shows "Saved to " with History. Nothing had to be re-entered. +Evidence: screenshots. + +## Not covered +- The web app's copies (`apps/web`): same gate and shared code, covered by + `apps/web/src/components/ReportTab.test.jsx`, `ClubStatusBanner.test.jsx` and + `TimerApp.test.jsx`. +- A club that lapses while `pro` is on: [club-renewal-reminder](club-renewal-reminder.md) and + the lapse steps in [club-card-sets](club-card-sets.md). diff --git a/e2e/zoom-client/club-renewal-reminder.md b/e2e/zoom-client/club-renewal-reminder.md index 7d3549a..c9a6a46 100644 --- a/e2e/zoom-client/club-renewal-reminder.md +++ b/e2e/zoom-client/club-renewal-reminder.md @@ -1,7 +1,7 @@ --- id: club-renewal-reminder title: Club renewal reminder (last 60 days of a club that will not renew) -status: current +status: stale covers: - packages/shared/club.js - packages/ui/ClubGraceBanner.jsx @@ -11,6 +11,7 @@ automated: - apps/zoom-app/src/components/ClubStatusBanner.test.jsx needs: - dev deployed + - FLAGS_FORCE "1" on dev (`pro` on) for CR-01 to CR-03; "0" for CR-04 - a club device (Claude Test Club on the test Mac) - for CR-02 and CR-03, the club record's `currentPeriodEnd` / `status` edited in dev KV (save a backup first, restore after; ask the user once, as a list), and a club device @@ -57,6 +58,13 @@ the period end, or "payment did not go through" without a date). Restore the rec afterwards. Evidence: screenshot. +### CR-04 · auto · No reminder at all while `pro` is off +Do: with FLAGS_FORCE "0" on dev, open the app on a club device whose club is inside the +60-day window or has a failed payment (the CR-02 or CR-03 state). +Expect: no amber renewal line. Club features are off until Pro is released (see +[club-pro-gate](club-pro-gate.md)). +Evidence: screenshot. + ## Not covered - CR-02 and CR-03 inside the Zoom client itself. Run 2026-11-14 on the web app instead (see needs); the banner component and `clubGraceReminder` are shared, and the Zoom client showed diff --git a/e2e/zoom.club-pro-gate.spec.js b/e2e/zoom.club-pro-gate.spec.js new file mode 100644 index 0000000..e487cd6 --- /dev/null +++ b/e2e/zoom.club-pro-gate.spec.js @@ -0,0 +1,70 @@ +import { test, expect } from '@playwright/test'; + +// Club features are on only when the club is active AND Pro is released. A club +// device whose `pro` flag is off behaves as a lapsed one: no badge, no club +// strip on the Report tab, no renewal line; nothing is deleted. +const CLUB = { + clubToken: 'club-token', + // Fresh, so the app does not refresh the club against the (unmocked) server. + lastRefreshAt: Date.now(), + ver: 1, + club: { id: 'club-1', name: 'Downtown Speakers' }, + kit: { name: 'Downtown Speakers', logoUrl: null, primaryColor: '#772432', showOnCards: true, showOnReports: true }, + presets: null, + badge: { x: 0.8, y: 0.12, scale: 0.12 }, + timezone: null, + role: 'admin', + plan: 'pro', + entitled: true, + status: 'active', + // Set not to renew and ending in 10 days: inside the reminder's 60-day window. + currentPeriodEnd: Date.now() + 10 * 24 * 60 * 60 * 1000, + cancelAtPeriodEnd: true, + source: 'club', +}; + +async function openAsClubDevice(page, flags) { + await page.addInitScript((club) => { + if (sessionStorage.getItem('e2e_seeded')) return; + sessionStorage.setItem('e2e_seeded', '1'); + localStorage.setItem('toastmaster_club', JSON.stringify(club)); + }, CLUB); + await page.route('**/api/zoom/session', (route) => + route.fulfill({ + json: { + identified: true, + uid: 'e2e-club', + entitlement: { plan: 'pro', status: 'active', entitled: true, source: 'club' }, + flags, + }, + }) + ); + await page.goto('/zoom/'); + await expect(page.getByRole('button', { name: /START/i })).toBeVisible(); +} + +test('a club device shows no club features while pro is off', async ({ page }) => { + await openAsClubDevice(page, { sounds: true, pro: false }); + + await expect(page.getByTestId('club-grace-banner')).toHaveCount(0); + await expect(page.getByTestId('club-badge')).toHaveCount(0); + await expect(page.getByText('Downtown Speakers', { exact: false })).toHaveCount(0); + + await page.getByRole('button', { name: 'REPORT' }).click(); + await expect(page.getByTestId('club-archive-indicator')).toHaveCount(0); + + // Nothing was deleted: the device is still in its club. + expect(await page.evaluate(() => JSON.parse(localStorage.getItem('toastmaster_club')).club.name)).toBe( + 'Downtown Speakers' + ); +}); + +test('the same device shows its club features once pro is on', async ({ page }) => { + await openAsClubDevice(page, { sounds: true, pro: true }); + + await expect(page.getByTestId('club-grace-banner')).toContainText("Downtown Speakers's Pro ends in 10 days"); + await expect(page.getByTestId('club-badge')).toContainText('Downtown Speakers'); + + await page.getByRole('button', { name: 'REPORT' }).click(); + await expect(page.getByTestId('club-archive-indicator')).toContainText('Saved to Downtown Speakers'); +}); diff --git a/packages/shared/__tests__/club.test.js b/packages/shared/__tests__/club.test.js index c3d4bbe..9100809 100644 --- a/packages/shared/__tests__/club.test.js +++ b/packages/shared/__tests__/club.test.js @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { resetFlagsForTests, setFlags } from '../flags.js'; +import { clubPresetsAvailable } from '../clubPresets.js'; import { CLUB_STORAGE_KEY, CLUB_PRESETS_STORAGE_KEY, @@ -12,6 +14,8 @@ import { dismissGraceReminder, graceReminderDismissedToday, lapsedClubName, + clubFeaturesOn, + clubKit, PAST_DUE_GRACE_MS, CLUB_GRACE_DISMISSED_STORAGE_KEY, initClubFromCache, @@ -55,6 +59,9 @@ const respondWith = (body, { ok = true, status = 200 } = {}) => vi.fn(async () => ({ ok, status, json: async () => body })); beforeEach(() => { + // Club features need Pro released (clubFeaturesOn); the gate itself is + // tested in club.test.js. + setFlags({ pro: true }); localStorage.clear(); resetClubForTests(); resetEntitlementForTests(); @@ -460,6 +467,62 @@ describe('grace and lapse', () => { }); }); +describe('club features and the pro flag', () => { + // One gate for every club surface: an active club, and Pro released. + const KIT = { name: 'Downtown Speakers', showOnCards: true, showOnReports: true }; + + const joinActive = (over = {}) => { + resetClubForTests(); + localStorage.setItem(CLUB_STORAGE_KEY, JSON.stringify(cached({ kit: KIT, ...over }))); + localStorage.setItem(CLUB_PRESETS_STORAGE_KEY, '{"rules":{"Evaluator":{"green":120}}}'); + }; + + it('are on for an active club once pro is released', () => { + joinActive(); + expect(clubFeaturesOn()).toBe(true); + expect(clubKit()).toMatchObject({ name: 'Downtown Speakers' }); + expect(clubPresetsAvailable()).toBe(true); + }); + + it('are off while pro is off, with nothing deleted', () => { + joinActive({ status: 'active', cancelAtPeriodEnd: true, currentPeriodEnd: NOW + 3 * DAY }); + setFlags({ pro: false }); + + expect(clubFeaturesOn()).toBe(false); + expect(clubKit()).toBeNull(); + expect(clubPresetsAvailable()).toBe(false); + expect(clubGraceReminder(NOW)).toBeNull(); + // The device stays in its club, and its lifecycle is still the server's. + expect(loadClub()).toMatchObject({ entitled: true }); + expect(clubLifecycle(NOW)).toMatchObject({ state: 'grace' }); + + setFlags({ pro: true }); + expect(clubFeaturesOn()).toBe(true); + expect(clubKit()).not.toBeNull(); + expect(clubGraceReminder(NOW)).not.toBeNull(); + }); + + it('stay off until the flags have arrived, so nothing flashes at load', () => { + joinActive(); + resetFlagsForTests(); + expect(clubFeaturesOn()).toBe(false); + expect(clubKit()).toBeNull(); + }); + + it('say nothing about a lapsed club while pro is off', () => { + joinActive({ plan: 'free', entitled: false, status: 'canceled', source: 'none' }); + expect(lapsedClubName()).toBe('Downtown Speakers'); + setFlags({ pro: false }); + expect(lapsedClubName()).toBeNull(); + }); + + it('stay off for a lapsed club whatever the flag says', () => { + joinActive({ plan: 'free', entitled: false, status: 'canceled', source: 'none' }); + expect(clubFeaturesOn()).toBe(false); + expect(clubKit()).toBeNull(); + }); +}); + describe('leaveClub', () => { it('clears the club and its presets, and returns the device to free', () => { localStorage.setItem(CLUB_STORAGE_KEY, JSON.stringify(cached())); diff --git a/packages/shared/__tests__/clubArchive.test.js b/packages/shared/__tests__/clubArchive.test.js index 2f72025..32196c4 100644 --- a/packages/shared/__tests__/clubArchive.test.js +++ b/packages/shared/__tests__/clubArchive.test.js @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { setFlags } from '../flags.js'; import { CLUB_OUTBOX_STORAGE_KEY, MEETING_SEQ_STORAGE_KEY, @@ -75,6 +76,9 @@ const speech = (over = {}) => ({ }); beforeEach(() => { + // Club features need Pro released (clubFeaturesOn); the gate itself is + // tested in club.test.js. + setFlags({ pro: true }); localStorage.clear(); resetClubForTests(); resetClubArchiveForTests(); diff --git a/packages/shared/__tests__/clubBadge.test.js b/packages/shared/__tests__/clubBadge.test.js index 40ec682..5ed2437 100644 --- a/packages/shared/__tests__/clubBadge.test.js +++ b/packages/shared/__tests__/clubBadge.test.js @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; +import { setFlags } from '../flags.js'; import { drawClubBadge, clubBadgeRect, @@ -66,6 +67,9 @@ const KIT = { name: 'Downtown Speakers', primaryColor: '#772432', showOnCards: t const PLACE = { x: 0.8, y: 0.12, scale: 0.12, visible: true }; beforeEach(() => { + // Club features need Pro released (clubFeaturesOn); the gate itself is + // tested in club.test.js. + setFlags({ pro: true }); localStorage.clear(); resetClubForTests(); }); diff --git a/packages/shared/__tests__/clubCards.test.js b/packages/shared/__tests__/clubCards.test.js index 7296512..0bcf292 100644 --- a/packages/shared/__tests__/clubCards.test.js +++ b/packages/shared/__tests__/clubCards.test.js @@ -20,10 +20,14 @@ const RED = 'b'.repeat(64); // page reload. async function load() { vi.resetModules(); + // Club features need Pro released (clubFeaturesOn), on the same fresh module + // copy club.js reads. The gate itself is tested in club.test.js. + const flags = await import('../flags.js'); + flags.setFlags({ pro: true }); const cardImages = await import('../cardImages.js'); const club = await import('../club.js'); const clubCards = await import('../clubCards.js'); - return { ...cardImages, ...club, ...clubCards }; + return { ...flags, ...cardImages, ...club, ...clubCards }; } function joinClub({ entitled = true, role = null, ver = 3, cards = { images: { blue: BLUE, red: RED } } } = {}) { diff --git a/packages/shared/__tests__/clubPresets.test.js b/packages/shared/__tests__/clubPresets.test.js index f2ac713..6a4aef3 100644 --- a/packages/shared/__tests__/clubPresets.test.js +++ b/packages/shared/__tests__/clubPresets.test.js @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { setFlags } from '../flags.js'; import { CLUB_STORAGE_KEY, CLUB_PRESETS_STORAGE_KEY, @@ -84,6 +85,9 @@ const respondWith = (body, { ok = true, status = 200 } = {}) => vi.fn(async () => ({ ok, status, json: async () => body })); beforeEach(() => { + // Club features need Pro released (clubFeaturesOn); the gate itself is + // tested in club.test.js. + setFlags({ pro: true }); localStorage.clear(); resetClubForTests(); resetEntitlementForTests(); diff --git a/packages/shared/cardImages.js b/packages/shared/cardImages.js index 8970531..4b294f9 100644 --- a/packages/shared/cardImages.js +++ b/packages/shared/cardImages.js @@ -23,7 +23,7 @@ // rest of the session, so migration is invisible. import { notifyLocalWrite } from './storageEvents.js'; -import { loadClub, loadClubCards, readCardSource, writeCardSource } from './club.js'; +import { clubFeaturesOn, loadClubCards, readCardSource, writeCardSource } from './club.js'; const SETTINGS_KEY = 'toastmaster_custom_card_images'; const SETTINGS_VERSION = 3; @@ -488,12 +488,13 @@ export function getCustomCardImage(setId, color) { // --------------------------------------------------------------------------- /** - * Whether this device has a club set it could show: a club that is still - * entitled, with a set published. A lapsed club keeps its cached record, so - * this is what makes lapse fall back to the device's own cards for free. + * Whether this device has a club set it could show: club features on (an + * active club, with Pro released), with a set published. A lapsed club keeps + * its cached record, so this is what makes lapse — or `pro` being off — fall + * back to the device's own cards for free. */ export function clubCardSetAvailable() { - return Boolean(loadClub()?.entitled && loadClubCards()); + return clubFeaturesOn() && Boolean(loadClubCards()); } /** diff --git a/packages/shared/club.js b/packages/shared/club.js index 906b801..a89949b 100644 --- a/packages/shared/club.js +++ b/packages/shared/club.js @@ -1,4 +1,5 @@ import { FREE_ENTITLEMENT, setEntitlement, getEntitlement } from './entitlement.js'; +import { areFlagsKnown, getFlags } from './flags.js'; import { DEFAULT_BADGE_PLACEMENT, DEFAULT_PRIMARY_COLOR, normalizeBadgePlacement } from './clubBadge.js'; /** @@ -369,6 +370,28 @@ export function clubBadgePlacement() { // Grace and lapse: the three stages the device follows the server through // --------------------------------------------------------------------------- +/** Whether Pro is released: the `pro` flag has arrived and is on. */ +function proReleased() { + return areFlagsKnown() && getFlags().pro === true; +} + +/** + * Whether this device's club features are on: its club is active (the server + * says entitled) and Pro is released (the `pro` flag is known and on). + * + * Every club surface reads this one gate: the badge on the card and the video, + * the club's card set, its shared timings, the meeting archive and sharing, the + * club's branding on reports, and the renewal reminder. While `pro` is off a + * club device behaves as a lapsed one does: nothing is deleted, the device stays + * in its club, and everything is back the moment `pro` is on. Until the flags + * arrive it answers false, so nothing appears and then vanishes at load. + * + * @param {Object|null} [club] the cached club; read fresh when omitted + */ +export function clubFeaturesOn(club = loadClub()) { + return Boolean(club?.entitled) && proReleased(); +} + export const CLUB_ACTIVE = 'active'; export const CLUB_GRACE = 'grace'; export const CLUB_LAPSED = 'lapsed'; @@ -430,7 +453,8 @@ export function clubLifecycle(now = Date.now()) { /** The club whose Pro has ended, for the copy that has to name it. */ export function lapsedClubName() { const club = loadClub(); - if (!club || club.entitled) return null; + // Nothing to say about a club's Pro ending while Pro itself is not released. + if (!club || club.entitled || !proReleased()) return null; return club.club?.name || 'Your club'; } @@ -486,6 +510,7 @@ export const GRACE_REMINDER_WINDOW_DAYS = 60; * @param {number} [now] */ export function clubGraceReminder(now = Date.now()) { + if (!clubFeaturesOn()) return null; const life = clubLifecycle(now); if (!life || life.state !== CLUB_GRACE) return null; if (life.reason === 'not_renewing') { @@ -507,7 +532,7 @@ export function clubGraceReminder(now = Date.now()) { */ export function clubKit() { const club = loadClub(); - if (!club?.entitled) return null; + if (!clubFeaturesOn(club)) return null; const kit = club.kit; if (!kit || typeof kit !== 'object') return null; const name = String(kit.name ?? club.club?.name ?? '').trim(); diff --git a/packages/shared/clubArchive.js b/packages/shared/clubArchive.js index 5e67b9a..3b4fb97 100644 --- a/packages/shared/clubArchive.js +++ b/packages/shared/clubArchive.js @@ -1,4 +1,4 @@ -import { loadClub, clubHeaders, clubKit, getClubLogoImage } from './club.js'; +import { clubFeaturesOn, loadClub, clubHeaders, clubKit, getClubLogoImage } from './club.js'; import { renderReportPng, reportImageFilename, isOvertime } from './reportImage.js'; /** @@ -280,7 +280,7 @@ function newSpeechId() { */ export function recordSpeech(speech, { getToken, fetchImpl, now = Date.now() } = {}) { const club = loadClub(); - if (!club?.clubToken || !club.entitled || !speech) return null; + if (!club?.clubToken || !clubFeaturesOn(club) || !speech) return null; const entry = { speechId: typeof speech.speechId === 'string' && speech.speechId ? speech.speechId : newSpeechId(), diff --git a/packages/shared/clubCards.js b/packages/shared/clubCards.js index 924aa57..de8224d 100644 --- a/packages/shared/clubCards.js +++ b/packages/shared/clubCards.js @@ -1,4 +1,5 @@ -import { loadClub, loadClubCards, subscribeClub, rememberPublishedCards } from './club.js'; +import { clubFeaturesOn, loadClub, loadClubCards, subscribeClub, rememberPublishedCards } from './club.js'; +import { subscribeFlags } from './flags.js'; import { CARD_COLORS, cardImageKey, @@ -41,7 +42,7 @@ export async function hashCardBlob(blob) { /** Whether this device's signed-in user may publish a card set to the club. */ export function canPublishCards() { const club = loadClub(); - if (!club?.entitled) return false; + if (!clubFeaturesOn(club)) return false; return club.role === 'admin' || club.role === 'editor'; } @@ -84,7 +85,7 @@ export async function syncClubCardImages({ fetchImpl } = {}) { function clubCardsSignature() { const club = loadClub(); const cards = loadClubCards(); - return `${Boolean(club?.entitled)}|${CARD_COLORS.map((color) => cards?.images?.[color] ?? '').join(',')}`; + return `${clubFeaturesOn(club)}|${CARD_COLORS.map((color) => cards?.images?.[color] ?? '').join(',')}`; } /** @@ -92,7 +93,8 @@ function clubCardsSignature() { * * Fetches what is missing now, and again whenever the club changes: a new * publish, a removal, a lapse or leaving. Each of those also tells the timer - * to re-show its card, since the set it should show may have changed. + * to re-show its card, since the set it should show may have changed. So does + * the `pro` flag arriving, which decides whether club features are on at all. * * @param {{fetchImpl?: typeof fetch}} [options] * @returns {() => void} stop @@ -100,13 +102,19 @@ function clubCardsSignature() { export function startClubCardSync({ fetchImpl } = {}) { let last = clubCardsSignature(); syncClubCardImages({ fetchImpl }).catch(() => {}); - return subscribeClub(() => { + const onChange = () => { const next = clubCardsSignature(); if (next === last) return; last = next; notifyCardImages(); syncClubCardImages({ fetchImpl }).catch(() => {}); - }); + }; + const stopClub = subscribeClub(onChange); + const stopFlags = subscribeFlags(onChange); + return () => { + stopClub(); + stopFlags(); + }; } function errorFor(status, body) { diff --git a/packages/shared/clubPresets.js b/packages/shared/clubPresets.js index dbcd092..6e1c7ef 100644 --- a/packages/shared/clubPresets.js +++ b/packages/shared/clubPresets.js @@ -8,6 +8,7 @@ import { saveHiddenBuiltinRoles, } from './storage.js'; import { + clubFeaturesOn, loadClub, loadClubPresets, readPresetSource, @@ -70,7 +71,7 @@ export function hasPersonalCustomization() { */ export function clubPresetsAvailable() { const club = loadClub(); - if (!club?.entitled) return false; + if (!clubFeaturesOn(club)) return false; const presets = loadClubPresets(); return Boolean(presets && Object.keys(presets.rules ?? {}).length > 0); } @@ -92,14 +93,14 @@ export function clubPresetsLive() { /** The club's name, when it has one worth putting in a banner. */ export function activeClubName() { const club = loadClub(); - if (!club?.entitled) return null; + if (!clubFeaturesOn(club)) return null; return club.club?.name || null; } /** Whether this device's signed-in user may publish to the club. */ export function canPublishPresets() { const club = loadClub(); - if (!club?.entitled) return false; + if (!clubFeaturesOn(club)) return false; return club.role === 'admin' || club.role === 'editor'; }