'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 }) => 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(null); const audioPlayerRef = useRef(null); const [commentText, setCommentText] = useState(''); const [commentSending, setCommentSending] = useState(false); const [commentSent, setCommentSent] = useState(false); const [commentError, setCommentError] = useState(null); const [commentCollapsed, setCommentCollapsed] = useState(true); const [rewrittenMessage, setRewrittenMessage] = useState(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); } } }, [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 (

{t('noPuzzleAvailable')}

{t('noPuzzleDescription')}

{t('noPuzzleGenre')}{genre ? ` für Genre "${genre}"` : ''}.

{t('goToAdmin')}
); if (!gameState) return
{t('loadingState')}
; 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); // 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 = () => { // 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); // 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); // 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 (hasWon && i === gameState.guesses.length - 1) { emojiGrid += '🟩'; } else { emojiGrid += '🟥'; } } else { // If game is lost, fill remaining slots with black squares emojiGrid += hasLost ? '⬛' : '⬜'; } } const speaker = hasWon ? '🔉' : '🔇'; const bonusStar = (hasWon && 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 (

{config.appName} #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}

{t('nextPuzzle')}: {timeUntilNext}
{t('attempt')} {currentAttempt} / {maxAttempts} {unlockedSeconds}s {t('unlocked')}
{gameState.guesses.map((guess, i) => { const isCorrect = hasWon && i === gameState.guesses.length - 1; return (
#{i + 1} {isCorrect ? t('correct') : guess}
); })}
{!hasWon && !hasLost && ( <>
{gameState.guesses.length < maxAttempts - 1 ? ( ) : ( )} )} {(hasWon || hasLost) && (

{hasWon ? t('won') : t('lost')}

{t('score')}: {gameState.score}
{t('scoreBreakdown')}
    {gameState.scoreBreakdown.map((item, i) => (
  • {item.reason} = 0 ? 'green' : 'red' }}> {item.value > 0 ? '+' : ''}{item.value}
  • ))}

{hasWon ? t('comeBackTomorrow') : t('theSongWas')}

{t('albumCover')}

{dailyPuzzle.title}

{dailyPuzzle.artist}

{dailyPuzzle.releaseYear && gameState.yearGuessed && (

{t('released')}: {dailyPuzzle.releaseYear}

)}

{t('shareExplanation')}

{/* Comment Form */} {!commentSent && (
setCommentCollapsed(!commentCollapsed)} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', marginBottom: commentCollapsed ? 0 : '1rem' }} >

{t('sendComment')}

{commentCollapsed ? '▼' : '▲'}
{!commentCollapsed && ( <>

{t('commentHelp')}