699 lines
30 KiB
TypeScript
699 lines
30 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useState, useRef } from 'react';
|
||
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
|
||
import GuessInput from './GuessInput';
|
||
import Statistics from './Statistics';
|
||
import { useGameState } from '../lib/gameState';
|
||
import { sendGotifyNotification, submitRating } from '../app/actions';
|
||
|
||
// Plausible Analytics
|
||
declare global {
|
||
interface Window {
|
||
plausible?: (eventName: string, options?: { props?: Record<string, string | number> }) => void;
|
||
}
|
||
}
|
||
|
||
interface GameProps {
|
||
dailyPuzzle: {
|
||
id: number;
|
||
puzzleNumber: number;
|
||
audioUrl: string;
|
||
songId: number;
|
||
title: string;
|
||
artist: string;
|
||
coverImage: string | null;
|
||
releaseYear?: number | null;
|
||
startTime?: number;
|
||
} | null;
|
||
genre?: string | null;
|
||
isSpecial?: boolean;
|
||
maxAttempts?: number;
|
||
unlockSteps?: number[];
|
||
}
|
||
|
||
const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
|
||
|
||
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
|
||
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts);
|
||
const [hasWon, setHasWon] = useState(false);
|
||
const [hasLost, setHasLost] = useState(false);
|
||
const [shareText, setShareText] = useState('🔗 Share');
|
||
const [lastAction, setLastAction] = useState<'GUESS' | 'SKIP' | null>(null);
|
||
const [isProcessingGuess, setIsProcessingGuess] = useState(false);
|
||
const [timeUntilNext, setTimeUntilNext] = useState('');
|
||
const [hasRated, setHasRated] = useState(false);
|
||
const [showYearModal, setShowYearModal] = useState(false);
|
||
const [hasPlayedAudio, setHasPlayedAudio] = useState(false);
|
||
const audioPlayerRef = useRef<AudioPlayerRef>(null);
|
||
|
||
useEffect(() => {
|
||
const updateCountdown = () => {
|
||
const now = new Date();
|
||
const tomorrow = new Date(now);
|
||
tomorrow.setHours(24, 0, 0, 0);
|
||
const diff = tomorrow.getTime() - now.getTime();
|
||
|
||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||
|
||
setTimeUntilNext(`${hours}h ${minutes}m`);
|
||
};
|
||
|
||
updateCountdown();
|
||
const interval = setInterval(updateCountdown, 1000);
|
||
return () => clearInterval(interval);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (gameState && dailyPuzzle) {
|
||
setHasWon(gameState.isSolved);
|
||
setHasLost(gameState.isFailed);
|
||
|
||
// Show year modal if won but year not guessed yet and release year is available
|
||
if (gameState.isSolved && !gameState.yearGuessed && dailyPuzzle.releaseYear) {
|
||
setShowYearModal(true);
|
||
}
|
||
}
|
||
}, [gameState, dailyPuzzle]);
|
||
|
||
useEffect(() => {
|
||
setLastAction(null);
|
||
}, [dailyPuzzle?.id]);
|
||
|
||
useEffect(() => {
|
||
if (dailyPuzzle) {
|
||
const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]');
|
||
if (ratedPuzzles.includes(dailyPuzzle.id)) {
|
||
setHasRated(true);
|
||
} else {
|
||
setHasRated(false);
|
||
}
|
||
}
|
||
}, [dailyPuzzle]);
|
||
|
||
if (!dailyPuzzle) return (
|
||
<div className="game-container" style={{ textAlign: 'center', padding: '2rem' }}>
|
||
<h2>No Puzzle Available</h2>
|
||
<p>Could not generate a daily puzzle.</p>
|
||
<p>Please ensure there are songs in the database{genre ? ` for genre "${genre}"` : ''}.</p>
|
||
<a href="/admin" style={{ color: 'var(--primary)', textDecoration: 'underline' }}>Go to Admin Dashboard</a>
|
||
</div>
|
||
);
|
||
if (!gameState) return <div>Loading state...</div>;
|
||
|
||
const handleGuess = (song: any) => {
|
||
if (isProcessingGuess) return;
|
||
|
||
setIsProcessingGuess(true);
|
||
setLastAction('GUESS');
|
||
if (song.id === dailyPuzzle.songId) {
|
||
addGuess(song.title, true);
|
||
setHasWon(true);
|
||
// Track puzzle solved event
|
||
if (typeof window !== 'undefined' && window.plausible) {
|
||
window.plausible('puzzle_solved', {
|
||
props: {
|
||
genre: genre || 'Global',
|
||
attempts: gameState.guesses.length + 1,
|
||
score: gameState.score + 20, // Include the win bonus
|
||
outcome: 'won'
|
||
}
|
||
});
|
||
}
|
||
// Notification sent after year guess or skip
|
||
if (!dailyPuzzle.releaseYear) {
|
||
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
|
||
}
|
||
} else {
|
||
addGuess(song.title, false);
|
||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||
setHasLost(true);
|
||
setHasWon(false);
|
||
// Track puzzle lost event
|
||
if (typeof window !== 'undefined' && window.plausible) {
|
||
window.plausible('puzzle_solved', {
|
||
props: {
|
||
genre: genre || 'Global',
|
||
attempts: maxAttempts,
|
||
score: 0,
|
||
outcome: 'lost'
|
||
}
|
||
});
|
||
}
|
||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
||
}
|
||
}
|
||
setTimeout(() => setIsProcessingGuess(false), 500);
|
||
};
|
||
|
||
const handleStartAudio = () => {
|
||
// This will be called when user clicks "Start" button on first attempt
|
||
// Trigger the audio player to start playing
|
||
audioPlayerRef.current?.play();
|
||
setHasPlayedAudio(true);
|
||
};
|
||
|
||
const handleSkip = () => {
|
||
// If user hasn't played audio yet on first attempt, start it instead of skipping
|
||
if (gameState.guesses.length === 0 && !hasPlayedAudio) {
|
||
handleStartAudio();
|
||
return;
|
||
}
|
||
|
||
setLastAction('SKIP');
|
||
addGuess("SKIPPED", false);
|
||
|
||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||
setHasLost(true);
|
||
setHasWon(false);
|
||
// Track puzzle lost event
|
||
if (typeof window !== 'undefined' && window.plausible) {
|
||
window.plausible('puzzle_solved', {
|
||
props: {
|
||
genre: genre || 'Global',
|
||
attempts: maxAttempts,
|
||
score: 0,
|
||
outcome: 'lost'
|
||
}
|
||
});
|
||
}
|
||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
||
}
|
||
};
|
||
|
||
const handleGiveUp = () => {
|
||
setLastAction('SKIP');
|
||
addGuess("SKIPPED", false);
|
||
giveUp(); // Ensure game is marked as failed and score reset to 0
|
||
setHasLost(true);
|
||
setHasWon(false);
|
||
// Track puzzle lost event
|
||
if (typeof window !== 'undefined' && window.plausible) {
|
||
window.plausible('puzzle_solved', {
|
||
props: {
|
||
genre: genre || 'Global',
|
||
attempts: gameState.guesses.length + 1,
|
||
score: 0,
|
||
outcome: 'lost'
|
||
}
|
||
});
|
||
}
|
||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0);
|
||
};
|
||
|
||
const handleYearGuess = (year: number) => {
|
||
const correct = year === dailyPuzzle.releaseYear;
|
||
addYearBonus(correct);
|
||
setShowYearModal(false);
|
||
|
||
// Update the puzzle_solved event with year bonus result
|
||
if (typeof window !== 'undefined' && window.plausible) {
|
||
window.plausible('puzzle_solved', {
|
||
props: {
|
||
genre: genre || 'Global',
|
||
attempts: gameState.guesses.length,
|
||
score: gameState.score + (correct ? 10 : 0), // Include year bonus if correct
|
||
outcome: 'won',
|
||
year_bonus: correct ? 'correct' : 'incorrect'
|
||
}
|
||
});
|
||
}
|
||
|
||
// Send notification now that game is fully complete
|
||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
|
||
};
|
||
|
||
const handleYearSkip = () => {
|
||
skipYearBonus();
|
||
setShowYearModal(false);
|
||
|
||
// Update the puzzle_solved event with year bonus result
|
||
if (typeof window !== 'undefined' && window.plausible) {
|
||
window.plausible('puzzle_solved', {
|
||
props: {
|
||
genre: genre || 'Global',
|
||
attempts: gameState.guesses.length,
|
||
score: gameState.score, // Score already includes win bonus
|
||
outcome: 'won',
|
||
year_bonus: 'skipped'
|
||
}
|
||
});
|
||
}
|
||
|
||
// Send notification now that game is fully complete
|
||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
|
||
};
|
||
|
||
const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
|
||
|
||
const handleShare = async () => {
|
||
let emojiGrid = '';
|
||
const totalGuesses = maxAttempts;
|
||
|
||
for (let i = 0; i < totalGuesses; i++) {
|
||
if (i < gameState.guesses.length) {
|
||
if (hasWon && i === gameState.guesses.length - 1) {
|
||
emojiGrid += '🟩';
|
||
} else if (gameState.guesses[i] === 'SKIPPED') {
|
||
emojiGrid += '⬛';
|
||
} else {
|
||
emojiGrid += '🟥';
|
||
}
|
||
} else {
|
||
emojiGrid += '⬜';
|
||
}
|
||
}
|
||
|
||
const speaker = hasWon ? '🔉' : '🔇';
|
||
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
|
||
const genreText = genre ? `${isSpecial ? 'Special' : 'Genre'}: ${genre}\n` : '';
|
||
|
||
let shareUrl = 'https://hoerdle.elpatron.me';
|
||
if (genre) {
|
||
if (isSpecial) {
|
||
shareUrl += `/special/${encodeURIComponent(genre)}`;
|
||
} else {
|
||
shareUrl += `/${encodeURIComponent(genre)}`;
|
||
}
|
||
}
|
||
|
||
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`;
|
||
|
||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||
|
||
if (isMobile && navigator.share) {
|
||
try {
|
||
await navigator.share({
|
||
title: `Hördle #${dailyPuzzle.puzzleNumber}`,
|
||
text: text,
|
||
});
|
||
setShareText('✓ Shared!');
|
||
setTimeout(() => setShareText('🔗 Share'), 2000);
|
||
return;
|
||
} catch (err) {
|
||
if ((err as Error).name !== 'AbortError') {
|
||
console.error('Share failed:', err);
|
||
}
|
||
}
|
||
}
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
setShareText('✓ Copied!');
|
||
setTimeout(() => setShareText('🔗 Share'), 2000);
|
||
} catch (err) {
|
||
console.error('Clipboard failed:', err);
|
||
setShareText('✗ Failed');
|
||
setTimeout(() => setShareText('🔗 Share'), 2000);
|
||
}
|
||
};
|
||
|
||
const handleRatingSubmit = async (rating: number) => {
|
||
if (!dailyPuzzle) return;
|
||
|
||
try {
|
||
await submitRating(dailyPuzzle.songId, rating, genre, isSpecial, dailyPuzzle.puzzleNumber);
|
||
setHasRated(true);
|
||
|
||
const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]');
|
||
if (!ratedPuzzles.includes(dailyPuzzle.id)) {
|
||
ratedPuzzles.push(dailyPuzzle.id);
|
||
localStorage.setItem('hoerdle_rated_puzzles', JSON.stringify(ratedPuzzles));
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to submit rating', error);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="container">
|
||
<header className="header">
|
||
<h1 id="tour-title" className="title">Hördle #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
|
||
<div style={{ fontSize: '0.9rem', color: '#666', marginTop: '0.5rem', marginBottom: '1rem' }}>
|
||
Next puzzle in: {timeUntilNext}
|
||
</div>
|
||
</header>
|
||
|
||
<main className="game-board">
|
||
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
||
<div id="tour-status" className="status-bar">
|
||
<span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
|
||
<span>{unlockedSeconds}s unlocked</span>
|
||
</div>
|
||
|
||
<div id="tour-score">
|
||
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
|
||
</div>
|
||
|
||
<div id="tour-player">
|
||
<AudioPlayer
|
||
ref={audioPlayerRef}
|
||
src={dailyPuzzle.audioUrl}
|
||
unlockedSeconds={unlockedSeconds}
|
||
startTime={dailyPuzzle.startTime}
|
||
autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)}
|
||
onReplay={addReplay}
|
||
onHasPlayedChange={setHasPlayedAudio}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="guess-list">
|
||
{gameState.guesses.map((guess, i) => {
|
||
const isCorrect = hasWon && i === gameState.guesses.length - 1;
|
||
return (
|
||
<div key={i} className="guess-item">
|
||
<span className="guess-number">#{i + 1}</span>
|
||
<span className={`guess-text ${guess === 'SKIPPED' ? 'skipped' : ''} ${isCorrect ? 'correct' : ''}`}>
|
||
{isCorrect ? 'Correct!' : guess}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{!hasWon && !hasLost && (
|
||
<>
|
||
<div id="tour-input">
|
||
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
|
||
</div>
|
||
{gameState.guesses.length < maxAttempts - 1 ? (
|
||
<button
|
||
id="tour-controls"
|
||
onClick={handleSkip}
|
||
className="skip-button"
|
||
>
|
||
{gameState.guesses.length === 0 && !hasPlayedAudio
|
||
? 'Start'
|
||
: `Skip (+${unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s)`
|
||
}
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={handleGiveUp}
|
||
className="skip-button"
|
||
style={{
|
||
background: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
||
boxShadow: '0 4px 15px rgba(245, 87, 108, 0.4)'
|
||
}}
|
||
>
|
||
Solve (Give Up)
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{(hasWon || hasLost) && (
|
||
<div className={`message-box ${hasWon ? 'success' : 'failure'}`}>
|
||
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
||
{hasWon ? 'You won!' : 'Game Over'}
|
||
</h2>
|
||
|
||
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? '#059669' : '#dc2626' }}>
|
||
Score: {gameState.score}
|
||
</div>
|
||
|
||
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: '#666' }}>
|
||
<summary>Score Breakdown</summary>
|
||
<ul style={{ listStyle: 'none', padding: '0.5rem', textAlign: 'left', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
|
||
{gameState.scoreBreakdown.map((item, i) => (
|
||
<li key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.25rem 0' }}>
|
||
<span>{item.reason}</span>
|
||
<span style={{ fontWeight: 'bold', color: item.value >= 0 ? 'green' : 'red' }}>
|
||
{item.value > 0 ? '+' : ''}{item.value}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</details>
|
||
|
||
<p>{hasWon ? 'Come back tomorrow for a new song.' : 'The song was:'}</p>
|
||
|
||
<div style={{ margin: '1.5rem 0', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||
<img
|
||
src={dailyPuzzle.coverImage || '/favicon.ico'}
|
||
alt="Album Cover"
|
||
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
|
||
/>
|
||
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
|
||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
|
||
{dailyPuzzle.releaseYear && gameState.yearGuessed && (
|
||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p>
|
||
)}
|
||
<audio controls style={{ width: '100%' }}>
|
||
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
|
||
Your browser does not support the audio element.
|
||
</audio>
|
||
</div>
|
||
|
||
<div style={{ marginBottom: '1rem' }}>
|
||
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
|
||
</div>
|
||
|
||
{statistics && <Statistics statistics={statistics} />}
|
||
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
|
||
{shareText}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</main>
|
||
|
||
{showYearModal && dailyPuzzle.releaseYear && (
|
||
<YearGuessModal
|
||
correctYear={dailyPuzzle.releaseYear}
|
||
onGuess={handleYearGuess}
|
||
onSkip={handleYearSkip}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{ value: number, reason: string }> }) {
|
||
const tooltipText = breakdown.map(item => `${item.reason}: ${item.value > 0 ? '+' : ''}${item.value}`).join('\n');
|
||
|
||
// Create expression: "90 - 2 - 5 + 10"
|
||
// Limit to last 5 items to avoid overflow if too long
|
||
const displayItems = breakdown.length > 5 ?
|
||
[{ value: breakdown[0].value, reason: 'Start' }, ...breakdown.slice(-4)] :
|
||
breakdown;
|
||
|
||
const expression = displayItems.map((item, index) => {
|
||
if (index === 0 && breakdown.length <= 5) return item.value.toString();
|
||
if (index === 0 && breakdown.length > 5) return `${item.value} ...`;
|
||
return item.value >= 0 ? `+ ${item.value}` : `- ${Math.abs(item.value)}`;
|
||
}).join(' ');
|
||
|
||
return (
|
||
<div className="score-display" title={tooltipText} style={{
|
||
textAlign: 'center',
|
||
margin: '0.5rem 0',
|
||
padding: '0.5rem',
|
||
background: '#f3f4f6',
|
||
borderRadius: '0.5rem',
|
||
fontSize: '0.9rem',
|
||
fontFamily: 'monospace',
|
||
cursor: 'help'
|
||
}}>
|
||
<span style={{ color: '#666' }}>{expression} = </span>
|
||
<span style={{ fontWeight: 'bold', color: 'var(--primary)', fontSize: '1.1rem' }}>{score}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
|
||
const [options, setOptions] = useState<number[]>([]);
|
||
const [feedback, setFeedback] = useState<{ show: boolean, correct: boolean, guessedYear?: number }>({ show: false, correct: false });
|
||
|
||
useEffect(() => {
|
||
const currentYear = new Date().getFullYear();
|
||
const minYear = 1950;
|
||
|
||
const closeOptions = new Set<number>();
|
||
closeOptions.add(correctYear);
|
||
|
||
// Add 2 close years (+/- 2)
|
||
while (closeOptions.size < 3) {
|
||
const offset = Math.floor(Math.random() * 5) - 2;
|
||
const year = correctYear + offset;
|
||
if (year <= currentYear && year >= minYear && year !== correctYear) {
|
||
closeOptions.add(year);
|
||
}
|
||
}
|
||
|
||
const allOptions = new Set(closeOptions);
|
||
|
||
// Fill up to 10 with random years
|
||
while (allOptions.size < 10) {
|
||
const year = Math.floor(Math.random() * (currentYear - minYear + 1)) + minYear;
|
||
allOptions.add(year);
|
||
}
|
||
|
||
setOptions(Array.from(allOptions).sort((a, b) => a - b));
|
||
}, [correctYear]);
|
||
|
||
const handleGuess = (year: number) => {
|
||
const correct = year === correctYear;
|
||
setFeedback({ show: true, correct, guessedYear: year });
|
||
|
||
// Close modal after showing feedback
|
||
setTimeout(() => {
|
||
onGuess(year);
|
||
}, 2500);
|
||
};
|
||
|
||
const handleSkip = () => {
|
||
setFeedback({ show: true, correct: false });
|
||
|
||
setTimeout(() => {
|
||
onSkip();
|
||
}, 2000);
|
||
};
|
||
|
||
return (
|
||
<div style={{
|
||
position: 'fixed',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
background: 'rgba(0,0,0,0.8)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
zIndex: 1000,
|
||
padding: '1rem'
|
||
}}>
|
||
<div style={{
|
||
background: 'white',
|
||
padding: '2rem',
|
||
borderRadius: '1rem',
|
||
maxWidth: '500px',
|
||
width: '100%',
|
||
textAlign: 'center',
|
||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
|
||
}}>
|
||
{!feedback.show ? (
|
||
<>
|
||
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: '#1f2937' }}>Bonus Round!</h3>
|
||
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>Guess the release year for <strong style={{ color: '#10b981' }}>+10 points</strong>!</p>
|
||
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
|
||
gap: '0.75rem',
|
||
marginBottom: '1.5rem'
|
||
}}>
|
||
{options.map(year => (
|
||
<button
|
||
key={year}
|
||
onClick={() => handleGuess(year)}
|
||
style={{
|
||
padding: '0.75rem',
|
||
background: '#f3f4f6',
|
||
border: '2px solid #e5e7eb',
|
||
borderRadius: '0.5rem',
|
||
fontSize: '1.1rem',
|
||
fontWeight: 'bold',
|
||
color: '#374151',
|
||
cursor: 'pointer',
|
||
transition: 'all 0.2s'
|
||
}}
|
||
onMouseOver={e => e.currentTarget.style.borderColor = '#10b981'}
|
||
onMouseOut={e => e.currentTarget.style.borderColor = '#e5e7eb'}
|
||
>
|
||
{year}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleSkip}
|
||
style={{
|
||
background: 'none',
|
||
border: 'none',
|
||
color: '#6b7280',
|
||
textDecoration: 'underline',
|
||
cursor: 'pointer',
|
||
fontSize: '0.9rem'
|
||
}}
|
||
>
|
||
Skip Bonus
|
||
</button>
|
||
</>
|
||
) : (
|
||
<div style={{ padding: '2rem 0' }}>
|
||
{feedback.guessedYear ? (
|
||
feedback.correct ? (
|
||
<>
|
||
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🎉</div>
|
||
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: '#10b981', marginBottom: '0.5rem' }}>Correct!</h3>
|
||
<p style={{ fontSize: '1.2rem', color: '#4b5563' }}>Released in {correctYear}</p>
|
||
<p style={{ fontSize: '1.5rem', fontWeight: 'bold', color: '#10b981', marginTop: '1rem' }}>+10 Points!</p>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>😕</div>
|
||
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: '#ef4444', marginBottom: '0.5rem' }}>Not quite!</h3>
|
||
<p style={{ fontSize: '1.2rem', color: '#4b5563' }}>You guessed {feedback.guessedYear}</p>
|
||
<p style={{ fontSize: '1.2rem', color: '#4b5563', marginTop: '0.5rem' }}>Actually released in <strong>{correctYear}</strong></p>
|
||
</>
|
||
)
|
||
) : (
|
||
<>
|
||
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>⏭️</div>
|
||
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: '#6b7280', marginBottom: '0.5rem' }}>Skipped</h3>
|
||
<p style={{ fontSize: '1.2rem', color: '#4b5563' }}>Released in {correctYear}</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, hasRated: boolean }) {
|
||
const [hover, setHover] = useState(0);
|
||
const [rating, setRating] = useState(0);
|
||
|
||
if (hasRated) {
|
||
return <div style={{ color: '#666', fontStyle: 'italic' }}>Thanks for rating!</div>;
|
||
}
|
||
|
||
return (
|
||
<div className="star-rating" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
|
||
<span style={{ fontSize: '0.875rem', color: '#666', fontWeight: '500' }}>Rate this puzzle:</span>
|
||
<div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}>
|
||
{[...Array(5)].map((_, index) => {
|
||
const ratingValue = index + 1;
|
||
return (
|
||
<button
|
||
key={index}
|
||
type="button"
|
||
style={{
|
||
background: 'none',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
fontSize: '2rem',
|
||
color: ratingValue <= (hover || rating) ? '#ffc107' : '#9ca3af',
|
||
transition: 'color 0.2s',
|
||
padding: '0 0.25rem'
|
||
}}
|
||
onClick={() => {
|
||
setRating(ratingValue);
|
||
onRate(ratingValue);
|
||
}}
|
||
onMouseEnter={() => setHover(ratingValue)}
|
||
onMouseLeave={() => setHover(0)}
|
||
>
|
||
★
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|