Compare commits

..

22 Commits

Author SHA1 Message Date
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
Hördle Bot
a59f6f747e chore: Bump version to 0.1.4.4 2025-12-02 01:51:43 +01:00
Hördle Bot
dc763c88a3 feat: Add device-specific isolation for player IDs
- Add device ID generation (unique per device, stored in localStorage)
- Extend player ID format to: {basePlayerId}:{deviceId}
- Enable cross-domain sync on same device while keeping devices isolated
- Update backend APIs to support new player ID format
- Maintain backward compatibility with legacy UUID format

This allows:
- Each device (Desktop, Android, iOS) to have separate game states
- Cross-domain sync still works on the same device (hoerdle.de ↔ hördle.de)
- Easier debugging with visible device ID in player identifier
2025-12-02 01:49:45 +01:00
Hördle Bot
1613bf0dda chore: Bump version to 0.1.4.3 2025-12-02 01:28:34 +01:00
Hördle Bot
b872e87b50 feat: Add -5 points penalty for track extension on wrong guesses
- Add -5 points penalty for track extension (unlock steps) on wrong guesses
- Wrong guess now costs -8 points total (-3 for wrong + -5 for extension)
- Skip remains at -5 points (no additional penalty)
- Update documentation (README.md, SCORING_OPTIONS.md)
- Add SCORING_OPTIONS.md with detailed scoring system analysis
2025-12-02 01:28:27 +01:00
Hördle Bot
87c1ee63ec feat: Add tooltip to star rating and support section updates
- Add tooltip to star rating component encouraging users to help curators
- Add curator application information to support section
- Add bug report email link to support section
- All changes localized (de/en)
2025-12-02 01:00:08 +01:00
Hördle Bot
8c57e938e8 chore: Bump version to 0.1.4.2 2025-12-01 23:59:22 +01:00
Hördle Bot
9eb07ee8d5 refactor: Ensure genreKey is always recomputed before use
- Recompute genreKey inside useEffect and save functions to ensure current values
- Prevents potential closure issues with stale genreKey values
- Improves code quality and prevents future bugs
2025-12-01 23:58:11 +01:00
Hördle Bot
3eb6c7f5cf chore: Remove privacy legal advice disclaimer from about page
- Remove privacyNoLegalAdvice text from about page
- Remove privacyNoLegalAdvice from German and English translations
2025-12-01 22:12:49 +01:00
Hördle Bot
2846afb6f7 feat: Remove localStorage for game states and implement cross-domain player ID sync
- Remove localStorage for game states and statistics (backend only)
- Add API route to suggest player ID based on recently updated states
- Add async player ID lookup that finds existing IDs across domains
- When visiting a new domain, automatically find and use existing player ID
- Enables cross-domain synchronization between hoerdle.de and hördle.de
2025-12-01 20:37:47 +01:00
Hördle Bot
27fa689b18 fix: Prevent replaying already solved puzzles across domains
- Add checks in handleGuess, handleSkip, and handleGiveUp to prevent actions on solved/failed puzzles
- Add protection in addGuess to prevent adding guesses to solved puzzles
- Fix and simplify backend state loading logic
- Ensure solved puzzles cannot be replayed when switching domains
2025-12-01 20:22:28 +01:00
Hördle Bot
61846a6982 feat: Add backend storage for cross-domain player state synchronization
- Add PlayerState model to database schema for storing game states
- Create player identifier system (UUID-based) for cross-domain sync
- Implement API endpoints for loading/saving player states
- Refactor gameState hook to use backend storage with localStorage fallback
- Support synchronization between hoerdle.de and hördle.de
- Migration automatically runs on Docker container start
2025-12-01 20:09:54 +01:00
Hördle Bot
bba6b9ef31 fix: Use current domain in share URL instead of static config.domain
- Share URL now uses window.location.hostname to support both hoerdle.de and hördle.de
- Protocol is automatically detected from window.location.protocol
- Fixes issue where share URL always used hoerdle.de even when accessed via hördle.de
2025-12-01 19:53:26 +01:00
Hördle Bot
a8867ac42e feat: Add dynamic Open Graph image generation with correct aspect ratio
- Create /api/og-image endpoint that generates SVG with 1.91:1 ratio (1200x630px)
- Prevents logo cropping on Facebook and Twitter
- Uses safe padding (150px) to ensure content is never cut off
- Update default OG image to use dynamic endpoint
- Add SEO testing documentation
2025-12-01 19:44:48 +01:00
Hördle Bot
9006b208af chore: Bump version to v0.1.4.1 2025-12-01 19:31:00 +01:00
Hördle Bot
20c8ad7eaf feat: Add comprehensive SEO implementation
- Add robots.txt with admin/API blocking
- Add dynamic sitemap.xml with static pages and genre pages
- Implement full meta tags (Open Graph, Twitter Cards, Canonical, Alternates)
- Add SEO helper functions for domain detection and URL generation
- Add generateMetadata to all pages (homepage, about, genre, special)
- Support automatic domain detection for hoerdle.de and hördle.de
- Add SEO configuration to lib/config.ts
2025-12-01 19:28:43 +01:00
Hördle Bot
03129a5611 Remove imprint disclaimer from About page and localization files for German and English. 2025-12-01 19:13:33 +01:00
32 changed files with 2679 additions and 93 deletions

View File

@@ -77,13 +77,15 @@ Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und d
- **Start-Punktestand:** 90 Punkte - **Start-Punktestand:** 90 Punkte
- **Richtige Antwort:** +20 Punkte - **Richtige Antwort:** +20 Punkte
- **Falsche Antwort:** -3 Punkte - **Falsche Antwort:** -3 Punkte (falscher Rateversuch) + -5 Punkte (Track-Verlängerung) = **-8 Punkte total**
- **Überspringen (Skip):** -5 Punkte - **Überspringen (Skip):** -5 Punkte
- **Snippet erneut abspielen (Replay):** -1 Punkt - **Snippet erneut abspielen (Replay):** -1 Punkt
- **Bonus-Runde (Release-Jahr erraten):** +10 Punkte (0 bei falscher Antwort) - **Bonus-Runde (Release-Jahr erraten):** +10 Punkte (0 bei falscher Antwort)
- **Aufgeben / Verloren:** Der Punktestand wird auf 0 gesetzt. - **Aufgeben / Verloren:** Der Punktestand wird auf 0 gesetzt.
- **Minimum:** Der Punktestand kann nicht unter 0 fallen. - **Minimum:** Der Punktestand kann nicht unter 0 fallen.
**Hinweis:** Bei falschen Rateversuchen werden zusätzlich -5 Punkte für die automatische Verlängerung des Audio-Snippets (unlockSteps) abgezogen, um die Verwendung dieses Hilfsmittels zu reflektieren.
## Tech Stack ## Tech Stack
- **Framework:** Next.js 16 (App Router) - **Framework:** Next.js 16 (App Router)

View File

@@ -6,6 +6,8 @@ import { PrismaClient } from '@prisma/client';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { getLocalizedValue } from '@/lib/i18n'; import { getLocalizedValue } from '@/lib/i18n';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { generateBaseMetadata } from '@/lib/metadata';
import type { Metadata } from 'next';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -15,6 +17,32 @@ interface PageProps {
params: Promise<{ locale: string; genre: string }>; params: Promise<{ locale: string; genre: string }>;
} }
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale, genre } = await params;
const decodedGenre = decodeURIComponent(genre);
// Fetch genre to get localized name
const allGenres = await prisma.genre.findMany();
const currentGenre = allGenres.find(g => getLocalizedValue(g.name, locale) === decodedGenre);
if (!currentGenre || !currentGenre.active) {
return await generateBaseMetadata(locale, genre);
}
const genreName = getLocalizedValue(currentGenre.name, locale);
const genreSubtitle = getLocalizedValue(currentGenre.subtitle, locale);
const title = locale === 'de'
? `${genreName} - Hördle`
: `${genreName} - Hördle`;
const description = genreSubtitle || (locale === 'de'
? `Spiele Hördle im Genre ${genreName} und errate Songs aus kurzen Audio-Clips!`
: `Play Hördle in the ${genreName} genre and guess songs from short audio clips!`);
return await generateBaseMetadata(locale, genre, title, description);
}
export default async function GenrePage({ params }: PageProps) { export default async function GenrePage({ params }: PageProps) {
const { locale, genre } = await params; const { locale, genre } = await params;
const decodedGenre = decodeURIComponent(genre); const decodedGenre = decodeURIComponent(genre);
@@ -59,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' }}>
@@ -128,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

@@ -1,10 +1,22 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { Link } from "@/lib/navigation"; import { Link } from "@/lib/navigation";
import { generateBaseMetadata } from "@/lib/metadata";
import type { Metadata } from "next";
interface AboutPageProps { interface AboutPageProps {
params: Promise<{ locale: string }>; params: Promise<{ locale: string }>;
} }
export async function generateMetadata({ params }: AboutPageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "About" });
const title = t("title");
const description = t("intro");
return await generateBaseMetadata(locale, "about", title, description);
}
export default async function AboutPage({ params }: AboutPageProps) { export default async function AboutPage({ params }: AboutPageProps) {
const { locale } = await params; const { locale } = await params;
const t = await getTranslations({ locale, namespace: "About" }); const t = await getTranslations({ locale, namespace: "About" });
@@ -51,11 +63,6 @@ export default async function AboutPage({ params }: AboutPageProps) {
{t("imprintEmailLabel")}{" "} {t("imprintEmailLabel")}{" "}
<a href="mailto:markus@hoerdle.de">markus@hoerdle.de</a> <a href="mailto:markus@hoerdle.de">markus@hoerdle.de</a>
</p> </p>
<p
style={{ marginTop: "0.5rem", fontSize: "0.9rem", color: "#6b7280" }}
>
{t("imprintDisclaimer")}
</p>
</section> </section>
<section style={{ marginBottom: "2rem" }}> <section style={{ marginBottom: "2rem" }}>
@@ -199,6 +206,58 @@ export default async function AboutPage({ params }: AboutPageProps) {
</p> </p>
</div> </div>
</div> </div>
<div
style={{
padding: "1rem",
border: "1px solid #e5e7eb",
borderRadius: "0.5rem",
backgroundColor: "#f9fafb",
marginBottom: "0.5rem",
}}
>
<h3
style={{
fontSize: "1.125rem",
fontWeight: "600",
marginBottom: "0.5rem",
}}
>
{t("supportCuratorTitle")}
</h3>
<p style={{ marginBottom: 0 }}>
{t("supportCuratorText")}
</p>
</div>
<div
style={{
padding: "1rem",
border: "1px solid #e5e7eb",
borderRadius: "0.5rem",
backgroundColor: "#f9fafb",
}}
>
<h3
style={{
fontSize: "1.125rem",
fontWeight: "600",
marginBottom: "0.5rem",
}}
>
{t("supportReportBugTitle")}
</h3>
<p style={{ marginBottom: 0 }}>
{t.rich("supportReportBugText", {
email: (chunks) => (
<a
href="mailto:admin@hoerdle.de"
style={{ textDecoration: "underline" }}
>
{chunks}
</a>
),
})}
</p>
</div>
</section> </section>
<section style={{ marginBottom: "2rem" }}> <section style={{ marginBottom: "2rem" }}>
@@ -234,11 +293,6 @@ export default async function AboutPage({ params }: AboutPageProps) {
</ul> </ul>
<p style={{ marginBottom: "0.5rem" }}>{t("privacyServerLogs")}</p> <p style={{ marginBottom: "0.5rem" }}>{t("privacyServerLogs")}</p>
<p style={{ marginBottom: "0.5rem" }}>{t("privacyContact")}</p> <p style={{ marginBottom: "0.5rem" }}>{t("privacyContact")}</p>
<p
style={{ marginTop: "0.5rem", fontSize: "0.9rem", color: "#6b7280" }}
>
{t("privacyNoLegalAdvice")}
</p>
</section> </section>
<section style={{ marginBottom: "2rem" }}> <section style={{ marginBottom: "2rem" }}>

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

@@ -8,8 +8,10 @@ import { notFound } from 'next/navigation';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { config } from "@/lib/config"; import { config } from "@/lib/config";
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",
@@ -21,10 +23,10 @@ const geistMono = Geist_Mono({
subsets: ["latin"], subsets: ["latin"],
}); });
export const metadata: Metadata = { export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
title: config.appName, const { locale } = await params;
description: config.appDescription, return await generateBaseMetadata(locale);
}; }
export const viewport: Viewport = { export const viewport: Viewport = {
themeColor: config.colors.themeColor, themeColor: config.colors.themeColor,
@@ -88,6 +90,7 @@ export default async function LocaleLayout({
{children} {children}
<InstallPrompt /> <InstallPrompt />
<AppFooter /> <AppFooter />
<PoliticalStatementBanner />
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
</html> </html>

View File

@@ -7,15 +7,33 @@ import { Link } from '@/lib/navigation';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { getLocalizedValue } from '@/lib/i18n'; import { getLocalizedValue } from '@/lib/i18n';
import { generateBaseMetadata } from '@/lib/metadata';
import type { Metadata } from 'next';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations('Home');
// Get localized title and description
const title = locale === 'de'
? 'Hördle - Tägliches Musik-Erraten'
: 'Hördle - Daily Music Guessing Game';
const description = locale === 'de'
? 'Spiele Hördle und errate Songs aus kurzen Audio-Clips! Täglich neue Rätsel aus verschiedenen Genres. Inspiriert von Wordle, aber für Musikfans.'
: 'Play Hördle and guess songs from short audio clips! Daily new puzzles from various genres. Inspired by Wordle, but for music lovers.';
return await generateBaseMetadata(locale, '', title, description);
}
export default async function Home({ export default async function Home({
params params
}: { }: {
params: { locale: string }; params: Promise<{ locale: string }>;
}) { }) {
const { locale } = await params; const { locale } = await params;
const t = await getTranslations('Home'); const t = await getTranslations('Home');
@@ -43,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' }}>
@@ -131,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

@@ -5,6 +5,8 @@ import { Link } from '@/lib/navigation';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { getLocalizedValue } from '@/lib/i18n'; import { getLocalizedValue } from '@/lib/i18n';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { generateBaseMetadata } from '@/lib/metadata';
import type { Metadata } from 'next';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -14,6 +16,30 @@ interface PageProps {
params: Promise<{ locale: string; name: string }>; params: Promise<{ locale: string; name: string }>;
} }
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale, name } = await params;
const decodedName = decodeURIComponent(name);
// Fetch special to get localized name
const allSpecials = await prisma.special.findMany();
const currentSpecial = allSpecials.find(s => getLocalizedValue(s.name, locale) === decodedName);
if (!currentSpecial) {
return await generateBaseMetadata(locale, `special/${name}`);
}
const specialName = getLocalizedValue(currentSpecial.name, locale);
const specialSubtitle = getLocalizedValue(currentSpecial.subtitle, locale);
const title = `${specialName} - Hördle`;
const description = specialSubtitle || (locale === 'de'
? `Spiele das Hördle-Special "${specialName}" und errate Songs aus kurzen Audio-Clips!`
: `Play the Hördle special "${specialName}" and guess songs from short audio clips!`);
return await generateBaseMetadata(locale, `special/${name}`, title, description);
}
export default async function SpecialPage({ params }: PageProps) { export default async function SpecialPage({ params }: PageProps) {
const { locale, name } = await params; const { locale, name } = await params;
const decodedName = decodeURIComponent(name); const decodedName = decodeURIComponent(name);

78
app/api/og-image/route.ts Normal file
View File

@@ -0,0 +1,78 @@
import { NextResponse } from 'next/server';
import { config } from '@/lib/config';
import { getBaseUrl } from '@/lib/seo';
import { headers } from 'next/headers';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
/**
* Generate Open Graph image as SVG with correct aspect ratio (1.91:1 = 1200x630)
* This prevents cropping on Facebook and Twitter
*/
export async function GET() {
const baseUrl = await getBaseUrl();
const appName = config.appName;
const bgColor = config.colors.backgroundColor || '#ffffff';
const primaryColor = config.colors.themeColor || '#000000';
// SVG with correct Open Graph dimensions: 1200x630 (1.91:1 ratio)
// Safe area: 150px padding on all sides to prevent cropping
// This ensures content is never cut off on Facebook/Twitter
const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="1200" height="630" fill="${bgColor}"/>
<!-- Gradient definition -->
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="50%" style="stop-color:#764ba2;stop-opacity:1" />
<stop offset="100%" style="stop-color:#06b6d4;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Content container - centered with safe padding (150px on all sides) -->
<g transform="translate(150, 150)">
<!-- Main graphic area (centered horizontally) -->
<g transform="translate(300, 0)">
<!-- Musical note (left side, within safe area) -->
<g fill="url(#gradient)" opacity="0.9">
<!-- Note head -->
<ellipse cx="0" cy="40" rx="40" ry="28"/>
<!-- Note stem -->
<rect x="30" y="-60" width="16" height="100" rx="2"/>
</g>
<!-- Waveform (center-right, within safe area) -->
<g transform="translate(70, 15)" fill="none" stroke="url(#gradient)" stroke-width="8" stroke-linecap="round" opacity="0.8">
<path d="M 0 25 Q 20 -15 40 25 T 80 25"/>
<path d="M 0 40 Q 20 0 40 40 T 80 40"/>
<path d="M 0 55 Q 20 15 40 55 T 80 55"/>
</g>
<!-- Vertical bar (right side, within safe area) -->
<rect x="170" y="0" width="10" height="120" fill="url(#gradient)" opacity="0.7" rx="5"/>
</g>
<!-- App name (centered, within safe vertical area) -->
<text x="450" y="180" font-family="system-ui, -apple-system, sans-serif" font-size="56" font-weight="bold" fill="${primaryColor}" text-anchor="middle" letter-spacing="-0.5">
${appName}
</text>
<!-- Domain/subtitle (centered, within safe vertical area) -->
<text x="450" y="220" font-family="system-ui, -apple-system, sans-serif" font-size="28" fill="#666666" text-anchor="middle">
${config.domain}
</text>
</g>
</svg>`;
return new NextResponse(svg, {
headers: {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
},
});
}

View File

@@ -0,0 +1,114 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
/**
* POST /api/player-id/suggest
*
* Tries to find a base player ID based on recently updated states for a genre and device.
* This helps synchronize player IDs across different domains (hoerdle.de and hördle.de)
* on the same device.
*
* Request body:
* - genreKey: Genre key (e.g., "global", "Rock", "special:00725")
* - deviceId: Device identifier (UUID)
*
* Returns:
* - basePlayerId: Suggested base player ID (UUID) if found, null otherwise
*/
export async function POST(request: Request) {
try {
const body = await request.json();
const { genreKey, deviceId } = body;
if (!genreKey || typeof genreKey !== 'string') {
return NextResponse.json(
{ error: 'Missing or invalid genreKey' },
{ status: 400 }
);
}
// Find the most recently updated player state for this genre
// Look for states updated in the last 48 hours
const cutoffDate = new Date();
cutoffDate.setHours(cutoffDate.getHours() - 48);
// If deviceId is provided, search for states with matching device ID
// Format: {basePlayerId}:{deviceId}
if (deviceId && typeof deviceId === 'string') {
// Search for states with the same device ID
const recentStates = await prisma.playerState.findMany({
where: {
genreKey: genreKey,
lastPlayed: {
gte: cutoffDate,
},
identifier: {
endsWith: `:${deviceId}`,
},
},
orderBy: {
lastPlayed: 'desc',
},
take: 1,
});
if (recentStates.length > 0) {
const recentState = recentStates[0];
// Extract base player ID from full identifier
const colonIndex = recentState.identifier.indexOf(':');
if (colonIndex !== -1) {
const basePlayerId = recentState.identifier.substring(0, colonIndex);
return NextResponse.json({
basePlayerId: basePlayerId,
lastPlayed: recentState.lastPlayed,
});
}
}
}
// Fallback: Find any recent state for this genre (legacy support)
const recentState = await prisma.playerState.findFirst({
where: {
genreKey: genreKey,
lastPlayed: {
gte: cutoffDate,
},
},
orderBy: {
lastPlayed: 'desc',
},
});
if (recentState) {
// Extract base player ID if format is basePlayerId:deviceId
const colonIndex = recentState.identifier.indexOf(':');
if (colonIndex !== -1) {
const basePlayerId = recentState.identifier.substring(0, colonIndex);
return NextResponse.json({
basePlayerId: basePlayerId,
lastPlayed: recentState.lastPlayed,
});
} else {
// Legacy format: return as-is
return NextResponse.json({
basePlayerId: recentState.identifier,
lastPlayed: recentState.lastPlayed,
});
}
}
// No recent state found
return NextResponse.json({
basePlayerId: null,
});
} catch (error) {
console.error('[player-id/suggest] Error finding player ID:', error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,192 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { getLocalizedValue } from '@/lib/i18n';
import type { GameState, Statistics } from '@/lib/gameState';
const prisma = new PrismaClient();
/**
* Validate UUID format (basic check)
* Supports both legacy format (single UUID) and new format (basePlayerId:deviceId)
*/
function isValidPlayerId(playerId: string): boolean {
// Legacy format: single UUID
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
// New format: basePlayerId:deviceId (two UUIDs separated by colon)
const combinedRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidRegex.test(playerId) || combinedRegex.test(playerId);
}
/**
* Extract base player ID from full player ID
* Format: {basePlayerId}:{deviceId} -> {basePlayerId}
* Legacy: {uuid} -> {uuid}
*/
function extractBasePlayerId(fullPlayerId: string): string {
const colonIndex = fullPlayerId.indexOf(':');
if (colonIndex === -1) {
// Legacy format (no device ID) - return as is
return fullPlayerId;
}
return fullPlayerId.substring(0, colonIndex);
}
/**
* GET /api/player-state
*
* Loads player state for a given identifier and genre/special.
*
* Query parameters:
* - genre: Genre name (e.g., "Rock")
* - special: Special name (e.g., "00725")
*
* Headers:
* - X-Player-Id: Player identifier (UUID)
*/
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const genreName = searchParams.get('genre');
const specialName = searchParams.get('special');
// Get player identifier from header
const playerId = request.headers.get('X-Player-Id');
if (!playerId || !isValidPlayerId(playerId)) {
return NextResponse.json(
{ error: 'Invalid or missing player identifier' },
{ status: 400 }
);
}
// Determine genre key
let genreKey: string;
if (specialName) {
genreKey = `special:${specialName}`;
} else if (genreName) {
genreKey = genreName;
} else {
genreKey = 'global';
}
// Load player state from database
const playerState = await prisma.playerState.findUnique({
where: {
identifier_genreKey: {
identifier: playerId,
genreKey: genreKey,
},
},
});
if (!playerState) {
return NextResponse.json(null, { status: 404 });
}
// Parse JSON strings
let gameState: GameState;
let statistics: Statistics;
try {
gameState = JSON.parse(playerState.gameState) as GameState;
statistics = JSON.parse(playerState.statistics) as Statistics;
} catch (parseError) {
console.error('[player-state] Failed to parse stored state:', parseError);
return NextResponse.json(
{ error: 'Invalid stored state format' },
{ status: 500 }
);
}
return NextResponse.json({
gameState,
statistics,
});
} catch (error) {
console.error('[player-state] Error loading player state:', error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
/**
* POST /api/player-state
*
* Saves player state for a given identifier and genre/special.
*
* Request body:
* - genreKey: Genre key (e.g., "global", "Rock", "special:00725")
* - gameState: GameState object
* - statistics: Statistics object
*
* Headers:
* - X-Player-Id: Player identifier (UUID)
*/
export async function POST(request: Request) {
try {
// Get player identifier from header
const playerId = request.headers.get('X-Player-Id');
if (!playerId || !isValidPlayerId(playerId)) {
return NextResponse.json(
{ error: 'Invalid or missing player identifier' },
{ status: 400 }
);
}
// Parse request body
const body = await request.json();
const { genreKey, gameState, statistics } = body;
if (!genreKey || !gameState || !statistics) {
return NextResponse.json(
{ error: 'Missing required fields: genreKey, gameState, statistics' },
{ status: 400 }
);
}
// Validate genre key format
if (typeof genreKey !== 'string' || genreKey.length === 0) {
return NextResponse.json(
{ error: 'Invalid genreKey format' },
{ status: 400 }
);
}
// Serialize to JSON strings
const gameStateJson = JSON.stringify(gameState);
const statisticsJson = JSON.stringify(statistics);
// Upsert player state (update if exists, create if not)
await prisma.playerState.upsert({
where: {
identifier_genreKey: {
identifier: playerId,
genreKey: genreKey,
},
},
update: {
gameState: gameStateJson,
statistics: statisticsJson,
lastPlayed: new Date(),
},
create: {
identifier: playerId,
genreKey: genreKey,
gameState: gameStateJson,
statistics: statisticsJson,
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('[player-state] Error saving player state:', error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

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

20
app/robots.ts Normal file
View File

@@ -0,0 +1,20 @@
import { MetadataRoute } from 'next';
import { config } from '@/lib/config';
export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_DOMAIN || config.domain;
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
const siteUrl = `${protocol}://${baseUrl}`;
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/admin/', '/api/'],
},
],
sitemap: `${siteUrl}/sitemap.xml`,
};
}

128
app/sitemap.ts Normal file
View File

@@ -0,0 +1,128 @@
import { MetadataRoute } from 'next';
import { PrismaClient } from '@prisma/client';
import { getLocalizedValue } from '@/lib/i18n';
import { config } from '@/lib/config';
const prisma = new PrismaClient();
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_DOMAIN || config.domain;
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
const siteUrl = `${protocol}://${baseUrl}`;
const now = new Date().toISOString();
// Static pages
const staticPages: MetadataRoute.Sitemap = [
{
url: `${siteUrl}/en`,
lastModified: now,
changeFrequency: 'daily',
priority: 1.0,
alternates: {
languages: {
'de': `${siteUrl}/de`,
'en': `${siteUrl}/en`,
'x-default': `${siteUrl}/en`,
},
},
},
{
url: `${siteUrl}/de`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.8,
alternates: {
languages: {
'de': `${siteUrl}/de`,
'en': `${siteUrl}/en`,
'x-default': `${siteUrl}/en`,
},
},
},
{
url: `${siteUrl}/en/about`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.7,
alternates: {
languages: {
'de': `${siteUrl}/de/about`,
'en': `${siteUrl}/en/about`,
'x-default': `${siteUrl}/en/about`,
},
},
},
{
url: `${siteUrl}/de/about`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.7,
alternates: {
languages: {
'de': `${siteUrl}/de/about`,
'en': `${siteUrl}/en/about`,
'x-default': `${siteUrl}/en/about`,
},
},
},
];
// Dynamic genre pages
try {
const genres = await prisma.genre.findMany({
where: { active: true },
});
const genrePages: MetadataRoute.Sitemap = [];
for (const genre of genres) {
const genreNameEn = getLocalizedValue(genre.name, 'en');
const genreNameDe = getLocalizedValue(genre.name, 'de');
// Only add if genre name is valid
if (genreNameEn && genreNameDe) {
const encodedEn = encodeURIComponent(genreNameEn);
const encodedDe = encodeURIComponent(genreNameDe);
genrePages.push(
{
url: `${siteUrl}/en/${encodedEn}`,
lastModified: now,
changeFrequency: 'daily',
priority: 0.9,
alternates: {
languages: {
'de': `${siteUrl}/de/${encodedDe}`,
'en': `${siteUrl}/en/${encodedEn}`,
'x-default': `${siteUrl}/en/${encodedEn}`,
},
},
},
{
url: `${siteUrl}/de/${encodedDe}`,
lastModified: now,
changeFrequency: 'daily',
priority: 0.9,
alternates: {
languages: {
'de': `${siteUrl}/de/${encodedDe}`,
'en': `${siteUrl}/en/${encodedEn}`,
'x-default': `${siteUrl}/en/${encodedEn}`,
},
},
}
);
}
}
return [...staticPages, ...genrePages];
} catch (error) {
console.error('Error generating sitemap:', error);
// Return static pages only if database query fails
return staticPages;
} finally {
await prisma.$disconnect();
}
}

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,14 +37,17 @@ 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); const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial);
const [hasWon, setHasWon] = useState(false); const [hasWon, setHasWon] = useState(false);
const [hasLost, setHasLost] = useState(false); const [hasLost, setHasLost] = useState(false);
const [shareText, setShareText] = useState(`🔗 ${t('share')}`); const [shareText, setShareText] = useState(`🔗 ${t('share')}`);
@@ -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]);
@@ -108,6 +149,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const handleGuess = (song: any) => { const handleGuess = (song: any) => {
if (isProcessingGuess) return; if (isProcessingGuess) return;
// Prevent guessing if already solved or failed
if (gameState?.isSolved || gameState?.isFailed) {
return;
}
setIsProcessingGuess(true); setIsProcessingGuess(true);
setLastAction('GUESS'); setLastAction('GUESS');
@@ -159,6 +204,9 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
}; };
const handleSkip = () => { 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 user hasn't played audio yet on first attempt, start it instead of skipping
if (gameState.guesses.length === 0 && !hasPlayedAudio) { if (gameState.guesses.length === 0 && !hasPlayedAudio) {
handleStartAudio(); handleStartAudio();
@@ -187,6 +235,9 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
}; };
const handleGiveUp = () => { const handleGiveUp = () => {
// Prevent giving up if already solved or failed
if (gameState?.isSolved || gameState?.isFailed) return;
setLastAction('SKIP'); setLastAction('SKIP');
addGuess("SKIPPED", false); addGuess("SKIPPED", false);
giveUp(); // Ensure game is marked as failed and score reset to 0 giveUp(); // Ensure game is marked as failed and score reset to 0
@@ -274,7 +325,12 @@ 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` : '';
let shareUrl = `https://${config.domain}`; // 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 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:';
let shareUrl = `${protocol}//${currentHost}`;
// Add locale prefix if not default (en) // Add locale prefix if not default (en)
if (locale !== 'en') { if (locale !== 'en') {
shareUrl += `/${locale}`; shareUrl += `/${locale}`;
@@ -475,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>
); );
} }
@@ -674,7 +737,11 @@ function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, ha
} }
return ( return (
<div className="star-rating" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}> <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> <span style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', fontWeight: '500' }}>{t('rateThisPuzzle')}</span>
<div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}> <div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}>
{[...Array(5)].map((_, index) => { {[...Array(5)].map((_, index) => {

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

293
docs/SCORING_OPTIONS.md Normal file
View File

@@ -0,0 +1,293 @@
# Scoring-System Optionen
## Problem-Analyse
### Aktuelle Situation
- **Start:** 90 Punkte
- **Richtige Antwort:** +20 Punkte
- **Falsche Antwort:** -3 Punkte (falscher Rateversuch) + -5 Punkte (Track-Verlängerung) = **-8 Punkte total**
- **Skip:** -5 Punkte
- **Replay:** -1 Punkt
### Problem (vor der Änderung)
Bei vielen Versuchen kam man mit einem relativ hohen Score heraus:
- Beispiel (alt): 7 Versuche = 90 + 20 - (6 × 3) = **92 Punkte**
### Lösung (aktuell implementiert)
Bei falschen Rateversuchen werden zusätzlich -5 Punkte für die Track-Verlängerung (unlockSteps) abgezogen:
- Beispiel (neu): 7 Versuche = 90 + 20 - (6 × 8) = **62 Punkte**
- Start: 90 Punkte
- 6 falsche Versuche: -48 Punkte (6 × -8, bestehend aus -3 für falsch + -5 für Verlängerung)
- 1 richtiger Versuch: +20 Punkte
- **Ergebnis: 62 Punkte**
Dies spiegelt nun besser die tatsächliche Leistung wider. Das System bleibt motivierend, da richtige Antworten weiterhin belohnt werden.
---
## Option 1: Progressive Abzüge ⚠️ (Intransparent)
### Konzept
Abzüge steigen mit jedem Versuch, aber das System ist schwer nachvollziehbar.
```
- Versuch 1-2: -2 Punkte pro falscher Antwort
- Versuch 3-4: -4 Punkte pro falscher Antwort
- Versuch 5-6: -6 Punkte pro falscher Antwort
- Versuch 7: -8 Punkte
```
### Beispiel
Bei 7 Versuchen: 90 + 20 - (2+2+4+4+6+6) = **86 Punkte**
### Probleme
- **Intransparent**: Spieler müssen sich merken, welche Abzüge in welcher Runde gelten
- **Schwer erklärbar**: Das Regelwerk ist komplex
- **Unklar im UI**: Aktuelle Abzüge sind nicht sofort ersichtlich
### Vorteile
- Progressive Bestrafung für viele Versuche
- Fairer als aktuelles System
---
## Option 2: Bonus-Malus-System
### Konzept
Höhere Belohnungen für frühe Erfolge + progressive Abzüge.
```
Start: 90 Punkte
Richtige Antwort (Bonus abhängig vom Versuch):
- Versuch 1: +30 Punkte (sehr gut!)
- Versuch 2: +25 Punkte (gut!)
- Versuch 3: +20 Punkte (okay)
- Versuch 4: +15 Punkte
- Versuch 5+: +10 Punkte
Falsche Antwort (progressive Abzüge):
- Versuch 1-2: -3 Punkte
- Versuch 3-4: -5 Punkte
- Versuch 5-6: -8 Punkte
- Versuch 7: -10 Punkte
```
### Beispiele
- Gelöst in Versuch 1: 90 + 30 = **120 Punkte**
- Gelöst in Versuch 4 (nach 3 Fehlern): 90 + 15 - (3+5+5) = **92 Punkte**
- Gelöst in Versuch 7 (nach 6 Fehlern): 90 + 10 - (3+5+5+8+8+10) = **61 Punkte**
### Vorteile
- **Transparent**: Klare Regeln pro Versuch
- **Motivierend**: Hohe Belohnungen für schnelles Lösen
- **Fair**: Späte Erfolge werden abgewertet
### Nachteile
- Etwas komplexer als aktuelles System
- Muss im UI klar kommuniziert werden
---
## Option 3: Effizienz-Multiplikator
### Konzept
Basis-System bleibt, aber Multiplikator basierend auf Versuchszahl.
```
Basis-System (wie aktuell, aber mit höheren Abzügen):
- Falsche Antwort: -5 Punkte (statt -3)
- Skip: -7 Punkte (statt -5)
Bonus-Multiplikatoren (basierend auf Versuch, in dem gelöst wurde):
- Gelöst in 1-2 Versuchen: ×1.2 (20% Bonus)
- Gelöst in 3-4 Versuchen: ×1.1 (10% Bonus)
- Gelöst in 5-6 Versuchen: ×1.0 (kein Bonus)
- Gelöst in 7 Versuchen: ×0.9 (10% Abzug)
```
### Beispiele
- Gelöst in Versuch 2 (1 Fehler): (90 + 20 - 5) × 1.2 = **126 Punkte**
- Gelöst in Versuch 4 (3 Fehler): (90 + 20 - 15) × 1.1 = **104.5 → 105 Punkte**
- Gelöst in Versuch 7 (6 Fehler): (90 + 20 - 30) × 0.9 = **72 Punkte**
### Vorteile
- Multiplikator ist einfach zu verstehen ("20% Bonus für schnelles Lösen")
- Basis-System bleibt ähnlich
- Gerechte Bestrafung für viele Versuche
### Nachteile
- Multiplikatoren müssen berechnet werden (könnte kompliziert wirken)
- Kombination aus Basis + Multiplikator kann verwirrend sein
---
## Option 4: Kombiniertes System
### Konzept
Höhere Abzüge + kleine Motivations-Boni.
```
Basis-System (höhere Abzüge):
- Falsche Antwort: -5 Punkte (statt -3)
- Skip: -7 Punkte (statt -5)
- Richtige Antwort: +20 Punkte (bleibt)
Motivations-Boni:
- "Erstversuch" Bonus: +2 Punkte wenn erster Versuch nicht skipped wurde
- "Perfekter Durchlauf": +5 Bonus wenn kein Skip verwendet wurde
- "Knapp daneben": +1 Punkt für Versuche, die fast richtig waren (optional, komplex)
```
### Beispiele
- Gelöst in Versuch 1: 90 + 20 + 2 + 5 = **117 Punkte**
- Gelöst in Versuch 4 (3 Fehler, kein Skip): 90 + 20 - 15 + 5 = **100 Punkte**
- Gelöst in Versuch 7 (6 Fehler, 2 Skips): 90 + 20 - 30 - 14 = **66 Punkte**
### Vorteile
- **Einfach verständlich**: Basis + kleine Boni
- **Motivierend**: Positive Verstärkung für gutes Verhalten
- **Fair**: Höhere Abzüge sorgen für differenzierten Score
### Nachteile
- Mehrere kleine Boni können unübersichtlich werden
- "Knapp daneben" ist schwer zu implementieren
---
## Option 5: Streak-System (Langfristige Motivation)
### Konzept
Zusätzliche Belohnungen für konsequentes Spielen über mehrere Tage.
```
Tägliche Streaks:
- 3 Tage in Folge gelöst: +5 Bonus-Punkte
- 7 Tage: +10 Bonus-Punkte
- 30 Tage: +15 Bonus-Punkte
```
**Kombiniert mit einem der anderen Systeme** (z.B. Option 2 oder 4).
### Vorteile
- Langfristige Spielermotivation
- Belohnt Engagement
### Nachteile
- Braucht Tracking über mehrere Tage
- Löst nicht das Hauptproblem (zu hoher Score bei vielen Versuchen)
---
## Option 6: Multiplikator-System (Vereinfacht)
### Konzept
Höhere Abzüge + einfache Multiplikatoren für Versuchszahl.
```
Höhere Basis-Abzüge:
- Falsche Antwort: -5 Punkte
- Skip: -7 Punkte
Multiplikator basierend auf Versuch, in dem gelöst wurde:
- Versuch 1: ×1.5 (50% Bonus) → Sehr schnelles Lösen
- Versuch 2: ×1.3 (30% Bonus)
- Versuch 3: ×1.1 (10% Bonus)
- Versuch 4: ×1.0 (kein Bonus/Aufschlag)
- Versuch 5+: ×0.9 (10% Abzug)
```
### Beispiele
- Gelöst in Versuch 1: (90 + 20) × 1.5 = **165 Punkte** ⭐⭐⭐
- Gelöst in Versuch 3 (2 Fehler): (90 + 20 - 10) × 1.1 = **110 Punkte**
- Gelöst in Versuch 7 (6 Fehler): (90 + 20 - 30) × 0.9 = **72 Punkte**
### Vorteile
- **Sehr transparent**: "50% Bonus für Erstversuch" ist einfach zu verstehen
- **Stark motivierend**: Hohe Belohnungen für schnelles Lösen
- **Fair**: Viele Versuche = niedriger Score
### Nachteile
- Multiplikatoren könnten als zu komplex empfunden werden
- Hohe Scores bei frühen Erfolgen (könnte als "zu leicht" empfunden werden)
---
## Empfehlungen
### Für Transparenz und Einfachheit: **Option 2 oder Option 4**
**Option 2 (Bonus-Malus)** ist am transparentesten:
- Klare Werte pro Versuch
- Einfach zu kommunizieren: "Erstversuch gibt +30, jeder weitere Versuch reduziert den Bonus"
- Fair und motivierend
**Option 4 (Kombiniert)** ist am einfachsten:
- Basis-System bleibt ähnlich (nur höhere Abzüge)
- Zusätzliche kleine Boni sind optional und motivierend
- Sehr einfach zu verstehen
### Für maximale Motivation: **Option 6**
- Hohe Belohnungen für schnelles Lösen
- Einfache Multiplikatoren ("50% Bonus")
- Sehr fair für viele Versuche
---
## Implementierungs-Hinweise
### UI-Kommunikation
Welche Option auch gewählt wird - sie muss im Spiel klar kommuniziert werden:
- Tooltips bei Versuchen
- Score-Breakdown zeigt Abzüge/Boni pro Versuch
- Vorschau: "Dieser Versuch würde X Punkte kosten/geben"
### Testing
Vor der Implementierung sollten verschiedene Szenarien durchgespielt werden:
- Erstversuch-Lösung
- Mittlere Versuche (3-4)
- Knappe Lösung (6-7 Versuche)
- Mit/ohne Skips
- Mit/ohne Replays
### Migration
- Bestehende Scores können nicht einfach migriert werden
- Neue Regeln gelten ab Start des neuen Systems
- Eventuell: "New Scoring System" Ankündigung
---
## ✅ Implementiert: Abzüge für zusätzliche Sekunden
**Status:****Aktuell implementiert**
Bei falschen Rateversuchen werden zusätzlich **-5 Punkte für die Track-Verlängerung** abgezogen:
- Falsche Antwort (Rateversuch): -3 Punkte (falsch) + -5 Punkte (Verlängerung) = **-8 Punkte total**
- Skip: -5 Punkte (kein zusätzlicher Abzug, da Skip keine Verlängerung bedeutet)
**Vorteile:**
- ✅ Reflektiert den "Hilfsmittel"-Charakter der zusätzlichen Sekunden
- ✅ Macht viele Versuche deutlich teurer
- ✅ Fairer Score bei vielen Versuchen
- ✅ Transparent: Klar getrennt als "Wrong guess" und "Track extension"
**Hinweis:** Dies ist die erste Anpassung des Scoring-Systems. Weitere Optionen (siehe oben) können in Zukunft ergänzt werden.
## Offene Fragen
1. Sollen Replays weiterhin -1 Punkt kosten?
2. Soll das Jahr-Bonus-System (+10) beibehalten werden?
3. Wie wichtig ist Backward-Compatibility mit bestehenden Scores?
4. Soll es eine "Preview"-Funktion geben ("Dieser Versuch kostet X Punkte")?
5. Sollen zusätzlich freigeschaltete Sekunden (Unlock-Steps) zusätzlich Punkte kosten?
---
## Status
📝 **Erstellt:** 2024-12-01
**Erste Änderung implementiert:** 2024-12-01 - Track-Verlängerung kostet jetzt -5 Punkte bei falschen Rateversuchen
🔄 **Status:** Teilweise umgesetzt
💡 **Nächste Schritte:** Weitere Optionen können bei Bedarf ergänzt werden (siehe Optionen oben)

235
docs/SEO_TESTING.md Normal file
View File

@@ -0,0 +1,235 @@
# SEO & Open Graph Testing Guide
## Übersicht
Diese Anleitung zeigt dir, wie du die SEO-Implementierung (Meta-Tags, Open Graph, Twitter Cards) testen kannst.
## Lokales Testen
### 1. Browser-Entwicklertools
1. **App starten:**
```bash
npm run dev
```
Die App läuft unter `http://localhost:3000`
2. **Meta-Tags im HTML prüfen:**
- Öffne eine Seite (z.B. `http://localhost:3000/en` oder `http://localhost:3000/de/about`)
- Rechtsklick → "Seite untersuchen" (F12)
- Tab "Elements" → `<head>` Bereich erweitern
- Suche nach Meta-Tags:
- `<meta property="og:title">`
- `<meta property="og:description">`
- `<meta property="og:image">`
- `<meta name="twitter:card">`
3. **View Page Source:**
- Rechtsklick → "Seitenquelltext anzeigen"
- Suche nach "og:" oder "twitter:" um alle Open Graph und Twitter Meta-Tags zu sehen
### 2. cURL-Test (für schnelle Prüfung)
```bash
# Prüfe Meta-Tags einer Seite
curl -s http://localhost:3000/en | grep -i "og:\|twitter:"
```
### 3. Node.js-Script zum Testen
Erstelle eine Test-Datei `test-og.js`:
```javascript
const https = require('https');
const http = require('http');
function fetchHTML(url) {
return new Promise((resolve, reject) => {
const client = url.startsWith('https') ? https : http;
client.get(url, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => resolve(data));
}).on('error', reject);
});
}
async function testOGTags(url) {
try {
const html = await fetchHTML(url);
const ogTags = {
title: html.match(/<meta property="og:title" content="([^"]*)"/)?.[1],
description: html.match(/<meta property="og:description" content="([^"]*)"/)?.[1],
image: html.match(/<meta property="og:image" content="([^"]*)"/)?.[1],
url: html.match(/<meta property="og:url" content="([^"]*)"/)?.[1],
};
console.log('Open Graph Tags:', ogTags);
return ogTags;
} catch (error) {
console.error('Error:', error.message);
}
}
// Test
testOGTags('http://localhost:3000/en');
```
## Online-Tools (für Produktions-URLs)
### 1. Facebook Sharing Debugger (Empfohlen)
**URL:** https://developers.facebook.com/tools/debug/
**Verwendung:**
1. Öffne die URL
2. Gib deine Produktions-URL ein (z.B. `https://hoerdle.de/en`)
3. Klicke auf "Debuggen"
4. Prüfe die Vorschau und alle Meta-Tags
**Wichtig:**
- Facebook cached die Vorschau! Klicke auf "Scraping erneut ausführen" um den Cache zu leeren
- Funktioniert nur mit öffentlich erreichbaren URLs (nicht localhost)
### 2. Twitter Card Validator
**URL:** https://cards-dev.twitter.com/validator
**Verwendung:**
1. Öffne die URL
2. Gib deine Produktions-URL ein
3. Prüfe die Twitter Card Vorschau
**Hinweis:** Twitter hat den Validator eingestellt, aber die Cards funktionieren trotzdem. Du kannst auch einfach einen Tweet mit deiner URL erstellen, um zu sehen, ob die Card angezeigt wird.
### 3. LinkedIn Post Inspector
**URL:** https://www.linkedin.com/post-inspector/
**Verwendung:**
1. Öffne die URL (Login erforderlich)
2. Gib deine Produktions-URL ein
3. Prüfe die LinkedIn Vorschau
### 4. OpenGraph.xyz (Universelles Tool)
**URL:** https://www.opengraph.xyz/
**Verwendung:**
1. Öffne die URL
2. Gib deine URL ein
3. Sieh dir alle Open Graph und Twitter Meta-Tags an
4. Sieh dir die Vorschau für verschiedene Plattformen an
### 5. Metatags.io
**URL:** https://metatags.io/
**Verwendung:**
- Gebe deine URL ein
- Sieh dir alle Meta-Tags an
- Vorschau für verschiedene Plattformen
## Produktions-Test (hoerdle.de / hördle.de)
Sobald die App deployed ist, kannst du alle oben genannten Tools mit deinen Produktions-URLs verwenden:
### Test-URLs:
- Homepage (EN): `https://hoerdle.de/en`
- Homepage (DE): `https://hoerdle.de/de`
- About (EN): `https://hoerdle.de/en/about`
- About (DE): `https://hoerdle.de/de/about`
- Genre-Seiten: `https://hoerdle.de/en/Rock` (Beispiel)
- Special-Seiten: `https://hoerdle.de/en/special/Weihnachtslieder` (Beispiel)
### Schnelltest mit cURL:
```bash
# Teste Homepage
curl -s https://hoerdle.de/en | grep -E "og:|twitter:" | head -10
# Teste About-Seite
curl -s https://hoerdle.de/de/about | grep -E "og:|twitter:" | head -10
```
## Erwartete Meta-Tags
Die folgenden Meta-Tags sollten auf allen Seiten vorhanden sein:
### Open Graph Tags:
- `og:title` - Seitentitel
- `og:description` - Seitenbeschreibung
- `og:image` - Bild für Social Media (Standard: `/favicon.ico`)
- `og:url` - Canonical URL
- `og:type` - Typ (sollte "website" sein)
- `og:site_name` - Name der Site
- `og:locale` - Sprache (de/en)
### Twitter Tags:
- `twitter:card` - Card-Typ (sollte "summary_large_image" sein)
- `twitter:title` - Titel
- `twitter:description` - Beschreibung
- `twitter:image` - Bild
### Canonical & Alternates:
- `<link rel="canonical">` - Canonical URL
- `<link rel="alternate" hreflang="de">` - Deutsche Version
- `<link rel="alternate" hreflang="en">` - Englische Version
- `<link rel="alternate" hreflang="x-default">` - Standard-Version
## Troubleshooting
### Problem: Meta-Tags werden nicht angezeigt
**Lösung:**
1. Prüfe, ob die App läuft: `npm run dev`
2. Prüfe Browser-Console auf Fehler
3. Stelle sicher, dass `generateMetadata` in der Seite exportiert ist
4. Prüfe, ob `lib/metadata.ts` korrekt importiert wird
### Problem: Open Graph Image wird nicht angezeigt
**Lösung:**
1. Prüfe, ob das Bild unter `/favicon.ico` existiert (oder konfiguriertes OG-Image)
2. Für bessere Ergebnisse: Erstelle ein dediziertes Open Graph Bild (1200x630px)
3. Platziere es in `public/og-image.png`
4. Setze in `.env`: `NEXT_PUBLIC_OG_IMAGE=/og-image.png`
### Problem: Facebook zeigt alte Vorschau
**Lösung:**
1. Öffne Facebook Sharing Debugger
2. Gib deine URL ein
3. Klicke auf "Scraping erneut ausführen" (mehrfach, falls nötig)
4. Facebook cached die Vorschau - Cache kann mehrere Stunden dauern
### Problem: Domain-Erkennung funktioniert nicht
**Lösung:**
1. Prüfe `lib/seo.ts` - `getBaseUrl()` Funktion
2. Stelle sicher, dass Request-Headers korrekt sind
3. In Produktion: Prüfe, ob Proxy-Headers (`x-forwarded-host`) korrekt gesetzt sind
## Open Graph Bild optimieren
Für bessere Social Media Vorschauen solltest du ein dediziertes OG-Bild erstellen:
**Empfohlene Größe:** 1200x630px
**Format:** PNG oder JPG
**Pfad:** `public/og-image.png`
**Konfiguration:**
```bash
# In .env
NEXT_PUBLIC_OG_IMAGE=/og-image.png
```
Dann wird dieses Bild in allen Open Graph Meta-Tags verwendet.
## Nützliche Links
- [Open Graph Protocol Dokumentation](https://ogp.me/)
- [Twitter Cards Dokumentation](https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/abouts-cards)
- [Facebook Sharing Best Practices](https://developers.facebook.com/docs/sharing/webmasters)

View File

@@ -12,5 +12,9 @@ export const config = {
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Vibe coded with ☕ and 🍺 by', text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Vibe coded with ☕ and 🍺 by',
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social', linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron', linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
},
seo: {
ogImage: process.env.NEXT_PUBLIC_OG_IMAGE || '/api/og-image',
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || undefined,
} }
}; };

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

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { getTodayISOString } from './dateUtils'; import { getTodayISOString } from './dateUtils';
import { loadPlayerState, savePlayerState, getGenreKey } from './playerStorage';
export interface GameState { export interface GameState {
date: string; date: string;
@@ -27,17 +28,19 @@ export interface Statistics {
failed: number; failed: number;
} }
const STORAGE_KEY_PREFIX = 'hoerdle_game_state';
const STATS_KEY_PREFIX = 'hoerdle_statistics';
const INITIAL_SCORE = 90; const INITIAL_SCORE = 90;
export function useGameState(genre: string | null = null, maxAttempts: number = 7) { export function useGameState(
genre: string | null = null,
maxAttempts: number = 7,
isSpecial: boolean = false
) {
const [gameState, setGameState] = useState<GameState | null>(null); const [gameState, setGameState] = useState<GameState | null>(null);
const [statistics, setStatistics] = useState<Statistics | null>(null); const [statistics, setStatistics] = useState<Statistics | null>(null);
const getStorageKey = () => genre ? `${STORAGE_KEY_PREFIX}_${genre}` : STORAGE_KEY_PREFIX; // Get genre key for backend storage
const getStatsKey = () => genre ? `${STATS_KEY_PREFIX}_${genre}` : STATS_KEY_PREFIX; // For specials, genre contains the special name
const genreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
const createNewState = (date: string): GameState => ({ const createNewState = (date: string): GameState => ({
date, date,
@@ -52,72 +55,94 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
yearGuessed: false yearGuessed: false
}); });
const createNewStatistics = (): Statistics => ({
solvedIn1: 0,
solvedIn2: 0,
solvedIn3: 0,
solvedIn4: 0,
solvedIn5: 0,
solvedIn6: 0,
solvedIn7: 0,
failed: 0,
});
useEffect(() => { useEffect(() => {
// Load game state
const storageKey = getStorageKey();
const stored = localStorage.getItem(storageKey);
const today = getTodayISOString(); const today = getTodayISOString();
if (stored) { // Always recompute genreKey to ensure it's current
const parsed = JSON.parse(stored); const currentGenreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
if (parsed.date === today) {
// Migration for existing states without score // Try to load from backend first
if (parsed.score === undefined) { const loadFromBackend = async () => {
parsed.score = INITIAL_SCORE; try {
parsed.replayCount = 0; const backendState = await loadPlayerState(currentGenreKey);
parsed.skipCount = 0;
parsed.scoreBreakdown = [{ value: INITIAL_SCORE, reason: 'Start value' }]; if (backendState) {
parsed.yearGuessed = false; const { gameState: loadedState, statistics: loadedStats } = backendState;
// Retroactively deduct points for existing guesses if possible, // Check if the loaded state is for today
// but simpler to just start at 90 for active games to avoid confusion if (loadedState.date === today) {
setGameState(loadedState);
setStatistics(loadedStats);
return; // Successfully loaded from backend
} else {
// State is for a different day - create new state
const newState = createNewState(today);
setGameState(newState);
setStatistics(loadedStats); // Keep statistics across days
// Save new state to backend
await savePlayerState(currentGenreKey, newState, loadedStats);
return;
}
} else {
// No backend state found - create new state
// This is the normal case for first-time players or new genre
const newState = createNewState(today);
setGameState(newState);
const newStats = createNewStatistics();
setStatistics(newStats);
// Save to backend for cross-domain sync
await savePlayerState(currentGenreKey, newState, newStats);
return;
} }
setGameState(parsed as GameState); } catch (error) {
} else { console.error('[gameState] Failed to load from backend:', error);
// New day
// On error, create new state and try to save to backend
// This handles network errors gracefully
const newState = createNewState(today); const newState = createNewState(today);
setGameState(newState); setGameState(newState);
localStorage.setItem(storageKey, JSON.stringify(newState)); const newStats = createNewStatistics();
setStatistics(newStats);
// Try to save to backend (may fail, but we try)
try {
await savePlayerState(currentGenreKey, newState, newStats);
} catch (saveError) {
console.error('[gameState] Failed to save new state to backend:', saveError);
}
} }
} else { };
// No state
const newState = createNewState(today); loadFromBackend();
setGameState(newState); }, [genre, isSpecial]); // Re-run when genre or isSpecial changes
localStorage.setItem(storageKey, JSON.stringify(newState));
}
// Load statistics const saveState = async (newState: GameState) => {
const statsKey = getStatsKey();
const storedStats = localStorage.getItem(statsKey);
if (storedStats) {
const parsedStats = JSON.parse(storedStats);
// Migration for existing stats without solvedIn7
if (parsedStats.solvedIn7 === undefined) {
parsedStats.solvedIn7 = 0;
}
setStatistics(parsedStats);
} else {
const newStats: Statistics = {
solvedIn1: 0,
solvedIn2: 0,
solvedIn3: 0,
solvedIn4: 0,
solvedIn5: 0,
solvedIn6: 0,
solvedIn7: 0,
failed: 0,
};
setStatistics(newStats);
localStorage.setItem(statsKey, JSON.stringify(newStats));
}
}, [genre]); // Re-run when genre changes
const saveState = (newState: GameState) => {
setGameState(newState); setGameState(newState);
localStorage.setItem(getStorageKey(), JSON.stringify(newState));
// Save to backend only
if (statistics) {
try {
// Always use the current genreKey (recompute it in case genre/isSpecial changed)
const currentGenreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
await savePlayerState(currentGenreKey, newState, statistics);
} catch (error) {
console.error('[gameState] Failed to save to backend:', error);
// No fallback - backend is required for cross-domain sync
}
}
}; };
const updateStatistics = (attempts: number, solved: boolean) => { const updateStatistics = async (attempts: number, solved: boolean) => {
if (!statistics) return; if (!statistics) return;
const newStats = { ...statistics }; const newStats = { ...statistics };
@@ -139,11 +164,24 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
} }
setStatistics(newStats); setStatistics(newStats);
localStorage.setItem(getStatsKey(), JSON.stringify(newStats));
// Save to backend only
if (gameState) {
try {
// Always use the current genreKey (recompute it in case genre/isSpecial changed)
const currentGenreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
await savePlayerState(currentGenreKey, gameState, newStats);
} catch (error) {
console.error('[gameState] Failed to save statistics to backend:', error);
// No fallback - backend is required for cross-domain sync
}
}
}; };
const addGuess = (guess: string, correct: boolean) => { const addGuess = (guess: string, correct: boolean) => {
if (!gameState) return; if (!gameState) return;
// Prevent adding guesses if already solved or failed
if (gameState.isSolved || gameState.isFailed) return;
const newGuesses = [...gameState.guesses, guess]; const newGuesses = [...gameState.guesses, guess];
const isSolved = correct; const isSolved = correct;
@@ -162,6 +200,9 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
} else { } else {
newScore -= 3; newScore -= 3;
newBreakdown.push({ value: -3, reason: 'Wrong guess' }); newBreakdown.push({ value: -3, reason: 'Wrong guess' });
// Additional penalty for track extension (unlock steps)
newScore -= 5;
newBreakdown.push({ value: -5, reason: 'Track extension' });
} }
} }

64
lib/metadata.ts Normal file
View File

@@ -0,0 +1,64 @@
import type { Metadata } from 'next';
import { config } from './config';
import { getBaseUrl } from './seo';
/**
* Generate base metadata with Open Graph, Twitter Cards, and canonical URLs
*/
export async function generateBaseMetadata(
locale: string,
path: string = '',
title?: string,
description?: string,
image?: string
): Promise<Metadata> {
const baseUrl = await getBaseUrl();
const pathSegment = path ? `/${path}` : '';
const fullUrl = `${baseUrl}/${locale}${pathSegment}`;
// Determine alternate URLs for both locales (same path for both)
const alternateLocale = locale === 'de' ? 'en' : 'de';
const alternateUrl = `${baseUrl}/${alternateLocale}${pathSegment}`;
// Default values
const metaTitle = title || config.appName;
const metaDescription = description || config.appDescription;
const ogImage = image || `${baseUrl}${config.seo.ogImage}`;
return {
title: metaTitle,
description: metaDescription,
alternates: {
canonical: fullUrl,
languages: {
[locale]: fullUrl,
[alternateLocale]: alternateUrl,
'x-default': `${baseUrl}/en${pathSegment}`,
},
},
openGraph: {
title: metaTitle,
description: metaDescription,
url: fullUrl,
siteName: config.appName,
images: [
{
url: ogImage,
width: 1200,
height: 630,
alt: metaTitle,
},
],
locale: locale,
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: metaTitle,
description: metaDescription,
images: [ogImage],
...(config.seo.twitterHandle && { creator: config.seo.twitterHandle }),
},
};
}

223
lib/playerId.ts Normal file
View File

@@ -0,0 +1,223 @@
/**
* Player Identifier Management
*
* Generates and manages a unique player identifier (UUID) that is stored
* in localStorage. This identifier is used to sync game states across
* different domains (hoerdle.de and hördle.de).
*
* Device-specific isolation:
* - Each device has its own device ID stored in localStorage
* - Player ID format: {basePlayerId}:{deviceId}
* - This allows cross-domain sync on the same device while keeping devices isolated
*/
const STORAGE_KEY_PLAYER = 'hoerdle_player_id';
const STORAGE_KEY_DEVICE = 'hoerdle_device_id';
/**
* Generate a UUID v4
*/
function generateUUID(): string {
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/**
* Get or create a device ID (unique per device)
*
* The device ID is stored in localStorage and persists across sessions.
* This allows device-specific isolation of game states.
*
* @returns Device identifier (UUID v4)
*/
export function getOrCreateDeviceId(): string {
if (typeof window === 'undefined') {
return '';
}
let deviceId = localStorage.getItem(STORAGE_KEY_DEVICE);
if (!deviceId) {
deviceId = generateUUID();
localStorage.setItem(STORAGE_KEY_DEVICE, deviceId);
}
return deviceId;
}
/**
* Get the device ID without creating a new one
*
* @returns Device identifier or null if not set
*/
export function getDeviceId(): string | null {
if (typeof window === 'undefined') {
return null;
}
return localStorage.getItem(STORAGE_KEY_DEVICE);
}
/**
* Generate a base player ID (for cross-domain sync)
*/
function generateBasePlayerId(): string {
return generateUUID();
}
/**
* Try to find an existing base player ID from the backend
*
* Extracts the base player ID from a full player ID (format: {basePlayerId}:{deviceId})
*
* @param genreKey - Genre key to search for
* @returns Base player ID if found, null otherwise
*/
async function findExistingBasePlayerId(genreKey: string): Promise<string | null> {
try {
const deviceId = getOrCreateDeviceId();
const response = await fetch('/api/player-id/suggest', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ genreKey, deviceId }),
});
if (response.ok) {
const data = await response.json();
if (data.basePlayerId) {
return data.basePlayerId;
}
}
} catch (error) {
console.warn('[playerId] Failed to find existing base player ID:', error);
}
return null;
}
/**
* Combine base player ID and device ID into full player ID
* Format: {basePlayerId}:{deviceId}
*/
function combinePlayerId(basePlayerId: string, deviceId: string): string {
return `${basePlayerId}:${deviceId}`;
}
/**
* Extract base player ID from full player ID
* Format: {basePlayerId}:{deviceId} -> {basePlayerId}
*/
function extractBasePlayerId(fullPlayerId: string): string {
const colonIndex = fullPlayerId.indexOf(':');
if (colonIndex === -1) {
// Legacy format (no device ID) - return as is
return fullPlayerId;
}
return fullPlayerId.substring(0, colonIndex);
}
/**
* Get or create a player identifier
*
* Player ID format: {basePlayerId}:{deviceId}
*
* If no identifier exists in localStorage, tries to find an existing base player ID
* from the backend (for cross-domain sync). If none found, generates a new base ID.
* The device ID is always device-specific.
*
* This enables:
* - Cross-domain synchronization on the same device (same base player ID)
* - Device isolation (different device IDs)
*
* @param genreKey - Optional genre key to search for existing base player ID
* @returns Full player identifier ({basePlayerId}:{deviceId})
*/
export async function getOrCreatePlayerIdAsync(genreKey?: string): Promise<string> {
if (typeof window === 'undefined') {
return '';
}
// Always get/create device ID (device-specific)
const deviceId = getOrCreateDeviceId();
// Try to get base player ID from localStorage
let basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
if (!basePlayerId) {
// Try to find an existing base player ID from backend if genreKey is provided
if (genreKey) {
const existingBaseId = await findExistingBasePlayerId(genreKey);
if (existingBaseId) {
basePlayerId = existingBaseId;
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
}
}
// Generate new base player ID if no existing one found
if (!basePlayerId) {
basePlayerId = generateBasePlayerId();
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
}
}
// Combine base player ID with device ID
return combinePlayerId(basePlayerId, deviceId);
}
/**
* Get or create a player identifier (synchronous version)
*
* This is the legacy synchronous version. For cross-domain sync, use getOrCreatePlayerIdAsync instead.
*
* @returns Full player identifier ({basePlayerId}:{deviceId})
*/
export function getOrCreatePlayerId(): string {
if (typeof window === 'undefined') {
return '';
}
const deviceId = getOrCreateDeviceId();
let basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
if (!basePlayerId) {
basePlayerId = generateBasePlayerId();
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
}
return combinePlayerId(basePlayerId, deviceId);
}
/**
* Get the current player identifier without creating a new one
*
* @returns Full player identifier ({basePlayerId}:{deviceId}) or null if not set
*/
export function getPlayerId(): string | null {
if (typeof window === 'undefined') {
return null;
}
const deviceId = getDeviceId();
const basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
if (!deviceId || !basePlayerId) {
return null;
}
return combinePlayerId(basePlayerId, deviceId);
}
/**
* Get base player ID (for debugging/logging)
*
* @returns Base player ID or null if not set
*/
export function getBasePlayerId(): string | null {
if (typeof window === 'undefined') {
return null;
}
return localStorage.getItem(STORAGE_KEY_PLAYER);
}

134
lib/playerStorage.ts Normal file
View File

@@ -0,0 +1,134 @@
/**
* Player Storage API
*
* Handles loading and saving player game states from/to the backend.
* This enables cross-domain synchronization between hoerdle.de and hördle.de.
*/
import { getOrCreatePlayerId } from './playerId';
import type { GameState, Statistics } from './gameState';
/**
* Get genre key for storage
*
* Formats the genre/special into a consistent key format:
* - Global: "global"
* - Genre: "Rock" (localized name)
* - Special: "special:00725" (special name)
*/
export function getGenreKey(
genre: string | null,
isSpecial: boolean,
specialName?: string
): string {
if (isSpecial && specialName) {
return `special:${specialName}`;
}
return genre || 'global';
}
/**
* Load player state from backend
*
* @param genreKey - Genre key (from getGenreKey)
* @returns GameState and Statistics, or null if not found
*/
export async function loadPlayerState(
genreKey: string
): Promise<{ gameState: GameState; statistics: Statistics } | null> {
try {
// Use async version to enable cross-domain player ID sync
const { getOrCreatePlayerIdAsync } = await import('./playerId');
const playerId = await getOrCreatePlayerIdAsync(genreKey);
if (!playerId) {
return null;
}
// Determine if it's a special or genre
const isSpecial = genreKey.startsWith('special:');
const params = new URLSearchParams();
if (isSpecial) {
const specialName = genreKey.replace('special:', '');
params.append('special', specialName);
} else if (genreKey !== 'global') {
params.append('genre', genreKey);
}
const response = await fetch(`/api/player-state?${params.toString()}`, {
method: 'GET',
headers: {
'X-Player-Id': playerId,
},
});
if (!response.ok) {
if (response.status === 404) {
// No state found - this is normal for new players
return null;
}
// Other errors: log and return null (will fallback to localStorage)
console.warn('[playerStorage] Failed to load player state:', response.status);
return null;
}
const data = await response.json();
if (!data || !data.gameState || !data.statistics) {
return null;
}
return {
gameState: data.gameState as GameState,
statistics: data.statistics as Statistics,
};
} catch (error) {
// Network errors or other issues: fallback to localStorage
console.warn('[playerStorage] Error loading player state:', error);
return null;
}
}
/**
* Save player state to backend
*
* @param genreKey - Genre key (from getGenreKey)
* @param gameState - Current game state
* @param statistics - Current statistics
*/
export async function savePlayerState(
genreKey: string,
gameState: GameState,
statistics: Statistics
): Promise<void> {
try {
// Use async version to ensure device ID is included
const { getOrCreatePlayerIdAsync } = await import('./playerId');
const playerId = await getOrCreatePlayerIdAsync();
if (!playerId) {
console.warn('[playerStorage] No player ID available, cannot save state');
return;
}
const response = await fetch('/api/player-state', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Player-Id': playerId,
},
body: JSON.stringify({
genreKey,
gameState,
statistics,
}),
});
if (!response.ok) {
console.warn('[playerStorage] Failed to save player state:', response.status);
// Don't throw - allow fallback to localStorage
}
} catch (error) {
// Network errors: log but don't throw (will fallback to localStorage)
console.warn('[playerStorage] Error saving player state:', error);
}
}

View File

@@ -0,0 +1,99 @@
import { promises as fs } from 'fs';
import path from 'path';
export type PoliticalStatement = {
id: number;
text: string;
active?: boolean;
source?: string;
};
function getFilePath(locale: string): string {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
return path.join(process.cwd(), 'data', `political-statements.${safeLocale}.json`);
}
async function readStatementsFile(locale: string): Promise<PoliticalStatement[]> {
const filePath = getFilePath(locale);
try {
const raw = await fs.readFile(filePath, 'utf-8');
const data = JSON.parse(raw);
if (Array.isArray(data)) {
return data;
}
return [];
} catch (err: any) {
if (err.code === 'ENOENT') {
// File does not exist yet
return [];
}
console.error('[politicalStatements] Failed to read file', filePath, err);
return [];
}
}
async function writeStatementsFile(locale: string, statements: PoliticalStatement[]): Promise<void> {
const filePath = getFilePath(locale);
const dir = path.dirname(filePath);
try {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, JSON.stringify(statements, null, 2), 'utf-8');
} catch (err) {
console.error('[politicalStatements] Failed to write file', filePath, err);
throw err;
}
}
export async function getRandomActiveStatement(locale: string): Promise<PoliticalStatement | null> {
const statements = await readStatementsFile(locale);
const active = statements.filter((s) => s.active !== false);
if (active.length === 0) {
return null;
}
const index = Math.floor(Math.random() * active.length);
return active[index] ?? null;
}
export async function getAllStatements(locale: string): Promise<PoliticalStatement[]> {
return readStatementsFile(locale);
}
export async function createStatement(locale: string, input: Omit<PoliticalStatement, 'id'>): Promise<PoliticalStatement> {
const statements = await readStatementsFile(locale);
const nextId = statements.length > 0 ? Math.max(...statements.map((s) => s.id)) + 1 : 1;
const newStatement: PoliticalStatement = {
id: nextId,
active: true,
...input,
};
statements.push(newStatement);
await writeStatementsFile(locale, statements);
return newStatement;
}
export async function updateStatement(locale: string, id: number, input: Partial<Omit<PoliticalStatement, 'id'>>): Promise<PoliticalStatement | null> {
const statements = await readStatementsFile(locale);
const index = statements.findIndex((s) => s.id === id);
if (index === -1) return null;
const updated: PoliticalStatement = {
...statements[index],
...input,
id,
};
statements[index] = updated;
await writeStatementsFile(locale, statements);
return updated;
}
export async function deleteStatement(locale: string, id: number): Promise<boolean> {
const statements = await readStatementsFile(locale);
const filtered = statements.filter((s) => s.id !== id);
if (filtered.length === statements.length) {
return false;
}
await writeStatementsFile(locale, filtered);
return true;
}

43
lib/seo.ts Normal file
View File

@@ -0,0 +1,43 @@
import { headers } from 'next/headers';
import { config } from './config';
/**
* Get the current base URL from request headers
* Automatically detects hoerdle.de or hördle.de (xn--hrdle-jua.de)
*/
export async function getBaseUrl(): Promise<string> {
const headersList = await headers();
const host = headersList.get('host') || headersList.get('x-forwarded-host') || '';
let domain = config.domain; // Default fallback
if (host) {
// Extract domain from host (remove port if present)
const detectedDomain = host.split(':')[0].toLowerCase();
// Map domains
if (detectedDomain === 'hoerdle.de') {
domain = 'hoerdle.de';
} else if (detectedDomain === 'hördle.de' || detectedDomain === 'xn--hrdle-jua.de') {
domain = 'hördle.de';
} else {
// Use detected domain if it's different from default
domain = detectedDomain;
}
}
// Always use HTTPS in production
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
return `${protocol}://${domain}`;
}
/**
* Get base URL synchronously (for use in non-async contexts)
* Uses environment variable or config as fallback
*/
export function getBaseUrlSync(): string {
const domain = process.env.NEXT_PUBLIC_DOMAIN || config.domain;
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
return `${protocol}://${domain}`;
}

View File

@@ -47,6 +47,7 @@
"yourBrowserDoesNotSupport": "Ihr Browser unterstützt das Audio-Element nicht.", "yourBrowserDoesNotSupport": "Ihr Browser unterstützt das Audio-Element nicht.",
"thanksForRating": "Danke für die Bewertung!", "thanksForRating": "Danke für die Bewertung!",
"rateThisPuzzle": "Bewerte dieses Rätsel:", "rateThisPuzzle": "Bewerte dieses Rätsel:",
"ratingTooltip": "Hilf unseren Kuratoren, gute Rätsel zu machen!",
"shared": "✓ Geteilt!", "shared": "✓ Geteilt!",
"copied": "✓ Kopiert!", "copied": "✓ Kopiert!",
"shareFailed": "✗ Fehlgeschlagen", "shareFailed": "✗ Fehlgeschlagen",
@@ -63,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",
@@ -162,7 +169,6 @@
"imprintOperator": "Verantwortlich für den Inhalt dieser Seite (Anbieter nach § 5 TMG):", "imprintOperator": "Verantwortlich für den Inhalt dieser Seite (Anbieter nach § 5 TMG):",
"imprintCountry": "Deutschland", "imprintCountry": "Deutschland",
"imprintEmailLabel": "E-Mail:", "imprintEmailLabel": "E-Mail:",
"imprintDisclaimer": "Hinweis: Diese Angaben entsprechen dem aktuellen Stand. Für rechtliche Fragen solltest du eine Fachperson konsultieren.",
"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:",
"costsDomain": "Domains (z. B. hördle.de / hoerdle.de)", "costsDomain": "Domains (z. B. hördle.de / hoerdle.de)",
@@ -180,6 +186,10 @@
"supportPaypalLink": "paypal.me/MBusche", "supportPaypalLink": "paypal.me/MBusche",
"supportSteadyTitle": "Steady", "supportSteadyTitle": "Steady",
"supportSteadyDescription": "Regelmäßige Unterstützung über Steady", "supportSteadyDescription": "Regelmäßige Unterstützung über Steady",
"supportCuratorTitle": "Als Kurator bewerben",
"supportCuratorText": "Du hast gute Kenntnisse in einem Genre und möchtest dich als Kurator bewerben? Wir freuen uns über deine Nachricht!",
"supportReportBugTitle": "Fehler melden",
"supportReportBugText": "Fehler in der App gefunden? Bitte melde sie per E-Mail an <email>admin@hoerdle.de</email>.",
"privacyTitle": "Datenschutz", "privacyTitle": "Datenschutz",
"privacyIntro": "Der Schutz deiner Privatsphäre ist wichtig. Dieses Projekt versucht, so datensparsam wie möglich zu arbeiten.", "privacyIntro": "Der Schutz deiner Privatsphäre ist wichtig. Dieses Projekt versucht, so datensparsam wie möglich zu arbeiten.",
"privacyPlausibleTitle": "Selbst gehostetes Plausible Analytics", "privacyPlausibleTitle": "Selbst gehostetes Plausible Analytics",
@@ -190,7 +200,6 @@
"privacyPlausibleAggregated": "Auswertungen erfolgen ausschließlich in aggregierter Form (z. B. Seitenaufrufe, genutzte Browser).", "privacyPlausibleAggregated": "Auswertungen erfolgen ausschließlich in aggregierter Form (z. B. Seitenaufrufe, genutzte Browser).",
"privacyServerLogs": "Der Server kann technisch bedingt Logdateien mit IP-Adresse, Zeitpunkt des Zugriffs und abgerufenen Ressourcen führen. Diese Daten werden nur zur Sicherstellung des Betriebs und zur Fehleranalyse verwendet und regelmäßig gelöscht.", "privacyServerLogs": "Der Server kann technisch bedingt Logdateien mit IP-Adresse, Zeitpunkt des Zugriffs und abgerufenen Ressourcen führen. Diese Daten werden nur zur Sicherstellung des Betriebs und zur Fehleranalyse verwendet und regelmäßig gelöscht.",
"privacyContact": "Wenn du Fragen zu den verarbeiteten Daten hast oder Auskunft wünschst, kannst du dich über die im Impressum genannte E-Mail-Adresse melden.", "privacyContact": "Wenn du Fragen zu den verarbeiteten Daten hast oder Auskunft wünschst, kannst du dich über die im Impressum genannte E-Mail-Adresse melden.",
"privacyNoLegalAdvice": "Hinweis: Diese Datenschutzhinweise dienen nur als Beispiel und ersetzen keine rechtliche Beratung. Für eine rechtskonforme Datenschutzerklärung solltest du eine Fachperson konsultieren.",
"backTitle": "Zurück zum Spiel", "backTitle": "Zurück zum Spiel",
"backToGame": "Zurück zu Hördle", "backToGame": "Zurück zu Hördle",
"footerLinkLabel": "Über & Impressum" "footerLinkLabel": "Über & Impressum"

View File

@@ -47,6 +47,7 @@
"yourBrowserDoesNotSupport": "Your browser does not support the audio element.", "yourBrowserDoesNotSupport": "Your browser does not support the audio element.",
"thanksForRating": "Thanks for rating!", "thanksForRating": "Thanks for rating!",
"rateThisPuzzle": "Rate this puzzle:", "rateThisPuzzle": "Rate this puzzle:",
"ratingTooltip": "Help our curators create good puzzles!",
"shared": "✓ Shared!", "shared": "✓ Shared!",
"copied": "✓ Copied!", "copied": "✓ Copied!",
"shareFailed": "✗ Failed", "shareFailed": "✗ Failed",
@@ -63,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",
@@ -162,7 +169,6 @@
"imprintOperator": "Responsible for the content of this site (provider under German law):", "imprintOperator": "Responsible for the content of this site (provider under German law):",
"imprintCountry": "Germany", "imprintCountry": "Germany",
"imprintEmailLabel": "Email:", "imprintEmailLabel": "Email:",
"imprintDisclaimer": "Note: This information is current as of the date indicated. For legal questions you should consult a legal professional.",
"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:",
"costsDomain": "Domains (e.g. hördle.de / hoerdle.de)", "costsDomain": "Domains (e.g. hördle.de / hoerdle.de)",
@@ -180,6 +186,10 @@
"supportPaypalLink": "paypal.me/MBusche", "supportPaypalLink": "paypal.me/MBusche",
"supportSteadyTitle": "Steady", "supportSteadyTitle": "Steady",
"supportSteadyDescription": "Regular support via Steady", "supportSteadyDescription": "Regular support via Steady",
"supportCuratorTitle": "Apply as Curator",
"supportCuratorText": "Do you have good knowledge in a genre and would like to apply as a curator? We'd be happy to hear from you!",
"supportReportBugTitle": "Report Bugs",
"supportReportBugText": "Found a bug in the app? Please report it via email to <email>admin@hoerdle.de</email>.",
"privacyTitle": "Privacy", "privacyTitle": "Privacy",
"privacyIntro": "Protecting your privacy matters. This project aims to collect as little data as possible.", "privacyIntro": "Protecting your privacy matters. This project aims to collect as little data as possible.",
"privacyPlausibleTitle": "Self-hosted Plausible Analytics", "privacyPlausibleTitle": "Self-hosted Plausible Analytics",
@@ -190,7 +200,6 @@
"privacyPlausibleAggregated": "Analytics are only performed in aggregated form (e.g. page views, browsers used).", "privacyPlausibleAggregated": "Analytics are only performed in aggregated form (e.g. page views, browsers used).",
"privacyServerLogs": "For technical reasons, the server may log IP address, time of access and accessed resources. This data is only used to keep the service running and to debug issues and is deleted on a regular basis.", "privacyServerLogs": "For technical reasons, the server may log IP address, time of access and accessed resources. This data is only used to keep the service running and to debug issues and is deleted on a regular basis.",
"privacyContact": "If you have questions about the data processed or want to request information, please contact the email address given in the imprint.", "privacyContact": "If you have questions about the data processed or want to request information, please contact the email address given in the imprint.",
"privacyNoLegalAdvice": "Note: These privacy notes are only an example and do not replace legal advice. For a legally compliant privacy policy you should consult a professional.",
"backTitle": "Back to the game", "backTitle": "Back to the game",
"backToGame": "Back to Hördle", "backToGame": "Back to Hördle",
"footerLinkLabel": "About & Imprint" "footerLinkLabel": "About & Imprint"

View File

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

View File

@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "PlayerState" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"identifier" TEXT NOT NULL,
"genreKey" TEXT NOT NULL,
"gameState" TEXT NOT NULL,
"statistics" TEXT NOT NULL,
"lastPlayed" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE INDEX "PlayerState_identifier_idx" ON "PlayerState"("identifier");
-- CreateIndex
CREATE UNIQUE INDEX "PlayerState_identifier_genreKey_key" ON "PlayerState"("identifier", "genreKey");

View File

@@ -88,3 +88,16 @@ model News {
@@index([publishedAt]) @@index([publishedAt])
} }
model PlayerState {
id Int @id @default(autoincrement())
identifier String // UUID des Spielers (für Cross-Domain-Sync)
genreKey String // Genre-Name oder "global" oder "special:<name>"
gameState String // JSON-String des GameState
statistics String // JSON-String der Statistics
lastPlayed DateTime @updatedAt
createdAt DateTime @default(now())
@@unique([identifier, genreKey])
@@index([identifier])
}