Compare commits

...

12 Commits

Author SHA1 Message Date
Hördle Bot
e60daa511b Add donation note for political beauty and bump version to v0.1.4.10 2025-12-02 14:42:46 +01:00
Hördle Bot
19706abacb Bump version to v0.1.4.9 2025-12-02 14:26:17 +01:00
Hördle Bot
170e7b5402 Store political statements in database 2025-12-02 14:14:53 +01:00
Hördle Bot
ade1043c3c chore: Update .gitignore to include new script and documentation files 2025-12-02 14:11:11 +01:00
Hördle Bot
d69af49e24 Bump version to v0.1.4.8 2025-12-02 13:56:45 +01:00
Hördle Bot
63687524e7 Merge branch 'partnerpuzzles' 2025-12-02 13:56:10 +01:00
Hördle Bot
0246cb58ee Include political statements feature files 2025-12-02 13:30:23 +01:00
Hördle Bot
d76aa9f4e9 Bump version to v0.1.4.7 2025-12-02 13:28:33 +01:00
Hördle Bot
28afaf598b Bump version to v0.1.4.6 2025-12-02 11:10:13 +01:00
Hördle Bot
8239753911 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.
2025-12-02 10:59:22 +01:00
Hördle Bot
0bfcf0737e Bump version to v0.1.4.5 2025-12-02 10:00:42 +01:00
Hördle Bot
5409196008 fix: Update domain handling for sharing URLs
- Modify currentHost logic to always share "hördle.de" instead of the Punycode variant when applicable.
- Ensure compatibility with both hoerdle.de and hördle.de for improved user experience.
2025-12-02 09:40:15 +01:00
18 changed files with 883 additions and 9 deletions

2
.gitignore vendored
View File

@@ -52,3 +52,5 @@ next-env.d.ts
.release-years-migrated .release-years-migrated
.covers-migrated .covers-migrated
docker-compose.yml docker-compose.yml
scripts/scrape-bahn-expert-statements.js
docs/bahn-expert-statements.txt

View File

@@ -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} />
</> </>
); );
} }

View File

@@ -70,6 +70,20 @@ export default async function AboutPage({ params }: AboutPageProps) {
{t("costsTitle")} {t("costsTitle")}
</h2> </h2>
<p style={{ marginBottom: "0.5rem" }}>{t("costsIntro")}</p> <p style={{ marginBottom: "0.5rem" }}>{t("costsIntro")}</p>
<p style={{ marginBottom: "0.5rem" }}>
{t.rich("costsDonationNote", {
link: (chunks) => (
<a
href="https://politicalbeauty.de/ueber-das-ZPS.html"
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: "underline" }}
>
{chunks}
</a>
),
})}
</p>
<ul <ul
style={{ style={{
marginLeft: "1.25rem", marginLeft: "1.25rem",

View File

@@ -68,6 +68,14 @@ interface News {
} | null; } | null;
} }
interface PoliticalStatement {
id: number;
text: string;
active?: boolean;
source?: string;
locale: string;
}
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating'; type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
type SortDirection = 'asc' | 'desc'; type SortDirection = 'asc' | 'desc';
@@ -169,6 +177,11 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
const [showSpecials, setShowSpecials] = useState(false); const [showSpecials, setShowSpecials] = useState(false);
const [showGenres, setShowGenres] = useState(false); const [showGenres, setShowGenres] = useState(false);
const [showNews, setShowNews] = useState(false); const [showNews, setShowNews] = useState(false);
const [showPoliticalStatements, setShowPoliticalStatements] = useState(false);
const [politicalStatementsLocale, setPoliticalStatementsLocale] = useState<'de' | 'en'>('de');
const [politicalStatements, setPoliticalStatements] = useState<PoliticalStatement[]>([]);
const [newPoliticalStatementText, setNewPoliticalStatementText] = useState('');
const [newPoliticalStatementActive, setNewPoliticalStatementActive] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Check for existing auth on mount // 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) => { const handleCreateNews = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if ((!newNewsTitle.de.trim() && !newNewsTitle.en.trim()) || (!newNewsContent.de.trim() && !newNewsContent.en.trim())) return; 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(() => { useEffect(() => {
if (isAuthenticated) fetchSpecials(); if (isAuthenticated) {
fetchSpecials();
fetchPoliticalStatements('de');
fetchPoliticalStatements('en');
}
}, [isAuthenticated]); }, [isAuthenticated]);
const deleteGenre = async (id: number) => { const deleteGenre = async (id: number) => {
@@ -1580,6 +1691,141 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
)} )}
</div> </div>
{/* Political Statements Management */}
<div className="admin-card" style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
Political Statements
</h2>
<button
onClick={() => setShowPoliticalStatements(!showPoliticalStatements)}
style={{
padding: '0.5rem 1rem',
background: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '0.25rem',
cursor: 'pointer',
fontSize: '0.875rem'
}}
>
{showPoliticalStatements ? t('hide') : t('show')}
</button>
</div>
{showPoliticalStatements && (
<>
{/* Language Tabs */}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
{(['de', 'en'] as const).map(lang => (
<button
key={lang}
type="button"
onClick={() => setPoliticalStatementsLocale(lang)}
style={{
padding: '0.4rem 0.8rem',
borderRadius: '999px',
border: '1px solid #d1d5db',
background: politicalStatementsLocale === lang ? '#111827' : 'white',
color: politicalStatementsLocale === lang ? 'white' : '#111827',
fontSize: '0.8rem',
cursor: 'pointer'
}}
>
{lang.toUpperCase()}
</button>
))}
</div>
{/* Create Form */}
<form onSubmit={handleCreatePoliticalStatement} style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<textarea
value={newPoliticalStatementText}
onChange={e => setNewPoliticalStatementText(e.target.value)}
placeholder="Statement text"
className="form-input"
rows={3}
required
/>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem' }}>
<input
type="checkbox"
checked={newPoliticalStatementActive}
onChange={e => setNewPoliticalStatementActive(e.target.checked)}
/>
Active
</label>
<button type="submit" className="btn-primary" style={{ fontSize: '0.875rem' }}>
Add Statement ({politicalStatementsLocale.toUpperCase()})
</button>
</div>
</div>
</form>
{/* List */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{politicalStatements
.filter(s => s.locale === politicalStatementsLocale)
.map(stmt => (
<div
key={`${stmt.locale}-${stmt.id}`}
style={{
background: stmt.active ? '#ecfdf5' : '#f3f4f6',
padding: '0.75rem',
borderRadius: '0.5rem',
border: '1px solid #e5e7eb',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem'
}}
>
<textarea
value={stmt.text}
onChange={e => handleEditPoliticalStatementText(stmt.locale, stmt.id, e.target.value)}
className="form-input"
rows={3}
style={{ fontSize: '0.85rem' }}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.5rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.8rem' }}>
<input
type="checkbox"
checked={stmt.active !== false}
onChange={e => handleEditPoliticalStatementActive(stmt.locale, stmt.id, e.target.checked)}
/>
Active
</label>
<div style={{ display: 'flex', gap: '0.25rem' }}>
<button
type="button"
className="btn-secondary"
style={{ padding: '0.25rem 0.6rem', fontSize: '0.75rem' }}
onClick={() => handleSavePoliticalStatement(stmt.locale, stmt.id)}
>
Save
</button>
<button
type="button"
className="btn-danger"
style={{ padding: '0.25rem 0.6rem', fontSize: '0.75rem' }}
onClick={() => handleDeletePoliticalStatement(stmt.locale, stmt.id)}
>
Delete
</button>
</div>
</div>
</div>
))}
{politicalStatements.filter(s => s.locale === politicalStatementsLocale).length === 0 && (
<p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', padding: '0.5rem' }}>
No statements for this language yet.
</p>
)}
</div>
</>
)}
</div>
<div className="admin-card" style={{ marginBottom: '2rem' }}> <div className="admin-card" style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>{t('uploadSongs')}</h2> <h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>{t('uploadSongs')}</h2>
<form onSubmit={handleBatchUpload}> <form onSubmit={handleBatchUpload}>

View File

@@ -11,6 +11,7 @@ import { config } from "@/lib/config";
import { generateBaseMetadata } from "@/lib/metadata"; import { generateBaseMetadata } from "@/lib/metadata";
import InstallPrompt from "@/components/InstallPrompt"; import InstallPrompt from "@/components/InstallPrompt";
import AppFooter from "@/components/AppFooter"; import AppFooter from "@/components/AppFooter";
import PoliticalStatementBanner from "@/components/PoliticalStatementBanner";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -89,6 +90,7 @@ export default async function LocaleLayout({
{children} {children}
<InstallPrompt /> <InstallPrompt />
<AppFooter /> <AppFooter />
<PoliticalStatementBanner />
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
</html> </html>

View File

@@ -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 />
</> </>
); );

View File

@@ -0,0 +1,113 @@
import { NextResponse } from 'next/server';
import { requireAdminAuth } from '@/lib/auth';
import {
getRandomActiveStatement,
getAllStatements,
createStatement,
updateStatement,
deleteStatement,
} from '@/lib/politicalStatements';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const locale = searchParams.get('locale') || 'en';
const admin = searchParams.get('admin') === 'true';
if (admin) {
const authError = await requireAdminAuth(request as any);
if (authError) {
return authError;
}
const statements = await getAllStatements(locale);
return NextResponse.json(statements);
}
const statement = await getRandomActiveStatement(locale);
return NextResponse.json(statement);
} catch (error) {
console.error('[political-statements] GET failed:', error);
return NextResponse.json({ error: 'Failed to load political statements' }, { status: 500 });
}
}
export async function POST(request: Request) {
const authError = await requireAdminAuth(request as any);
if (authError) {
return authError;
}
try {
const body = await request.json();
const { locale, text, active = true, source } = body;
if (!locale || typeof text !== 'string' || !text.trim()) {
return NextResponse.json({ error: 'locale and text are required' }, { status: 400 });
}
const created = await createStatement(locale, { text: text.trim(), active, source });
return NextResponse.json(created, { status: 201 });
} catch (error) {
console.error('[political-statements] POST failed:', error);
return NextResponse.json({ error: 'Failed to create statement' }, { status: 500 });
}
}
export async function PUT(request: Request) {
const authError = await requireAdminAuth(request as any);
if (authError) {
return authError;
}
try {
const body = await request.json();
const { locale, id, text, active, source } = body;
if (!locale || typeof id !== 'number') {
return NextResponse.json({ error: 'locale and numeric id are required' }, { status: 400 });
}
const updated = await updateStatement(locale, id, {
text: typeof text === 'string' ? text.trim() : undefined,
active,
source,
});
if (!updated) {
return NextResponse.json({ error: 'Statement not found' }, { status: 404 });
}
return NextResponse.json(updated);
} catch (error) {
console.error('[political-statements] PUT failed:', error);
return NextResponse.json({ error: 'Failed to update statement' }, { status: 500 });
}
}
export async function DELETE(request: Request) {
const authError = await requireAdminAuth(request as any);
if (authError) {
return authError;
}
try {
const body = await request.json();
const { locale, id } = body;
if (!locale || typeof id !== 'number') {
return NextResponse.json({ error: 'locale and numeric id are required' }, { status: 400 });
}
const ok = await deleteStatement(locale, id);
if (!ok) {
return NextResponse.json({ error: 'Statement not found' }, { status: 404 });
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('[political-statements] DELETE failed:', error);
return NextResponse.json({ error: 'Failed to delete statement' }, { status: 500 });
}
}

View 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>
);
}

View File

@@ -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]);
@@ -284,8 +325,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : ''; 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` : ''; 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 // Use current domain from window.location to support both hoerdle.de and hördle.de,
const currentHost = typeof window !== 'undefined' ? window.location.hostname : config.domain; // but always share the pretty Unicode-Domain "hördle.de" instead of the Punycode variant.
const rawHost = typeof window !== 'undefined' ? window.location.hostname : config.domain;
const currentHost = rawHost === 'xn--hrdle-jua.de' ? 'hördle.de' : rawHost;
const protocol = typeof window !== 'undefined' ? window.location.protocol : 'https:'; const protocol = typeof window !== 'undefined' ? window.location.protocol : 'https:';
let shareUrl = `${protocol}//${currentHost}`; let shareUrl = `${protocol}//${currentHost}`;
// Add locale prefix if not default (en) // Add locale prefix if not default (en)
@@ -488,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>
); );
} }

View File

@@ -0,0 +1,95 @@
'use client';
import { useEffect, useState } from 'react';
import { useLocale } from 'next-intl';
interface ApiStatement {
id: number;
text: string;
active?: boolean;
}
export default function PoliticalStatementBanner() {
const locale = useLocale();
const [statement, setStatement] = useState<ApiStatement | null>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const today = new Date().toISOString().split('T')[0];
const storageKey = `hoerdle_political_statement_shown_${today}_${locale}`;
try {
const alreadyShown = typeof window !== 'undefined' && window.localStorage.getItem(storageKey);
if (alreadyShown) {
return;
}
} catch {
// ignore localStorage errors
}
let timeoutId: number | undefined;
const fetchStatement = async () => {
try {
const res = await fetch(`/api/political-statements?locale=${encodeURIComponent(locale)}`, {
cache: 'no-store',
});
if (!res.ok) return;
const data = await res.json();
if (!data || !data.text) return;
setStatement(data);
setVisible(true);
timeoutId = window.setTimeout(() => {
setVisible(false);
try {
window.localStorage.setItem(storageKey, 'true');
} catch {
// ignore
}
}, 5000);
} catch (e) {
console.warn('[PoliticalStatementBanner] Failed to load statement', e);
}
};
fetchStatement();
return () => {
if (timeoutId) {
window.clearTimeout(timeoutId);
}
};
}, [locale]);
if (!visible || !statement) return null;
return (
<div
style={{
position: 'fixed',
bottom: '1.25rem',
left: '50%',
transform: 'translateX(-50%)',
maxWidth: '640px',
width: 'calc(100% - 2.5rem)',
zIndex: 1050,
background: 'rgba(17,24,39,0.95)',
color: '#e5e7eb',
padding: '0.75rem 1rem',
borderRadius: '999px',
fontSize: '0.85rem',
lineHeight: 1.4,
boxShadow: '0 10px 25px rgba(0,0,0,0.45)',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
}}
>
<span style={{ fontSize: '0.9rem' }}></span>
<span style={{ flex: 1 }}>{statement.text}</span>
</div>
);
}

47
lib/externalPuzzles.ts Normal file
View 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;
}

View 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);
}
}

View File

@@ -0,0 +1,94 @@
import { PrismaClient, PoliticalStatement as PrismaPoliticalStatement } from '@prisma/client';
const prisma = new PrismaClient();
export type PoliticalStatement = {
id: number;
locale: string;
text: string;
active: boolean;
source?: string | null;
};
function mapFromPrisma(stmt: PrismaPoliticalStatement): PoliticalStatement {
return {
id: stmt.id,
locale: stmt.locale,
text: stmt.text,
active: stmt.active,
source: stmt.source,
};
}
export async function getRandomActiveStatement(locale: string): Promise<PoliticalStatement | null> {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
const all = await prisma.politicalStatement.findMany({
where: {
locale: safeLocale,
active: true,
},
});
if (all.length === 0) {
return null;
}
const index = Math.floor(Math.random() * all.length);
return mapFromPrisma(all[index]);
}
export async function getAllStatements(locale: string): Promise<PoliticalStatement[]> {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
const all = await prisma.politicalStatement.findMany({
where: { locale: safeLocale },
orderBy: { id: 'asc' },
});
return all.map(mapFromPrisma);
}
export async function createStatement(locale: string, input: Omit<PoliticalStatement, 'id' | 'locale'>): Promise<PoliticalStatement> {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
const created = await prisma.politicalStatement.create({
data: {
locale: safeLocale,
text: input.text,
active: input.active ?? true,
source: input.source ?? null,
},
});
return mapFromPrisma(created);
}
export async function updateStatement(locale: string, id: number, input: Partial<Omit<PoliticalStatement, 'id' | 'locale'>>): Promise<PoliticalStatement | null> {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
// Optional: sicherstellen, dass das Statement zu dieser Locale gehört
const existing = await prisma.politicalStatement.findUnique({ where: { id } });
if (!existing || existing.locale !== safeLocale) {
return null;
}
const updated = await prisma.politicalStatement.update({
where: { id },
data: {
text: input.text ?? existing.text,
active: input.active ?? existing.active,
source: input.source !== undefined ? input.source : existing.source,
},
});
return mapFromPrisma(updated);
}
export async function deleteStatement(locale: string, id: number): Promise<boolean> {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
const existing = await prisma.politicalStatement.findUnique({ where: { id } });
if (!existing || existing.locale !== safeLocale) {
return false;
}
await prisma.politicalStatement.delete({ where: { id } });
return true;
}

View File

@@ -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",
@@ -153,7 +159,7 @@
"deletePuzzle": "Löschen", "deletePuzzle": "Löschen",
"wrongPassword": "Falsches Passwort" "wrongPassword": "Falsches Passwort"
}, },
"About": { "About": {
"title": "Über Hördle & Impressum", "title": "Über Hördle & Impressum",
"intro": "Hördle ist ein nicht-kommerzielles, privat betriebenes Hobbyprojekt. Es gibt keine Werbeanzeigen, keine gesponserten Inhalte und keine versteckten Abo-Modelle.", "intro": "Hördle ist ein nicht-kommerzielles, privat betriebenes Hobbyprojekt. Es gibt keine Werbeanzeigen, keine gesponserten Inhalte und keine versteckten Abo-Modelle.",
"projectTitle": "Über dieses Projekt", "projectTitle": "Über dieses Projekt",
@@ -165,6 +171,7 @@
"imprintEmailLabel": "E-Mail:", "imprintEmailLabel": "E-Mail:",
"costsTitle": "Laufende Kosten des Projekts", "costsTitle": "Laufende Kosten des Projekts",
"costsIntro": "Auch wenn Hördle ein privates Projekt ist, entstehen für den Betrieb laufende Kosten, zum Beispiel:", "costsIntro": "Auch wenn Hördle ein privates Projekt ist, entstehen für den Betrieb laufende Kosten, zum Beispiel:",
"costsDonationNote": "Alle Einnahmen, die die Betriebskosten des Projekts übersteigen, werden am Jahresende an die Aktion <link>Zentrum für politische Schönheit</link> gespendet.",
"costsDomain": "Domains (z. B. hördle.de / hoerdle.de)", "costsDomain": "Domains (z. B. hördle.de / hoerdle.de)",
"costsServer": "Server / vServer für App und Tracking", "costsServer": "Server / vServer für App und Tracking",
"costsEmail": "E-Mail-Hosting", "costsEmail": "E-Mail-Hosting",

View File

@@ -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",
@@ -165,6 +171,7 @@
"imprintEmailLabel": "Email:", "imprintEmailLabel": "Email:",
"costsTitle": "Ongoing costs of the project", "costsTitle": "Ongoing costs of the project",
"costsIntro": "Even though Hördle is a private project, there are ongoing costs for running it, for example:", "costsIntro": "Even though Hördle is a private project, there are ongoing costs for running it, for example:",
"costsDonationNote": "All income that exceeds the operating costs of the project will be donated at the end of the year to the campaign <link>Zentrum für politische Schönheit</link>.",
"costsDomain": "Domains (e.g. hördle.de / hoerdle.de)", "costsDomain": "Domains (e.g. hördle.de / hoerdle.de)",
"costsServer": "Servers / vServers for the app and tracking", "costsServer": "Servers / vServers for the app and tracking",
"costsEmail": "Email hosting", "costsEmail": "Email hosting",

View File

@@ -1,6 +1,6 @@
{ {
"name": "hoerdle", "name": "hoerdle",
"version": "0.1.4.4", "version": "0.1.4.10",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",

View File

@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "PoliticalStatement" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"locale" TEXT NOT NULL,
"text" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"source" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE INDEX "PoliticalStatement_locale_active_idx" ON "PoliticalStatement"("locale", "active");

View File

@@ -101,3 +101,15 @@ model PlayerState {
@@unique([identifier, genreKey]) @@unique([identifier, genreKey])
@@index([identifier]) @@index([identifier])
} }
model PoliticalStatement {
id Int @id @default(autoincrement())
locale String
text String
active Boolean @default(true)
source String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([locale, active])
}