diff --git a/components/Lobby.tsx b/components/Lobby.tsx index 75288ff..c88f63d 100644 --- a/components/Lobby.tsx +++ b/components/Lobby.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { Player } from '../types'; import { motion, AnimatePresence } from 'framer-motion'; import { Sparkles, User, X, Link, Check } from 'lucide-react'; +import { QRCodeSVG } from 'qrcode.react'; import { PlayerAvatar } from './PlayerAvatar'; import toast from 'react-hot-toast'; @@ -22,6 +23,15 @@ export const Lobby: React.FC = ({ quizTitle, players, gamePin, role, const realPlayers = players.filter(p => p.id !== 'host'); const currentPlayer = currentPlayerId ? players.find(p => p.id === currentPlayerId) : null; const [linkCopied, setLinkCopied] = useState(false); + const [isQrModalOpen, setIsQrModalOpen] = useState(false); + + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setIsQrModalOpen(false); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); const copyJoinLink = async () => { if (!gamePin) return; @@ -35,21 +45,36 @@ export const Lobby: React.FC = ({ quizTitle, players, gamePin, role, return (
-
- Game PIN -
-
- {gamePin} +
+ {isHost && gamePin && ( +
setIsQrModalOpen(true)} + className="bg-white p-2 rounded-xl shadow-lg hidden md:block cursor-pointer hover:scale-105 transition-transform" + title="Click to expand" + > + +
+ )} +
+ Game PIN +
+
+ {gamePin} +
+ {isHost && ( + + )}
- {isHost && ( - - )}
@@ -153,6 +178,43 @@ export const Lobby: React.FC = ({ quizTitle, players, gamePin, role,
)} + + + {isQrModalOpen && ( + setIsQrModalOpen(false)} + > + e.stopPropagation()} + > +

Scan to Join

+
+ +
+
+
Or visit URL
+
+ {`${window.location.origin}/play/${gamePin}`} +
+
+
Tap backdrop or ESC to close
+
+
+ )} +
); }; diff --git a/package-lock.json b/package-lock.json index 874a4e1..94ece97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "lucide-react": "^0.562.0", "oidc-client-ts": "^3.1.0", "peerjs": "^1.5.2", + "qrcode.react": "^4.2.0", "react": "^19.2.3", "react-dom": "^19.2.3", "react-hot-toast": "^2.6.0", @@ -4232,6 +4233,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", diff --git a/package.json b/package.json index a9ee5fb..05565fa 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "lucide-react": "^0.562.0", "oidc-client-ts": "^3.1.0", "peerjs": "^1.5.2", + "qrcode.react": "^4.2.0", "react": "^19.2.3", "react-dom": "^19.2.3", "react-hot-toast": "^2.6.0", diff --git a/tests/components/Lobby.test.tsx b/tests/components/Lobby.test.tsx index 7aefac0..be7b49c 100644 --- a/tests/components/Lobby.test.tsx +++ b/tests/components/Lobby.test.tsx @@ -18,6 +18,12 @@ vi.mock('framer-motion', () => ({ AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}, })); +vi.mock('qrcode.react', () => ({ + QRCodeSVG: ({ value, size }: { value: string; size: number }) => ( + + ), +})); + describe('Lobby', () => { const defaultProps = { quizTitle: 'Test Quiz', @@ -206,6 +212,35 @@ describe('Lobby', () => { }); }); + describe('QR code', () => { + it('shows QR code for host', () => { + render(); + + const qrCode = screen.getByTestId('qr-code'); + expect(qrCode).toBeInTheDocument(); + expect(qrCode).toHaveAttribute('data-value', 'http://localhost:5173/play/ABC123'); + }); + + it('does not show QR code for client', () => { + render(); + + expect(screen.queryByTestId('qr-code')).not.toBeInTheDocument(); + }); + + it('does not show QR code when gamePin is null', () => { + render(); + + expect(screen.queryByTestId('qr-code')).not.toBeInTheDocument(); + }); + + it('generates correct QR code URL with game PIN', () => { + render(); + + const qrCode = screen.getByTestId('qr-code'); + expect(qrCode).toHaveAttribute('data-value', 'http://localhost:5173/play/XYZ789'); + }); + }); + describe('host participates mode', () => { it('shows host in player list when hostParticipates is true', () => { const players = [