From 202115d7b450fe8d31cd46c468d56eef77ff35ea Mon Sep 17 00:00:00 2001 From: Shuhao Date: Sat, 14 Nov 2026 20:24:25 -0500 Subject: [PATCH 1/3] feat(zoom-app): design B's Pro sheets, launch card, club question and stage label A LookContext tells shared dialogs they are inside design B's shell: Upgrade and the club question open as sheets, the Pro launch window becomes the card with the four timing cards (same words), and the stage's share button reads 'Share to the meeting'. Outside the new look nothing changes. --- apps/zoom-app/src/b/LookContext.js | 13 ++ apps/zoom-app/src/b/NewLookShell.jsx | 3 + apps/zoom-app/src/b/Phase3.test.jsx | 92 +++++++++++ .../src/components/ClubSurveyModal.jsx | 152 ++++++++++-------- .../src/components/ProLaunchModal.jsx | 57 +++++++ apps/zoom-app/src/components/TimerStage.jsx | 7 +- apps/zoom-app/src/components/UpgradeModal.jsx | 21 ++- 7 files changed, 275 insertions(+), 70 deletions(-) create mode 100644 apps/zoom-app/src/b/LookContext.js create mode 100644 apps/zoom-app/src/b/Phase3.test.jsx diff --git a/apps/zoom-app/src/b/LookContext.js b/apps/zoom-app/src/b/LookContext.js new file mode 100644 index 0000000..dc55308 --- /dev/null +++ b/apps/zoom-app/src/b/LookContext.js @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react'; + +/** + * Which panel a shared component is drawn in: 'classic' (today's) or 'b' + * (design B, plan 13). Dialogs that both panels open (Upgrade, the club + * question, the Pro launch) read it to present themselves the new way inside + * design B's shell, and stay exactly as they are everywhere else. + */ +export const LookContext = createContext('classic'); + +export function useLook() { + return useContext(LookContext); +} diff --git a/apps/zoom-app/src/b/NewLookShell.jsx b/apps/zoom-app/src/b/NewLookShell.jsx index f1f9ebb..e7c3b65 100644 --- a/apps/zoom-app/src/b/NewLookShell.jsx +++ b/apps/zoom-app/src/b/NewLookShell.jsx @@ -6,6 +6,7 @@ import PeriodicPrompts from '../components/PeriodicPrompts'; import TimerSounds from '../components/TimerSounds'; import ContactCapture from '../components/ContactCapture'; import TopBar from './TopBar'; +import { LookContext } from './LookContext'; const TimerTab = lazy(() => import('./TimerTab')); const AgendaTabB = lazy(() => import('./AgendaTabB')); @@ -40,6 +41,7 @@ export default function NewLookShell() { }; return ( +
@@ -76,5 +78,6 @@ export default function NewLookShell() {
+
); } diff --git a/apps/zoom-app/src/b/Phase3.test.jsx b/apps/zoom-app/src/b/Phase3.test.jsx new file mode 100644 index 0000000..022156e --- /dev/null +++ b/apps/zoom-app/src/b/Phase3.test.jsx @@ -0,0 +1,92 @@ +import '@testing-library/jest-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { resetClubForTests, resetEntitlementForTests, resetFlagsForTests, setEntitlement, setFlags } from '@toastmaster-timer/shared'; +import { resolveZoomIdentity } from '../utils/zoomIdentity'; +import UpgradeModal from '../components/UpgradeModal'; +import ClubSurveyModal from '../components/ClubSurveyModal'; +import ProLaunchModal from '../components/ProLaunchModal'; +import TimerStage from '../components/TimerStage'; +import { LookContext } from './LookContext'; + +vi.mock('../utils/zoomSdk', () => ({ + openExternalUrl: vi.fn(), + getBackgroundUrl: vi.fn((color) => `/backgrounds/${color}.png`), + getZoomParticipants: vi.fn(async () => ({ participants: [], restricted: false })), +})); +vi.mock('../utils/posthog', () => ({ trackEvent: vi.fn() })); +vi.mock('../utils/zoomIdentity', () => ({ + resolveZoomIdentity: vi.fn(), + getSessionToken: vi.fn(() => 'session-token'), +})); +vi.mock('../utils/clubSurvey', () => ({ submitClubResponse: vi.fn(() => true) })); + +const inB = (ui) => render({ui}); + +beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + resetClubForTests(); + resetEntitlementForTests(); + resetFlagsForTests(); + resolveZoomIdentity.mockResolvedValue({ identified: true, uid: 'u1' }); + global.fetch = vi.fn(); + setFlags({ pro: true, sounds: true, new_look: true }); + setEntitlement({ plan: 'free' }); +}); + +describe('Pro in design B', () => { + it('opens Upgrade as a sheet with the PRO tag, the same content inside', async () => { + const onClose = vi.fn(); + inB(); + const sheet = screen.getByRole('dialog', { name: 'One Pro account for your whole club' }); + expect(sheet).toHaveAttribute('aria-modal', 'true'); + expect(sheet).toHaveTextContent('PRO'); + expect(await screen.findByLabelText('Already on Pro through your club?')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(onClose).toHaveBeenCalled(); + }); + + it('keeps today\'s dialog outside design B', () => { + render( {}} source="footer" />); + expect(screen.queryByTestId('sheet-backdrop')).toBeNull(); + }); + + it('shows the launch announcement under the four cards, then hands off to the Upgrade sheet', () => { + const onDismiss = vi.fn(); + inB( {}} onDismiss={onDismiss} />); + const dialog = screen.getByRole('dialog', { name: 'New: Pro for your club' }); + expect(dialog).toHaveTextContent('NEW'); + expect(dialog).toHaveTextContent('The timer itself stays free. Not the one who decides? Tell your club officers.'); + expect(dialog).toHaveTextContent('A chime when the card changes, and music during breaks'); + fireEvent.click(screen.getByRole('button', { name: 'Not now' })); + expect(onDismiss).toHaveBeenCalled(); + }); +}); + +describe('notices in design B', () => { + it('asks which club as a sheet', () => { + const onDismiss = vi.fn(); + inB( {}} onDismiss={onDismiss} onDecline={() => {}} />); + expect(screen.getByRole('dialog', { name: 'Which club are you from?' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: "Don't ask me again" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Not now' })); + expect(onDismiss).toHaveBeenCalled(); + }); +}); + +describe('the stage in design B', () => { + it('names the share for what the meeting will see', () => { + const props = { + status: 'green', elapsedTime: 267, rules: { green: 240, yellow: 300, red: 360 }, speakerName: 'Bob', role: 'Ice Breaker', + isRunning: true, onStart() {}, onContinue() {}, onStop() {}, onReset() {}, onFinish() {}, onExit() {}, + clockHidden: false, onToggleClock() {}, isSharing: false, onToggleShare: vi.fn(), isPoppedOut: false, onTogglePopout() {}, + canPopout: true, onSpeakerNameChange() {}, agendaItems: [], activeSpeakerId: null, onSelectSpeaker() {}, onAddSpeaker() {}, + onRenameSpeaker() {}, clubKit: null, badgePlacement: null, + }; + const view = inB(); + expect(screen.getByRole('button', { name: 'Share the timer to the meeting' })).toHaveTextContent('Share to the meeting'); + view.unmount(); + render(); + expect(screen.getByRole('button', { name: 'Screenshare the timer' })).toHaveTextContent('Screenshare'); + }); +}); diff --git a/apps/zoom-app/src/components/ClubSurveyModal.jsx b/apps/zoom-app/src/components/ClubSurveyModal.jsx index a3dcb6d..0f040ff 100644 --- a/apps/zoom-app/src/components/ClubSurveyModal.jsx +++ b/apps/zoom-app/src/components/ClubSurveyModal.jsx @@ -3,6 +3,8 @@ import { X, Send } from 'lucide-react'; import { CLUB_SURVEY_ID } from '@toastmaster-timer/shared'; import { trackEvent } from '../utils/posthog'; import { submitClubResponse } from '../utils/clubSurvey'; +import { useLook } from '../b/LookContext'; +import Sheet from '../b/Sheet'; /** * Periodic prompt asking which Toastmasters club the user belongs to. @@ -12,6 +14,7 @@ export default function ClubSurveyModal({ isOpen, onAnswered, onDismiss, onDecli const [club, setClub] = useState(''); const [district, setDistrict] = useState(''); const [submitted, setSubmitted] = useState(false); + const look = useLook(); const handleSubmit = () => { if (submitted) return; @@ -28,8 +31,89 @@ export default function ClubSurveyModal({ isOpen, onAnswered, onDismiss, onDecli onDismiss(); }; + const body = ( + <> + {submitted ? ( +

+ Thank you — that helps a lot! +

+ ) : ( + <> +

+ Knowing which clubs use the timer helps us build the right features. One question, + then we won't ask again. +

+ +
+ + setClub(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSubmit()} + placeholder="e.g. Downtown Speakers" + autoFocus + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm" + /> +
+ +
+ + setDistrict(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSubmit()} + placeholder="e.g. 61" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm" + /> +
+ +
+ + +
+ + + + )} + + ); + if (!isOpen) return null; + // Design B (plan 13): the same question as a sheet from the bottom edge. + if (look === 'b') { + return ( + + {body} + + ); + } + return (
@@ -44,73 +128,7 @@ export default function ClubSurveyModal({ isOpen, onAnswered, onDismiss, onDecli
- {submitted ? ( -

- Thank you — that helps a lot! -

- ) : ( - <> -

- Knowing which clubs use the timer helps us build the right features. One question, - then we won't ask again. -

- -
- - setClub(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleSubmit()} - placeholder="e.g. Downtown Speakers" - autoFocus - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm" - /> -
- -
- - setDistrict(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleSubmit()} - placeholder="e.g. 61" - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm" - /> -
- -
- - -
- - - - )} + {body}
); diff --git a/apps/zoom-app/src/components/ProLaunchModal.jsx b/apps/zoom-app/src/components/ProLaunchModal.jsx index d9d0a39..dd8bc56 100644 --- a/apps/zoom-app/src/components/ProLaunchModal.jsx +++ b/apps/zoom-app/src/components/ProLaunchModal.jsx @@ -2,6 +2,7 @@ import { lazy, Suspense, useState } from 'react'; import { X, Sparkles } from 'lucide-react'; import { useFlag } from '../hooks/useFlag'; import { trackEvent } from '../utils/posthog'; +import { useLook } from '../b/LookContext'; const UpgradeModal = lazy(() => import('./UpgradeModal')); @@ -20,6 +21,7 @@ export default function ProLaunchModal({ isOpen, onAnswered, onDismiss }) { // Sounds are only pitched once they are released: while `sounds` is off a // Pro user would find them locked as "coming soon". const { enabled: soundsReleased } = useFlag('sounds'); + const look = useLook(); if (!isOpen) return null; @@ -36,6 +38,61 @@ export default function ProLaunchModal({ isOpen, onAnswered, onDismiss }) { setShowUpgrade(true); }; + // Design B (plan 13, board B-P04): the same words under a row of the four + // timing cards, as one card in the middle of the panel. + if (look === 'b') { + return ( +
+
+
+ +
+ NEW +

Pro for your club

+
+ +
+
+

One plan covers every timer in your club:

+
    +
  • Your club's brand kit on every timer card and report
  • +
  • Shared timing presets, set once for every timer
  • +
  • Every meeting saved to your club's archive
  • + {soundsReleased &&
  • A chime when the card changes, and music during breaks
  • } +
+

+ The timer itself stays free. Not the one who decides? Tell your club officers. +

+
+ + +
+
+
+
+ ); + } + return (
{isSharing ? ( @@ -193,7 +195,8 @@ export default memo(function TimerStage({ ) : ( )} - {isSharing ? 'Stop sharing' : 'Screenshare'} + {/* Design B (board B-T16) names what the meeting will see. */} + {isSharing ? 'Stop sharing' : look === 'b' ? 'Share to the meeting' : 'Screenshare'} {/* Desktop only, and never while sharing: Zoom refuses to undock an diff --git a/apps/zoom-app/src/components/UpgradeModal.jsx b/apps/zoom-app/src/components/UpgradeModal.jsx index 25acc6b..3d12ad1 100644 --- a/apps/zoom-app/src/components/UpgradeModal.jsx +++ b/apps/zoom-app/src/components/UpgradeModal.jsx @@ -19,6 +19,8 @@ import { openExternalUrl } from '../utils/zoomSdk'; import { getSessionToken, resolveZoomIdentity } from '../utils/zoomIdentity'; import { useEntitlement } from '../hooks/useEntitlement'; import { useFlag } from '../hooks/useFlag'; +import { useLook } from '../b/LookContext'; +import Sheet from '../b/Sheet'; /** * Buying Pro from inside Zoom, and joining a club that already bought it. @@ -94,6 +96,7 @@ async function openPortal() { export default function UpgradeModal({ isOpen, onClose, source = 'unknown', onUpgraded }) { const { entitlement, isPro } = useEntitlement(); + const look = useLook(); // Sounds join the pitch only once released; while `sounds` is off they are // locked as "coming soon" even for Pro. const { enabled: soundsReleased } = useFlag('sounds'); @@ -533,6 +536,22 @@ export default function UpgradeModal({ isOpen, onClose, source = 'unknown', onUp ); }; + const title = isPro || phase === 'done' ? 'Toastmasters Timer Pro' : 'One Pro account for your whole club'; + + // Design B (plan 13) opens every setting as a sheet from the bottom edge. + if (look === 'b') { + return ( + PRO} + > + {renderBody()} + + ); + } + return (
@@ -542,7 +561,7 @@ export default function UpgradeModal({ isOpen, onClose, source = 'unknown', onUp {/* The pitch that passes the 30-second test: a treasurer who never times a meeting has to see what their whole club gets, not what one laptop gets. */} - {isPro || phase === 'done' ? 'Toastmasters Timer Pro' : 'One Pro account for your whole club'} + {title}