Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/src/components/CardImagesModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {

<p className="text-xs text-gray-500 mt-4">
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&apos;s own materials.
This app is independent and cannot grant permission for third-party logos or artwork.
</p>
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/ClubStatusBanner.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions apps/web/src/components/ClubStatusBanner.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -86,20 +88,19 @@ 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,
cache({ status: 'active', cancelAtPeriodEnd: true, currentPeriodEnd: Date.now() + DAY, role: 'admin' })
);
render(<ClubStatusBanner />);

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();
});

Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/components/ReportTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 () => {
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/components/ReportTab.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
resetClubForTests,
resetClubArchiveForTests,
resetEntitlementForTests,
setFlags,
} from '@toastmaster-timer/shared';
import { TimerProvider, useTimer } from '../context/TimerContext';
import { ToastProvider } from '../context/ToastContext';
Expand Down Expand Up @@ -47,6 +48,8 @@ beforeEach(() => {
resetClubForTests();
resetClubArchiveForTests();
resetEntitlementForTests();
// Club features need Pro released (clubFeaturesOn).
setFlags({ pro: true });
global.fetch = vi.fn();
});

Expand Down Expand Up @@ -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(<ReportTab />);
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', () => {
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/hooks/useClub.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/pages/TimerApp.test.jsx
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion apps/zoom-app/src/components/CardImagesModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getCardImageSettings,
activeCardSetId,
clubCardSetAvailable,
clubFeaturesOn,
getClubCardImage,
selectCardSet,
addCustomCardSet,
Expand Down Expand Up @@ -368,7 +369,7 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {
/>

<p className="text-xs text-gray-500 mt-4">
{club?.entitled ? (
{clubFeaturesOn(club) ? (
<>
Custom images are stored only in this browser.
{canPublishCards() ? ' Share a set to put it on every club device. ' : ' '}
Expand Down
12 changes: 12 additions & 0 deletions apps/zoom-app/src/components/CardImagesModal.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion apps/zoom-app/src/components/ClubStatusBanner.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions apps/zoom-app/src/components/ClubStatusBanner.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ClubStatusBanner />);

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();
});
});
3 changes: 3 additions & 0 deletions apps/zoom-app/src/components/EditRulesModal.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
loadClubPresets,
resetClubForTests,
resetEntitlementForTests,
setFlags,
} from '@toastmaster-timer/shared';

/**
Expand Down Expand Up @@ -85,6 +86,8 @@ beforeEach(() => {
localStorage.clear();
resetClubForTests();
resetEntitlementForTests();
// Club features need Pro released (clubFeaturesOn).
setFlags({ pro: true });
global.fetch = vi.fn();
});

Expand Down
7 changes: 4 additions & 3 deletions apps/zoom-app/src/components/ReportTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 () => {
Expand Down
1 change: 1 addition & 0 deletions apps/zoom-app/src/components/UpgradeModal.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
5 changes: 4 additions & 1 deletion apps/zoom-app/src/hooks/useClub.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
Expand Down
6 changes: 5 additions & 1 deletion apps/zoom-app/src/utils/zoomSdk.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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')}`;
Expand Down
7 changes: 4 additions & 3 deletions e2e/zoom-client/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<club>'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 "<club>'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 |
Expand All @@ -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).
8 changes: 6 additions & 2 deletions e2e/zoom-client/club-card-sets.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -108,6 +109,9 @@ Evidence: `PUT /api/club/cards` → 200; KV `club-cards:<clubId>` 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.
Expand Down
Loading
Loading