From 32ebeb7d6b5d568efd2327c6d48dce99035b9d7f Mon Sep 17 00:00:00 2001 From: Shuhao Date: Sat, 14 Nov 2026 16:21:32 -0500 Subject: [PATCH 1/2] feat(pro): club features only while Pro is released Every club feature now reads one gate, clubFeaturesOn (packages/shared/club.js): the club is active AND the pro flag is known and on. Until now they checked only club.entitled, so a club device kept its badge, club card set, club timings, archive strip / History / share, report branding and renewal line while pro was off. While pro is off a club device behaves as a lapsed one: nothing is deleted, it stays in its club, and everything is back once pro is on. Speeches are not sent to the club's archive meanwhile. useClub and the renewal banner re-render when the flags arrive; the club card sync re-checks on a flag change too. Both apps. --- apps/web/src/components/CardImagesModal.jsx | 2 +- apps/web/src/components/ClubStatusBanner.jsx | 4 +- .../src/components/ClubStatusBanner.test.jsx | 11 +-- apps/web/src/components/ReportTab.jsx | 7 +- apps/web/src/components/ReportTab.test.jsx | 12 ++++ apps/web/src/hooks/useClub.js | 5 +- apps/web/src/pages/TimerApp.test.jsx | 5 +- .../src/components/CardImagesModal.jsx | 3 +- .../src/components/CardImagesModal.test.jsx | 12 ++++ .../src/components/ClubStatusBanner.jsx | 4 +- .../src/components/ClubStatusBanner.test.jsx | 6 +- .../src/components/EditRulesModal.test.jsx | 3 + apps/zoom-app/src/components/ReportTab.jsx | 7 +- .../src/components/UpgradeModal.test.jsx | 1 + apps/zoom-app/src/hooks/useClub.js | 5 +- apps/zoom-app/src/utils/zoomSdk.test.js | 6 +- e2e/zoom-client/INDEX.md | 3 +- e2e/zoom-client/club-card-sets.md | 6 +- e2e/zoom-client/club-pro-gate.md | 66 +++++++++++++++++ e2e/zoom-client/club-renewal-reminder.md | 8 +++ e2e/zoom.club-pro-gate.spec.js | 70 +++++++++++++++++++ packages/shared/__tests__/club.test.js | 63 +++++++++++++++++ packages/shared/__tests__/clubArchive.test.js | 4 ++ packages/shared/__tests__/clubBadge.test.js | 4 ++ packages/shared/__tests__/clubCards.test.js | 6 +- packages/shared/__tests__/clubPresets.test.js | 4 ++ packages/shared/cardImages.js | 11 +-- packages/shared/club.js | 29 +++++++- packages/shared/clubArchive.js | 4 +- packages/shared/clubCards.js | 20 ++++-- packages/shared/clubPresets.js | 7 +- 31 files changed, 355 insertions(+), 43 deletions(-) create mode 100644 e2e/zoom-client/club-pro-gate.md create mode 100644 e2e/zoom.club-pro-gate.spec.js 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..250d588 100644 --- a/e2e/zoom-client/INDEX.md +++ b/e2e/zoom-client/INDEX.md @@ -15,6 +15,7 @@ Read this first. Update it in the same PR as any spec change. | [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-pro-gate](club-pro-gate.md) | draft | never | 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..441f5ff 100644 --- a/e2e/zoom-client/club-card-sets.md +++ b/e2e/zoom-client/club-card-sets.md @@ -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..2408ec6 --- /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: draft +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: never +verified_on: never +--- + +# 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..194bf35 100644 --- a/e2e/zoom-client/club-renewal-reminder.md +++ b/e2e/zoom-client/club-renewal-reminder.md @@ -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'; } From dc5575211bdcd75890a72503aea37a3a62c0907f Mon Sep 17 00:00:00 2001 From: Shuhao Date: Sat, 14 Nov 2026 16:31:34 -0500 Subject: [PATCH 2/2] test(zoom-client): run club-pro-gate on 32ebeb7 --- e2e/zoom-client/INDEX.md | 6 +++--- e2e/zoom-client/club-card-sets.md | 2 +- e2e/zoom-client/club-pro-gate.md | 6 +++--- e2e/zoom-client/club-renewal-reminder.md | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/e2e/zoom-client/INDEX.md b/e2e/zoom-client/INDEX.md index 250d588..fca4eca 100644 --- a/e2e/zoom-client/INDEX.md +++ b/e2e/zoom-client/INDEX.md @@ -13,9 +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-pro-gate](club-pro-gate.md) | draft | never | 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 | +| [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 | diff --git a/e2e/zoom-client/club-card-sets.md b/e2e/zoom-client/club-card-sets.md index 441f5ff..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 diff --git a/e2e/zoom-client/club-pro-gate.md b/e2e/zoom-client/club-pro-gate.md index 2408ec6..a0b4bf8 100644 --- a/e2e/zoom-client/club-pro-gate.md +++ b/e2e/zoom-client/club-pro-gate.md @@ -1,7 +1,7 @@ --- id: club-pro-gate title: Club features only while Pro is released -status: draft +status: current covers: - packages/shared/club.js - packages/shared/clubPresets.js @@ -17,8 +17,8 @@ needs: - 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: never -verified_on: never +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 diff --git a/e2e/zoom-client/club-renewal-reminder.md b/e2e/zoom-client/club-renewal-reminder.md index 194bf35..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