Add show rankings
This commit is contained in:
parent
cc30b13383
commit
3655d4d456
2 changed files with 282 additions and 9 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Player } from '../types';
|
import { Player } from '../types';
|
||||||
import { motion } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Trophy, Medal, RotateCcw } from 'lucide-react';
|
import { Trophy, Medal, RotateCcw, List, X } from 'lucide-react';
|
||||||
import confetti from 'canvas-confetti';
|
import confetti from 'canvas-confetti';
|
||||||
import { PlayerAvatar } from './PlayerAvatar';
|
import { PlayerAvatar } from './PlayerAvatar';
|
||||||
|
|
||||||
|
|
@ -11,6 +11,7 @@ interface PodiumProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Podium: React.FC<PodiumProps> = ({ players, onRestart }) => {
|
export const Podium: React.FC<PodiumProps> = ({ players, onRestart }) => {
|
||||||
|
const [showRankings, setShowRankings] = useState(false);
|
||||||
const sorted = [...players].sort((a, b) => b.score - a.score);
|
const sorted = [...players].sort((a, b) => b.score - a.score);
|
||||||
const winner = sorted[0];
|
const winner = sorted[0];
|
||||||
const second = sorted[1];
|
const second = sorted[1];
|
||||||
|
|
@ -87,12 +88,78 @@ export const Podium: React.FC<PodiumProps> = ({ players, onRestart }) => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="flex items-center gap-3">
|
||||||
onClick={onRestart}
|
<button
|
||||||
className="flex items-center gap-3 bg-white text-theme-primary px-10 py-4 rounded-2xl text-2xl font-black hover:scale-105 transition shadow-[0_8px_0_rgba(0,0,0,0.2)] active:shadow-none active:translate-y-[8px]"
|
onClick={() => setShowRankings(true)}
|
||||||
>
|
className="flex items-center gap-2 bg-white/20 text-white px-5 py-4 rounded-2xl text-lg font-bold hover:bg-white/30 transition"
|
||||||
<RotateCcw size={28} /> Play Again
|
>
|
||||||
</button>
|
<List size={22} /> All Rankings
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onRestart}
|
||||||
|
className="flex items-center gap-3 bg-white text-theme-primary px-10 py-4 rounded-2xl text-2xl font-black hover:scale-105 transition shadow-[0_8px_0_rgba(0,0,0,0.2)] active:shadow-none active:translate-y-[8px]"
|
||||||
|
>
|
||||||
|
<RotateCcw size={28} /> Play Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showRankings && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
||||||
|
onClick={() => setShowRankings(false)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.9, opacity: 0 }}
|
||||||
|
className="bg-white rounded-3xl p-6 max-w-md w-full max-h-[80vh] overflow-hidden flex flex-col shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-2xl font-black text-gray-900">Final Rankings</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRankings(false)}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-full transition"
|
||||||
|
>
|
||||||
|
<X size={24} className="text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto flex-1 -mx-2 px-2">
|
||||||
|
{sorted.map((player, index) => (
|
||||||
|
<div
|
||||||
|
key={player.id}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-xl mb-2 ${
|
||||||
|
index === 0 ? 'bg-yellow-100' :
|
||||||
|
index === 1 ? 'bg-gray-100' :
|
||||||
|
index === 2 ? 'bg-orange-100' :
|
||||||
|
'bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-black text-sm ${
|
||||||
|
index === 0 ? 'bg-yellow-400 text-yellow-900' :
|
||||||
|
index === 1 ? 'bg-gray-300 text-gray-700' :
|
||||||
|
index === 2 ? 'bg-orange-400 text-orange-900' :
|
||||||
|
'bg-gray-200 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<PlayerAvatar seed={player.avatarSeed} size={36} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-bold text-gray-900 truncate">{player.name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="font-black text-lg text-gray-700">{player.score}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
206
tests/components/Podium.test.tsx
Normal file
206
tests/components/Podium.test.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { Podium } from '../../components/Podium';
|
||||||
|
import { Player } from '../../types';
|
||||||
|
|
||||||
|
vi.mock('framer-motion', () => ({
|
||||||
|
motion: {
|
||||||
|
div: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => <div {...props}>{children}</div>,
|
||||||
|
},
|
||||||
|
AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('canvas-confetti', () => ({
|
||||||
|
default: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Podium', () => {
|
||||||
|
const createPlayer = (overrides: Partial<Player> = {}): Player => ({
|
||||||
|
id: 'player-1',
|
||||||
|
name: 'Test Player',
|
||||||
|
score: 100,
|
||||||
|
previousScore: 0,
|
||||||
|
streak: 0,
|
||||||
|
lastAnswerCorrect: null,
|
||||||
|
selectedShape: null,
|
||||||
|
pointsBreakdown: null,
|
||||||
|
isBot: false,
|
||||||
|
avatarSeed: 0.5,
|
||||||
|
color: '#ff0000',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultPlayers = [
|
||||||
|
createPlayer({ id: '1', name: 'Alice', score: 300 }),
|
||||||
|
createPlayer({ id: '2', name: 'Bob', score: 200 }),
|
||||||
|
createPlayer({ id: '3', name: 'Charlie', score: 100 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockOnRestart = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('podium display', () => {
|
||||||
|
it('renders podium title', () => {
|
||||||
|
render(<Podium players={defaultPlayers} onRestart={mockOnRestart} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Podium')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays top 3 players on podium', () => {
|
||||||
|
render(<Podium players={defaultPlayers} onRestart={mockOnRestart} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Bob')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Charlie')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Play Again button', () => {
|
||||||
|
render(<Podium players={defaultPlayers} onRestart={mockOnRestart} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Play Again')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onRestart when Play Again clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Podium players={defaultPlayers} onRestart={mockOnRestart} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Play Again'));
|
||||||
|
|
||||||
|
expect(mockOnRestart).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('all rankings feature', () => {
|
||||||
|
it('shows All Rankings button', () => {
|
||||||
|
render(<Podium players={defaultPlayers} onRestart={mockOnRestart} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('All Rankings')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens rankings modal when All Rankings clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Podium players={defaultPlayers} onRestart={mockOnRestart} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByText('All Rankings'));
|
||||||
|
|
||||||
|
expect(screen.getByText('Final Rankings')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows all players in rankings modal', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const players = [
|
||||||
|
createPlayer({ id: '1', name: 'RankAlice', score: 300 }),
|
||||||
|
createPlayer({ id: '2', name: 'RankBob', score: 100 }),
|
||||||
|
createPlayer({ id: '3', name: 'RankCharlie', score: 200 }),
|
||||||
|
createPlayer({ id: '4', name: 'RankDiana', score: 50 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<Podium players={players} onRestart={mockOnRestart} />);
|
||||||
|
await user.click(screen.getByText('All Rankings'));
|
||||||
|
|
||||||
|
const modal = screen.getByText('Final Rankings').parentElement?.parentElement;
|
||||||
|
expect(modal).toHaveTextContent('RankAlice');
|
||||||
|
expect(modal).toHaveTextContent('RankBob');
|
||||||
|
expect(modal).toHaveTextContent('RankCharlie');
|
||||||
|
expect(modal).toHaveTextContent('RankDiana');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows rank numbers in modal', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Podium players={defaultPlayers} onRestart={mockOnRestart} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByText('All Rankings'));
|
||||||
|
|
||||||
|
expect(screen.getByText('1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('3')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows player scores in modal', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Podium players={defaultPlayers} onRestart={mockOnRestart} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByText('All Rankings'));
|
||||||
|
|
||||||
|
const modal = screen.getByText('Final Rankings').parentElement?.parentElement;
|
||||||
|
expect(modal).toHaveTextContent('300');
|
||||||
|
expect(modal).toHaveTextContent('200');
|
||||||
|
expect(modal).toHaveTextContent('100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes modal when X button clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Podium players={defaultPlayers} onRestart={mockOnRestart} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByText('All Rankings'));
|
||||||
|
expect(screen.getByText('Final Rankings')).toBeInTheDocument();
|
||||||
|
|
||||||
|
const modal = screen.getByText('Final Rankings').parentElement;
|
||||||
|
const closeButton = modal?.querySelector('button');
|
||||||
|
await user.click(closeButton!);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Final Rankings')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('modal content does not close when clicking inside', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Podium players={defaultPlayers} onRestart={mockOnRestart} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByText('All Rankings'));
|
||||||
|
expect(screen.getByText('Final Rankings')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Final Rankings'));
|
||||||
|
|
||||||
|
expect(screen.getByText('Final Rankings')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles many players in rankings', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const manyPlayers = Array.from({ length: 20 }, (_, i) =>
|
||||||
|
createPlayer({ id: `${i}`, name: `ManyPlayer${i + 1}`, score: 1000 - i * 50 })
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<Podium players={manyPlayers} onRestart={mockOnRestart} />);
|
||||||
|
await user.click(screen.getByText('All Rankings'));
|
||||||
|
|
||||||
|
const modal = screen.getByText('Final Rankings').parentElement?.parentElement;
|
||||||
|
expect(modal).toHaveTextContent('ManyPlayer1');
|
||||||
|
expect(modal).toHaveTextContent('ManyPlayer20');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles single player', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const singlePlayer = [createPlayer({ id: '1', name: 'SoloPlayer', score: 500 })];
|
||||||
|
|
||||||
|
render(<Podium players={singlePlayer} onRestart={mockOnRestart} />);
|
||||||
|
await user.click(screen.getByText('All Rankings'));
|
||||||
|
|
||||||
|
const modal = screen.getByText('Final Rankings').parentElement?.parentElement;
|
||||||
|
expect(modal).toHaveTextContent('SoloPlayer');
|
||||||
|
expect(modal).toHaveTextContent('500');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles players with same score', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const tiedPlayers = [
|
||||||
|
createPlayer({ id: '1', name: 'TiedAlice', score: 200 }),
|
||||||
|
createPlayer({ id: '2', name: 'TiedBob', score: 200 }),
|
||||||
|
createPlayer({ id: '3', name: 'TiedCharlie', score: 200 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<Podium players={tiedPlayers} onRestart={mockOnRestart} />);
|
||||||
|
await user.click(screen.getByText('All Rankings'));
|
||||||
|
|
||||||
|
expect(screen.getByText('Final Rankings')).toBeInTheDocument();
|
||||||
|
const modal = screen.getByText('Final Rankings').parentElement?.parentElement;
|
||||||
|
expect(modal).toHaveTextContent('TiedAlice');
|
||||||
|
expect(modal).toHaveTextContent('TiedBob');
|
||||||
|
expect(modal).toHaveTextContent('TiedCharlie');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue