diff --git a/README.md b/README.md index ba26998..f5ee8a0 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,12 @@ Player data provenance and clue rules are documented in `docs/data-sources.md`. - **Localized home pages**: `/` (en) plus `/es`, `/fr`, `/de`, `/it`, `/pt` — server-rendered intro, how-to-play and FAQ (FAQPage JSON-LD) with hreflang alternates on every home. -- **Archive**: `/archive` lists past puzzles; `/puzzle/` publishes answers for past days only - (today and future 404); `/play/` replays a past puzzle without touching stats or streak. +- **Archive, in six languages**: `/archive` and `//archive` list past puzzles; + `/puzzle/` and `//puzzle/` publish answers for past days only (today and future + 404). Answer pages carry a sentence of real context per player — club, league, nationality, + former clubs and honours — plus Article and BreadcrumbList JSON-LD, and are prerendered + (`generateStaticParams` + hourly ISR) rather than rendered per request. + `/play/` replays a past puzzle without touching stats or streak. - **Challenge loop**: shared result links (`/r/`) offer "Accept the challenge" → `/?challenge=`. If the challenge matches today's puzzle, the game shows the rival's score and a head-to-head verdict on the result screen. @@ -17,10 +21,23 @@ Player data provenance and clue rules are documented in `docs/data-sources.md`. `lib/share.ts`. - **PWA**: `app/manifest.ts` + icons in `public/` (regenerate with `node scripts/generate-icons.mjs`). +- **Named challenges**: the share sheet takes an optional display name that travels inside the + share code, so `/r/` reads " challenges you" in the page, the OG image and the + head-to-head verdict. The name is sanitised on the way out *and* on the way in. +- **Daily reminder**: an opt-in Web Push subscription plus a PWA install prompt, offered once the + day is finished. The push is sent without a payload (VAPID header only, no content encryption); + the notification copy lives in the service worker's Cache Storage (`public/sw.js`). +- **First-party funnel**: every game event is mirrored to `POST /api/events` alongside GA4, so the + funnel survives ad blockers. Schema and the funnel/retention/share-channel views are in + `deploy/sql/hidden11-events.sql`. - **Daily teaser**: `GET /api/teaser` returns a spoiler-free localized teaser for today's puzzle (first Easy clue). `deploy/n8n-daily-teaser.json` is an importable n8n workflow that posts it daily through the Postiz public API (set `POSTIZ_API_KEY` in n8n and fill in the integration ids inside the Code node). +- **IndexNow**: `node scripts/submit-indexnow.mjs [--today]` submits the sitemap URLs to Bing, + Yandex and Seznam. The key file lives in `public/.txt`; the key is in + `pass show hidden11/indexnow-key`. Google has no ping endpoint any more — its sitemap is + submitted once from Search Console. ## Local development @@ -40,6 +57,26 @@ Optional feedback env vars: - `N8N_FEEDBACK_WEBHOOK_URL`: n8n webhook URL used to receive feedback submissions +Optional funnel-analytics env vars: + +- `N8N_EVENTS_WEBHOOK_URL`: n8n webhook backing `POST /api/events` (workflow: + `deploy/n8n-events.json`, schema: `deploy/sql/hidden11-events.sql`). When unset the endpoint + accepts and discards, so the game is unaffected. + +Optional daily-reminder env vars: + +- `VAPID_PUBLIC_KEY`: application server key served by `GET /api/push` + (`pass show hidden11/vapid-public-key`) +- `N8N_PUSH_WEBHOOK_URL`: n8n webhook that stores push subscriptions (workflow: + `deploy/n8n-push-subscriptions.json`). Sending is `deploy/n8n-daily-push.json`; the VAPID + private key never leaves n8n (`pass show hidden11/vapid-private-key`). + +Optional search-verification env vars: + +- `GOOGLE_SITE_VERIFICATION`: token for the `google-site-verification` meta tag. hidden11.app is + already verified by DNS TXT, so this is only needed for a second property. +- `BING_SITE_VERIFICATION`: token for the `msvalidate.01` meta tag + Optional social-proof counter env vars: - `N8N_PLAYS_WEBHOOK_URL`: n8n webhook URL backing the daily play counter shown on @@ -110,6 +147,24 @@ To enable feedback submission, create a secret named `hidden11-feedback` in the - `N8N_FEEDBACK_WEBHOOK_URL` +For the social-proof play counter, create `hidden11-plays` with: + +- `N8N_PLAYS_WEBHOOK_URL` + +For the funnel pipeline, create `hidden11-events` with: + +- `N8N_EVENTS_WEBHOOK_URL` + +For the daily reminder, create `hidden11-push` with: + +- `VAPID_PUBLIC_KEY` +- `N8N_PUSH_WEBHOOK_URL` + +For search-engine verification meta tags, create `hidden11-seo` with: + +- `GOOGLE_SITE_VERIFICATION` +- `BING_SITE_VERIFICATION` + To configure the sponsor slot in Kubernetes, create a secret named `hidden11-sponsor` in the `hidden11` namespace with any of: - `NEXT_PUBLIC_SPONSOR_ENABLED` diff --git a/app/(en)/archive/page.tsx b/app/(en)/archive/page.tsx index 5669554..19e2a35 100644 --- a/app/(en)/archive/page.tsx +++ b/app/(en)/archive/page.tsx @@ -1,76 +1,23 @@ import type { Metadata } from "next"; -import Link from "next/link"; -import { getArchiveDateKeys, getPuzzleNumber, getTodayKey } from "@/lib/game-engine"; +import ArchiveList from "@/components/ArchiveList"; +import { ARCHIVE_COPY } from "@/lib/archive-content"; +import { BASE_URL, archiveHreflang, archivePath } from "@/lib/seo-content"; -export const dynamic = "force-dynamic"; +// Static with a daily-ish refresh: the list only changes when a new day rolls +// over, so there is no reason to rebuild it per request. +export const revalidate = 3600; + +const copy = ARCHIVE_COPY.en; export const metadata: Metadata = { - title: "Puzzle archive", - description: - "Every past hidden11 puzzle: replay any daily football lineup or check the answers for Easy, Medium and Hard.", + title: { absolute: copy.archiveMetaTitle }, + description: copy.archiveMetaDescription, alternates: { - canonical: "https://hidden11.app/archive", + canonical: `${BASE_URL}${archivePath("en")}`, + languages: archiveHreflang(), }, }; export default function ArchivePage() { - const todayKey = getTodayKey(); - const dateKeys = getArchiveDateKeys(todayKey); - - return ( -
-
-
-
-

hidden11

-

Puzzle archive

-

- Replay any past puzzle or check its answers. Archive games don't affect your daily streak. -

-
- - Play today - -
- -
- {dateKeys.map((dateKey) => { - const number = getPuzzleNumber(dateKey); - return ( -
-
-

#{number}

-

{dateKey}

-
-
- - Play - - - Answers - -
-
- ); - })} -
- - {dateKeys.length === 0 ? ( -

No past puzzles yet. Come back tomorrow!

- ) : null} -
-
- ); + return ; } diff --git a/app/(en)/layout.tsx b/app/(en)/layout.tsx index 93a72e6..560a312 100644 --- a/app/(en)/layout.tsx +++ b/app/(en)/layout.tsx @@ -3,39 +3,49 @@ import { Suspense, type ReactNode } from "react"; import Analytics from "@/components/Analytics"; import "../globals.css"; -export const metadata: Metadata = { - title: { - default: "hidden11", - template: "%s | hidden11", - }, - description: "Daily football lineup puzzle with sharper clues, harder challenges and shareable results.", - metadataBase: new URL("https://hidden11.app"), - applicationName: "hidden11", - manifest: "/manifest.webmanifest", - keywords: ["football puzzle", "soccer puzzle", "daily game", "lineup puzzle", "football wordle", "hidden11"], - icons: { - icon: "/favicon.svg", - apple: "/icon-192.png", - }, - appleWebApp: { - capable: true, - title: "hidden11", - statusBarStyle: "black-translucent", - }, - openGraph: { - title: "hidden11", - description: "Beat Easy, Medium and Hard in the daily football lineup puzzle and share the result.", - url: "https://hidden11.app", - siteName: "hidden11", - locale: "en_US", - type: "website", - }, - twitter: { - card: "summary_large_image", - title: "hidden11", - description: "Beat Easy, Medium and Hard in the daily football lineup puzzle and share the result.", - }, -}; +// generateMetadata (not a static object) so the verification tokens are read +// from the container env at request time, the same way the analytics IDs are. +export function generateMetadata(): Metadata { + return { + title: { + default: "hidden11", + template: "%s | hidden11", + }, + description: "Daily football lineup puzzle with sharper clues, harder challenges and shareable results.", + metadataBase: new URL("https://hidden11.app"), + applicationName: "hidden11", + manifest: "/manifest.webmanifest", + keywords: ["football puzzle", "soccer puzzle", "daily game", "lineup puzzle", "football wordle", "hidden11"], + icons: { + icon: "/favicon.svg", + apple: "/icon-192.png", + }, + appleWebApp: { + capable: true, + title: "hidden11", + statusBarStyle: "black-translucent", + }, + openGraph: { + title: "hidden11", + description: "Beat Easy, Medium and Hard in the daily football lineup puzzle and share the result.", + url: "https://hidden11.app", + siteName: "hidden11", + locale: "en_US", + type: "website", + }, + twitter: { + card: "summary_large_image", + title: "hidden11", + description: "Beat Easy, Medium and Hard in the daily football lineup puzzle and share the result.", + }, + verification: { + google: process.env.GOOGLE_SITE_VERIFICATION, + other: process.env.BING_SITE_VERIFICATION + ? { "msvalidate.01": process.env.BING_SITE_VERIFICATION } + : {}, + }, + }; +} export const viewport: Viewport = { themeColor: "#04110c", diff --git a/app/(en)/play/[number]/page.tsx b/app/(en)/play/[number]/page.tsx index 0a65099..fd72625 100644 --- a/app/(en)/play/[number]/page.tsx +++ b/app/(en)/play/[number]/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import HomeClient from "@/components/HomeClient"; -import { DIFFICULTIES, getDateKeyForPuzzleNumber, getTodayKey, puzzles } from "@/lib/game-engine"; +import { getPastPuzzleSet } from "@/lib/game-engine"; import { getSponsorConfig } from "@/lib/sponsor"; export const dynamic = "force-dynamic"; @@ -10,29 +10,19 @@ type PlayPageProps = { params: Promise<{ number: string }>; }; -function getPastDateKey(rawNumber: string): string | null { - const number = Number.parseInt(rawNumber, 10); - if (!Number.isFinite(number) || number < 1 || String(number) !== rawNumber) return null; - - const dateKey = getDateKeyForPuzzleNumber(number); - if (dateKey >= getTodayKey()) return null; - if (puzzles.filter((puzzle) => puzzle.date === dateKey).length !== DIFFICULTIES.length) return null; - - return dateKey; -} - export async function generateMetadata({ params }: PlayPageProps): Promise { const { number } = await params; return { title: `Play puzzle #${number}`, + // Replays duplicate the playable home page; only the answer pages are indexed. robots: { index: false, follow: true }, }; } export default async function PlayArchivePage({ params }: PlayPageProps) { const { number } = await params; - const dateKey = getPastDateKey(number); - if (!dateKey) notFound(); + const data = getPastPuzzleSet(number); + if (!data) notFound(); - return ; + return ; } diff --git a/app/(en)/puzzle/[number]/page.tsx b/app/(en)/puzzle/[number]/page.tsx index 5ede18a..cbeb1f6 100644 --- a/app/(en)/puzzle/[number]/page.tsx +++ b/app/(en)/puzzle/[number]/page.tsx @@ -1,127 +1,46 @@ import type { Metadata } from "next"; -import Link from "next/link"; import { notFound } from "next/navigation"; -import { - DIFFICULTIES, - POSITIONS, - findPlayer, - getDateKeyForPuzzleNumber, - getTodayKey, - puzzles, -} from "@/lib/game-engine"; -import type { Puzzle } from "@/lib/types"; +import PuzzleAnswers from "@/components/PuzzleAnswers"; +import { ARCHIVE_COPY } from "@/lib/archive-content"; +import { getArchiveDateKeys, getPastPuzzleSet, getPuzzleNumber, getTodayKey } from "@/lib/game-engine"; +import { BASE_URL, puzzleHreflang, puzzlePath } from "@/lib/seo-content"; -export const dynamic = "force-dynamic"; +export const revalidate = 3600; type PuzzlePageProps = { params: Promise<{ number: string }>; }; -// Answer pages exist only for past days with a dedicated puzzle set — today's -// and future solutions are never rendered, whatever the URL says. -function getPastPuzzles(rawNumber: string): { number: number; dateKey: string; sets: Puzzle[] } | null { - const number = Number.parseInt(rawNumber, 10); - if (!Number.isFinite(number) || number < 1 || String(number) !== rawNumber) return null; - - const dateKey = getDateKeyForPuzzleNumber(number); - if (dateKey >= getTodayKey()) return null; - - const sets = puzzles.filter((puzzle) => puzzle.date === dateKey); - if (sets.length !== DIFFICULTIES.length) return null; - - return { number, dateKey, sets }; +// Past answers never change, so every one of them is prerendered. Days that roll +// over after the build are still served: dynamicParams stays on by default and +// they get rendered (and cached) on first request. +export function generateStaticParams() { + return getArchiveDateKeys(getTodayKey()).map((dateKey) => ({ + number: String(getPuzzleNumber(dateKey)), + })); } export async function generateMetadata({ params }: PuzzlePageProps): Promise { const { number: rawNumber } = await params; - const data = getPastPuzzles(rawNumber); + const data = getPastPuzzleSet(rawNumber); if (!data) return {}; + const copy = ARCHIVE_COPY.en; + return { - title: `hidden11 #${data.number} answers (${data.dateKey})`, - description: `Answers and clues for hidden11 puzzle #${data.number} from ${data.dateKey}: the Easy, Medium and Hard hidden lineups, fully revealed.`, + title: { absolute: copy.answersMetaTitle(data.number, data.dateKey) }, + description: copy.answersMetaDescription(data.number, data.dateKey), alternates: { - canonical: `https://hidden11.app/puzzle/${data.number}`, + canonical: `${BASE_URL}${puzzlePath("en", data.number)}`, + languages: puzzleHreflang(data.number), }, }; } export default async function PuzzleAnswersPage({ params }: PuzzlePageProps) { const { number: rawNumber } = await params; - const data = getPastPuzzles(rawNumber); + const data = getPastPuzzleSet(rawNumber); if (!data) notFound(); - const { number, dateKey, sets } = data; - - return ( -
-
-
-
-

Past puzzle

-

hidden11 #{number} answers

-

{dateKey}

-
- - Play this puzzle - -
- -
- {DIFFICULTIES.map((difficulty) => { - const puzzle = sets.find((item) => item.difficulty === difficulty); - if (!puzzle) return null; - const label = difficulty.charAt(0).toUpperCase() + difficulty.slice(1); - - return ( -
-

{label}

- -

Clues

-
    - {puzzle.clues.map((clue) => ( -
  • - · {clue} -
  • - ))} -
- -

Hidden lineup

-
    - {POSITIONS.map((position) => { - const player = findPlayer(puzzle.solution[position]); - return ( -
  • - {position} - {player?.name ?? puzzle.solution[position]} - {player ? {player.club} : null} -
  • - ); - })} -
-
- ); - })} -
- -
- - Play today's puzzle - - - Full archive - -
-
-
- ); + return ; } diff --git a/app/(en)/result/[shareCode]/opengraph-image.tsx b/app/(en)/result/[shareCode]/opengraph-image.tsx index 78ca906..7594645 100644 --- a/app/(en)/result/[shareCode]/opengraph-image.tsx +++ b/app/(en)/result/[shareCode]/opengraph-image.tsx @@ -48,7 +48,7 @@ export default async function OpenGraphImage({ params }: ResultImageProps) {
- Shared Result + {payload?.name ? `${payload.name} challenges you` : "Shared Result"}
hidden11 {payload ? `#${payload.puzzleNumber}` : ""} diff --git a/app/(en)/result/[shareCode]/page.tsx b/app/(en)/result/[shareCode]/page.tsx index 2b8dd10..4ab2ec6 100644 --- a/app/(en)/result/[shareCode]/page.tsx +++ b/app/(en)/result/[shareCode]/page.tsx @@ -22,9 +22,14 @@ export async function generateMetadata({ params }: ResultPageProps): Promise
-

Shared result

+

+ {payload.name ? `${payload.name}'s result` : "Shared result"} +

hidden11 #{payload.puzzleNumber}

{payload.dateKey}

@@ -110,7 +117,9 @@ export default async function ResultPage({ params }: ResultPageProps) {
-

🔥 Can you beat this?

+

+ {payload.name ? `🔥 ${payload.name} challenges you` : "🔥 Can you beat this?"} +

Play the same puzzle and see the head-to-head result when you finish.

diff --git a/app/(intl)/[locale]/archive/page.tsx b/app/(intl)/[locale]/archive/page.tsx new file mode 100644 index 0000000..783cfe8 --- /dev/null +++ b/app/(intl)/[locale]/archive/page.tsx @@ -0,0 +1,41 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import ArchiveList from "@/components/ArchiveList"; +import { ARCHIVE_COPY } from "@/lib/archive-content"; +import { BASE_URL, INTL_LOCALES, archiveHreflang, archivePath } from "@/lib/seo-content"; +import { resolveIntlLocale } from "@/lib/intl-route"; + +export const revalidate = 3600; + +export function generateStaticParams() { + return INTL_LOCALES.map((locale) => ({ locale })); +} + +type LocaleArchiveProps = { + params: Promise<{ locale: string }>; +}; + +export async function generateMetadata({ params }: LocaleArchiveProps): Promise { + const { locale: rawLocale } = await params; + const locale = resolveIntlLocale(rawLocale); + if (!locale) return {}; + + const copy = ARCHIVE_COPY[locale]; + + return { + title: { absolute: copy.archiveMetaTitle }, + description: copy.archiveMetaDescription, + alternates: { + canonical: `${BASE_URL}${archivePath(locale)}`, + languages: archiveHreflang(), + }, + }; +} + +export default async function LocaleArchivePage({ params }: LocaleArchiveProps) { + const { locale: rawLocale } = await params; + const locale = resolveIntlLocale(rawLocale); + if (!locale) notFound(); + + return ; +} diff --git a/app/(intl)/[locale]/layout.tsx b/app/(intl)/[locale]/layout.tsx index e06807b..7c636da 100644 --- a/app/(intl)/[locale]/layout.tsx +++ b/app/(intl)/[locale]/layout.tsx @@ -3,20 +3,30 @@ import { Suspense, type ReactNode } from "react"; import Analytics from "@/components/Analytics"; import "../../globals.css"; -export const metadata: Metadata = { - metadataBase: new URL("https://hidden11.app"), - applicationName: "hidden11", - manifest: "/manifest.webmanifest", - icons: { - icon: "/favicon.svg", - apple: "/icon-192.png", - }, - appleWebApp: { - capable: true, - title: "hidden11", - statusBarStyle: "black-translucent", - }, -}; +// See app/(en)/layout.tsx: a function so the verification tokens come from the +// container env at request time rather than being baked at build. +export function generateMetadata(): Metadata { + return { + metadataBase: new URL("https://hidden11.app"), + applicationName: "hidden11", + manifest: "/manifest.webmanifest", + icons: { + icon: "/favicon.svg", + apple: "/icon-192.png", + }, + appleWebApp: { + capable: true, + title: "hidden11", + statusBarStyle: "black-translucent", + }, + verification: { + google: process.env.GOOGLE_SITE_VERIFICATION, + other: process.env.BING_SITE_VERIFICATION + ? { "msvalidate.01": process.env.BING_SITE_VERIFICATION } + : {}, + }, + }; +} export const viewport: Viewport = { themeColor: "#04110c", diff --git a/app/(intl)/[locale]/page.tsx b/app/(intl)/[locale]/page.tsx index 284362c..6a6ef63 100644 --- a/app/(intl)/[locale]/page.tsx +++ b/app/(intl)/[locale]/page.tsx @@ -2,9 +2,9 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import HomeClient from "@/components/HomeClient"; import SeoContent from "@/components/SeoContent"; -import type { Locale } from "@/lib/i18n"; import { INTL_LOCALES, OG_LOCALES, SEO_COPY, hreflangLanguages, localeUrl } from "@/lib/seo-content"; import { getSponsorConfig } from "@/lib/sponsor"; +import { resolveIntlLocale } from "@/lib/intl-route"; export const dynamic = "force-dynamic"; @@ -12,13 +12,13 @@ type LocalePageProps = { params: Promise<{ locale: string }>; }; -function resolveLocale(locale: string): Locale | null { - return (INTL_LOCALES as string[]).includes(locale) ? (locale as Locale) : null; +export function generateStaticParams() { + return INTL_LOCALES.map((locale) => ({ locale })); } export async function generateMetadata({ params }: LocalePageProps): Promise { const { locale: rawLocale } = await params; - const locale = resolveLocale(rawLocale); + const locale = resolveIntlLocale(rawLocale); if (!locale) return {}; const copy = SEO_COPY[locale]; @@ -50,7 +50,7 @@ export async function generateMetadata({ params }: LocalePageProps): Promise; +}; + +export async function generateMetadata({ params }: LocalePlayProps): Promise { + const { number } = await params; + return { + title: `Play puzzle #${number}`, + // Replays duplicate the playable home page; only the answer pages are indexed. + robots: { index: false, follow: true }, + }; +} + +export default async function LocalePlayArchivePage({ params }: LocalePlayProps) { + const { locale: rawLocale, number: rawNumber } = await params; + const locale = resolveIntlLocale(rawLocale); + const data = getPastPuzzleSet(rawNumber); + if (!locale || !data) notFound(); + + return ; +} diff --git a/app/(intl)/[locale]/puzzle/[number]/page.tsx b/app/(intl)/[locale]/puzzle/[number]/page.tsx new file mode 100644 index 0000000..ba41a48 --- /dev/null +++ b/app/(intl)/[locale]/puzzle/[number]/page.tsx @@ -0,0 +1,47 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import PuzzleAnswers from "@/components/PuzzleAnswers"; +import { ARCHIVE_COPY } from "@/lib/archive-content"; +import { getArchiveDateKeys, getPastPuzzleSet, getPuzzleNumber, getTodayKey } from "@/lib/game-engine"; +import { BASE_URL, INTL_LOCALES, puzzleHreflang, puzzlePath } from "@/lib/seo-content"; +import { resolveIntlLocale } from "@/lib/intl-route"; + +export const revalidate = 3600; + +type LocalePuzzleProps = { + params: Promise<{ locale: string; number: string }>; +}; + +// 5 locales x every past day. They are tiny data-only pages, and prerendering +// them is what makes the localized long tail crawlable without a cold render. +export function generateStaticParams() { + const numbers = getArchiveDateKeys(getTodayKey()).map((dateKey) => String(getPuzzleNumber(dateKey))); + return INTL_LOCALES.flatMap((locale) => numbers.map((number) => ({ locale, number }))); +} + +export async function generateMetadata({ params }: LocalePuzzleProps): Promise { + const { locale: rawLocale, number: rawNumber } = await params; + const locale = resolveIntlLocale(rawLocale); + const data = getPastPuzzleSet(rawNumber); + if (!locale || !data) return {}; + + const copy = ARCHIVE_COPY[locale]; + + return { + title: { absolute: copy.answersMetaTitle(data.number, data.dateKey) }, + description: copy.answersMetaDescription(data.number, data.dateKey), + alternates: { + canonical: `${BASE_URL}${puzzlePath(locale, data.number)}`, + languages: puzzleHreflang(data.number), + }, + }; +} + +export default async function LocalePuzzleAnswersPage({ params }: LocalePuzzleProps) { + const { locale: rawLocale, number: rawNumber } = await params; + const locale = resolveIntlLocale(rawLocale); + const data = getPastPuzzleSet(rawNumber); + if (!locale || !data) notFound(); + + return ; +} diff --git a/app/api/events/route.ts b/app/api/events/route.ts new file mode 100644 index 0000000..f1d04c8 --- /dev/null +++ b/app/api/events/route.ts @@ -0,0 +1,83 @@ +import { NextResponse } from "next/server"; + +// First-party event ingestion for the funnel dashboard. +// +// Forwards to an n8n webhook that appends to Postgres (deploy/n8n-events.json). +// When N8N_EVENTS_WEBHOOK_URL is unset the route accepts and discards, so the +// game keeps working in environments where the pipeline is not wired up. + +export const dynamic = "force-dynamic"; + +// Only the events the funnel is built on. An allow-list keeps a public endpoint +// from becoming a free write channel into the warehouse. +const ALLOWED_EVENTS = new Set([ + "game_started", + "guess_submitted", + "difficulty_unlocked", + "daily_completed", + "solution_revealed", + "share_clicked", + "challenge_share_clicked", + "challenge_accepted", + "feedback_sent", + "sponsor_click", + "reminder_requested", + "reminder_enabled", + "install_prompted", + "install_choice", +]); + +const MAX_PROPERTY_KEYS = 12; + +type EventBody = { + name?: unknown; + anonymousId?: unknown; + path?: unknown; + properties?: unknown; +}; + +function scalarProperties(input: unknown): Record { + if (!input || typeof input !== "object") return {}; + + const entries = Object.entries(input as Record) + .filter(([, value]) => ["string", "number", "boolean"].includes(typeof value)) + .slice(0, MAX_PROPERTY_KEYS) + .map(([key, value]) => [key.slice(0, 40), typeof value === "string" ? value.slice(0, 120) : value] as const); + + return Object.fromEntries(entries) as Record; +} + +export async function POST(request: Request) { + const body = (await request.json().catch(() => null)) as EventBody | null; + const name = typeof body?.name === "string" ? body.name : ""; + + if (!ALLOWED_EVENTS.has(name)) { + return NextResponse.json({ ok: false, reason: "unknown event" }, { status: 400 }); + } + + const webhookUrl = process.env.N8N_EVENTS_WEBHOOK_URL; + if (!webhookUrl) { + return new NextResponse(null, { status: 204 }); + } + + const payload = { + name, + anonymousId: typeof body?.anonymousId === "string" ? body.anonymousId.slice(0, 64) : "unknown", + path: typeof body?.path === "string" ? body.path.slice(0, 120) : "/", + properties: scalarProperties(body?.properties), + occurredAt: new Date().toISOString(), + }; + + try { + await fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + cache: "no-store", + }); + } catch { + // best effort: a dropped analytics event is never worth a 5xx to the player + } + + return new NextResponse(null, { status: 204 }); +} diff --git a/app/api/push/route.ts b/app/api/push/route.ts new file mode 100644 index 0000000..5c0c36d --- /dev/null +++ b/app/api/push/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server"; + +// Daily-reminder push subscriptions. +// +// GET -> { publicKey } the VAPID application server key, read at +// request time so it is never baked into the build +// POST -> { ok } forwards a subscription to the n8n webhook that +// persists it (same integration pattern as +// /api/feedback and /api/plays) +// +// Sending the daily notification is n8n's job (deploy/n8n-daily-push.json): +// this app never holds the VAPID private key. + +export const dynamic = "force-dynamic"; + +type PushSubscriptionBody = { + action?: unknown; + endpoint?: unknown; + keys?: { p256dh?: unknown; auth?: unknown }; + locale?: unknown; +}; + +export function GET() { + return NextResponse.json({ publicKey: process.env.VAPID_PUBLIC_KEY ?? null }); +} + +export async function POST(request: Request) { + const webhookUrl = process.env.N8N_PUSH_WEBHOOK_URL; + const body = (await request.json().catch(() => null)) as PushSubscriptionBody | null; + + const endpoint = typeof body?.endpoint === "string" ? body.endpoint : ""; + const p256dh = typeof body?.keys?.p256dh === "string" ? body.keys.p256dh : ""; + const auth = typeof body?.keys?.auth === "string" ? body.keys.auth : ""; + const action = body?.action === "unsubscribe" ? "unsubscribe" : "subscribe"; + const locale = typeof body?.locale === "string" ? body.locale.slice(0, 5) : "en"; + + // Only accept endpoints from a real push service, so the webhook never gets + // pointed at an arbitrary URL through this route. + if (!endpoint.startsWith("https://") || (action === "subscribe" && (!p256dh || !auth))) { + return NextResponse.json({ error: "invalid subscription" }, { status: 400 }); + } + + if (!webhookUrl) { + // Feature not wired up in this environment: report it instead of pretending. + return NextResponse.json({ ok: false, reason: "not-configured" }, { status: 503 }); + } + + try { + const response = await fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, endpoint, keys: { p256dh, auth }, locale }), + cache: "no-store", + }); + + if (!response.ok) { + return NextResponse.json({ ok: false }, { status: 502 }); + } + + return NextResponse.json({ ok: true }); + } catch { + return NextResponse.json({ ok: false }, { status: 502 }); + } +} diff --git a/app/sitemap.ts b/app/sitemap.ts index c8f5fc7..e573410 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,8 +1,16 @@ import type { MetadataRoute } from "next"; import { getArchiveDateKeys, getPuzzleNumber, getTodayKey } from "@/lib/game-engine"; -import { INTL_LOCALES, hreflangLanguages, localeUrl } from "@/lib/seo-content"; - -const BASE_URL = "https://hidden11.app"; +import { + ALL_LOCALES, + BASE_URL, + INTL_LOCALES, + archiveHreflang, + archivePath, + hreflangLanguages, + localeUrl, + puzzleHreflang, + puzzlePath, +} from "@/lib/seo-content"; // Recompute daily: today's key gates which answer pages exist. export const revalidate = 3600; @@ -11,6 +19,7 @@ export default function sitemap(): MetadataRoute.Sitemap { const todayKey = getTodayKey(); const lastModified = new Date(`${todayKey}T00:00:00.000Z`); const languages = hreflangLanguages(); + const archiveLanguages = archiveHreflang(); const homes: MetadataRoute.Sitemap = [ { @@ -29,27 +38,37 @@ export default function sitemap(): MetadataRoute.Sitemap { })), ]; - const archivePages: MetadataRoute.Sitemap = getArchiveDateKeys(todayKey).map((dateKey) => ({ - url: `${BASE_URL}/puzzle/${getPuzzleNumber(dateKey)}`, - lastModified: new Date(`${dateKey}T00:00:00.000Z`), - changeFrequency: "yearly", - priority: 0.5, + const archives: MetadataRoute.Sitemap = ALL_LOCALES.map((locale) => ({ + url: `${BASE_URL}${archivePath(locale)}`, + lastModified, + changeFrequency: "daily" as const, + priority: 0.7, + alternates: { languages: archiveLanguages }, })); + const answerPages: MetadataRoute.Sitemap = getArchiveDateKeys(todayKey).flatMap((dateKey) => { + const number = getPuzzleNumber(dateKey); + const puzzleLanguages = puzzleHreflang(number); + const puzzleLastModified = new Date(`${dateKey}T00:00:00.000Z`); + + return ALL_LOCALES.map((locale) => ({ + url: `${BASE_URL}${puzzlePath(locale, number)}`, + lastModified: puzzleLastModified, + changeFrequency: "yearly" as const, + priority: 0.5, + alternates: { languages: puzzleLanguages }, + })); + }); + return [ ...homes, - { - url: `${BASE_URL}/archive`, - lastModified, - changeFrequency: "daily", - priority: 0.7, - }, + ...archives, { url: `${BASE_URL}/sponsor`, lastModified, changeFrequency: "monthly", priority: 0.4, }, - ...archivePages, + ...answerPages, ]; } diff --git a/components/ArchiveList.tsx b/components/ArchiveList.tsx new file mode 100644 index 0000000..d344f81 --- /dev/null +++ b/components/ArchiveList.tsx @@ -0,0 +1,67 @@ +import Link from "next/link"; +import { ARCHIVE_COPY } from "@/lib/archive-content"; +import { getArchiveDateKeys, getPuzzleNumber, getTodayKey } from "@/lib/game-engine"; +import type { Locale } from "@/lib/i18n"; +import { localePath, playPath, puzzlePath } from "@/lib/seo-content"; + +type ArchiveListProps = { + locale: Locale; +}; + +export default function ArchiveList({ locale }: ArchiveListProps) { + const copy = ARCHIVE_COPY[locale]; + const dateKeys = getArchiveDateKeys(getTodayKey()); + + return ( +
+
+
+
+

hidden11

+

{copy.archiveTitle}

+

{copy.archiveIntro}

+
+ + {copy.playToday} + +
+ +
+ {dateKeys.map((dateKey) => { + const number = getPuzzleNumber(dateKey); + return ( +
+
+

#{number}

+

{dateKey}

+
+
+ + {copy.play} + + + {copy.answers} + +
+
+ ); + })} +
+ + {dateKeys.length === 0 ?

{copy.emptyArchive}

: null} +
+
+ ); +} diff --git a/components/DailyReminder.tsx b/components/DailyReminder.tsx new file mode 100644 index 0000000..abefa30 --- /dev/null +++ b/components/DailyReminder.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { trackEvent } from "@/lib/analytics-events"; +import type { Dictionary, Locale } from "@/lib/i18n"; +import { getPushState, subscribeToDailyReminder, type PushState } from "@/lib/push"; + +type DailyReminderProps = { + locale: Locale; + t: Dictionary; +}; + +type InstallPromptEvent = Event & { + prompt: () => Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; +}; + +function isIosSafari(): boolean { + const ua = window.navigator.userAgent; + return /iPad|iPhone|iPod/.test(ua) && !/CriOS|FxiOS/.test(ua); +} + +function isStandalone(): boolean { + return ( + window.matchMedia("(display-mode: standalone)").matches || + (window.navigator as Navigator & { standalone?: boolean }).standalone === true + ); +} + +// Shown once the day is finished: the moment the player has nothing left to do +// is the only moment a "come back tomorrow" ask makes sense. +export default function DailyReminder({ locale, t }: DailyReminderProps) { + const [pushState, setPushState] = useState("unsupported"); + const [busy, setBusy] = useState(false); + const [installEvent, setInstallEvent] = useState(null); + const [showIosHint, setShowIosHint] = useState(false); + const [installed, setInstalled] = useState(true); + + useEffect(() => { + getPushState().then(setPushState); + setInstalled(isStandalone()); + + function onBeforeInstallPrompt(event: Event) { + event.preventDefault(); + setInstallEvent(event as InstallPromptEvent); + } + + window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt); + return () => window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt); + }, []); + + async function enableReminder() { + setBusy(true); + trackEvent("reminder_requested", { locale }); + const next = await subscribeToDailyReminder(locale, { + title: t.reminderTitle, + body: t.reminderBody, + url: locale === "en" ? "/" : `/${locale}`, + }).catch(() => "default" as PushState); + setPushState(next); + setBusy(false); + if (next === "subscribed") trackEvent("reminder_enabled", { locale }); + } + + async function install() { + if (!installEvent) { + setShowIosHint(true); + return; + } + trackEvent("install_prompted", {}); + await installEvent.prompt(); + const choice = await installEvent.userChoice; + trackEvent("install_choice", { outcome: choice.outcome }); + if (choice.outcome === "accepted") setInstalled(true); + setInstallEvent(null); + } + + const canAskForPush = pushState === "default" || pushState === "granted"; + const canOfferInstall = !installed && (installEvent !== null || isIosSafari()); + + if (!canAskForPush && !canOfferInstall && pushState !== "subscribed" && pushState !== "denied") { + return null; + } + + return ( +
+

{t.comeBackTomorrow}

+ +
+ {pushState === "subscribed" ? ( +

✅ {t.reminderOn}

+ ) : pushState === "denied" ? ( +

{t.reminderBlocked}

+ ) : canAskForPush ? ( + + ) : null} + + {canOfferInstall ? ( + + ) : null} +
+ + {showIosHint ?

{t.installIosHint}

: null} +
+ ); +} diff --git a/components/HomeClient.tsx b/components/HomeClient.tsx index 42d6913..59d1209 100644 --- a/components/HomeClient.tsx +++ b/components/HomeClient.tsx @@ -427,7 +427,10 @@ export default function Home({ sponsor, initialLocale, dateKey: dateKeyProp, arc {challenge ? (

- 🎯 {t.challengeLead(formatScoreLine(payloadScore(challenge)))} + 🎯{" "} + {challenge.name + ? t.challengeLeadNamed(challenge.name, formatScoreLine(payloadScore(challenge))) + : t.challengeLead(formatScoreLine(payloadScore(challenge)))}

) : null} diff --git a/components/PuzzleAnswers.tsx b/components/PuzzleAnswers.tsx new file mode 100644 index 0000000..fcb850d --- /dev/null +++ b/components/PuzzleAnswers.tsx @@ -0,0 +1,149 @@ +import Link from "next/link"; +import { ARCHIVE_COPY, describePlayer, playerHonours } from "@/lib/archive-content"; +import { DIFFICULTIES, POSITIONS, findPlayer } from "@/lib/game-engine"; +import { getDictionary, type Locale } from "@/lib/i18n"; +import { BASE_URL, archivePath, localePath, playPath, puzzlePath } from "@/lib/seo-content"; +import type { Puzzle } from "@/lib/types"; + +type PuzzleAnswersProps = { + dateKey: string; + locale: Locale; + number: number; + sets: Puzzle[]; +}; + +export default function PuzzleAnswers({ dateKey, locale, number, sets }: PuzzleAnswersProps) { + const copy = ARCHIVE_COPY[locale]; + const t = getDictionary(locale); + + const articleJsonLd = { + "@context": "https://schema.org", + "@type": "Article", + headline: copy.answersTitle(number), + datePublished: dateKey, + inLanguage: locale, + mainEntityOfPage: `${BASE_URL}${puzzlePath(locale, number)}`, + description: copy.answersMetaDescription(number, dateKey), + author: { "@type": "Organization", name: "hidden11" }, + publisher: { "@type": "Organization", name: "hidden11" }, + }; + + const breadcrumbJsonLd = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { "@type": "ListItem", position: 1, name: "hidden11", item: `${BASE_URL}${localePath(locale)}` }, + { "@type": "ListItem", position: 2, name: copy.archiveTitle, item: `${BASE_URL}${archivePath(locale)}` }, + { + "@type": "ListItem", + position: 3, + name: copy.answersTitle(number), + item: `${BASE_URL}${puzzlePath(locale, number)}`, + }, + ], + }; + + return ( +
+
+
+
+

{copy.eyebrow}

+

{copy.answersTitle(number)}

+

{dateKey}

+
+ + {copy.playThisPuzzle} + +
+ +

{copy.answersIntro(number, dateKey)}

+ +
+ {DIFFICULTIES.map((difficulty) => { + const puzzle = sets.find((item) => item.difficulty === difficulty); + if (!puzzle) return null; + + return ( +
+

{t.difficulty[difficulty]}

+ +

{copy.cluesTitle}

+
    + {puzzle.clues.map((clue) => ( +
  • + · {t.clue[clue] ?? clue} +
  • + ))} +
+ +

{copy.lineupTitle}

+
    + {POSITIONS.map((position) => { + const player = findPlayer(puzzle.solution[position]); + if (!player) { + return ( +
  • + {puzzle.solution[position]} +
  • + ); + } + + const honours = playerHonours(player, locale); + const formerClubs = player.formerClubs ?? []; + + return ( +
  • +

    + {position} + {player.name} + {position === puzzle.fixedPosition ? ( + + {copy.freeGiven} + + ) : null} +

    +

    {describePlayer(player, locale)}

    + {formerClubs.length ? ( +

    + {copy.previously}: {formerClubs.join(", ")}. +

    + ) : null} + {honours.length ? ( +

    + {copy.honours}: {honours.join(", ")}. +

    + ) : null} +
  • + ); + })} +
+
+ ); + })} +
+ +
+ + {copy.playTodaysPuzzle} + + + {copy.fullArchive} + +
+
+ +