Skip to content
Draft
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
13 changes: 13 additions & 0 deletions apps/zoom-app/src/b/LookContext.js
Original file line number Diff line number Diff line change
@@ -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);
}
3 changes: 3 additions & 0 deletions apps/zoom-app/src/b/NewLookShell.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down Expand Up @@ -40,6 +41,7 @@ export default function NewLookShell() {
};

return (
<LookContext.Provider value="b">
<div className="app-container flex h-screen w-full flex-col bg-white" data-look="b">
<ZoomConnectionNotice />
<ClubStatusBanner />
Expand Down Expand Up @@ -76,5 +78,6 @@ export default function NewLookShell() {
<TimerSounds />
<ContactCapture />
</div>
</LookContext.Provider>
);
}
92 changes: 92 additions & 0 deletions apps/zoom-app/src/b/Phase3.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<LookContext.Provider value="b">{ui}</LookContext.Provider>);

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(<UpgradeModal isOpen onClose={onClose} source="pro_chip" />);
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(<UpgradeModal isOpen onClose={() => {}} 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(<ProLaunchModal isOpen onAnswered={() => {}} 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(<ClubSurveyModal isOpen onAnswered={() => {}} 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(<TimerStage {...props} />);
expect(screen.getByRole('button', { name: 'Share the timer to the meeting' })).toHaveTextContent('Share to the meeting');
view.unmount();
render(<TimerStage {...props} />);
expect(screen.getByRole('button', { name: 'Screenshare the timer' })).toHaveTextContent('Screenshare');
});
});
152 changes: 85 additions & 67 deletions apps/zoom-app/src/components/ClubSurveyModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand All @@ -28,8 +31,89 @@ export default function ClubSurveyModal({ isOpen, onAnswered, onDismiss, onDecli
onDismiss();
};

const body = (
<>
{submitted ? (
<p className="text-green-600 font-medium py-4 text-center">
Thank you — that helps a lot!
</p>
) : (
<>
<p className="text-sm text-gray-600 mb-4">
Knowing which clubs use the timer helps us build the right features. One question,
then we won&apos;t ask again.
</p>

<div className="mb-3">
<label htmlFor="club-name" className="block text-sm font-medium text-gray-700 mb-1">
Club name
</label>
<input
id="club-name"
type="text"
value={club}
onChange={(e) => 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"
/>
</div>

<div className="mb-4">
<label htmlFor="club-district" className="block text-sm font-medium text-gray-700 mb-1">
District <span className="text-gray-400 font-normal">(optional)</span>
</label>
<input
id="club-district"
type="text"
value={district}
onChange={(e) => 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"
/>
</div>

<div className="flex gap-2 justify-end">
<button
onClick={handleDismiss}
className="px-4 py-2 bg-gray-300 hover:bg-gray-400 text-gray-800 font-semibold rounded-lg transition-colors text-sm"
>
Not now
</button>
<button
onClick={handleSubmit}
disabled={!club.trim()}
className="flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors text-sm"
>
<Send className="w-4 h-4" />
Submit
</button>
</div>

<button
onClick={onDecline}
className="mt-3 w-full text-center text-xs text-gray-400 hover:text-gray-600 transition-colors"
>
Don&apos;t ask me again
</button>
</>
)}
</>
);

if (!isOpen) return null;

// Design B (plan 13): the same question as a sheet from the bottom edge.
if (look === 'b') {
return (
<Sheet open onClose={handleDismiss} title="Which club are you from?">
{body}
</Sheet>
);
}

return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
Expand All @@ -44,73 +128,7 @@ export default function ClubSurveyModal({ isOpen, onAnswered, onDismiss, onDecli
</button>
</div>

{submitted ? (
<p className="text-green-600 font-medium py-4 text-center">
Thank you — that helps a lot!
</p>
) : (
<>
<p className="text-sm text-gray-600 mb-4">
Knowing which clubs use the timer helps us build the right features. One question,
then we won&apos;t ask again.
</p>

<div className="mb-3">
<label htmlFor="club-name" className="block text-sm font-medium text-gray-700 mb-1">
Club name
</label>
<input
id="club-name"
type="text"
value={club}
onChange={(e) => 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"
/>
</div>

<div className="mb-4">
<label htmlFor="club-district" className="block text-sm font-medium text-gray-700 mb-1">
District <span className="text-gray-400 font-normal">(optional)</span>
</label>
<input
id="club-district"
type="text"
value={district}
onChange={(e) => 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"
/>
</div>

<div className="flex gap-2 justify-end">
<button
onClick={handleDismiss}
className="px-4 py-2 bg-gray-300 hover:bg-gray-400 text-gray-800 font-semibold rounded-lg transition-colors text-sm"
>
Not now
</button>
<button
onClick={handleSubmit}
disabled={!club.trim()}
className="flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors text-sm"
>
<Send className="w-4 h-4" />
Submit
</button>
</div>

<button
onClick={onDecline}
className="mt-3 w-full text-center text-xs text-gray-400 hover:text-gray-600 transition-colors"
>
Don&apos;t ask me again
</button>
</>
)}
{body}
</div>
</div>
);
Expand Down
57 changes: 57 additions & 0 deletions apps/zoom-app/src/components/ProLaunchModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));

Expand All @@ -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;

Expand All @@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/45 p-4">
<div
role="dialog"
aria-modal="true"
aria-label="New: Pro for your club"
className="w-full max-w-[340px] overflow-hidden rounded-2xl bg-white shadow-[0_16px_40px_rgba(15,23,42,0.3)]"
>
<div className="relative bg-gradient-to-b from-blue-50 to-white px-4 pb-3 pt-4">
<div className="grid grid-cols-4 gap-1.5" aria-hidden="true">
{['#4f86f7', '#16a34a', '#facc15', '#dc2626'].map((colour) => (
<span key={colour} className="aspect-video rounded-md shadow-sm" style={{ background: colour }} />
))}
</div>
<div className="mt-3 flex items-center gap-2">
<span className="rounded bg-blue-600 px-1.5 py-0.5 text-[10px] font-bold tracking-wide text-white">NEW</span>
<h2 className="m-0 text-lg font-bold text-gray-900">Pro for your club</h2>
</div>
<button
type="button"
onClick={onDismiss}
aria-label="Close"
className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full bg-white/80 text-slate-600 hover:bg-white"
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
<div className="px-4 pb-4">
<p className="mb-2 mt-0 text-sm text-slate-600">One plan covers every timer in your club:</p>
<ul className="mb-3 mt-0 list-disc space-y-1 pl-5 text-sm text-gray-700">
<li>Your club&apos;s brand kit on every timer card and report</li>
<li>Shared timing presets, set once for every timer</li>
<li>Every meeting saved to your club&apos;s archive</li>
{soundsReleased && <li>A chime when the card changes, and music during breaks</li>}
</ul>
<p className="mb-4 mt-0 text-sm text-slate-600">
The timer itself stays free. Not the one who decides? Tell your club officers.
</p>
<div className="flex gap-2">
<button type="button" onClick={onDismiss} className="h-11 flex-1 rounded-xl border border-gray-300 text-sm font-semibold hover:bg-slate-50">
Not now
</button>
<button type="button" onClick={handleLearnMore} className="h-11 flex-1 rounded-xl bg-blue-600 text-sm font-bold text-white hover:bg-blue-700">
See what&apos;s in Pro
</button>
</div>
</div>
</div>
</div>
);
}

return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div
Expand Down
Loading