Skip to content
Merged
18 changes: 12 additions & 6 deletions apps/web/src/hooks/useSounds.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,29 @@ export function useSoundSettings() {
// The lock tooltip on the Sounds button, its modal, and the modal's controls.
export const SOUNDS_LOCK_MESSAGES = Object.freeze({
pro: 'Sounds are a Pro feature. Upgrade to Pro to use them.',
unreleased: 'Sounds are coming soon.',
unreleased: 'Hold tight! Sounds are coming soon.',
})

/**
* Whether this user may turn sounds on: the release flag and Pro both. The
* button and modal always show, so free users can see what Pro adds; anyone
* who cannot use them gets them locked, with lockReason saying why. Until both
* button and modal always show, so everyone can see what is on the way; anyone
* who cannot use them gets them locked, with lockReason saying why. 'pro' only
* when buying Pro would unlock them, which needs both `sounds` and `pro`
* released. While either is off, Pro cannot get them, so the lock says they are
* coming rather than selling a plan that cannot be bought yet. Until both
* answers are in, sounds are unusable but not yet locked, so a Pro user never
* sees a lock flash on load.
*
* @returns {{usable: boolean, lockReason: 'pro'|'unreleased'|null}}
*/
export function useSoundsAccess() {
const { enabled, known: flagsKnown } = useFlag('sounds')
const { enabled: soundsReleased, known: flagsKnown } = useFlag('sounds')
const { enabled: proReleased } = useFlag('pro')
const { isPro, known: entitlementKnown } = useEntitlement()
const usable = flagsKnown && enabled && isPro
const usable = flagsKnown && soundsReleased && isPro
let lockReason = null
if (flagsKnown && entitlementKnown && !usable) lockReason = isPro ? 'unreleased' : 'pro'
if (flagsKnown && entitlementKnown && !usable) {
lockReason = soundsReleased && proReleased ? 'pro' : 'unreleased'
}
return { usable, lockReason }
}
2 changes: 2 additions & 0 deletions apps/zoom-app/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import TimerSounds from './components/TimerSounds';
import ContactCapture from './components/ContactCapture';
import ZoomConnectionNotice from './components/ZoomConnectionNotice';
import ClubStatusBanner from './components/ClubStatusBanner';
import AnnouncementBanner from './components/AnnouncementBanner';
import './App.css';

const LiveTab = lazy(() => import('./components/LiveTab'));
Expand All @@ -26,6 +27,7 @@ function App() {
gets before it loses Pro has to be seen by whoever is timing, who
is rarely whoever pays. */}
<ClubStatusBanner />
<AnnouncementBanner />
<NavTabs activeTab={activeTab} onTabChange={setActiveTab} />

<Suspense fallback={
Expand Down
70 changes: 70 additions & 0 deletions apps/zoom-app/src/components/AnnouncementBanner.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
import { AnnouncementBanner as Banner } from '@toastmaster-timer/ui';
import {
areFlagsKnown,
dismissAnnouncement,
getFlags,
pickAnnouncement,
subscribeFlags,
} from '@toastmaster-timer/shared';
import { trackEvent } from '../utils/posthog';
import { openExternalUrl } from '../utils/zoomSdk';

/**
* Whichever announcement applies now, above the tabs. What to announce lives
* in ANNOUNCEMENTS (packages/shared/announcements.js); this only picks one for
* the Zoom app, remembers a close, and opens a link in the system browser,
* since Zoom's webview does not leave the app.
*
* Renders nothing when no announcement applies, so the app is unchanged.
*
* @param {{announcements?: ReadonlyArray<Object>}} props - a list for tests;
* the app uses the shipped one
*/
export default function AnnouncementBanner({ announcements } = {}) {
const flags = useSyncExternalStore(subscribeFlags, getFlags, getFlags);
const flagsKnown = useSyncExternalStore(subscribeFlags, areFlagsKnown, areFlagsKnown);
// Bumped by a close so the memo below asks again; the answer itself lives
// in localStorage.
const [dismissals, setDismissals] = useState(0);
const [closedHere, setClosedHere] = useState(null);

const announcement = useMemo(
() => pickAnnouncement({ surface: 'zoom', flags, flagsKnown, announcements }),
[flags, flagsKnown, announcements, dismissals],
);
const id = announcement?.id ?? null;

useEffect(() => {
if (id) trackEvent('announcement_shown', { surface: 'zoom', announcement_id: id });
}, [id]);

// Storage that refuses the write still hides it until the app reopens.
if (!announcement || announcement.id === closedHere) return null;

const handleDismiss = () => {
dismissAnnouncement(announcement.id);
trackEvent('announcement_dismissed', { surface: 'zoom', announcement_id: announcement.id });
setClosedHere(announcement.id);
setDismissals((count) => count + 1);
};

const handleLink = announcement.link?.url
? () => {
trackEvent('announcement_link_opened', { surface: 'zoom', announcement_id: announcement.id });
openExternalUrl(announcement.link.url);
}
: undefined;

return (
<Banner
id={announcement.id}
lead={announcement.lead}
text={announcement.text}
tone={announcement.tone}
linkLabel={announcement.link?.label}
onLink={handleLink}
onDismiss={handleDismiss}
/>
);
}
101 changes: 101 additions & 0 deletions apps/zoom-app/src/components/AnnouncementBanner.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import '@testing-library/jest-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import {
ANNOUNCEMENTS_DISMISSED_KEY,
resetFlagsForTests,
setFlags,
} from '@toastmaster-timer/shared';
import AnnouncementBanner from './AnnouncementBanner';
import { trackEvent } from '../utils/posthog';
import { openExternalUrl } from '../utils/zoomSdk';

vi.mock('../utils/posthog', () => ({ trackEvent: vi.fn() }));
// Stubbed: the real module pulls in @zoom/appssdk, which hangs vitest under jsdom.
vi.mock('../utils/zoomSdk', () => ({ openExternalUrl: vi.fn() }));

const banner = () => screen.queryByTestId('announcement-banner');

beforeEach(() => {
localStorage.clear();
resetFlagsForTests();
trackEvent.mockClear();
openExternalUrl.mockClear();
});

describe('AnnouncementBanner with the shipped announcements', () => {
it('says a new look and Pro are coming while pro is off', () => {
setFlags({ pro: false, sounds: true });
render(<AnnouncementBanner />);
expect(banner()).toHaveAttribute('data-announcement-id', 'pre-launch-2026-11');
expect(banner()).toHaveTextContent(
'Coming soon: a fresh new look for the timer, and Pro features for your club.',
);
expect(trackEvent).toHaveBeenCalledWith('announcement_shown', {
surface: 'zoom',
announcement_id: 'pre-launch-2026-11',
});
});

it('stays hidden until the flags are known, so it never flashes', () => {
render(<AnnouncementBanner />);
expect(banner()).toBeNull();
expect(trackEvent).not.toHaveBeenCalled();
});

it('retires the pre-launch notice once pro is released', () => {
setFlags({ pro: true, sounds: true });
render(<AnnouncementBanner />);
expect(banner()).toBeNull();
});

it('stays closed on this device', () => {
setFlags({ pro: false });
const first = render(<AnnouncementBanner />);
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
expect(banner()).toBeNull();
expect(JSON.parse(localStorage.getItem(ANNOUNCEMENTS_DISMISSED_KEY))).toEqual(['pre-launch-2026-11']);
expect(trackEvent).toHaveBeenCalledWith('announcement_dismissed', {
surface: 'zoom',
announcement_id: 'pre-launch-2026-11',
});
first.unmount();

render(<AnnouncementBanner />);
expect(banner()).toBeNull();
});
});

describe('AnnouncementBanner with any announcement', () => {
const news = {
id: 'news-1',
lead: 'New:',
text: 'club card sets.',
tone: 'success',
link: { label: 'How it works', url: 'https://www.timer.simple-tech.app/help' },
};

it('shows the lead, the text and the link, and opens the link outside Zoom', () => {
render(<AnnouncementBanner announcements={[news]} />);
expect(banner()).toHaveTextContent('New: club card sets. How it works');
expect(banner()).toHaveClass('bg-green-50');
fireEvent.click(screen.getByRole('button', { name: 'How it works' }));
expect(openExternalUrl).toHaveBeenCalledWith('https://www.timer.simple-tech.app/help');
expect(trackEvent).toHaveBeenCalledWith('announcement_link_opened', {
surface: 'zoom',
announcement_id: 'news-1',
});
});

it('moves on to the next announcement after one is closed', () => {
const later = { id: 'news-2', text: 'Something else.' };
render(<AnnouncementBanner announcements={[news, later]} />);
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
expect(banner()).toHaveAttribute('data-announcement-id', 'news-2');
expect(banner()).toHaveTextContent('Something else.');
});

it('skips one meant only for the web app', () => {
render(<AnnouncementBanner announcements={[{ id: 'web', text: 'Web only.', surfaces: ['web'] }]} />);
expect(banner()).toBeNull();
});
});
4 changes: 4 additions & 0 deletions apps/zoom-app/src/components/Footer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { trackEvent } from '../utils/posthog';
import { openExternalUrl } from '../utils/zoomSdk';
import { useToast } from '../context/ToastContext';
import ProComingSoon from './ProComingSoon';
const FeedbackModal = lazy(() => import('./FeedbackModal'));
const UpgradeModal = lazy(() => import('./UpgradeModal'));

Expand Down Expand Up @@ -91,6 +92,9 @@ export default memo(function Footer() {
<span>{isPro ? 'Pro' : 'Upgrade'}</span>
</button>
)}
{/* Before Pro is released: the same place, locked, saying what is
coming. Nothing can be bought yet, so there is no upgrade here. */}
{flagsKnown && !proEnabled && <ProComingSoon />}
</footer>
{showUpgradeModal && (
<Suspense fallback={null}>
Expand Down
30 changes: 30 additions & 0 deletions apps/zoom-app/src/components/Footer.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,36 @@ describe('the Footer\'s Pro button', () => {
expect(upgradeButton()).toBeInTheDocument();
});

// Before the launch the button stays in its place, locked, and says what is
// coming instead of selling a plan nobody can buy yet.
it('shows a locked Pro button while pro is off, for every plan', () => {
setEntitlement({ plan: 'free' });
setFlags({ pro: false });
const free = renderFooter();
expect(screen.getByRole('button', { name: 'Pro (coming soon)' })).toBeInTheDocument();
free.unmount();

setEntitlement(PRO);
renderFooter();
expect(screen.getByRole('button', { name: 'Pro (coming soon)' })).toBeInTheDocument();
expect(proButton()).toBeNull();
});

it('shows no locked Pro button before the flags land, or once pro is on', () => {
setEntitlement({ plan: 'free' });
const view = renderFooter();
expect(screen.queryByRole('button', { name: 'Pro (coming soon)' })).toBeNull();

act(() => setFlags({ pro: true }));
view.rerender(
<ToastProvider>
<Footer />
</ToastProvider>
);
expect(screen.queryByRole('button', { name: 'Pro (coming soon)' })).toBeNull();
expect(upgradeButton()).toBeInTheDocument();
});

it('leaves the other footer buttons alone whatever the flag says', () => {
setEntitlement({ plan: 'free' });
setFlags({ pro: false });
Expand Down
106 changes: 106 additions & 0 deletions apps/zoom-app/src/components/ProComingSoon.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { useEffect, useId, useRef, useState } from 'react';
import { Check, Lock, Sparkles } from 'lucide-react';
import { trackEvent } from '../utils/posthog';

// What Pro will bring, in a few words each. Keep it short: it opens in the
// footer of a 400px side panel.
export const PRO_COMING_SOON_ITEMS = Object.freeze([
'Sounds: a chime at each colour, music for breaks',
"Your club's card sets on every club timer",
'Voting for Best Table Topics and Best Evaluator',
]);
export const PRO_COMING_SOON_MORE = "And more: your club's logo, shared timings, meeting history.";

/**
* The footer's Pro button before Pro is released: greyed, with a lock. Pointing
* at it or clicking it opens a short list of what is coming. A click keeps the
* list open until the next click, Escape or a click elsewhere, so it works
* without a mouse too.
*/
export default function ProComingSoon() {
const [hovered, setHovered] = useState(false);
const [pinned, setPinned] = useState(false);
const open = hovered || pinned;
const wrapperRef = useRef(null);
const trackedHover = useRef(false);
const panelId = useId();

useEffect(() => {
if (!pinned) return undefined;
const onKey = (event) => {
if (event.key === 'Escape') {
setPinned(false);
setHovered(false);
}
};
const onPointer = (event) => {
if (!wrapperRef.current?.contains(event.target)) setPinned(false);
};
document.addEventListener('keydown', onKey);
document.addEventListener('mousedown', onPointer);
return () => {
document.removeEventListener('keydown', onKey);
document.removeEventListener('mousedown', onPointer);
};
}, [pinned]);

const handleEnter = () => {
setHovered(true);
if (!trackedHover.current) {
trackedHover.current = true;
trackEvent('pro_coming_soon_opened', { trigger: 'hover' });
}
};

const handleClick = () => {
if (!pinned) trackEvent('pro_coming_soon_opened', { trigger: 'click' });
setPinned(!pinned);
};

return (
<div
ref={wrapperRef}
className="relative"
onMouseEnter={handleEnter}
onMouseLeave={() => setHovered(false)}
>
<button
type="button"
onClick={handleClick}
aria-expanded={open}
aria-controls={panelId}
aria-label="Pro (coming soon)"
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-400 hover:text-gray-500 hover:bg-gray-50 rounded-md transition-colors duration-150"
>
{/* The lock takes the sparkle's place, so the button is exactly as wide
as the launched one and the footer still fits one line at 400px. */}
<Lock className="w-4 h-4 flex-shrink-0" aria-hidden="true" />
<span>Pro</span>
</button>
{open && (
<div
id={panelId}
role="dialog"
aria-label="Pro is coming soon"
data-testid="pro-coming-soon"
className="absolute bottom-full right-0 z-40 mb-2 w-64 rounded-lg border border-gray-200 bg-white p-3 text-left shadow-lg"
>
<p className="flex items-center gap-1.5 text-sm font-semibold text-gray-900">
<Sparkles className="h-4 w-4 text-amber-500" aria-hidden="true" />
Pro is coming soon
</p>
<ul className="mt-2 space-y-1.5">
{PRO_COMING_SOON_ITEMS.map((item) => (
<li key={item} className="flex gap-1.5 text-xs text-gray-700">
<Check className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-green-600" aria-hidden="true" />
<span>{item}</span>
</li>
))}
</ul>
<p className="mt-2 text-xs text-gray-500">{PRO_COMING_SOON_MORE}</p>
<p className="mt-2 border-t border-gray-100 pt-2 text-xs text-gray-500">The timer itself stays free.</p>
</div>
)}
</div>
);
}
Loading
Loading