Files
hoerdle/components/Game.tsx
Hördle Bot 8c16c72489 Fix: Verwende gameState.isSolved/isFailed direkt für Ergebnisanzeige
- Entferne Fallback auf hasWon/hasLost States
- isSolved/isFailed werden jetzt direkt aus gameState gelesen
- Boolean() Konvertierung für explizite Typ-Sicherheit
- Behebt Problem, dass Ergebnisanzeige bei zurückkehrenden Rätseln nicht angezeigt wurde
2026-01-24 13:15:43 +01:00

1017 lines
47 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { config } from '@/lib/config';
import { useEffect, useState, useRef } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
import GuessInput from './GuessInput';
import Statistics from './Statistics';
import ExtraPuzzlesPopover from './ExtraPuzzlesPopover';
import { useGameState } from '../lib/gameState';
import { getGenreKey } from '@/lib/playerStorage';
import type { ExternalPuzzle } from '@/lib/externalPuzzles';
import { getRandomExternalPuzzle } from '@/lib/externalPuzzles';
import { hasPlayedAllDailyPuzzlesForToday, hasSeenExtraPuzzlesPopoverToday, markDailyPuzzlePlayedToday, markExtraPuzzlesPopoverShownToday } from '@/lib/extraPuzzlesTracker';
import { sendGotifyNotification, submitRating } from '../app/actions';
import { getOrCreatePlayerId } from '@/lib/playerId';
// 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[];
// List of genre keys that zusammen alle Tagesrätsel des Tages repräsentieren (z. B. ['global', 'Rock', 'Pop']).
// Wird genutzt, um zu prüfen, ob der Spieler alle Tagesrätsel gespielt hat.
requiredDailyKeys?: string[];
}
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, requiredDailyKeys }: GameProps) {
const t = useTranslations('Game');
const locale = useLocale();
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial);
const [hasWon, setHasWon] = useState(gameState?.isSolved ?? false);
const [hasLost, setHasLost] = useState(gameState?.isFailed ?? false);
const [shareText, setShareText] = useState(`🔗 ${t('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 [showExtraPuzzlesPopover, setShowExtraPuzzlesPopover] = useState(false);
const [extraPuzzle, setExtraPuzzle] = useState<ExternalPuzzle | null>(null);
const audioPlayerRef = useRef<AudioPlayerRef>(null);
const [commentText, setCommentText] = useState('');
const [commentSending, setCommentSending] = useState(false);
const [commentSent, setCommentSent] = useState(false);
const [commentError, setCommentError] = useState<string | null>(null);
const [commentCollapsed, setCommentCollapsed] = useState(true);
const [rewrittenMessage, setRewrittenMessage] = useState<string | null>(null);
const [commentAIConsent, setCommentAIConsent] = useState(false);
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) {
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);
}
} else {
// Reset states when gameState is null (e.g., during loading)
setHasWon(false);
setHasLost(false);
}
}, [gameState, dailyPuzzle]);
// Track gespielte Tagesrätsel & entscheide, ob das Partner-Popover gezeigt werden soll
useEffect(() => {
if (!gameState || !dailyPuzzle) return;
const gameEnded = gameState.isSolved || gameState.isFailed;
if (!gameEnded) return;
const genreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
markDailyPuzzlePlayedToday(genreKey);
if (!requiredDailyKeys || requiredDailyKeys.length === 0) return;
if (hasSeenExtraPuzzlesPopoverToday()) return;
if (!hasPlayedAllDailyPuzzlesForToday(requiredDailyKeys)) return;
const partnerPuzzle = getRandomExternalPuzzle();
if (!partnerPuzzle) return;
setExtraPuzzle(partnerPuzzle);
setShowExtraPuzzlesPopover(true);
markExtraPuzzlesPopoverShownToday();
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('extra_puzzles_popover_shown', {
props: {
partner: partnerPuzzle.id,
url: partnerPuzzle.url,
},
});
}
}, [gameState?.isSolved, gameState?.isFailed, dailyPuzzle?.id, genre, isSpecial, requiredDailyKeys]);
useEffect(() => {
setLastAction(null);
}, [dailyPuzzle?.id]);
useEffect(() => {
if (dailyPuzzle) {
const ratedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_rated_puzzles`) || '[]');
if (ratedPuzzles.includes(dailyPuzzle.id)) {
setHasRated(true);
} else {
setHasRated(false);
}
// Check if comment already sent for this puzzle
const playerIdentifier = getOrCreatePlayerId();
if (playerIdentifier) {
const commentedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_commented_puzzles`) || '[]');
if (commentedPuzzles.includes(dailyPuzzle.id)) {
setCommentSent(true);
}
}
}
}, [dailyPuzzle]);
if (!dailyPuzzle) return (
<div className="game-container" style={{ textAlign: 'center', padding: '2rem' }}>
<h2>{t('noPuzzleAvailable')}</h2>
<p>{t('noPuzzleDescription')}</p>
<p>{t('noPuzzleGenre')}{genre ? ` für Genre "${genre}"` : ''}.</p>
<a href="/admin" style={{ color: 'var(--primary)', textDecoration: 'underline' }}>{t('goToAdmin')}</a>
</div>
);
if (!gameState) return <div>{t('loadingState')}</div>;
// Use gameState directly for isSolved/isFailed to ensure consistency when returning to completed puzzles
// Always use gameState values directly - they are the source of truth
// This ensures that when returning to a completed puzzle, the result is shown immediately
const isSolved = Boolean(gameState.isSolved);
const isFailed = Boolean(gameState.isFailed);
const handleGuess = (song: any) => {
if (isProcessingGuess) return;
// Prevent guessing if already solved or failed
if (gameState?.isSolved || gameState?.isFailed) {
return;
}
setIsProcessingGuess(true);
setLastAction('GUESS');
if (song.id === dailyPuzzle.songId) {
addGuess(song.title, true);
setHasWon(true);
// gameState.isSolved will be updated by useGameState
// 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);
// gameState.isFailed will be updated by useGameState
// 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 = () => {
// Prevent skipping if already solved or failed
if (gameState?.isSolved || gameState?.isFailed) return;
// 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);
// gameState.isFailed will be updated by useGameState
// 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 = () => {
// Prevent giving up if already solved or failed
if (gameState?.isSolved || gameState?.isFailed) return;
setLastAction('SKIP');
addGuess("SKIPPED", false);
giveUp(); // Ensure game is marked as failed and score reset to 0
setHasLost(true);
setHasWon(false);
// gameState.isFailed will be updated by useGameState
// 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 handleCommentSubmit = async () => {
if (!commentText.trim() || commentSending || commentSent || !dailyPuzzle || !commentAIConsent) {
return;
}
setCommentSending(true);
setCommentError(null);
setRewrittenMessage(null);
try {
const playerIdentifier = getOrCreatePlayerId();
if (!playerIdentifier) {
throw new Error('Could not get player identifier');
}
// 1. Rewrite message using AI
const rewriteResponse = await fetch('/api/rewrite-message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: commentText.trim() })
});
let finalMessage = commentText.trim();
if (rewriteResponse.ok) {
const rewriteData = await rewriteResponse.json();
if (rewriteData.rewrittenMessage) {
finalMessage = rewriteData.rewrittenMessage;
// Only show rewritten message if it was actually changed
// The API adds "(autocorrected by Polite-Bot)" suffix only if message was changed
const wasChanged = finalMessage.includes('(autocorrected by Polite-Bot)');
if (wasChanged) {
// Remove the suffix for display
const displayMessage = finalMessage.replace(/\s*\(autocorrected by Polite-Bot\)\s*/g, '').trim();
setRewrittenMessage(displayMessage);
} else {
// Ensure rewrittenMessage is not set if message wasn't changed
setRewrittenMessage(null);
}
}
}
// 2. Send comment
// For specials, genreId should be null. For global, also null. For genres, we pass null and let API determine from puzzle
const genreId = isSpecial ? null : null; // API will determine from puzzle
const response = await fetch('/api/curator-comment', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
puzzleId: dailyPuzzle.id,
genreId: genreId,
message: finalMessage,
originalMessage: commentText.trim() !== finalMessage ? commentText.trim() : undefined,
playerIdentifier: playerIdentifier
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to send comment');
}
setCommentSent(true);
setCommentText('');
// Store in localStorage that comment was sent
const commentedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_commented_puzzles`) || '[]');
if (!commentedPuzzles.includes(dailyPuzzle.id)) {
commentedPuzzles.push(dailyPuzzle.id);
localStorage.setItem(`${config.appName.toLowerCase()}_commented_puzzles`, JSON.stringify(commentedPuzzles));
}
} catch (error) {
console.error('Error sending comment:', error);
setCommentError(error instanceof Error ? error.message : 'Failed to send comment');
} finally {
setCommentSending(false);
}
};
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 (gameState.guesses[i] === 'SKIPPED') {
emojiGrid += '⬛';
} else if (isSolved && i === gameState.guesses.length - 1) {
emojiGrid += '🟩';
} else {
emojiGrid += '🟥';
}
} else {
// If game is lost, fill remaining slots with black squares
emojiGrid += isFailed ? '⬛' : '⬜';
}
}
const speaker = isSolved ? '🔉' : '🔇';
const bonusStar = (isSolved && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
const genreText = genre ? `${isSpecial ? t('special') : t('genre')}: ${genre}\n` : '';
// Use current domain from window.location to support both hoerdle.de and hördle.de
const rawHost = typeof window !== 'undefined' ? window.location.hostname : config.domain;
const protocol = typeof window !== 'undefined' ? window.location.protocol : 'https:';
// For users on hördle.de, use Punycode domain (xn--hrdle-jua.de) in share message
// to avoid rendering issues with Unicode domains
let currentHost = rawHost;
if (rawHost === 'hördle.de' || rawHost === 'xn--hrdle-jua.de') {
currentHost = 'xn--hrdle-jua.de';
}
// OLD CODE (commented out - may be needed again in the future):
// Use current domain from window.location to support both hoerdle.de and hördle.de,
// but always share the pretty Unicode-Domain "hördle.de" instead of the Punycode variant.
// const currentHost = rawHost === 'xn--hrdle-jua.de' ? 'hördle.de' : rawHost;
let shareUrl = `${protocol}//${currentHost}`;
// Add locale prefix if not default (en)
if (locale !== 'en') {
shareUrl += `/${locale}`;
}
if (genre) {
if (isSpecial) {
shareUrl += `/special/${encodeURIComponent(genre)}`;
} else {
shareUrl += `/${encodeURIComponent(genre)}`;
}
}
const text = `${config.appName} #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\n${t('score')}: ${gameState.score}\n\n#${config.appName} #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(t('shared'));
setTimeout(() => setShareText(`🔗 ${t('share')}`), 2000);
return;
} catch (err) {
if ((err as Error).name !== 'AbortError') {
console.error('Share failed:', err);
}
}
}
try {
await navigator.clipboard.writeText(text);
setShareText(t('copied'));
setTimeout(() => setShareText(`🔗 ${t('share')}`), 2000);
} catch (err) {
console.error('Clipboard failed:', err);
setShareText(t('shareFailed'));
setTimeout(() => setShareText(`🔗 ${t('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(`${config.appName.toLowerCase()}_rated_puzzles`) || '[]');
if (!ratedPuzzles.includes(dailyPuzzle.id)) {
ratedPuzzles.push(dailyPuzzle.id);
localStorage.setItem(`${config.appName.toLowerCase()}_rated_puzzles`, JSON.stringify(ratedPuzzles));
}
} catch (error) {
console.error('Failed to submit rating', error);
}
};
// Aktuelle Attempt-Anzeige:
// - Während des Spiels: nächster Versuch = guesses.length + 1
// - Nach Spielende (gelöst oder verloren): letzter Versuch = guesses.length
const currentAttempt = (gameState.isSolved || gameState.isFailed)
? gameState.guesses.length
: gameState.guesses.length + 1;
return (
<div className="container">
<header className="header">
<h1 id="tour-title" className="title">{config.appName} #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
<div style={{ fontSize: '0.9rem', color: 'var(--muted-foreground)', marginTop: '0.5rem', marginBottom: '1rem' }}>
{t('nextPuzzle')}: {timeUntilNext}
</div>
</header>
<main className="game-board">
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
<div id="tour-status" className="status-bar">
<span>{t('attempt')} {currentAttempt} / {maxAttempts}</span>
<span>{unlockedSeconds}s {t('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' && !isSolved && !isFailed)}
onReplay={addReplay}
onHasPlayedChange={setHasPlayedAudio}
/>
</div>
</div>
<div className="guess-list">
{gameState.guesses.map((guess, i) => {
const isCorrect = isSolved && 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 ? t('correct') : guess}
</span>
</div>
);
})}
</div>
{!isSolved && !isFailed && (
<>
<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
? t('start')
: t('skipWithBonus', { seconds: unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds })
}
</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)'
}}
>
{t('solveGiveUp')}
</button>
)}
</>
)}
{(isSolved || isFailed) && (
<div className={`message-box ${isSolved ? 'success' : 'failure'}`}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
{isSolved ? t('won') : t('lost')}
</h2>
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: isSolved ? 'var(--success)' : 'var(--danger)' }}>
{t('score')}: {gameState.score}
</div>
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: 'var(--muted-foreground)' }}>
<summary>{t('scoreBreakdown')}</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>{isSolved ? t('comeBackTomorrow') : t('theSongWas')}</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={t('albumCover')}
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: 'var(--muted-foreground)', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
{dailyPuzzle.releaseYear && gameState.yearGuessed && (
<p style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', margin: '0 0 1rem 0' }}>{t('released')}: {dailyPuzzle.releaseYear}</p>
)}
<audio controls style={{ width: '100%' }}>
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
{t('yourBrowserDoesNotSupport')}
</audio>
</div>
<div style={{ marginBottom: '1.25rem' }}>
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
</div>
<div style={{ marginBottom: '1.25rem', textAlign: 'center' }}>
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.5rem' }}>
{t('shareExplanation')}
</p>
<button onClick={handleShare} className="btn-primary">
{shareText}
</button>
</div>
{/* Comment Form */}
{!commentSent && (
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem' }}>
<div
onClick={() => setCommentCollapsed(!commentCollapsed)}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
marginBottom: commentCollapsed ? 0 : '1rem'
}}
>
<h3 style={{ fontSize: '1rem', fontWeight: 'bold', margin: 0 }}>
{t('sendComment')}
</h3>
<span>{commentCollapsed ? '▼' : '▲'}</span>
</div>
{!commentCollapsed && (
<>
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.75rem' }}>
{t('commentHelp')}
</p>
<textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder={t('commentPlaceholder')}
maxLength={300}
style={{
width: '100%',
minHeight: '100px',
padding: '0.75rem',
borderRadius: '0.5rem',
border: '1px solid var(--border)',
fontSize: '0.9rem',
fontFamily: 'inherit',
resize: 'vertical',
marginBottom: '0.5rem',
display: 'block',
boxSizing: 'border-box' // Ensure padding and border are included in width
}}
disabled={commentSending}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--muted-foreground)' }}>
{commentText.length}/300
</span>
{commentError && (
<span style={{ fontSize: '0.75rem', color: 'var(--danger)' }}>
{commentError}
</span>
)}
</div>
<div style={{ marginBottom: '0.75rem' }}>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', fontSize: '0.85rem', color: 'var(--foreground)', cursor: 'pointer' }}>
<input
type="checkbox"
checked={commentAIConsent}
onChange={(e) => setCommentAIConsent(e.target.checked)}
disabled={commentSending || commentSent}
style={{ marginTop: '0.2rem', cursor: (commentSending || commentSent) ? 'not-allowed' : 'pointer' }}
/>
<span>{t('commentAIConsent')}</span>
</label>
</div>
<button
onClick={handleCommentSubmit}
disabled={!commentText.trim() || commentSending || commentSent || !commentAIConsent}
className="btn-primary"
style={{
width: '100%',
opacity: (!commentText.trim() || commentSending || commentSent || !commentAIConsent) ? 0.5 : 1,
cursor: (!commentText.trim() || commentSending || commentSent || !commentAIConsent) ? 'not-allowed' : 'pointer'
}}
>
{commentSending ? t('sending') : t('sendComment')}
</button>
</>
)}
</div>
)}
{commentSent && (
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(16, 185, 129, 0.1)', borderRadius: '0.5rem', border: '1px solid rgba(16, 185, 129, 0.3)' }}>
{rewrittenMessage ? (
<>
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center', marginBottom: '0.5rem' }}>
{t('commentSent')}
</p>
<div style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', textAlign: 'center' }}>
<p style={{ marginBottom: '0.25rem' }}>{t('commentRewritten')}</p>
<p style={{ fontStyle: 'italic' }}>"{rewrittenMessage}"</p>
</div>
</>
) : (
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center', marginBottom: 0 }}>
{t('commentThankYou')}
</p>
)}
</div>
)}
{statistics && <Statistics statistics={statistics} />}
</div>
)}
</main>
{showYearModal && dailyPuzzle.releaseYear && (
<YearGuessModal
correctYear={dailyPuzzle.releaseYear}
onGuess={handleYearGuess}
onSkip={handleYearSkip}
/>
)}
{showExtraPuzzlesPopover && extraPuzzle && (
<ExtraPuzzlesPopover
puzzle={extraPuzzle}
onClose={() => setShowExtraPuzzlesPopover(false)}
/>
)}
</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: 'var(--muted)',
borderRadius: '0.5rem',
fontSize: '0.9rem',
fontFamily: 'monospace',
cursor: 'help'
}}>
<span style={{ color: 'var(--muted-foreground)' }}>{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 t = useTranslations('Game');
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: 'var(--primary)' }}>{t('bonusRound')}</h3>
<p style={{ marginBottom: '1.5rem', color: 'var(--secondary)' }}>{t('guessReleaseYear')} <strong style={{ color: 'var(--success)' }}>+10 {t('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: 'var(--muted)',
border: '2px solid var(--border)',
borderRadius: '0.5rem',
fontSize: '1.1rem',
fontWeight: 'bold',
color: 'var(--secondary)',
cursor: 'pointer',
transition: 'all 0.2s'
}}
onMouseOver={e => e.currentTarget.style.borderColor = 'var(--success)'}
onMouseOut={e => e.currentTarget.style.borderColor = 'var(--border)'}
>
{year}
</button>
))}
</div>
<button
onClick={handleSkip}
style={{
background: 'none',
border: 'none',
color: 'var(--muted-foreground)',
textDecoration: 'underline',
cursor: 'pointer',
fontSize: '0.9rem'
}}
>
{t('skipBonus')}
</button>
</>
) : (
<div style={{ padding: '2rem 0' }}>
{feedback.guessedYear ? (
feedback.correct ? (
<>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🎉</div>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--success)', marginBottom: '0.5rem' }}>{t('correct')}</h3>
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>{t('released')} {correctYear}</p>
<p style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--success)', marginTop: '1rem' }}>+10 {t('points')}!</p>
</>
) : (
<>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>😕</div>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--danger)', marginBottom: '0.5rem' }}>{t('notQuite')}</h3>
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>{t('youGuessed')} {feedback.guessedYear}</p>
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)', marginTop: '0.5rem' }}>{t('actuallyReleasedIn')} <strong>{correctYear}</strong></p>
</>
)
) : (
<>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}></div>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--muted-foreground)', marginBottom: '0.5rem' }}>{t('skipped')}</h3>
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>{t('released')} {correctYear}</p>
</>
)}
</div>
)}
</div>
</div>
);
}
function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, hasRated: boolean }) {
const t = useTranslations('Game');
const [hover, setHover] = useState(0);
const [rating, setRating] = useState(0);
if (hasRated) {
return <div style={{ color: 'var(--muted-foreground)', fontStyle: 'italic' }}>{t('thanksForRating')}</div>;
}
return (
<div
className="star-rating"
title={t('ratingTooltip')}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
>
<span style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', fontWeight: '500' }}>{t('rateThisPuzzle')}</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) ? 'var(--warning)' : 'var(--muted-foreground)',
transition: 'color 0.2s',
padding: '0 0.25rem'
}}
onClick={() => {
setRating(ratingValue);
onRate(ratingValue);
}}
onMouseEnter={() => setHover(ratingValue)}
onMouseLeave={() => setHover(0)}
>
</button>
);
})}
</div>
</div>
);
}