- 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
1017 lines
47 KiB
TypeScript
1017 lines
47 KiB
TypeScript
'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>
|
||
);
|
||
}
|