Add qr code
This commit is contained in:
parent
1078ece85c
commit
89caf4fd79
4 changed files with 122 additions and 14 deletions
|
|
@ -2,6 +2,7 @@ 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, Link, Check } from 'lucide-react';
|
import { Sparkles, User, X, Link, Check } from 'lucide-react';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import { PlayerAvatar } from './PlayerAvatar';
|
import { PlayerAvatar } from './PlayerAvatar';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
|
@ -22,6 +23,15 @@ export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role,
|
||||||
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 [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 () => {
|
const copyJoinLink = async () => {
|
||||||
if (!gamePin) return;
|
if (!gamePin) return;
|
||||||
|
|
@ -35,21 +45,36 @@ export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role,
|
||||||
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 items-center gap-4">
|
||||||
<span className="text-white/80 font-bold uppercase tracking-widest text-xs md:text-sm mb-1">Game PIN</span>
|
{isHost && gamePin && (
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
<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">
|
onClick={() => setIsQrModalOpen(true)}
|
||||||
{gamePin}
|
className="bg-white p-2 rounded-xl shadow-lg hidden md:block cursor-pointer hover:scale-105 transition-transform"
|
||||||
|
title="Click to expand"
|
||||||
|
>
|
||||||
|
<QRCodeSVG
|
||||||
|
value={`${window.location.origin}/play/${gamePin}`}
|
||||||
|
size={80}
|
||||||
|
level="M"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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>
|
||||||
{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>
|
||||||
|
|
||||||
|
|
@ -153,6 +178,43 @@ export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role,
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isQrModalOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||||
|
onClick={() => setIsQrModalOpen(false)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.8, opacity: 0 }}
|
||||||
|
className="bg-white p-8 md:p-12 rounded-[2rem] shadow-2xl flex flex-col items-center gap-6 max-w-lg w-full"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 className="text-3xl md:text-4xl font-black text-theme-primary font-display">Scan to Join</h2>
|
||||||
|
<div className="bg-white p-4 rounded-3xl shadow-[inset_0_4px_12px_rgba(0,0,0,0.1)] border-4 border-gray-100">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={`${window.location.origin}/play/${gamePin}`}
|
||||||
|
size={300}
|
||||||
|
level="H"
|
||||||
|
className="w-full h-full max-w-[300px] max-h-[300px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-2 w-full text-center">
|
||||||
|
<div className="text-gray-400 font-bold uppercase tracking-widest text-sm">Or visit URL</div>
|
||||||
|
<div className="text-lg md:text-xl font-black bg-gray-100 text-gray-800 px-6 py-3 rounded-xl break-all w-full select-all">
|
||||||
|
{`${window.location.origin}/play/${gamePin}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400 font-bold mt-2">Tap backdrop or ESC to close</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -18,6 +18,7 @@
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"oidc-client-ts": "^3.1.0",
|
"oidc-client-ts": "^3.1.0",
|
||||||
"peerjs": "^1.5.2",
|
"peerjs": "^1.5.2",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
|
|
@ -4232,6 +4233,15 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"oidc-client-ts": "^3.1.0",
|
"oidc-client-ts": "^3.1.0",
|
||||||
"peerjs": "^1.5.2",
|
"peerjs": "^1.5.2",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,12 @@ vi.mock('framer-motion', () => ({
|
||||||
AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}</>,
|
AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}</>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('qrcode.react', () => ({
|
||||||
|
QRCodeSVG: ({ value, size }: { value: string; size: number }) => (
|
||||||
|
<svg data-testid="qr-code" data-value={value} width={size} height={size} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('Lobby', () => {
|
describe('Lobby', () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
quizTitle: 'Test Quiz',
|
quizTitle: 'Test Quiz',
|
||||||
|
|
@ -206,6 +212,35 @@ describe('Lobby', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('QR code', () => {
|
||||||
|
it('shows QR code for host', () => {
|
||||||
|
render(<Lobby {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<Lobby {...defaultProps} role="CLIENT" />);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('qr-code')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show QR code when gamePin is null', () => {
|
||||||
|
render(<Lobby {...defaultProps} gamePin={null} />);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('qr-code')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates correct QR code URL with game PIN', () => {
|
||||||
|
render(<Lobby {...defaultProps} gamePin="XYZ789" />);
|
||||||
|
|
||||||
|
const qrCode = screen.getByTestId('qr-code');
|
||||||
|
expect(qrCode).toHaveAttribute('data-value', 'http://localhost:5173/play/XYZ789');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('host participates mode', () => {
|
describe('host participates mode', () => {
|
||||||
it('shows host in player list when hostParticipates is true', () => {
|
it('shows host in player list when hostParticipates is true', () => {
|
||||||
const players = [
|
const players = [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue