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';
}