Add share pin
This commit is contained in:
parent
3e9a988748
commit
1078ece85c
5 changed files with 318 additions and 5 deletions
|
|
@ -67,6 +67,12 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
||||||
const [pin, setPin] = useState(initialPin || '');
|
const [pin, setPin] = useState(initialPin || '');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialPin) {
|
||||||
|
setPin(initialPin);
|
||||||
|
}
|
||||||
|
}, [initialPin]);
|
||||||
|
|
||||||
const modalParam = searchParams.get('modal');
|
const modalParam = searchParams.get('modal');
|
||||||
const libraryOpen = modalParam === 'library';
|
const libraryOpen = modalParam === 'library';
|
||||||
const importOpen = modalParam === 'import';
|
const importOpen = modalParam === 'import';
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Player } from '../types';
|
import { Player } from '../types';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Sparkles, User, X } from 'lucide-react';
|
import { Sparkles, User, X, Link, Check } from 'lucide-react';
|
||||||
import { PlayerAvatar } from './PlayerAvatar';
|
import { PlayerAvatar } from './PlayerAvatar';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
interface LobbyProps {
|
interface LobbyProps {
|
||||||
quizTitle: string;
|
quizTitle: string;
|
||||||
|
|
@ -20,14 +21,35 @@ export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role,
|
||||||
const hostPlayer = players.find(p => p.id === 'host');
|
const hostPlayer = players.find(p => p.id === 'host');
|
||||||
const realPlayers = players.filter(p => p.id !== 'host');
|
const realPlayers = players.filter(p => p.id !== 'host');
|
||||||
const currentPlayer = currentPlayerId ? players.find(p => p.id === currentPlayerId) : null;
|
const currentPlayer = currentPlayerId ? players.find(p => p.id === currentPlayerId) : null;
|
||||||
|
const [linkCopied, setLinkCopied] = useState(false);
|
||||||
|
|
||||||
|
const copyJoinLink = async () => {
|
||||||
|
if (!gamePin) return;
|
||||||
|
const joinUrl = `${window.location.origin}/play/${gamePin}`;
|
||||||
|
await navigator.clipboard.writeText(joinUrl);
|
||||||
|
setLinkCopied(true);
|
||||||
|
toast.success('Join link copied!');
|
||||||
|
setTimeout(() => setLinkCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen p-4 md:p-6 overflow-hidden">
|
<div className="flex flex-col h-screen p-4 md:p-6 overflow-hidden">
|
||||||
<header className="flex flex-col md:flex-row justify-between items-center bg-white/10 p-4 md:p-6 rounded-2xl md:rounded-[2rem] backdrop-blur-md mb-4 md:mb-8 gap-3 md:gap-6 border-4 border-white/20 shadow-xl shrink-0">
|
<header className="flex flex-col md:flex-row justify-between items-center bg-white/10 p-4 md:p-6 rounded-2xl md:rounded-[2rem] backdrop-blur-md mb-4 md:mb-8 gap-3 md:gap-6 border-4 border-white/20 shadow-xl shrink-0">
|
||||||
<div className="flex flex-col items-center md:items-start">
|
<div className="flex flex-col items-center md:items-start">
|
||||||
<span className="text-white/80 font-bold uppercase tracking-widest text-xs md:text-sm mb-1">Game PIN</span>
|
<span className="text-white/80 font-bold uppercase tracking-widest text-xs md:text-sm mb-1">Game PIN</span>
|
||||||
<div className="text-4xl md:text-6xl font-black bg-white text-theme-primary px-6 md:px-8 py-1 md:py-2 rounded-full shadow-[0_6px_0_rgba(0,0,0,0.2)] tracking-wider">
|
<div className="flex items-center gap-2">
|
||||||
{gamePin}
|
<div className="text-4xl md:text-6xl font-black bg-white text-theme-primary px-6 md:px-8 py-1 md:py-2 rounded-full shadow-[0_6px_0_rgba(0,0,0,0.2)] tracking-wider">
|
||||||
|
{gamePin}
|
||||||
|
</div>
|
||||||
|
{isHost && (
|
||||||
|
<button
|
||||||
|
onClick={copyJoinLink}
|
||||||
|
className="bg-white/20 hover:bg-white/30 p-3 rounded-full transition-all active:scale-95"
|
||||||
|
title="Copy join link"
|
||||||
|
>
|
||||||
|
{linkCopied ? <Check size={24} className="text-green-400" /> : <Link size={24} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
229
tests/components/Lobby.test.tsx
Normal file
229
tests/components/Lobby.test.tsx
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { Lobby } from '../../components/Lobby';
|
||||||
|
|
||||||
|
vi.mock('react-hot-toast', () => ({
|
||||||
|
default: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('framer-motion', () => ({
|
||||||
|
motion: {
|
||||||
|
div: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => <div {...props}>{children}</div>,
|
||||||
|
},
|
||||||
|
AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Lobby', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
quizTitle: 'Test Quiz',
|
||||||
|
players: [],
|
||||||
|
gamePin: 'ABC123',
|
||||||
|
role: 'HOST' as const,
|
||||||
|
onStart: vi.fn(),
|
||||||
|
onEndGame: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { origin: 'http://localhost:5173' },
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('copy join link', () => {
|
||||||
|
it('shows copy link button for host', () => {
|
||||||
|
render(<Lobby {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByTitle('Copy join link')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show copy link button for client', () => {
|
||||||
|
render(<Lobby {...defaultProps} role="CLIENT" />);
|
||||||
|
|
||||||
|
expect(screen.queryByTitle('Copy join link')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies join URL to clipboard when clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Lobby {...defaultProps} />);
|
||||||
|
|
||||||
|
const copyButton = screen.getByTitle('Copy join link');
|
||||||
|
expect(copyButton.querySelector('.lucide-link')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(copyButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(copyButton.querySelector('.lucide-check')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows success toast when link is copied', async () => {
|
||||||
|
const toast = await import('react-hot-toast');
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Lobby {...defaultProps} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByTitle('Copy join link'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toast.default.success).toHaveBeenCalledWith('Join link copied!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows checkmark after copying link', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Lobby {...defaultProps} gamePin="XYZ789" />);
|
||||||
|
|
||||||
|
const copyButton = screen.getByTitle('Copy join link');
|
||||||
|
|
||||||
|
await user.click(copyButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(copyButton.querySelector('.lucide-check')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('display', () => {
|
||||||
|
it('displays game PIN', () => {
|
||||||
|
render(<Lobby {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('ABC123')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays quiz title on larger screens', () => {
|
||||||
|
render(<Lobby {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Quiz')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays player count', () => {
|
||||||
|
const players = [
|
||||||
|
{ id: '1', name: 'Player 1', score: 0, avatarSeed: 0.1 },
|
||||||
|
{ id: '2', name: 'Player 2', score: 0, avatarSeed: 0.2 },
|
||||||
|
];
|
||||||
|
render(<Lobby {...defaultProps} players={players} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows waiting message when no players have joined', () => {
|
||||||
|
render(<Lobby {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Waiting for players to join...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays player names when players join', () => {
|
||||||
|
const players = [
|
||||||
|
{ id: '1', name: 'Alice', score: 0, avatarSeed: 0.1 },
|
||||||
|
{ id: '2', name: 'Bob', score: 0, avatarSeed: 0.2 },
|
||||||
|
];
|
||||||
|
render(<Lobby {...defaultProps} players={players} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Bob')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('host controls', () => {
|
||||||
|
it('shows Start button for host', () => {
|
||||||
|
render(<Lobby {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables Start button when no players', () => {
|
||||||
|
render(<Lobby {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Start' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enables Start button when players joined', () => {
|
||||||
|
const players = [{ id: '1', name: 'Player 1', score: 0, avatarSeed: 0.1 }];
|
||||||
|
render(<Lobby {...defaultProps} players={players} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Start' })).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onStart when Start clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const players = [{ id: '1', name: 'Player 1', score: 0, avatarSeed: 0.1 }];
|
||||||
|
render(<Lobby {...defaultProps} players={players} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Start' }));
|
||||||
|
|
||||||
|
expect(defaultProps.onStart).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows End Game button when onEndGame provided', () => {
|
||||||
|
render(<Lobby {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('End Game')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onEndGame when End Game clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Lobby {...defaultProps} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByText('End Game'));
|
||||||
|
|
||||||
|
expect(defaultProps.onEndGame).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('client view', () => {
|
||||||
|
it('shows waiting message for client', () => {
|
||||||
|
render(<Lobby {...defaultProps} role="CLIENT" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Waiting for the host to start...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows player name when currentPlayerId matches', () => {
|
||||||
|
const players = [
|
||||||
|
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
|
||||||
|
];
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
role="CLIENT"
|
||||||
|
players={players}
|
||||||
|
currentPlayerId="player-1"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('My Name')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show Start button for client', () => {
|
||||||
|
render(<Lobby {...defaultProps} role="CLIENT" />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: 'Start' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('host participates mode', () => {
|
||||||
|
it('shows host in player list when hostParticipates is true', () => {
|
||||||
|
const players = [
|
||||||
|
{ id: 'host', name: 'Host Player', score: 0, avatarSeed: 0.99 },
|
||||||
|
];
|
||||||
|
render(<Lobby {...defaultProps} players={players} hostParticipates={true} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Host Player')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('HOST')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show waiting message when host participates and no other players', () => {
|
||||||
|
const players = [
|
||||||
|
{ id: 'host', name: 'Host Player', score: 0, avatarSeed: 0.99 },
|
||||||
|
];
|
||||||
|
render(<Lobby {...defaultProps} players={players} hostParticipates={true} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Waiting for players to join...')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1279,6 +1279,51 @@ describe('OAuth Callback Handling', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('/play/:pin URL Join Flow', () => {
|
||||||
|
describe('Landing PIN sync (regression: PIN field empty on /play/:pin)', () => {
|
||||||
|
it('should sync local PIN state when initialPin prop changes after async load', () => {
|
||||||
|
let initialPin: string | null = null;
|
||||||
|
let localPin = initialPin || '';
|
||||||
|
|
||||||
|
const syncPin = (newInitialPin: string | null) => {
|
||||||
|
if (newInitialPin) {
|
||||||
|
localPin = newInitialPin;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initialPin = 'TESTPIN';
|
||||||
|
syncPin(initialPin);
|
||||||
|
|
||||||
|
expect(localPin).toBe('TESTPIN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve user-typed PIN when initialPin becomes null', () => {
|
||||||
|
let localPin = 'USER_TYPED';
|
||||||
|
|
||||||
|
const syncPin = (newInitialPin: string | null) => {
|
||||||
|
if (newInitialPin) {
|
||||||
|
localPin = newInitialPin;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
syncPin(null);
|
||||||
|
|
||||||
|
expect(localPin).toBe('USER_TYPED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should force JOIN mode when initialPin is provided via URL', () => {
|
||||||
|
const getModeFromUrl = (searchParams: URLSearchParams, initialPin?: string): 'HOST' | 'JOIN' => {
|
||||||
|
if (initialPin) return 'JOIN';
|
||||||
|
const modeParam = searchParams.get('mode');
|
||||||
|
return modeParam === 'host' ? 'HOST' : 'JOIN';
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams('?mode=host');
|
||||||
|
expect(getModeFromUrl(searchParams, 'TESTPIN')).toBe('JOIN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('State Sync with Async Data Loading', () => {
|
describe('State Sync with Async Data Loading', () => {
|
||||||
describe('Settings modal data sync', () => {
|
describe('Settings modal data sync', () => {
|
||||||
it('should update editingDefaultConfig when defaultConfig loads', () => {
|
it('should update editingDefaultConfig when defaultConfig loads', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,18 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
import { cleanup } from '@testing-library/react';
|
import { cleanup } from '@testing-library/react';
|
||||||
import { afterEach, vi } from 'vitest';
|
import { afterEach, vi, beforeAll } from 'vitest';
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
value: {
|
||||||
|
writeText: vi.fn().mockResolvedValue(undefined),
|
||||||
|
readText: vi.fn().mockResolvedValue(''),
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue