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}