diff --git a/apps/web/src/hooks/useSounds.js b/apps/web/src/hooks/useSounds.js index 4925bdd..6f4b4ac 100644 --- a/apps/web/src/hooks/useSounds.js +++ b/apps/web/src/hooks/useSounds.js @@ -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 } } diff --git a/apps/zoom-app/src/App.jsx b/apps/zoom-app/src/App.jsx index a0c34ae..1b7da4a 100644 --- a/apps/zoom-app/src/App.jsx +++ b/apps/zoom-app/src/App.jsx @@ -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')); @@ -26,6 +27,7 @@ function App() { gets before it loses Pro has to be seen by whoever is timing, who is rarely whoever pays. */} + }} 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 ( + + ); +} diff --git a/apps/zoom-app/src/components/AnnouncementBanner.test.jsx b/apps/zoom-app/src/components/AnnouncementBanner.test.jsx new file mode 100644 index 0000000..84e5116 --- /dev/null +++ b/apps/zoom-app/src/components/AnnouncementBanner.test.jsx @@ -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(); + 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(); + expect(banner()).toBeNull(); + expect(trackEvent).not.toHaveBeenCalled(); + }); + + it('retires the pre-launch notice once pro is released', () => { + setFlags({ pro: true, sounds: true }); + render(); + expect(banner()).toBeNull(); + }); + + it('stays closed on this device', () => { + setFlags({ pro: false }); + const first = render(); + 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(); + 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(); + 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(); + 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(); + expect(banner()).toBeNull(); + }); +}); diff --git a/apps/zoom-app/src/components/Footer.jsx b/apps/zoom-app/src/components/Footer.jsx index 0a2a3b7..7c5cd72 100644 --- a/apps/zoom-app/src/components/Footer.jsx +++ b/apps/zoom-app/src/components/Footer.jsx @@ -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')); @@ -91,6 +92,9 @@ export default memo(function Footer() { {isPro ? 'Pro' : 'Upgrade'} )} + {/* 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 && } {showUpgradeModal && ( diff --git a/apps/zoom-app/src/components/Footer.test.jsx b/apps/zoom-app/src/components/Footer.test.jsx index edf437b..cbac5c1 100644 --- a/apps/zoom-app/src/components/Footer.test.jsx +++ b/apps/zoom-app/src/components/Footer.test.jsx @@ -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( + +