From 82397539114a7456236b262d7a63918ccee1c595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Tue, 2 Dec 2025 10:59:22 +0100 Subject: [PATCH 1/4] 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. --- app/[locale]/[genre]/page.tsx | 5 +- app/[locale]/page.tsx | 5 +- components/ExtraPuzzlesPopover.tsx | 98 ++++++++++++++++++++++++++++++ components/Game.tsx | 50 ++++++++++++++- lib/externalPuzzles.ts | 47 ++++++++++++++ lib/extraPuzzlesTracker.ts | 68 +++++++++++++++++++++ messages/de.json | 6 ++ messages/en.json | 6 ++ 8 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 components/ExtraPuzzlesPopover.tsx create mode 100644 lib/externalPuzzles.ts create mode 100644 lib/extraPuzzlesTracker.ts diff --git a/app/[locale]/[genre]/page.tsx b/app/[locale]/[genre]/page.tsx index f105ea8..b26d083 100644 --- a/app/[locale]/[genre]/page.tsx +++ b/app/[locale]/[genre]/page.tsx @@ -87,6 +87,9 @@ export default async function GenrePage({ params }: PageProps) { 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 ( <>
@@ -156,7 +159,7 @@ export default async function GenrePage({ params }: PageProps) { )}
- + ); } diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index e50aefa..28571ca 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -61,6 +61,9 @@ export default async function Home({ 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 ( <>
@@ -149,7 +152,7 @@ export default async function Home({
- + ); diff --git a/components/ExtraPuzzlesPopover.tsx b/components/ExtraPuzzlesPopover.tsx new file mode 100644 index 0000000..8e023c0 --- /dev/null +++ b/components/ExtraPuzzlesPopover.tsx @@ -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 ( +
+
+

+ {t('title')} +

+ +
+ +

+ {t('message', { name })} +

+ + + {t('cta', { name })} + +
+ ); +} + + diff --git a/components/Game.tsx b/components/Game.tsx index 7a51806..d01aa9e 100644 --- a/components/Game.tsx +++ b/components/Game.tsx @@ -6,7 +6,12 @@ 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'; // Plausible Analytics @@ -32,11 +37,14 @@ interface GameProps { 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 }: GameProps) { +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); @@ -49,6 +57,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max 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); useEffect(() => { @@ -81,6 +91,37 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max } }, [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]); @@ -490,6 +531,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max onSkip={handleYearSkip} /> )} + + {showExtraPuzzlesPopover && extraPuzzle && ( + setShowExtraPuzzlesPopover(false)} + /> + )} ); } diff --git a/lib/externalPuzzles.ts b/lib/externalPuzzles.ts new file mode 100644 index 0000000..46cab6c --- /dev/null +++ b/lib/externalPuzzles.ts @@ -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; +} + + diff --git a/lib/extraPuzzlesTracker.ts b/lib/extraPuzzlesTracker.ts new file mode 100644 index 0000000..e273e6b --- /dev/null +++ b/lib/extraPuzzlesTracker.ts @@ -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); + } +} + + diff --git a/messages/de.json b/messages/de.json index c7e9605..cb2707e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -64,6 +64,12 @@ "special": "Special", "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": { "yourStatistics": "Deine Statistiken", "totalPuzzles": "Gesamte Rätsel", diff --git a/messages/en.json b/messages/en.json index c68a43e..d951d2d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -64,6 +64,12 @@ "special": "Special", "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": { "yourStatistics": "Your Statistics", "totalPuzzles": "Total puzzles", From 28afaf598bd8ac13212728acf0b36abc1d1785c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Tue, 2 Dec 2025 11:10:13 +0100 Subject: [PATCH 2/4] Bump version to v0.1.4.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e53b2c3..2eba51d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hoerdle", - "version": "0.1.4.5", + "version": "0.1.4.6", "private": true, "scripts": { "dev": "next dev", From d76aa9f4e92d889aaf52b1d05db676380c0c404d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Tue, 2 Dec 2025 13:28:33 +0100 Subject: [PATCH 3/4] Bump version to v0.1.4.7 --- app/[locale]/admin/page.tsx | 250 +++++++++++++++++++++++++++++++++++- app/[locale]/layout.tsx | 2 + package.json | 2 +- 3 files changed, 251 insertions(+), 3 deletions(-) diff --git a/app/[locale]/admin/page.tsx b/app/[locale]/admin/page.tsx index 58d907d..ba3f881 100644 --- a/app/[locale]/admin/page.tsx +++ b/app/[locale]/admin/page.tsx @@ -68,6 +68,14 @@ interface News { } | null; } +interface PoliticalStatement { + id: number; + text: string; + active?: boolean; + source?: string; + locale: string; +} + type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating'; type SortDirection = 'asc' | 'desc'; @@ -169,6 +177,11 @@ export default function AdminPage({ params }: { params: { locale: string } }) { const [showSpecials, setShowSpecials] = useState(false); const [showGenres, setShowGenres] = useState(false); const [showNews, setShowNews] = useState(false); + const [showPoliticalStatements, setShowPoliticalStatements] = useState(false); + const [politicalStatementsLocale, setPoliticalStatementsLocale] = useState<'de' | 'en'>('de'); + const [politicalStatements, setPoliticalStatements] = useState([]); + const [newPoliticalStatementText, setNewPoliticalStatementText] = useState(''); + const [newPoliticalStatementActive, setNewPoliticalStatementActive] = useState(true); const fileInputRef = useRef(null); // Check for existing auth on mount @@ -447,6 +460,100 @@ export default function AdminPage({ params }: { params: { locale: string } }) { } }; + // Political Statements functions (JSON-backed via API) + const fetchPoliticalStatements = async (targetLocale: 'de' | 'en') => { + const res = await fetch(`/api/political-statements?locale=${encodeURIComponent(targetLocale)}&admin=true`, { + headers: getAuthHeaders(), + }); + if (res.ok) { + const data = await res.json(); + const enriched: PoliticalStatement[] = data.map((s: any) => ({ + id: s.id, + text: s.text, + active: s.active !== false, + source: s.source, + locale: targetLocale, + })); + setPoliticalStatements(prev => { + const others = prev.filter(p => p.locale !== targetLocale); + return [...others, ...enriched]; + }); + } + }; + + const handleCreatePoliticalStatement = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newPoliticalStatementText.trim()) return; + + const res = await fetch('/api/political-statements', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ + locale: politicalStatementsLocale, + text: newPoliticalStatementText.trim(), + active: newPoliticalStatementActive, + }), + }); + + if (res.ok) { + setNewPoliticalStatementText(''); + setNewPoliticalStatementActive(true); + fetchPoliticalStatements(politicalStatementsLocale); + } else { + alert('Failed to create statement'); + } + }; + + const handleEditPoliticalStatementText = (locale: string, id: number, text: string) => { + setPoliticalStatements(prev => + prev.map(s => (s.locale === locale && s.id === id ? { ...s, text } : s)), + ); + }; + + const handleEditPoliticalStatementActive = (locale: string, id: number, active: boolean) => { + setPoliticalStatements(prev => + prev.map(s => (s.locale === locale && s.id === id ? { ...s, active } : s)), + ); + }; + + const handleSavePoliticalStatement = async (locale: string, id: number) => { + const stmt = politicalStatements.find(s => s.locale === locale && s.id === id); + if (!stmt) return; + + const res = await fetch('/api/political-statements', { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify({ + locale, + id, + text: stmt.text, + active: stmt.active !== false, + source: stmt.source, + }), + }); + + if (!res.ok) { + alert('Failed to save statement'); + fetchPoliticalStatements(locale as 'de' | 'en'); + } + }; + + const handleDeletePoliticalStatement = async (locale: string, id: number) => { + if (!confirm('Delete this statement?')) return; + + const res = await fetch('/api/political-statements', { + method: 'DELETE', + headers: getAuthHeaders(), + body: JSON.stringify({ locale, id }), + }); + + if (res.ok) { + setPoliticalStatements(prev => prev.filter(s => !(s.locale === locale && s.id === id))); + } else { + alert('Failed to delete statement'); + } + }; + const handleCreateNews = async (e: React.FormEvent) => { e.preventDefault(); if ((!newNewsTitle.de.trim() && !newNewsTitle.en.trim()) || (!newNewsContent.de.trim() && !newNewsContent.en.trim())) return; @@ -524,9 +631,13 @@ export default function AdminPage({ params }: { params: { locale: string } }) { } }; - // Load specials after auth + // Load specials and political statements after auth useEffect(() => { - if (isAuthenticated) fetchSpecials(); + if (isAuthenticated) { + fetchSpecials(); + fetchPoliticalStatements('de'); + fetchPoliticalStatements('en'); + } }, [isAuthenticated]); const deleteGenre = async (id: number) => { @@ -1580,6 +1691,141 @@ export default function AdminPage({ params }: { params: { locale: string } }) { )} + {/* Political Statements Management */} +
+
+

+ Political Statements +

+ +
+ {showPoliticalStatements && ( + <> + {/* Language Tabs */} +
+ {(['de', 'en'] as const).map(lang => ( + + ))} +
+ + {/* Create Form */} +
+
+