feat: Enhance Game component with extra puzzles feature
- Introduce requiredDailyKeys to track daily puzzle completion across genres. - Implement logic to show an ExtraPuzzlesPopover when all daily puzzles are completed. - Add localized messages for extra puzzles in both English and German. - Update GenrePage and Home components to pass requiredDailyKeys to the Game component.
This commit is contained in:
@@ -87,6 +87,9 @@ export default async function GenrePage({ params }: PageProps) {
|
|||||||
return s.launchDate && s.launchDate > now;
|
return s.launchDate && s.launchDate > now;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Required daily keys: global + all active genres (by localized name, as used in gameState storage)
|
||||||
|
const requiredDailyKeys = ['global', ...genres.map(g => getLocalizedValue(g.name, locale))];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||||
@@ -156,7 +159,7 @@ export default async function GenrePage({ params }: PageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<NewsSection locale={locale} />
|
<NewsSection locale={locale} />
|
||||||
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
|
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} requiredDailyKeys={requiredDailyKeys} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ export default async function Home({
|
|||||||
return s.launchDate && s.launchDate > now;
|
return s.launchDate && s.launchDate > now;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Required daily keys: global + all active genres (by localized name, as used in gameState storage)
|
||||||
|
const requiredDailyKeys = ['global', ...genres.map(g => getLocalizedValue(g.name, locale))];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6', position: 'relative' }}>
|
<div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6', position: 'relative' }}>
|
||||||
@@ -149,7 +152,7 @@ export default async function Home({
|
|||||||
<NewsSection locale={locale} />
|
<NewsSection locale={locale} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
<Game dailyPuzzle={dailyPuzzle} genre={null} requiredDailyKeys={requiredDailyKeys} />
|
||||||
<OnboardingTour />
|
<OnboardingTour />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
98
components/ExtraPuzzlesPopover.tsx
Normal file
98
components/ExtraPuzzlesPopover.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
|
import type { ExternalPuzzle } from '@/lib/externalPuzzles';
|
||||||
|
|
||||||
|
interface ExtraPuzzlesPopoverProps {
|
||||||
|
puzzle: ExternalPuzzle;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExtraPuzzlesPopover({ puzzle, onClose }: ExtraPuzzlesPopoverProps) {
|
||||||
|
const t = useTranslations('ExtraPuzzles');
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
const name = locale === 'de' ? puzzle.nameDe : puzzle.nameEn;
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (typeof window !== 'undefined' && window.plausible) {
|
||||||
|
window.plausible('extra_puzzles_click', {
|
||||||
|
props: {
|
||||||
|
partner: puzzle.id,
|
||||||
|
url: puzzle.url,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '1.5rem',
|
||||||
|
right: '1.5rem',
|
||||||
|
zIndex: 1100,
|
||||||
|
maxWidth: '320px',
|
||||||
|
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
background: 'white',
|
||||||
|
padding: '1rem 1.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '1rem', fontWeight: 700 }}>
|
||||||
|
{t('title')}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('close')}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
lineHeight: 1,
|
||||||
|
color: '#6b7280',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ margin: 0, fontSize: '0.9rem', color: '#4b5563' }}>
|
||||||
|
{t('message', { name })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={puzzle.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={handleClick}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '0.4rem',
|
||||||
|
marginTop: '0.25rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'linear-gradient(135deg, #4f46e5, #ec4899)',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
textDecoration: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('cta', { name })}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -6,7 +6,12 @@ import { useTranslations, useLocale } from 'next-intl';
|
|||||||
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
|
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
|
||||||
import GuessInput from './GuessInput';
|
import GuessInput from './GuessInput';
|
||||||
import Statistics from './Statistics';
|
import Statistics from './Statistics';
|
||||||
|
import ExtraPuzzlesPopover from './ExtraPuzzlesPopover';
|
||||||
import { useGameState } from '../lib/gameState';
|
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 { sendGotifyNotification, submitRating } from '../app/actions';
|
||||||
|
|
||||||
// Plausible Analytics
|
// Plausible Analytics
|
||||||
@@ -32,11 +37,14 @@ interface GameProps {
|
|||||||
isSpecial?: boolean;
|
isSpecial?: boolean;
|
||||||
maxAttempts?: number;
|
maxAttempts?: number;
|
||||||
unlockSteps?: 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];
|
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) {
|
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS, requiredDailyKeys }: GameProps) {
|
||||||
const t = useTranslations('Game');
|
const t = useTranslations('Game');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial);
|
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial);
|
||||||
@@ -49,6 +57,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
const [hasRated, setHasRated] = useState(false);
|
const [hasRated, setHasRated] = useState(false);
|
||||||
const [showYearModal, setShowYearModal] = useState(false);
|
const [showYearModal, setShowYearModal] = useState(false);
|
||||||
const [hasPlayedAudio, setHasPlayedAudio] = 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 audioPlayerRef = useRef<AudioPlayerRef>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -81,6 +91,37 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
}
|
}
|
||||||
}, [gameState, dailyPuzzle]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
setLastAction(null);
|
setLastAction(null);
|
||||||
}, [dailyPuzzle?.id]);
|
}, [dailyPuzzle?.id]);
|
||||||
@@ -490,6 +531,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
onSkip={handleYearSkip}
|
onSkip={handleYearSkip}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showExtraPuzzlesPopover && extraPuzzle && (
|
||||||
|
<ExtraPuzzlesPopover
|
||||||
|
puzzle={extraPuzzle}
|
||||||
|
onClose={() => setShowExtraPuzzlesPopover(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
47
lib/externalPuzzles.ts
Normal file
47
lib/externalPuzzles.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
export type ExternalPuzzle = {
|
||||||
|
id: string;
|
||||||
|
nameDe: string;
|
||||||
|
nameEn: string;
|
||||||
|
url: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentrale Liste externer Rätselangebote.
|
||||||
|
*
|
||||||
|
* Erweiterung: Einfach neuen Eintrag in dieses Array hinzufügen.
|
||||||
|
*/
|
||||||
|
export const externalPuzzles: ExternalPuzzle[] = [
|
||||||
|
{
|
||||||
|
id: 'pastpuzzle',
|
||||||
|
nameDe: 'Past Puzzle',
|
||||||
|
nameEn: 'Past Puzzle',
|
||||||
|
url: 'https://www.pastpuzzle.de/#/',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'woerdle',
|
||||||
|
nameDe: 'Wördle',
|
||||||
|
nameEn: 'Wördle',
|
||||||
|
url: 'https://www.wördle.de',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ciddle',
|
||||||
|
nameDe: 'Ciddle',
|
||||||
|
nameEn: 'Ciddle',
|
||||||
|
url: 'https://ciddle.winklerweb.net',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getRandomExternalPuzzle(): ExternalPuzzle | null {
|
||||||
|
const activePuzzles = externalPuzzles.filter(p => p.isActive !== false);
|
||||||
|
if (activePuzzles.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const index = Math.floor(Math.random() * activePuzzles.length);
|
||||||
|
return activePuzzles[index] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
68
lib/extraPuzzlesTracker.ts
Normal file
68
lib/extraPuzzlesTracker.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { getTodayISOString } from './dateUtils';
|
||||||
|
|
||||||
|
const DAILY_PLAYED_PREFIX = 'hoerdle_daily_played_';
|
||||||
|
const EXTRA_POPOVER_PREFIX = 'hoerdle_extra_puzzles_shown_';
|
||||||
|
|
||||||
|
function getTodayKey(prefix: string): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
const today = getTodayISOString();
|
||||||
|
return `${prefix}${today}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markDailyPuzzlePlayedToday(genreKey: string) {
|
||||||
|
const storageKey = getTodayKey(DAILY_PLAYED_PREFIX);
|
||||||
|
if (!storageKey) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(storageKey);
|
||||||
|
const list: string[] = raw ? JSON.parse(raw) : [];
|
||||||
|
if (!list.includes(genreKey)) {
|
||||||
|
list.push(genreKey);
|
||||||
|
window.localStorage.setItem(storageKey, JSON.stringify(list));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[extraPuzzles] Failed to mark daily puzzle as played', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPlayedAllDailyPuzzlesForToday(requiredGenreKeys: string[]): boolean {
|
||||||
|
const storageKey = getTodayKey(DAILY_PLAYED_PREFIX);
|
||||||
|
if (!storageKey) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(storageKey);
|
||||||
|
const played: string[] = raw ? JSON.parse(raw) : [];
|
||||||
|
if (!Array.isArray(played) || played.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return requiredGenreKeys.every(key => played.includes(key));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[extraPuzzles] Failed to read played puzzles', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasSeenExtraPuzzlesPopoverToday(): boolean {
|
||||||
|
const storageKey = getTodayKey(EXTRA_POPOVER_PREFIX);
|
||||||
|
if (!storageKey) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(storageKey) === 'true';
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[extraPuzzles] Failed to read popover state', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markExtraPuzzlesPopoverShownToday() {
|
||||||
|
const storageKey = getTodayKey(EXTRA_POPOVER_PREFIX);
|
||||||
|
if (!storageKey) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(storageKey, 'true');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[extraPuzzles] Failed to persist popover state', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -64,6 +64,12 @@
|
|||||||
"special": "Special",
|
"special": "Special",
|
||||||
"genre": "Genre"
|
"genre": "Genre"
|
||||||
},
|
},
|
||||||
|
"ExtraPuzzles": {
|
||||||
|
"title": "Noch nicht genug Rätsel?",
|
||||||
|
"message": "Hey, hast du Lust auf weitere Rätsel? Dann schau doch mal bei {name} vorbei!",
|
||||||
|
"cta": "Zu {name}",
|
||||||
|
"close": "Schließen"
|
||||||
|
},
|
||||||
"Statistics": {
|
"Statistics": {
|
||||||
"yourStatistics": "Deine Statistiken",
|
"yourStatistics": "Deine Statistiken",
|
||||||
"totalPuzzles": "Gesamte Rätsel",
|
"totalPuzzles": "Gesamte Rätsel",
|
||||||
|
|||||||
@@ -64,6 +64,12 @@
|
|||||||
"special": "Special",
|
"special": "Special",
|
||||||
"genre": "Genre"
|
"genre": "Genre"
|
||||||
},
|
},
|
||||||
|
"ExtraPuzzles": {
|
||||||
|
"title": "Still in the mood for puzzles?",
|
||||||
|
"message": "Hey, would you like to try some more puzzles? Then take a look at {name}!",
|
||||||
|
"cta": "Go to {name}",
|
||||||
|
"close": "Close"
|
||||||
|
},
|
||||||
"Statistics": {
|
"Statistics": {
|
||||||
"yourStatistics": "Your Statistics",
|
"yourStatistics": "Your Statistics",
|
||||||
"totalPuzzles": "Total puzzles",
|
"totalPuzzles": "Total puzzles",
|
||||||
|
|||||||
Reference in New Issue
Block a user