Localize the archive, add reminders, named challenges and a funnel
Build and push image / build (push) Failing after 15m20s
Build and push image / build (push) Failing after 15m20s
Archive and answer pages now exist in all six languages and are prerendered instead of rendered per request, and each answer page carries a sentence of real context per player (club, league, nationality, former clubs, honours) plus Article and BreadcrumbList JSON-LD. The sitemap grows from 121 to 691 URLs, every one with hreflang alternates. Adds an optional display name to shared results, so a challenge link reads "<name> challenges you" in the page, the OG image and the head-to-head verdict; the name is sanitised both when written and when parsed back. Adds an opt-in daily reminder (Web Push without payload, so no content encryption) and a PWA install prompt shown once the day is finished, plus first-party event ingestion at /api/events that mirrors GA4 so the funnel survives ad blockers. Wires the missing N8N_PLAYS_WEBHOOK_URL into the deployment along with the new events, push and search-verification secrets, and ships the n8n workflows, the Postgres schema with funnel/retention views, and an IndexNow submitter. Refs #2 #3 #5 #6 #7 #8 #9 #10 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QvQ1ErZNRUcWgCEkJ9uyrn
This commit is contained in:
@@ -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/<n>` publishes answers for past days only
|
||||
(today and future 404); `/play/<n>` replays a past puzzle without touching stats or streak.
|
||||
- **Archive, in six languages**: `/archive` and `/<locale>/archive` list past puzzles;
|
||||
`/puzzle/<n>` and `/<locale>/puzzle/<n>` 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/<n>` replays a past puzzle without touching stats or streak.
|
||||
- **Challenge loop**: shared result links (`/r/<code>`) offer "Accept the challenge" →
|
||||
`/?challenge=<code>`. 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/<code>` reads "<name> 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/<key>.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`
|
||||
|
||||
+13
-66
@@ -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 (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top,#115c39_0,#061b12_45%,#04110c_100%)] px-4 py-8 text-white">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase tracking-[0.24em] text-emerald-200/70">hidden11</p>
|
||||
<h1 className="mt-2 text-3xl font-black">Puzzle archive</h1>
|
||||
<p className="mt-2 text-sm text-white/65">
|
||||
Replay any past puzzle or check its answers. Archive games don't affect your daily streak.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex-none rounded-full bg-emerald-400 px-4 py-2 text-sm font-black text-pitch-950 transition hover:bg-emerald-300"
|
||||
>
|
||||
Play today
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-2">
|
||||
{dateKeys.map((dateKey) => {
|
||||
const number = getPuzzleNumber(dateKey);
|
||||
return (
|
||||
<div
|
||||
key={dateKey}
|
||||
className="flex items-center justify-between gap-3 rounded-xl border border-white/10 bg-white/[0.06] px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<p className="text-base font-black">#{number}</p>
|
||||
<p className="text-xs text-white/55">{dateKey}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/play/${number}`}
|
||||
className="rounded-full bg-white px-3 py-1.5 text-xs font-black uppercase text-pitch-950 transition hover:bg-emerald-100"
|
||||
>
|
||||
Play
|
||||
</Link>
|
||||
<Link
|
||||
href={`/puzzle/${number}`}
|
||||
className="rounded-full border border-white/15 px-3 py-1.5 text-xs font-black uppercase text-white transition hover:bg-white/10"
|
||||
>
|
||||
Answers
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{dateKeys.length === 0 ? (
|
||||
<p className="mt-6 text-sm text-white/55">No past puzzles yet. Come back tomorrow!</p>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
return <ArchiveList locale="en" />;
|
||||
}
|
||||
|
||||
+43
-33
@@ -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",
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 <HomeClient sponsor={getSponsorConfig()} dateKey={dateKey} archive />;
|
||||
return <HomeClient sponsor={getSponsorConfig()} dateKey={data.dateKey} archive />;
|
||||
}
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top,#115c39_0,#061b12_45%,#04110c_100%)] px-4 py-8 text-white">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase tracking-[0.24em] text-emerald-200/70">Past puzzle</p>
|
||||
<h1 className="mt-2 text-3xl font-black">hidden11 #{number} answers</h1>
|
||||
<p className="mt-2 text-sm text-white/65">{dateKey}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/play/${number}`}
|
||||
className="flex-none rounded-full bg-emerald-400 px-4 py-2 text-sm font-black text-pitch-950 transition hover:bg-emerald-300"
|
||||
>
|
||||
Play this puzzle
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4">
|
||||
{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 (
|
||||
<section key={difficulty} className="rounded-2xl border border-white/10 bg-white/[0.06] p-4">
|
||||
<h2 className="text-lg font-black">{label}</h2>
|
||||
|
||||
<h3 className="mt-3 text-xs font-black uppercase text-emerald-200/70">Clues</h3>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{puzzle.clues.map((clue) => (
|
||||
<li key={clue} className="text-sm text-white/70">
|
||||
· {clue}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="mt-4 text-xs font-black uppercase text-emerald-200/70">Hidden lineup</h3>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{POSITIONS.map((position) => {
|
||||
const player = findPlayer(puzzle.solution[position]);
|
||||
return (
|
||||
<li key={position} className="flex items-baseline gap-2 text-sm">
|
||||
<span className="w-12 flex-none font-mono text-xs font-bold text-white/45">{position}</span>
|
||||
<span className="font-bold text-white">{player?.name ?? puzzle.solution[position]}</span>
|
||||
{player ? <span className="text-xs text-white/45">{player.club}</span> : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-full bg-white px-4 py-2 text-sm font-black text-pitch-950 transition hover:bg-emerald-100"
|
||||
>
|
||||
Play today's puzzle
|
||||
</Link>
|
||||
<Link
|
||||
href="/archive"
|
||||
className="rounded-full border border-white/15 px-4 py-2 text-sm font-black text-white transition hover:bg-white/10"
|
||||
>
|
||||
Full archive
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
return <PuzzleAnswers dateKey={data.dateKey} locale="en" number={data.number} sets={data.sets} />;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export default async function OpenGraphImage({ params }: ResultImageProps) {
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ display: "flex", fontSize: 22, fontWeight: 700, textTransform: "uppercase", letterSpacing: 2 }}>
|
||||
Shared Result
|
||||
{payload?.name ? `${payload.name} challenges you` : "Shared Result"}
|
||||
</div>
|
||||
<div style={{ display: "flex", marginTop: 8, fontSize: 74, fontWeight: 900 }}>
|
||||
hidden11 {payload ? `#${payload.puzzleNumber}` : ""}
|
||||
|
||||
@@ -22,9 +22,14 @@ export async function generateMetadata({ params }: ResultPageProps): Promise<Met
|
||||
};
|
||||
}
|
||||
|
||||
const who = payload.name ? `${payload.name}'s` : "Shared";
|
||||
const challengeLine = payload.name
|
||||
? `${payload.name} challenges you on hidden11 #${payload.puzzleNumber}. Can you beat this score?`
|
||||
: "Can you beat this score? Play the same puzzle now.";
|
||||
|
||||
return {
|
||||
title: `hidden11 #${payload.puzzleNumber} shared result`,
|
||||
description: `Shared hidden11 result for puzzle #${payload.puzzleNumber}.`,
|
||||
title: `hidden11 #${payload.puzzleNumber} — ${who} result`,
|
||||
description: challengeLine,
|
||||
robots: { index: false, follow: true },
|
||||
alternates: {
|
||||
canonical: `https://hidden11.app/r/${shareCode}`,
|
||||
@@ -32,16 +37,16 @@ export async function generateMetadata({ params }: ResultPageProps): Promise<Met
|
||||
// No explicit images here: the sibling opengraph-image.tsx / twitter-image.tsx
|
||||
// file conventions attach the correct hashed URLs automatically.
|
||||
openGraph: {
|
||||
title: `hidden11 #${payload.puzzleNumber}`,
|
||||
description: "Can you beat this score? Play the same puzzle now.",
|
||||
title: payload.name ? `${payload.name} — hidden11 #${payload.puzzleNumber}` : `hidden11 #${payload.puzzleNumber}`,
|
||||
description: challengeLine,
|
||||
url: `https://hidden11.app/r/${shareCode}`,
|
||||
siteName: "hidden11",
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: `hidden11 #${payload.puzzleNumber}`,
|
||||
description: "Can you beat this score? Play the same puzzle now.",
|
||||
title: payload.name ? `${payload.name} — hidden11 #${payload.puzzleNumber}` : `hidden11 #${payload.puzzleNumber}`,
|
||||
description: challengeLine,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -72,7 +77,9 @@ export default async function ResultPage({ params }: ResultPageProps) {
|
||||
<div className="mx-auto max-w-2xl rounded-[28px] border border-white/10 bg-black/25 p-5 shadow-2xl sm:p-7">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase tracking-[0.24em] text-emerald-200/70">Shared result</p>
|
||||
<p className="text-xs font-black uppercase tracking-[0.24em] text-emerald-200/70">
|
||||
{payload.name ? `${payload.name}'s result` : "Shared result"}
|
||||
</p>
|
||||
<h1 className="mt-2 text-4xl font-black">hidden11 #{payload.puzzleNumber}</h1>
|
||||
<p className="mt-2 text-sm text-white/65">{payload.dateKey}</p>
|
||||
</div>
|
||||
@@ -110,7 +117,9 @@ export default async function ResultPage({ params }: ResultPageProps) {
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl border border-emerald-300/25 bg-emerald-300/10 p-5 text-center">
|
||||
<p className="text-lg font-black">🔥 Can you beat this?</p>
|
||||
<p className="text-lg font-black">
|
||||
{payload.name ? `🔥 ${payload.name} challenges you` : "🔥 Can you beat this?"}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-white/65">
|
||||
Play the same puzzle and see the head-to-head result when you finish.
|
||||
</p>
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 <ArchiveList locale={locale} />;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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<Met
|
||||
|
||||
export default async function LocalePage({ params }: LocalePageProps) {
|
||||
const { locale: rawLocale } = await params;
|
||||
const locale = resolveLocale(rawLocale);
|
||||
const locale = resolveIntlLocale(rawLocale);
|
||||
if (!locale) notFound();
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import HomeClient from "@/components/HomeClient";
|
||||
import { getPastPuzzleSet } from "@/lib/game-engine";
|
||||
import { getSponsorConfig } from "@/lib/sponsor";
|
||||
import { resolveIntlLocale } from "@/lib/intl-route";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type LocalePlayProps = {
|
||||
params: Promise<{ locale: string; number: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: LocalePlayProps): Promise<Metadata> {
|
||||
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 <HomeClient sponsor={getSponsorConfig()} dateKey={data.dateKey} initialLocale={locale} archive />;
|
||||
}
|
||||
@@ -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<Metadata> {
|
||||
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 <PuzzleAnswers dateKey={data.dateKey} locale={locale} number={data.number} sets={data.sets} />;
|
||||
}
|
||||
@@ -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<string, string | number | boolean> {
|
||||
if (!input || typeof input !== "object") return {};
|
||||
|
||||
const entries = Object.entries(input as Record<string, unknown>)
|
||||
.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<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
+34
-15
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top,#115c39_0,#061b12_45%,#04110c_100%)] px-4 py-8 text-white">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase tracking-[0.24em] text-emerald-200/70">hidden11</p>
|
||||
<h1 className="mt-2 text-3xl font-black">{copy.archiveTitle}</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-white/65">{copy.archiveIntro}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={localePath(locale)}
|
||||
className="flex-none rounded-full bg-emerald-400 px-4 py-2 text-sm font-black text-pitch-950 transition hover:bg-emerald-300"
|
||||
>
|
||||
{copy.playToday}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-2">
|
||||
{dateKeys.map((dateKey) => {
|
||||
const number = getPuzzleNumber(dateKey);
|
||||
return (
|
||||
<div
|
||||
key={dateKey}
|
||||
className="flex items-center justify-between gap-3 rounded-xl border border-white/10 bg-white/[0.06] px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<p className="text-base font-black">#{number}</p>
|
||||
<p className="text-xs text-white/55">{dateKey}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={playPath(locale, number)}
|
||||
className="rounded-full bg-white px-3 py-1.5 text-xs font-black uppercase text-pitch-950 transition hover:bg-emerald-100"
|
||||
>
|
||||
{copy.play}
|
||||
</Link>
|
||||
<Link
|
||||
href={puzzlePath(locale, number)}
|
||||
className="rounded-full border border-white/15 px-3 py-1.5 text-xs font-black uppercase text-white transition hover:bg-white/10"
|
||||
>
|
||||
{copy.answers}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{dateKeys.length === 0 ? <p className="mt-6 text-sm text-white/55">{copy.emptyArchive}</p> : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
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<PushState>("unsupported");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [installEvent, setInstallEvent] = useState<InstallPromptEvent | null>(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 (
|
||||
<div className="mt-4 rounded-lg border border-white/10 bg-white/[0.06] p-3">
|
||||
<p className="text-xs font-black uppercase tracking-wide text-emerald-100/60">{t.comeBackTomorrow}</p>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{pushState === "subscribed" ? (
|
||||
<p className="text-xs font-bold text-emerald-200">✅ {t.reminderOn}</p>
|
||||
) : pushState === "denied" ? (
|
||||
<p className="text-xs font-bold text-white/50">{t.reminderBlocked}</p>
|
||||
) : canAskForPush ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={enableReminder}
|
||||
className="h-9 rounded-full bg-emerald-400 px-4 text-xs font-black uppercase text-pitch-950 transition hover:bg-emerald-300 disabled:opacity-60"
|
||||
>
|
||||
🔔 {t.remindMe}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{canOfferInstall ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={install}
|
||||
className="h-9 rounded-full border border-white/15 px-4 text-xs font-black uppercase text-white transition hover:bg-white/10"
|
||||
>
|
||||
📲 {t.installApp}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showIosHint ? <p className="mt-2 text-xs leading-5 text-white/55">{t.installIosHint}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -427,7 +427,10 @@ export default function Home({ sponsor, initialLocale, dateKey: dateKeyProp, arc
|
||||
{challenge ? (
|
||||
<div className="mt-3 rounded-lg border border-yellow-300/30 bg-yellow-300/10 px-3 py-2">
|
||||
<p className="text-xs font-bold text-yellow-100">
|
||||
🎯 {t.challengeLead(formatScoreLine(payloadScore(challenge)))}
|
||||
🎯{" "}
|
||||
{challenge.name
|
||||
? t.challengeLeadNamed(challenge.name, formatScoreLine(payloadScore(challenge)))
|
||||
: t.challengeLead(formatScoreLine(payloadScore(challenge)))}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -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 (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top,#115c39_0,#061b12_45%,#04110c_100%)] px-4 py-8 text-white">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase tracking-[0.24em] text-emerald-200/70">{copy.eyebrow}</p>
|
||||
<h1 className="mt-2 text-3xl font-black">{copy.answersTitle(number)}</h1>
|
||||
<p className="mt-2 text-sm text-white/65">{dateKey}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={playPath(locale, number)}
|
||||
className="flex-none rounded-full bg-emerald-400 px-4 py-2 text-sm font-black text-pitch-950 transition hover:bg-emerald-300"
|
||||
>
|
||||
{copy.playThisPuzzle}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm leading-6 text-white/70">{copy.answersIntro(number, dateKey)}</p>
|
||||
|
||||
<div className="mt-6 grid gap-4">
|
||||
{DIFFICULTIES.map((difficulty) => {
|
||||
const puzzle = sets.find((item) => item.difficulty === difficulty);
|
||||
if (!puzzle) return null;
|
||||
|
||||
return (
|
||||
<section key={difficulty} className="rounded-2xl border border-white/10 bg-white/[0.06] p-4">
|
||||
<h2 className="text-lg font-black">{t.difficulty[difficulty]}</h2>
|
||||
|
||||
<h3 className="mt-3 text-xs font-black uppercase text-emerald-200/70">{copy.cluesTitle}</h3>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{puzzle.clues.map((clue) => (
|
||||
<li key={clue} className="text-sm text-white/70">
|
||||
· {t.clue[clue] ?? clue}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="mt-4 text-xs font-black uppercase text-emerald-200/70">{copy.lineupTitle}</h3>
|
||||
<ul className="mt-2 space-y-3">
|
||||
{POSITIONS.map((position) => {
|
||||
const player = findPlayer(puzzle.solution[position]);
|
||||
if (!player) {
|
||||
return (
|
||||
<li key={position} className="text-sm font-bold text-white">
|
||||
{puzzle.solution[position]}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const honours = playerHonours(player, locale);
|
||||
const formerClubs = player.formerClubs ?? [];
|
||||
|
||||
return (
|
||||
<li key={position} className="border-l-2 border-emerald-400/30 pl-3">
|
||||
<p className="flex flex-wrap items-baseline gap-2">
|
||||
<span className="font-mono text-xs font-bold text-white/45">{position}</span>
|
||||
<span className="font-black text-white">{player.name}</span>
|
||||
{position === puzzle.fixedPosition ? (
|
||||
<span className="rounded-full bg-emerald-400/15 px-2 py-0.5 text-[10px] font-black uppercase text-emerald-200">
|
||||
{copy.freeGiven}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="mt-1 text-sm leading-6 text-white/70">{describePlayer(player, locale)}</p>
|
||||
{formerClubs.length ? (
|
||||
<p className="mt-0.5 text-xs text-white/45">
|
||||
{copy.previously}: {formerClubs.join(", ")}.
|
||||
</p>
|
||||
) : null}
|
||||
{honours.length ? (
|
||||
<p className="mt-0.5 text-xs text-white/45">
|
||||
{copy.honours}: {honours.join(", ")}.
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
href={localePath(locale)}
|
||||
className="rounded-full bg-white px-4 py-2 text-sm font-black text-pitch-950 transition hover:bg-emerald-100"
|
||||
>
|
||||
{copy.playTodaysPuzzle}
|
||||
</Link>
|
||||
<Link
|
||||
href={archivePath(locale)}
|
||||
className="rounded-full border border-white/15 px-4 py-2 text-sm font-black text-white transition hover:bg-white/10"
|
||||
>
|
||||
{copy.fullArchive}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }} />
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import type { Locale } from "@/lib/i18n";
|
||||
import { SEO_COPY, localeUrl } from "@/lib/seo-content";
|
||||
import { SEO_COPY, archivePath, localeUrl } from "@/lib/seo-content";
|
||||
import { ARCHIVE_COPY } from "@/lib/archive-content";
|
||||
|
||||
type SeoContentProps = {
|
||||
locale: Locale;
|
||||
@@ -68,8 +69,11 @@ export default function SeoContent({ locale }: SeoContentProps) {
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-sm text-white/55">
|
||||
<Link href="/archive" className="font-bold text-emerald-200 underline-offset-4 hover:underline">
|
||||
hidden11 archive →
|
||||
<Link
|
||||
href={archivePath(locale)}
|
||||
className="font-bold text-emerald-200 underline-offset-4 hover:underline"
|
||||
>
|
||||
{ARCHIVE_COPY[locale].archiveTitle} →
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
formatScoreLine,
|
||||
payloadScore,
|
||||
progressScore,
|
||||
MAX_SHARE_NAME_LENGTH,
|
||||
type SharePayload,
|
||||
} from "@/lib/share";
|
||||
import Countdown from "./Countdown";
|
||||
import DailyReminder from "./DailyReminder";
|
||||
import { fetchPlaysCount } from "@/lib/plays";
|
||||
import type { SponsorConfig } from "@/lib/sponsor";
|
||||
import { formatElapsed } from "@/lib/time";
|
||||
@@ -38,16 +40,29 @@ export default function ShareResult({ challenge, dateKey, locale, onClose, onOpe
|
||||
const [shared, setShared] = useState(false);
|
||||
const [origin, setOrigin] = useState("https://hidden11.app");
|
||||
const [playsCount, setPlaysCount] = useState<number | null>(null);
|
||||
const shareUrl = useMemo(() => buildShareResultUrl(dateKey, progress, origin), [dateKey, origin, progress]);
|
||||
const [name, setName] = useState("");
|
||||
const shareUrl = useMemo(
|
||||
() => buildShareResultUrl(dateKey, progress, origin, name),
|
||||
[dateKey, name, origin, progress],
|
||||
);
|
||||
const shareText = useMemo(() => buildShareText(dateKey, progress, t), [dateKey, progress, t]);
|
||||
const challengeText = useMemo(() => buildChallengeShareText(dateKey, progress, t), [dateKey, progress, t]);
|
||||
const challengeText = useMemo(
|
||||
() => buildChallengeShareText(dateKey, progress, t, name),
|
||||
[dateKey, name, progress, t],
|
||||
);
|
||||
const solvedCount = DIFFICULTIES.filter((difficulty) => progress[difficulty].solved).length;
|
||||
const totalSeconds = DIFFICULTIES.reduce((sum, difficulty) => sum + progress[difficulty].elapsedSeconds, 0);
|
||||
|
||||
useEffect(() => {
|
||||
setOrigin(window.location.origin);
|
||||
setName(window.localStorage.getItem("hidden11-player-name") ?? "");
|
||||
}, []);
|
||||
|
||||
function updateName(value: string) {
|
||||
setName(value);
|
||||
window.localStorage.setItem("hidden11-player-name", value);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
fetchPlaysCount(dateKey).then((count) => {
|
||||
@@ -187,6 +202,18 @@ export default function ShareResult({ challenge, dateKey, locale, onClose, onOpe
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<label className="mt-3 block">
|
||||
<span className="text-[10px] font-black uppercase tracking-wide text-emerald-100/60">{t.yourName}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
maxLength={MAX_SHARE_NAME_LENGTH}
|
||||
onChange={(event) => updateName(event.target.value)}
|
||||
placeholder={t.yourNamePlaceholder}
|
||||
className="mt-1 h-9 w-full rounded-lg border border-white/15 bg-white/[0.06] px-3 text-sm font-bold text-white placeholder:font-normal placeholder:text-white/35 focus:border-emerald-300/50 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -230,7 +257,9 @@ export default function ShareResult({ challenge, dateKey, locale, onClose, onOpe
|
||||
{formatScoreLine(mine)}
|
||||
</div>
|
||||
<div className="rounded-md bg-white/10 px-2 py-1.5 text-white">
|
||||
<span className="block text-[10px] uppercase text-white/50">{t.challengeRival}</span>
|
||||
<span className="block text-[10px] uppercase text-white/50">
|
||||
{challenge.name || t.challengeRival}
|
||||
</span>
|
||||
{formatScoreLine(rival)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,6 +288,7 @@ export default function ShareResult({ challenge, dateKey, locale, onClose, onOpe
|
||||
<div className="mt-4">
|
||||
<Countdown label={t.nextPuzzleIn} />
|
||||
</div>
|
||||
<DailyReminder locale={locale} t={t} />
|
||||
<div className="mt-4" />
|
||||
<StatsPanel stats={stats} t={t} />
|
||||
<div className="mt-4">
|
||||
|
||||
@@ -40,6 +40,42 @@ spec:
|
||||
name: hidden11-feedback
|
||||
key: N8N_FEEDBACK_WEBHOOK_URL
|
||||
optional: true
|
||||
- name: N8N_PLAYS_WEBHOOK_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: hidden11-plays
|
||||
key: N8N_PLAYS_WEBHOOK_URL
|
||||
optional: true
|
||||
- name: N8N_EVENTS_WEBHOOK_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: hidden11-events
|
||||
key: N8N_EVENTS_WEBHOOK_URL
|
||||
optional: true
|
||||
- name: N8N_PUSH_WEBHOOK_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: hidden11-push
|
||||
key: N8N_PUSH_WEBHOOK_URL
|
||||
optional: true
|
||||
- name: VAPID_PUBLIC_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: hidden11-push
|
||||
key: VAPID_PUBLIC_KEY
|
||||
optional: true
|
||||
- name: GOOGLE_SITE_VERIFICATION
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: hidden11-seo
|
||||
key: GOOGLE_SITE_VERIFICATION
|
||||
optional: true
|
||||
- name: BING_SITE_VERIFICATION
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: hidden11-seo
|
||||
key: BING_SITE_VERIFICATION
|
||||
optional: true
|
||||
- name: NEXT_PUBLIC_SPONSOR_ENABLED
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
{
|
||||
"name": "hidden11 — daily push reminder",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"rule": {
|
||||
"interval": [
|
||||
{
|
||||
"triggerAtHour": 9,
|
||||
"triggerAtMinute": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "push-cron",
|
||||
"name": "Every day 09:00",
|
||||
"type": "n8n-nodes-base.scheduleTrigger",
|
||||
"typeVersion": 1.2,
|
||||
"position": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "pub",
|
||||
"name": "vapidPublicKey",
|
||||
"value": "",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "priv",
|
||||
"name": "vapidPrivateKey",
|
||||
"value": "",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "sub",
|
||||
"name": "subject",
|
||||
"value": "mailto:support@hidden11.app",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "push-config",
|
||||
"name": "VAPID config",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
220,
|
||||
0
|
||||
],
|
||||
"notes": "Fill in from pass: hidden11/vapid-public-key and hidden11/vapid-private-key. Never commit these."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "executeQuery",
|
||||
"query": "SELECT endpoint, locale FROM hidden11_push_subscriptions;",
|
||||
"options": {}
|
||||
},
|
||||
"id": "push-load",
|
||||
"name": "Load subscriptions",
|
||||
"type": "n8n-nodes-base.postgres",
|
||||
"typeVersion": 2.4,
|
||||
"position": [
|
||||
440,
|
||||
0
|
||||
],
|
||||
"notes": "Credential: shared Postgres (LXC 121)."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "\n// Sends the daily reminder to every stored subscription.\n//\n// The push carries NO payload: with an empty body a Web Push message only needs\n// a VAPID Authorization header, which removes the whole aes128gcm content\n// encryption problem. The notification copy lives in the service worker's Cache\n// Storage, written when the player subscribed (see public/sw.js).\n\nconst config = $('VAPID config').first().json;\nconst publicKey = config.vapidPublicKey;\nconst privateD = config.vapidPrivateKey;\nconst subject = config.subject || 'mailto:support@hidden11.app';\n\nif (!publicKey || !privateD) {\n throw new Error('Fill in the \"VAPID config\" node (values in pass: hidden11/vapid-public-key, hidden11/vapid-private-key)');\n}\n\nconst b64urlToBuf = (value) => {\n const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4);\n return Uint8Array.from(Buffer.from(padded, 'base64'));\n};\nconst bufToB64url = (buf) =>\n Buffer.from(buf).toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n\n// The application server key is an uncompressed EC point: 0x04 || x || y.\nconst point = b64urlToBuf(publicKey);\nconst jwk = {\n kty: 'EC',\n crv: 'P-256',\n x: bufToB64url(point.slice(1, 33)),\n y: bufToB64url(point.slice(33, 65)),\n d: privateD,\n ext: true,\n};\n\nconst key = await crypto.subtle.importKey('jwk', jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']);\n\nasync function vapidHeader(audience) {\n const header = bufToB64url(JSON.stringify({ typ: 'JWT', alg: 'ES256' }));\n const claims = bufToB64url(\n JSON.stringify({ aud: audience, exp: Math.floor(Date.now() / 1000) + 12 * 3600, sub: subject }),\n );\n const signingInput = `${header}.${claims}`;\n // Web Crypto returns the raw r||s pair, which is exactly the ES256 encoding.\n const signature = await crypto.subtle.sign(\n { name: 'ECDSA', hash: 'SHA-256' },\n key,\n new TextEncoder().encode(signingInput),\n );\n return `vapid t=${signingInput}.${bufToB64url(signature)}, k=${publicKey}`;\n}\n\nconst results = [];\n\nfor (const item of $input.all()) {\n const endpoint = item.json.endpoint;\n let status = 0;\n\n try {\n const audience = new URL(endpoint).origin;\n const response = await fetch(endpoint, {\n method: 'POST',\n headers: {\n Authorization: await vapidHeader(audience),\n TTL: '43200',\n 'Content-Length': '0',\n },\n });\n status = response.status;\n } catch (error) {\n status = 0;\n }\n\n // 404/410 mean the subscription is dead and must be dropped.\n results.push({ json: { endpoint, status, gone: status === 404 || status === 410 } });\n}\n\nreturn results;\n"
|
||||
},
|
||||
"id": "push-send",
|
||||
"name": "Send push",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
660,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "gone",
|
||||
"operator": {
|
||||
"type": "boolean",
|
||||
"operation": "true"
|
||||
},
|
||||
"leftValue": "={{ $json.gone }}",
|
||||
"rightValue": ""
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "push-filter",
|
||||
"name": "Only dead endpoints",
|
||||
"type": "n8n-nodes-base.filter",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
880,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "executeQuery",
|
||||
"query": "DELETE FROM hidden11_push_subscriptions WHERE endpoint = $1;",
|
||||
"options": {
|
||||
"queryReplacement": "={{ $json.endpoint }}"
|
||||
}
|
||||
},
|
||||
"id": "push-prune",
|
||||
"name": "Prune dead subscriptions",
|
||||
"type": "n8n-nodes-base.postgres",
|
||||
"typeVersion": 2.4,
|
||||
"position": [
|
||||
1100,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Every day 09:00": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "VAPID config",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"VAPID config": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Load subscriptions",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Load subscriptions": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send push",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Send push": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Only dead endpoints",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Only dead endpoints": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Prune dead subscriptions",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "hidden11 — event ingestion",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "hidden11-events",
|
||||
"responseMode": "onReceived",
|
||||
"options": {}
|
||||
},
|
||||
"id": "events-webhook",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"webhookId": "hidden11-events"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "executeQuery",
|
||||
"query": "INSERT INTO hidden11_events (occurred_at, name, anonymous_id, path, properties)\nVALUES ($1::timestamptz, $2, $3, $4, $5::jsonb);",
|
||||
"options": {
|
||||
"queryReplacement": "={{ $json.body.occurredAt }},={{ $json.body.name }},={{ $json.body.anonymousId }},={{ $json.body.path }},={{ JSON.stringify($json.body.properties || {}) }}"
|
||||
}
|
||||
},
|
||||
"id": "events-pg",
|
||||
"name": "Insert event",
|
||||
"type": "n8n-nodes-base.postgres",
|
||||
"typeVersion": 2.4,
|
||||
"position": [
|
||||
220,
|
||||
0
|
||||
],
|
||||
"notes": "Credential: shared Postgres (LXC 121). Table hidden11_events."
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Insert event",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "hidden11 — plays counter",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "hidden11-plays",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"id": "plays-webhook",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"webhookId": "hidden11-plays"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "executeQuery",
|
||||
"query": "WITH upsert AS (\n INSERT INTO hidden11_plays (play_date, count)\n SELECT $1::date, 1\n WHERE $2 = 'increment'\n ON CONFLICT (play_date) DO UPDATE SET count = hidden11_plays.count + 1\n RETURNING count\n)\nSELECT coalesce(\n (SELECT count FROM upsert),\n (SELECT count FROM hidden11_plays WHERE play_date = $1::date),\n 0\n) AS count;",
|
||||
"options": {
|
||||
"queryReplacement": "={{ $json.body.date }},={{ $json.body.action }}"
|
||||
}
|
||||
},
|
||||
"id": "plays-pg",
|
||||
"name": "Count",
|
||||
"type": "n8n-nodes-base.postgres",
|
||||
"typeVersion": 2.4,
|
||||
"position": [
|
||||
220,
|
||||
0
|
||||
],
|
||||
"notes": "Credential: shared Postgres (LXC 121). Schema in deploy/sql/hidden11-events.sql"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ JSON.stringify({ count: $json.count }) }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "plays-respond",
|
||||
"name": "Respond",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
440,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Count",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Count": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Respond",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "hidden11 — push subscriptions",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "hidden11-push",
|
||||
"responseMode": "onReceived",
|
||||
"options": {}
|
||||
},
|
||||
"id": "sub-webhook",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"webhookId": "hidden11-push"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "executeQuery",
|
||||
"query": "WITH removed AS (\n DELETE FROM hidden11_push_subscriptions\n WHERE endpoint = $1 AND $5 = 'unsubscribe'\n)\nINSERT INTO hidden11_push_subscriptions (endpoint, p256dh, auth, locale)\nSELECT $1, $2, $3, $4 WHERE $5 = 'subscribe'\nON CONFLICT (endpoint) DO UPDATE SET p256dh = EXCLUDED.p256dh, auth = EXCLUDED.auth, locale = EXCLUDED.locale;",
|
||||
"options": {
|
||||
"queryReplacement": "={{ $json.body.endpoint }},={{ $json.body.keys.p256dh }},={{ $json.body.keys.auth }},={{ $json.body.locale }},={{ $json.body.action }}"
|
||||
}
|
||||
},
|
||||
"id": "sub-pg",
|
||||
"name": "Store subscription",
|
||||
"type": "n8n-nodes-base.postgres",
|
||||
"typeVersion": 2.4,
|
||||
"position": [
|
||||
220,
|
||||
0
|
||||
],
|
||||
"notes": "Credential: shared Postgres (LXC 121). Table hidden11_push_subscriptions."
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Store subscription",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
-- First-party analytics store for hidden11 (see app/api/events/route.ts).
|
||||
-- Runs on the shared Postgres (LXC 121 / 192.168.1.29), read by Metabase.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hidden11_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
name TEXT NOT NULL,
|
||||
anonymous_id TEXT NOT NULL,
|
||||
path TEXT NOT NULL DEFAULT '/',
|
||||
properties JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS hidden11_events_occurred_at_idx ON hidden11_events (occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS hidden11_events_name_idx ON hidden11_events (name);
|
||||
CREATE INDEX IF NOT EXISTS hidden11_events_anon_idx ON hidden11_events (anonymous_id);
|
||||
|
||||
-- Daily counter behind the social-proof line on the result screen.
|
||||
CREATE TABLE IF NOT EXISTS hidden11_plays (
|
||||
play_date DATE PRIMARY KEY,
|
||||
count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Daily-reminder push subscriptions.
|
||||
CREATE TABLE IF NOT EXISTS hidden11_push_subscriptions (
|
||||
endpoint TEXT PRIMARY KEY,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
locale TEXT NOT NULL DEFAULT 'en',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_sent TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- One row per day per funnel step: the question the dashboard is built on.
|
||||
CREATE OR REPLACE VIEW hidden11_funnel_daily AS
|
||||
WITH per_visitor AS (
|
||||
SELECT
|
||||
date_trunc('day', occurred_at)::date AS day,
|
||||
anonymous_id,
|
||||
bool_or(name = 'game_started') AS started,
|
||||
bool_or(name = 'guess_submitted') AS guessed,
|
||||
bool_or(name = 'daily_completed') AS completed,
|
||||
bool_or(name IN ('share_clicked', 'challenge_share_clicked')) AS shared,
|
||||
bool_or(name = 'challenge_accepted') AS accepted_challenge,
|
||||
bool_or(name = 'reminder_enabled') AS enabled_reminder
|
||||
FROM hidden11_events
|
||||
GROUP BY 1, 2
|
||||
)
|
||||
SELECT
|
||||
day,
|
||||
count(*) AS visitors,
|
||||
count(*) FILTER (WHERE started) AS started,
|
||||
count(*) FILTER (WHERE guessed) AS guessed,
|
||||
count(*) FILTER (WHERE completed) AS completed,
|
||||
count(*) FILTER (WHERE shared) AS shared,
|
||||
count(*) FILTER (WHERE accepted_challenge) AS accepted_challenge,
|
||||
count(*) FILTER (WHERE enabled_reminder) AS enabled_reminder,
|
||||
round(100.0 * count(*) FILTER (WHERE completed) / nullif(count(*) FILTER (WHERE started), 0), 1) AS completion_rate,
|
||||
round(100.0 * count(*) FILTER (WHERE shared) / nullif(count(*) FILTER (WHERE completed), 0), 1) AS share_rate
|
||||
FROM per_visitor
|
||||
GROUP BY day
|
||||
ORDER BY day DESC;
|
||||
|
||||
-- Which share channel actually gets used.
|
||||
CREATE OR REPLACE VIEW hidden11_share_channels AS
|
||||
SELECT
|
||||
date_trunc('day', occurred_at)::date AS day,
|
||||
coalesce(properties ->> 'channel', 'unknown') AS channel,
|
||||
count(*) AS shares
|
||||
FROM hidden11_events
|
||||
WHERE name IN ('share_clicked', 'challenge_share_clicked')
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1 DESC, 3 DESC;
|
||||
|
||||
-- D1 retention: of the visitors seen on a day, how many came back the next day.
|
||||
CREATE OR REPLACE VIEW hidden11_retention_d1 AS
|
||||
WITH days AS (
|
||||
SELECT DISTINCT anonymous_id, date_trunc('day', occurred_at)::date AS day
|
||||
FROM hidden11_events
|
||||
)
|
||||
SELECT
|
||||
base.day,
|
||||
count(*) AS visitors,
|
||||
count(next.anonymous_id) AS returned_next_day,
|
||||
round(100.0 * count(next.anonymous_id) / nullif(count(*), 0), 1) AS d1_retention
|
||||
FROM days base
|
||||
LEFT JOIN days next
|
||||
ON next.anonymous_id = base.anonymous_id
|
||||
AND next.day = base.day + 1
|
||||
GROUP BY base.day
|
||||
ORDER BY base.day DESC;
|
||||
+45
-2
@@ -6,10 +6,53 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const ANON_ID_KEY = "hidden11-anon-id";
|
||||
|
||||
// A random, first-party, per-browser id. It exists only to stitch the funnel
|
||||
// steps of one visit together — no personal data, never sent anywhere else.
|
||||
function anonymousId(): string {
|
||||
try {
|
||||
const existing = window.localStorage.getItem(ANON_ID_KEY);
|
||||
if (existing) return existing;
|
||||
const generated = crypto.randomUUID();
|
||||
window.localStorage.setItem(ANON_ID_KEY, generated);
|
||||
return generated;
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the event into our own store so the funnel survives ad blockers, which
|
||||
// take out a large share of GA4 hits on a game like this.
|
||||
function sendFirstParty(name: string, properties: EventProperties): void {
|
||||
const body = JSON.stringify({
|
||||
name,
|
||||
anonymousId: anonymousId(),
|
||||
path: window.location.pathname,
|
||||
properties,
|
||||
});
|
||||
|
||||
try {
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon("/api/events", new Blob([body], { type: "application/json" }));
|
||||
return;
|
||||
}
|
||||
void fetch("/api/events", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
keepalive: true,
|
||||
});
|
||||
} catch {
|
||||
// analytics must never break the game
|
||||
}
|
||||
}
|
||||
|
||||
export function trackEvent(name: string, properties: EventProperties = {}): void {
|
||||
if (typeof window === "undefined" || !window.gtag) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
window.gtag("event", name, properties);
|
||||
window.gtag?.("event", name, properties);
|
||||
sendFirstParty(name, properties);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
import type { Locale } from "./i18n";
|
||||
import type { Player, Position } from "./types";
|
||||
|
||||
// Localized copy for the server-rendered archive and answer pages. These pages
|
||||
// are pure SEO surface: the game UI has its own dictionary in lib/i18n.ts, but
|
||||
// nothing there is server-rendered, so the strings below are kept separate.
|
||||
|
||||
export type ArchiveCopy = {
|
||||
archiveTitle: string;
|
||||
archiveIntro: string;
|
||||
archiveMetaTitle: string;
|
||||
archiveMetaDescription: string;
|
||||
playToday: string;
|
||||
play: string;
|
||||
answers: string;
|
||||
emptyArchive: string;
|
||||
eyebrow: string;
|
||||
answersTitle: (puzzleNumber: number) => string;
|
||||
answersMetaTitle: (puzzleNumber: number, dateKey: string) => string;
|
||||
answersMetaDescription: (puzzleNumber: number, dateKey: string) => string;
|
||||
answersIntro: (puzzleNumber: number, dateKey: string) => string;
|
||||
playThisPuzzle: string;
|
||||
cluesTitle: string;
|
||||
lineupTitle: string;
|
||||
fullArchive: string;
|
||||
playTodaysPuzzle: string;
|
||||
previously: string;
|
||||
honours: string;
|
||||
freeGiven: string;
|
||||
describePlayer: (player: Player, nationality: string, position: string) => string;
|
||||
honourLabels: {
|
||||
championsLeague: (count: number) => string;
|
||||
leagueTitles: (count: number) => string;
|
||||
ballonDOr: (count: number) => string;
|
||||
goldenBoot: (count: number) => string;
|
||||
worldCup: string;
|
||||
copaAmerica: string;
|
||||
euro: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const POSITION_LABEL: Record<Locale, Record<Position, string>> = {
|
||||
en: { GK: "goalkeeper", DEF: "defender", MID: "midfielder", WING: "winger", ST: "striker" },
|
||||
es: { GK: "portero", DEF: "defensa", MID: "centrocampista", WING: "extremo", ST: "delantero" },
|
||||
fr: { GK: "gardien", DEF: "défenseur", MID: "milieu", WING: "ailier", ST: "attaquant" },
|
||||
de: { GK: "Torwart", DEF: "Verteidiger", MID: "Mittelfeldspieler", WING: "Flügelspieler", ST: "Stürmer" },
|
||||
it: { GK: "portiere", DEF: "difensore", MID: "centrocampista", WING: "ala", ST: "attaccante" },
|
||||
pt: { GK: "guarda-redes", DEF: "defesa", MID: "médio", WING: "extremo", ST: "avançado" },
|
||||
};
|
||||
|
||||
export const NATIONALITY: Record<Locale, Record<string, string>> = {
|
||||
en: {},
|
||||
es: {
|
||||
Argentina: "Argentina",
|
||||
Belgium: "Bélgica",
|
||||
Brazil: "Brasil",
|
||||
Canada: "Canadá",
|
||||
Croatia: "Croacia",
|
||||
Egypt: "Egipto",
|
||||
England: "Inglaterra",
|
||||
France: "Francia",
|
||||
Georgia: "Georgia",
|
||||
Germany: "Alemania",
|
||||
Hungary: "Hungría",
|
||||
Italy: "Italia",
|
||||
Netherlands: "Países Bajos",
|
||||
Norway: "Noruega",
|
||||
Poland: "Polonia",
|
||||
Portugal: "Portugal",
|
||||
Spain: "España",
|
||||
Sweden: "Suecia",
|
||||
Ukraine: "Ucrania",
|
||||
},
|
||||
fr: {
|
||||
Argentina: "Argentine",
|
||||
Belgium: "Belgique",
|
||||
Brazil: "Brésil",
|
||||
Canada: "Canada",
|
||||
Croatia: "Croatie",
|
||||
Egypt: "Égypte",
|
||||
England: "Angleterre",
|
||||
France: "France",
|
||||
Georgia: "Géorgie",
|
||||
Germany: "Allemagne",
|
||||
Hungary: "Hongrie",
|
||||
Italy: "Italie",
|
||||
Netherlands: "Pays-Bas",
|
||||
Norway: "Norvège",
|
||||
Poland: "Pologne",
|
||||
Portugal: "Portugal",
|
||||
Spain: "Espagne",
|
||||
Sweden: "Suède",
|
||||
Ukraine: "Ukraine",
|
||||
},
|
||||
de: {
|
||||
Argentina: "Argentinien",
|
||||
Belgium: "Belgien",
|
||||
Brazil: "Brasilien",
|
||||
Canada: "Kanada",
|
||||
Croatia: "Kroatien",
|
||||
Egypt: "Ägypten",
|
||||
England: "England",
|
||||
France: "Frankreich",
|
||||
Georgia: "Georgien",
|
||||
Germany: "Deutschland",
|
||||
Hungary: "Ungarn",
|
||||
Italy: "Italien",
|
||||
Netherlands: "Niederlande",
|
||||
Norway: "Norwegen",
|
||||
Poland: "Polen",
|
||||
Portugal: "Portugal",
|
||||
Spain: "Spanien",
|
||||
Sweden: "Schweden",
|
||||
Ukraine: "Ukraine",
|
||||
},
|
||||
it: {
|
||||
Argentina: "Argentina",
|
||||
Belgium: "Belgio",
|
||||
Brazil: "Brasile",
|
||||
Canada: "Canada",
|
||||
Croatia: "Croazia",
|
||||
Egypt: "Egitto",
|
||||
England: "Inghilterra",
|
||||
France: "Francia",
|
||||
Georgia: "Georgia",
|
||||
Germany: "Germania",
|
||||
Hungary: "Ungheria",
|
||||
Italy: "Italia",
|
||||
Netherlands: "Paesi Bassi",
|
||||
Norway: "Norvegia",
|
||||
Poland: "Polonia",
|
||||
Portugal: "Portogallo",
|
||||
Spain: "Spagna",
|
||||
Sweden: "Svezia",
|
||||
Ukraine: "Ucraina",
|
||||
},
|
||||
pt: {
|
||||
Argentina: "Argentina",
|
||||
Belgium: "Bélgica",
|
||||
Brazil: "Brasil",
|
||||
Canada: "Canadá",
|
||||
Croatia: "Croácia",
|
||||
Egypt: "Egito",
|
||||
England: "Inglaterra",
|
||||
France: "França",
|
||||
Georgia: "Geórgia",
|
||||
Germany: "Alemanha",
|
||||
Hungary: "Hungria",
|
||||
Italy: "Itália",
|
||||
Netherlands: "Países Baixos",
|
||||
Norway: "Noruega",
|
||||
Poland: "Polónia",
|
||||
Portugal: "Portugal",
|
||||
Spain: "Espanha",
|
||||
Sweden: "Suécia",
|
||||
Ukraine: "Ucrânia",
|
||||
},
|
||||
};
|
||||
|
||||
export const ARCHIVE_COPY: Record<Locale, ArchiveCopy> = {
|
||||
en: {
|
||||
archiveTitle: "Puzzle archive",
|
||||
archiveIntro:
|
||||
"Every past hidden11 lineup, with its clues and answers. Replay any day you missed — archive games never affect your daily streak.",
|
||||
archiveMetaTitle: "hidden11 archive — every past football lineup puzzle",
|
||||
archiveMetaDescription:
|
||||
"Browse every past hidden11 puzzle: replay a daily football lineup or read the answers for Easy, Medium and Hard.",
|
||||
playToday: "Play today",
|
||||
play: "Play",
|
||||
answers: "Answers",
|
||||
emptyArchive: "No past puzzles yet. Come back tomorrow!",
|
||||
eyebrow: "Past puzzle",
|
||||
answersTitle: (puzzleNumber) => `hidden11 #${puzzleNumber} answers`,
|
||||
answersMetaTitle: (puzzleNumber, dateKey) => `hidden11 #${puzzleNumber} answers — ${dateKey}`,
|
||||
answersMetaDescription: (puzzleNumber, dateKey) =>
|
||||
`Full solution to hidden11 puzzle #${puzzleNumber} (${dateKey}): the Easy, Medium and Hard hidden lineups with every clue explained.`,
|
||||
answersIntro: (puzzleNumber, dateKey) =>
|
||||
`Here is the complete solution to hidden11 puzzle #${puzzleNumber}, played on ${dateKey}. Each difficulty below lists the clues that were shown in game and the five players of the hidden lineup, with the details that made each clue point at them.`,
|
||||
playThisPuzzle: "Play this puzzle",
|
||||
cluesTitle: "Clues",
|
||||
lineupTitle: "Hidden lineup",
|
||||
fullArchive: "Full archive",
|
||||
playTodaysPuzzle: "Play today's puzzle",
|
||||
previously: "Previously",
|
||||
honours: "Honours",
|
||||
freeGiven: "Given for free",
|
||||
describePlayer: (player, nationality, position) =>
|
||||
`${player.name} is a ${nationality} ${position} playing for ${player.club} in the ${player.league}.`,
|
||||
honourLabels: {
|
||||
championsLeague: (count) => `${count} Champions League${count === 1 ? "" : "s"}`,
|
||||
leagueTitles: (count) => `${count} league title${count === 1 ? "" : "s"}`,
|
||||
ballonDOr: (count) => `${count} Ballon d'Or${count === 1 ? "" : "s"}`,
|
||||
goldenBoot: (count) => `${count} Golden Boot${count === 1 ? "" : "s"}`,
|
||||
worldCup: "World Cup winner",
|
||||
copaAmerica: "Copa América winner",
|
||||
euro: "European Championship winner",
|
||||
},
|
||||
},
|
||||
es: {
|
||||
archiveTitle: "Archivo de puzzles",
|
||||
archiveIntro:
|
||||
"Todas las alineaciones pasadas de hidden11, con sus pistas y soluciones. Rejuega cualquier día que te perdieras: las partidas del archivo nunca afectan a tu racha diaria.",
|
||||
archiveMetaTitle: "Archivo de hidden11 — todos los puzzles de alineaciones",
|
||||
archiveMetaDescription:
|
||||
"Consulta todos los puzzles pasados de hidden11: rejuega una alineación diaria o lee las soluciones de Fácil, Medio y Difícil.",
|
||||
playToday: "Jugar hoy",
|
||||
play: "Jugar",
|
||||
answers: "Solución",
|
||||
emptyArchive: "Todavía no hay puzzles pasados. ¡Vuelve mañana!",
|
||||
eyebrow: "Puzzle anterior",
|
||||
answersTitle: (puzzleNumber) => `Solución de hidden11 #${puzzleNumber}`,
|
||||
answersMetaTitle: (puzzleNumber, dateKey) => `Solución hidden11 #${puzzleNumber} — ${dateKey}`,
|
||||
answersMetaDescription: (puzzleNumber, dateKey) =>
|
||||
`Solución completa del puzzle hidden11 #${puzzleNumber} (${dateKey}): las alineaciones ocultas de Fácil, Medio y Difícil con todas las pistas explicadas.`,
|
||||
answersIntro: (puzzleNumber, dateKey) =>
|
||||
`Esta es la solución completa del puzzle hidden11 #${puzzleNumber}, jugado el ${dateKey}. En cada dificultad tienes las pistas que aparecieron en la partida y los cinco jugadores de la alineación oculta, con los datos que hacían que cada pista apuntara hacia ellos.`,
|
||||
playThisPuzzle: "Jugar este puzzle",
|
||||
cluesTitle: "Pistas",
|
||||
lineupTitle: "Alineación oculta",
|
||||
fullArchive: "Archivo completo",
|
||||
playTodaysPuzzle: "Jugar el puzzle de hoy",
|
||||
previously: "Antes en",
|
||||
honours: "Palmarés",
|
||||
freeGiven: "Regalado",
|
||||
describePlayer: (player, nationality, position) =>
|
||||
`${player.name} es un ${position} de ${nationality} que juega en ${player.club} (${player.league}).`,
|
||||
honourLabels: {
|
||||
championsLeague: (count) => `${count} Champions League`,
|
||||
leagueTitles: (count) => `${count} ${count === 1 ? "campeonato liguero" : "campeonatos ligueros"}`,
|
||||
ballonDOr: (count) => `${count} ${count === 1 ? "Balón de Oro" : "Balones de Oro"}`,
|
||||
goldenBoot: (count) => `${count} ${count === 1 ? "Bota de Oro" : "Botas de Oro"}`,
|
||||
worldCup: "campeón del mundo",
|
||||
copaAmerica: "campeón de la Copa América",
|
||||
euro: "campeón de Europa",
|
||||
},
|
||||
},
|
||||
fr: {
|
||||
archiveTitle: "Archives des puzzles",
|
||||
archiveIntro:
|
||||
"Toutes les compositions passées de hidden11, avec leurs indices et leurs solutions. Rejoue n'importe quel jour manqué : les parties d'archives n'affectent jamais ta série quotidienne.",
|
||||
archiveMetaTitle: "Archives hidden11 — tous les puzzles de compositions",
|
||||
archiveMetaDescription:
|
||||
"Parcours tous les puzzles passés de hidden11 : rejoue une composition quotidienne ou lis les solutions Facile, Moyen et Difficile.",
|
||||
playToday: "Jouer aujourd'hui",
|
||||
play: "Jouer",
|
||||
answers: "Solution",
|
||||
emptyArchive: "Pas encore de puzzles passés. Reviens demain !",
|
||||
eyebrow: "Puzzle précédent",
|
||||
answersTitle: (puzzleNumber) => `Solution de hidden11 #${puzzleNumber}`,
|
||||
answersMetaTitle: (puzzleNumber, dateKey) => `Solution hidden11 #${puzzleNumber} — ${dateKey}`,
|
||||
answersMetaDescription: (puzzleNumber, dateKey) =>
|
||||
`Solution complète du puzzle hidden11 #${puzzleNumber} (${dateKey}) : les compositions cachées Facile, Moyen et Difficile avec tous les indices expliqués.`,
|
||||
answersIntro: (puzzleNumber, dateKey) =>
|
||||
`Voici la solution complète du puzzle hidden11 #${puzzleNumber}, joué le ${dateKey}. Chaque difficulté ci-dessous reprend les indices affichés en jeu et les cinq joueurs de la composition cachée, avec les détails qui rendaient chaque indice pertinent.`,
|
||||
playThisPuzzle: "Jouer ce puzzle",
|
||||
cluesTitle: "Indices",
|
||||
lineupTitle: "Composition cachée",
|
||||
fullArchive: "Toutes les archives",
|
||||
playTodaysPuzzle: "Jouer le puzzle du jour",
|
||||
previously: "Auparavant",
|
||||
honours: "Palmarès",
|
||||
freeGiven: "Offert",
|
||||
describePlayer: (player, nationality, position) =>
|
||||
`${player.name} est un ${position} ${nationality === "France" ? "français" : `de ${nationality}`} qui joue à ${player.club} (${player.league}).`,
|
||||
honourLabels: {
|
||||
championsLeague: (count) => `${count} Ligue des champions`,
|
||||
leagueTitles: (count) => `${count} titre${count === 1 ? "" : "s"} de champion national`,
|
||||
ballonDOr: (count) => `${count} Ballon d'Or`,
|
||||
goldenBoot: (count) => `${count} Soulier d'Or`,
|
||||
worldCup: "champion du monde",
|
||||
copaAmerica: "vainqueur de la Copa América",
|
||||
euro: "champion d'Europe",
|
||||
},
|
||||
},
|
||||
de: {
|
||||
archiveTitle: "Rätsel-Archiv",
|
||||
archiveIntro:
|
||||
"Alle vergangenen hidden11-Aufstellungen mit Hinweisen und Lösungen. Spiele jeden verpassten Tag nach — Archivpartien beeinflussen deine tägliche Serie nie.",
|
||||
archiveMetaTitle: "hidden11 Archiv — alle bisherigen Aufstellungs-Rätsel",
|
||||
archiveMetaDescription:
|
||||
"Alle vergangenen hidden11-Rätsel: Spiele eine tägliche Aufstellung nach oder lies die Lösungen für Leicht, Mittel und Schwer.",
|
||||
playToday: "Heute spielen",
|
||||
play: "Spielen",
|
||||
answers: "Lösung",
|
||||
emptyArchive: "Noch keine vergangenen Rätsel. Komm morgen wieder!",
|
||||
eyebrow: "Vergangenes Rätsel",
|
||||
answersTitle: (puzzleNumber) => `Lösung zu hidden11 #${puzzleNumber}`,
|
||||
answersMetaTitle: (puzzleNumber, dateKey) => `hidden11 #${puzzleNumber} Lösung — ${dateKey}`,
|
||||
answersMetaDescription: (puzzleNumber, dateKey) =>
|
||||
`Komplette Lösung zum hidden11-Rätsel #${puzzleNumber} (${dateKey}): die versteckten Aufstellungen für Leicht, Mittel und Schwer mit allen erklärten Hinweisen.`,
|
||||
answersIntro: (puzzleNumber, dateKey) =>
|
||||
`Hier ist die vollständige Lösung zum hidden11-Rätsel #${puzzleNumber} vom ${dateKey}. Zu jedem Schwierigkeitsgrad findest du die im Spiel gezeigten Hinweise und die fünf Spieler der versteckten Aufstellung samt der Details, die jeden Hinweis auf sie zeigen ließen.`,
|
||||
playThisPuzzle: "Dieses Rätsel spielen",
|
||||
cluesTitle: "Hinweise",
|
||||
lineupTitle: "Versteckte Aufstellung",
|
||||
fullArchive: "Ganzes Archiv",
|
||||
playTodaysPuzzle: "Heutiges Rätsel spielen",
|
||||
previously: "Zuvor",
|
||||
honours: "Titel",
|
||||
freeGiven: "Geschenkt",
|
||||
describePlayer: (player, nationality, position) =>
|
||||
`${player.name} ist ein ${position} aus ${nationality} und spielt für ${player.club} in der ${player.league}.`,
|
||||
honourLabels: {
|
||||
championsLeague: (count) => `${count}× Champions League`,
|
||||
leagueTitles: (count) => `${count}× nationaler Meister`,
|
||||
ballonDOr: (count) => `${count}× Ballon d'Or`,
|
||||
goldenBoot: (count) => `${count}× Goldener Schuh`,
|
||||
worldCup: "Weltmeister",
|
||||
copaAmerica: "Copa-América-Sieger",
|
||||
euro: "Europameister",
|
||||
},
|
||||
},
|
||||
it: {
|
||||
archiveTitle: "Archivio dei puzzle",
|
||||
archiveIntro:
|
||||
"Tutte le formazioni passate di hidden11, con indizi e soluzioni. Rigioca qualsiasi giorno che ti sei perso: le partite d'archivio non toccano mai la tua striscia quotidiana.",
|
||||
archiveMetaTitle: "Archivio hidden11 — tutti i puzzle sulle formazioni",
|
||||
archiveMetaDescription:
|
||||
"Sfoglia tutti i puzzle passati di hidden11: rigioca una formazione quotidiana o leggi le soluzioni di Facile, Medio e Difficile.",
|
||||
playToday: "Gioca oggi",
|
||||
play: "Gioca",
|
||||
answers: "Soluzione",
|
||||
emptyArchive: "Ancora nessun puzzle passato. Torna domani!",
|
||||
eyebrow: "Puzzle precedente",
|
||||
answersTitle: (puzzleNumber) => `Soluzione di hidden11 #${puzzleNumber}`,
|
||||
answersMetaTitle: (puzzleNumber, dateKey) => `Soluzione hidden11 #${puzzleNumber} — ${dateKey}`,
|
||||
answersMetaDescription: (puzzleNumber, dateKey) =>
|
||||
`Soluzione completa del puzzle hidden11 #${puzzleNumber} (${dateKey}): le formazioni nascoste di Facile, Medio e Difficile con tutti gli indizi spiegati.`,
|
||||
answersIntro: (puzzleNumber, dateKey) =>
|
||||
`Questa è la soluzione completa del puzzle hidden11 #${puzzleNumber}, giocato il ${dateKey}. Per ogni difficoltà trovi gli indizi mostrati in partita e i cinque giocatori della formazione nascosta, con i dettagli che rendevano ogni indizio calzante.`,
|
||||
playThisPuzzle: "Gioca questo puzzle",
|
||||
cluesTitle: "Indizi",
|
||||
lineupTitle: "Formazione nascosta",
|
||||
fullArchive: "Archivio completo",
|
||||
playTodaysPuzzle: "Gioca il puzzle di oggi",
|
||||
previously: "In passato",
|
||||
honours: "Palmarès",
|
||||
freeGiven: "Regalato",
|
||||
describePlayer: (player, nationality, position) =>
|
||||
`${player.name} è un ${position} ${nationality === "Italia" ? "italiano" : `della ${nationality}`} che gioca nel ${player.club} (${player.league}).`,
|
||||
honourLabels: {
|
||||
championsLeague: (count) => `${count} Champions League`,
|
||||
leagueTitles: (count) => `${count} ${count === 1 ? "titolo nazionale" : "titoli nazionali"}`,
|
||||
ballonDOr: (count) => `${count} ${count === 1 ? "Pallone d'Oro" : "Palloni d'Oro"}`,
|
||||
goldenBoot: (count) => `${count} Scarpa d'Oro`,
|
||||
worldCup: "campione del mondo",
|
||||
copaAmerica: "vincitore della Copa América",
|
||||
euro: "campione d'Europa",
|
||||
},
|
||||
},
|
||||
pt: {
|
||||
archiveTitle: "Arquivo de puzzles",
|
||||
archiveIntro:
|
||||
"Todos os onzes passados do hidden11, com pistas e soluções. Volta a jogar qualquer dia que tenhas perdido: os jogos do arquivo nunca afetam a tua sequência diária.",
|
||||
archiveMetaTitle: "Arquivo hidden11 — todos os puzzles de onzes",
|
||||
archiveMetaDescription:
|
||||
"Consulta todos os puzzles passados do hidden11: volta a jogar um onze diário ou lê as soluções de Fácil, Médio e Difícil.",
|
||||
playToday: "Jogar hoje",
|
||||
play: "Jogar",
|
||||
answers: "Solução",
|
||||
emptyArchive: "Ainda não há puzzles passados. Volta amanhã!",
|
||||
eyebrow: "Puzzle anterior",
|
||||
answersTitle: (puzzleNumber) => `Solução do hidden11 #${puzzleNumber}`,
|
||||
answersMetaTitle: (puzzleNumber, dateKey) => `Solução hidden11 #${puzzleNumber} — ${dateKey}`,
|
||||
answersMetaDescription: (puzzleNumber, dateKey) =>
|
||||
`Solução completa do puzzle hidden11 #${puzzleNumber} (${dateKey}): os onzes escondidos de Fácil, Médio e Difícil com todas as pistas explicadas.`,
|
||||
answersIntro: (puzzleNumber, dateKey) =>
|
||||
`Esta é a solução completa do puzzle hidden11 #${puzzleNumber}, jogado a ${dateKey}. Em cada dificuldade tens as pistas que apareceram no jogo e os cinco jogadores do onze escondido, com os dados que faziam cada pista apontar para eles.`,
|
||||
playThisPuzzle: "Jogar este puzzle",
|
||||
cluesTitle: "Pistas",
|
||||
lineupTitle: "Onze escondido",
|
||||
fullArchive: "Arquivo completo",
|
||||
playTodaysPuzzle: "Jogar o puzzle de hoje",
|
||||
previously: "Antes em",
|
||||
honours: "Palmarés",
|
||||
freeGiven: "Oferecido",
|
||||
describePlayer: (player, nationality, position) =>
|
||||
`${player.name} é um ${position} de ${nationality} que joga no ${player.club} (${player.league}).`,
|
||||
honourLabels: {
|
||||
championsLeague: (count) => `${count} Liga dos Campeões`,
|
||||
leagueTitles: (count) => `${count} ${count === 1 ? "título nacional" : "títulos nacionais"}`,
|
||||
ballonDOr: (count) => `${count} Bola de Ouro`,
|
||||
goldenBoot: (count) => `${count} Bota de Ouro`,
|
||||
worldCup: "campeão do mundo",
|
||||
copaAmerica: "vencedor da Copa América",
|
||||
euro: "campeão da Europa",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function localizeNationality(nationality: string, locale: Locale): string {
|
||||
return NATIONALITY[locale][nationality] ?? nationality;
|
||||
}
|
||||
|
||||
// One sentence of real context per player, so answer pages carry text of their
|
||||
// own instead of being 113 near-identical lists of names.
|
||||
export function describePlayer(player: Player, locale: Locale): string {
|
||||
const copy = ARCHIVE_COPY[locale];
|
||||
return copy.describePlayer(player, localizeNationality(player.nationality, locale), POSITION_LABEL[locale][player.position]);
|
||||
}
|
||||
|
||||
export function playerHonours(player: Player, locale: Locale): string[] {
|
||||
const labels = ARCHIVE_COPY[locale].honourLabels;
|
||||
const honours: string[] = [];
|
||||
|
||||
if (player.championsLeagueTitles) honours.push(labels.championsLeague(player.championsLeagueTitles));
|
||||
if (player.leagueTitles) honours.push(labels.leagueTitles(player.leagueTitles));
|
||||
if (player.ballonDOr) honours.push(labels.ballonDOr(player.ballonDOr));
|
||||
if (player.goldenBoot) honours.push(labels.goldenBoot(player.goldenBoot));
|
||||
if (player.worldCupWinner) honours.push(labels.worldCup);
|
||||
if (player.copaAmericaWinner) honours.push(labels.copaAmerica);
|
||||
if (player.eurocupWinner) honours.push(labels.euro);
|
||||
|
||||
return honours;
|
||||
}
|
||||
@@ -39,6 +39,22 @@ export function getArchiveDateKeys(todayKey: string): string[] {
|
||||
.sort((left, right) => right.localeCompare(left));
|
||||
}
|
||||
|
||||
// Resolves a public answer-page target from a raw URL segment. Returns null for
|
||||
// malformed numbers, for days without a dedicated puzzle set, and for today or
|
||||
// any future day — solutions are never rendered before they have been played.
|
||||
export function getPastPuzzleSet(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 };
|
||||
}
|
||||
|
||||
export function getDailyPuzzles(dateKey: string): Puzzle[] {
|
||||
const dated = puzzles.filter((puzzle) => puzzle.date === dateKey);
|
||||
const source = dated.length === DIFFICULTIES.length ? dated : getRotatingPuzzleSet(dateKey);
|
||||
|
||||
+84
@@ -62,6 +62,7 @@ export type Dictionary = {
|
||||
share: string;
|
||||
challengeFriend: string;
|
||||
challengeShareLead: string;
|
||||
challengeShareLeadNamed: (name: string) => string;
|
||||
completedTitle: string;
|
||||
completedPerfect: string;
|
||||
completedPartial: string;
|
||||
@@ -100,8 +101,19 @@ export type Dictionary = {
|
||||
noHintAvailable: string;
|
||||
hintRevealed: (position: string) => string;
|
||||
nextPuzzleIn: string;
|
||||
comeBackTomorrow: string;
|
||||
remindMe: string;
|
||||
reminderOn: string;
|
||||
reminderBlocked: string;
|
||||
reminderTitle: string;
|
||||
reminderBody: string;
|
||||
installApp: string;
|
||||
installIosHint: string;
|
||||
earlySharePrompt: (difficulty: string, result: string) => string;
|
||||
challengeLead: (score: string) => string;
|
||||
challengeLeadNamed: (name: string, score: string) => string;
|
||||
yourName: string;
|
||||
yourNamePlaceholder: string;
|
||||
challengeExpired: string;
|
||||
challengeYou: string;
|
||||
challengeRival: string;
|
||||
@@ -163,6 +175,7 @@ const dictionaries: Record<Locale, Dictionary> = {
|
||||
share: "Share",
|
||||
challengeFriend: "Challenge a friend",
|
||||
challengeShareLead: "I played today's hidden11. Can you beat me?",
|
||||
challengeShareLeadNamed: (name) => `${name} played today's hidden11. Can you beat them?`,
|
||||
completedTitle: "Daily result ready",
|
||||
completedPerfect: "Perfect day. All three levels cleared.",
|
||||
completedPartial: "Day finished. Share the score and come back tomorrow.",
|
||||
@@ -201,8 +214,19 @@ const dictionaries: Record<Locale, Dictionary> = {
|
||||
noHintAvailable: "No hints available.",
|
||||
hintRevealed: (position) => `${position} revealed.`,
|
||||
nextPuzzleIn: "Next puzzle in",
|
||||
comeBackTomorrow: "Come back tomorrow",
|
||||
remindMe: "Remind me daily",
|
||||
reminderOn: "Daily reminder is on",
|
||||
reminderBlocked: "Notifications are blocked in your browser settings.",
|
||||
reminderTitle: "hidden11",
|
||||
reminderBody: "Today's hidden XI is live. Can you find it?",
|
||||
installApp: "Install app",
|
||||
installIosHint: "On iPhone: tap the Share button, then “Add to Home Screen”.",
|
||||
earlySharePrompt: (difficulty, result) => `${difficulty} solved in ${result}. Share it while you finish the rest!`,
|
||||
challengeLead: (score) => `Challenge accepted: your rival scored ${score}. Beat it!`,
|
||||
challengeLeadNamed: (name, score) => `${name} challenged you with ${score}. Beat it!`,
|
||||
yourName: "Your name (optional)",
|
||||
yourNamePlaceholder: "e.g. Alex",
|
||||
challengeExpired: "That challenge was for a previous day. Play today's puzzle!",
|
||||
challengeYou: "You",
|
||||
challengeRival: "Rival",
|
||||
@@ -262,6 +286,7 @@ const dictionaries: Record<Locale, Dictionary> = {
|
||||
share: "Compartir",
|
||||
challengeFriend: "Retar a un amigo",
|
||||
challengeShareLead: "He jugado al hidden11 de hoy. ¿Puedes superarme?",
|
||||
challengeShareLeadNamed: (name) => `${name} ha jugado al hidden11 de hoy. ¿Puedes superarle?`,
|
||||
completedTitle: "Resultado diario listo",
|
||||
completedPerfect: "Día perfecto. Los tres niveles completados.",
|
||||
completedPartial: "Día terminado. Comparte tu resultado y vuelve mañana.",
|
||||
@@ -300,8 +325,19 @@ const dictionaries: Record<Locale, Dictionary> = {
|
||||
noHintAvailable: "No quedan pistas disponibles.",
|
||||
hintRevealed: (position) => `${position} revelado.`,
|
||||
nextPuzzleIn: "Próximo puzzle en",
|
||||
comeBackTomorrow: "Vuelve mañana",
|
||||
remindMe: "Avísame cada día",
|
||||
reminderOn: "Recordatorio diario activado",
|
||||
reminderBlocked: "Las notificaciones están bloqueadas en tu navegador.",
|
||||
reminderTitle: "hidden11",
|
||||
reminderBody: "El XI oculto de hoy ya está disponible. ¿Lo sacas?",
|
||||
installApp: "Instalar app",
|
||||
installIosHint: "En iPhone: toca el botón Compartir y luego «Añadir a pantalla de inicio».",
|
||||
earlySharePrompt: (difficulty, result) => `${difficulty} resuelto en ${result}. ¡Compártelo mientras terminas el resto!`,
|
||||
challengeLead: (score) => `Reto aceptado: tu rival hizo ${score}. ¡Supéralo!`,
|
||||
challengeLeadNamed: (name, score) => `${name} te reta con ${score}. ¡Supéralo!`,
|
||||
yourName: "Tu nombre (opcional)",
|
||||
yourNamePlaceholder: "p. ej. Alex",
|
||||
challengeExpired: "Ese reto era de un día anterior. ¡Juega el puzzle de hoy!",
|
||||
challengeYou: "Tú",
|
||||
challengeRival: "Rival",
|
||||
@@ -375,6 +411,7 @@ const dictionaries: Record<Locale, Dictionary> = {
|
||||
share: "Partager",
|
||||
challengeFriend: "Défier un ami",
|
||||
challengeShareLead: "J'ai joué au hidden11 du jour. Peux-tu faire mieux ?",
|
||||
challengeShareLeadNamed: (name) => `${name} a joué au hidden11 du jour. Peux-tu faire mieux ?`,
|
||||
completedTitle: "Résultat du jour prêt",
|
||||
completedPerfect: "Journée parfaite. Les trois niveaux terminés.",
|
||||
completedPartial: "Journée terminée. Partage ton score et reviens demain.",
|
||||
@@ -413,8 +450,19 @@ const dictionaries: Record<Locale, Dictionary> = {
|
||||
noHintAvailable: "Aucun indice disponible.",
|
||||
hintRevealed: (position) => `${position} révélé.`,
|
||||
nextPuzzleIn: "Prochain puzzle dans",
|
||||
comeBackTomorrow: "Reviens demain",
|
||||
remindMe: "Me rappeler chaque jour",
|
||||
reminderOn: "Rappel quotidien activé",
|
||||
reminderBlocked: "Les notifications sont bloquées dans ton navigateur.",
|
||||
reminderTitle: "hidden11",
|
||||
reminderBody: "Le onze caché du jour est en ligne. Tu le trouves ?",
|
||||
installApp: "Installer l'app",
|
||||
installIosHint: "Sur iPhone : appuie sur Partager, puis «Sur l’écran d’accueil».",
|
||||
earlySharePrompt: (difficulty, result) => `${difficulty} résolu en ${result}. Partage-le en attendant la suite !`,
|
||||
challengeLead: (score) => `Défi accepté : ton rival a fait ${score}. Bats-le !`,
|
||||
challengeLeadNamed: (name, score) => `${name} te défie avec ${score}. Bats-le !`,
|
||||
yourName: "Ton nom (facultatif)",
|
||||
yourNamePlaceholder: "ex. Alex",
|
||||
challengeExpired: "Ce défi datait d'un jour précédent. Joue au puzzle du jour !",
|
||||
challengeYou: "Toi",
|
||||
challengeRival: "Rival",
|
||||
@@ -474,6 +522,7 @@ const dictionaries: Record<Locale, Dictionary> = {
|
||||
share: "Teilen",
|
||||
challengeFriend: "Freund herausfordern",
|
||||
challengeShareLead: "Ich habe das heutige hidden11 gespielt. Schaffst du mehr?",
|
||||
challengeShareLeadNamed: (name) => `${name} hat das heutige hidden11 gespielt. Schaffst du mehr?`,
|
||||
completedTitle: "Tagesergebnis bereit",
|
||||
completedPerfect: "Perfekter Tag. Alle drei Stufen geschafft.",
|
||||
completedPartial: "Tag beendet. Teile dein Ergebnis und komm morgen zurück.",
|
||||
@@ -512,8 +561,19 @@ const dictionaries: Record<Locale, Dictionary> = {
|
||||
noHintAvailable: "Keine Tipps verfügbar.",
|
||||
hintRevealed: (position) => `${position} aufgedeckt.`,
|
||||
nextPuzzleIn: "Nächstes Puzzle in",
|
||||
comeBackTomorrow: "Komm morgen wieder",
|
||||
remindMe: "Täglich erinnern",
|
||||
reminderOn: "Tägliche Erinnerung ist an",
|
||||
reminderBlocked: "Benachrichtigungen sind in deinem Browser blockiert.",
|
||||
reminderTitle: "hidden11",
|
||||
reminderBody: "Die versteckte Elf von heute ist da. Findest du sie?",
|
||||
installApp: "App installieren",
|
||||
installIosHint: "Auf dem iPhone: Teilen antippen, dann „Zum Home-Bildschirm“.",
|
||||
earlySharePrompt: (difficulty, result) => `${difficulty} in ${result} gelöst. Teile es, während du den Rest löst!`,
|
||||
challengeLead: (score) => `Duell angenommen: Dein Rivale schaffte ${score}. Schlag ihn!`,
|
||||
challengeLeadNamed: (name, score) => `${name} fordert dich mit ${score} heraus. Schlag das!`,
|
||||
yourName: "Dein Name (optional)",
|
||||
yourNamePlaceholder: "z. B. Alex",
|
||||
challengeExpired: "Dieses Duell war für einen früheren Tag. Spiel das heutige Puzzle!",
|
||||
challengeYou: "Du",
|
||||
challengeRival: "Rivale",
|
||||
@@ -573,6 +633,7 @@ const dictionaries: Record<Locale, Dictionary> = {
|
||||
share: "Condividi",
|
||||
challengeFriend: "Sfida un amico",
|
||||
challengeShareLead: "Ho giocato all'hidden11 di oggi. Riesci a battermi?",
|
||||
challengeShareLeadNamed: (name) => `${name} ha giocato all'hidden11 di oggi. Riesci a battere il suo punteggio?`,
|
||||
completedTitle: "Risultato giornaliero pronto",
|
||||
completedPerfect: "Giornata perfetta. Tutti e tre i livelli completati.",
|
||||
completedPartial: "Giornata finita. Condividi il risultato e torna domani.",
|
||||
@@ -611,8 +672,19 @@ const dictionaries: Record<Locale, Dictionary> = {
|
||||
noHintAvailable: "Nessun aiuto disponibile.",
|
||||
hintRevealed: (position) => `${position} rivelato.`,
|
||||
nextPuzzleIn: "Prossimo puzzle tra",
|
||||
comeBackTomorrow: "Torna domani",
|
||||
remindMe: "Ricordamelo ogni giorno",
|
||||
reminderOn: "Promemoria giornaliero attivo",
|
||||
reminderBlocked: "Le notifiche sono bloccate nel tuo browser.",
|
||||
reminderTitle: "hidden11",
|
||||
reminderBody: "L'undici nascosto di oggi è online. Lo indovini?",
|
||||
installApp: "Installa app",
|
||||
installIosHint: "Su iPhone: tocca Condividi, poi «Aggiungi a Home».",
|
||||
earlySharePrompt: (difficulty, result) => `${difficulty} risolto in ${result}. Condividilo mentre finisci il resto!`,
|
||||
challengeLead: (score) => `Sfida accettata: il tuo rivale ha fatto ${score}. Superalo!`,
|
||||
challengeLeadNamed: (name, score) => `${name} ti sfida con ${score}. Superalo!`,
|
||||
yourName: "Il tuo nome (facoltativo)",
|
||||
yourNamePlaceholder: "es. Alex",
|
||||
challengeExpired: "Quella sfida era di un giorno precedente. Gioca al puzzle di oggi!",
|
||||
challengeYou: "Tu",
|
||||
challengeRival: "Rivale",
|
||||
@@ -672,6 +744,7 @@ const dictionaries: Record<Locale, Dictionary> = {
|
||||
share: "Partilhar",
|
||||
challengeFriend: "Desafiar um amigo",
|
||||
challengeShareLead: "Joguei o hidden11 de hoje. Consegues superar-me?",
|
||||
challengeShareLeadNamed: (name) => `${name} jogou o hidden11 de hoje. Consegues superar?`,
|
||||
completedTitle: "Resultado diário pronto",
|
||||
completedPerfect: "Dia perfeito. Os três níveis concluídos.",
|
||||
completedPartial: "Dia terminado. Partilha o resultado e volta amanhã.",
|
||||
@@ -710,8 +783,19 @@ const dictionaries: Record<Locale, Dictionary> = {
|
||||
noHintAvailable: "Sem dicas disponíveis.",
|
||||
hintRevealed: (position) => `${position} revelado.`,
|
||||
nextPuzzleIn: "Próximo puzzle em",
|
||||
comeBackTomorrow: "Volta amanhã",
|
||||
remindMe: "Lembra-me todos os dias",
|
||||
reminderOn: "Lembrete diário ativado",
|
||||
reminderBlocked: "As notificações estão bloqueadas no teu navegador.",
|
||||
reminderTitle: "hidden11",
|
||||
reminderBody: "O onze escondido de hoje já está disponível. Consegues?",
|
||||
installApp: "Instalar app",
|
||||
installIosHint: "No iPhone: toca em Partilhar e depois «Adicionar ao ecrã principal».",
|
||||
earlySharePrompt: (difficulty, result) => `${difficulty} resolvido em ${result}. Partilha enquanto terminas o resto!`,
|
||||
challengeLead: (score) => `Desafio aceite: o teu rival fez ${score}. Supera-o!`,
|
||||
challengeLeadNamed: (name, score) => `${name} desafia-te com ${score}. Supera-o!`,
|
||||
yourName: "O teu nome (opcional)",
|
||||
yourNamePlaceholder: "ex. Alex",
|
||||
challengeExpired: "Esse desafio era de um dia anterior. Joga o puzzle de hoje!",
|
||||
challengeYou: "Tu",
|
||||
challengeRival: "Rival",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Locale } from "./i18n";
|
||||
import { INTL_LOCALES } from "./seo-content";
|
||||
|
||||
// The /[locale] segment is a catch-all as far as the router is concerned, so
|
||||
// every intl page funnels its raw segment through here before using it.
|
||||
export function resolveIntlLocale(locale: string): Locale | null {
|
||||
return (INTL_LOCALES as string[]).includes(locale) ? (locale as Locale) : null;
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
// Client helpers for the daily-reminder push subscription (see app/api/push).
|
||||
|
||||
export type PushState = "unsupported" | "default" | "granted" | "denied" | "subscribed";
|
||||
|
||||
const MESSAGE_CACHE = "hidden11-push";
|
||||
const MESSAGE_KEY = "/__push-message";
|
||||
|
||||
export type ReminderMessage = {
|
||||
title: string;
|
||||
body: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
function pushSupported(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
"serviceWorker" in navigator &&
|
||||
"PushManager" in window &&
|
||||
"Notification" in window
|
||||
);
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const raw = window.atob(base64);
|
||||
const output = new Uint8Array(raw.length);
|
||||
for (let index = 0; index < raw.length; index += 1) {
|
||||
output[index] = raw.charCodeAt(index);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export async function getPushState(): Promise<PushState> {
|
||||
if (!pushSupported()) return "unsupported";
|
||||
if (Notification.permission === "denied") return "denied";
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration("/sw.js");
|
||||
const subscription = await registration?.pushManager.getSubscription();
|
||||
if (subscription) return "subscribed";
|
||||
} catch {
|
||||
// fall through to the raw permission state
|
||||
}
|
||||
|
||||
return Notification.permission === "granted" ? "granted" : "default";
|
||||
}
|
||||
|
||||
// The daily push is sent without a payload — a payload would need full aes128gcm
|
||||
// content encryption for no benefit here — so the copy the notification shows is
|
||||
// stashed in the Cache API, the only storage a service worker can read.
|
||||
async function storeReminderMessage(message: ReminderMessage): Promise<void> {
|
||||
const cache = await caches.open(MESSAGE_CACHE);
|
||||
await cache.put(
|
||||
MESSAGE_KEY,
|
||||
new Response(JSON.stringify(message), { headers: { "Content-Type": "application/json" } }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function subscribeToDailyReminder(
|
||||
locale: string,
|
||||
message: ReminderMessage,
|
||||
): Promise<PushState> {
|
||||
if (!pushSupported()) return "unsupported";
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") return permission === "denied" ? "denied" : "default";
|
||||
|
||||
const config = await fetch("/api/push", { cache: "no-store" })
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.catch(() => null);
|
||||
|
||||
const publicKey = typeof config?.publicKey === "string" ? config.publicKey : "";
|
||||
if (!publicKey) return "granted";
|
||||
|
||||
const registration = await navigator.serviceWorker.register("/sw.js");
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
const existing = await registration.pushManager.getSubscription();
|
||||
const subscription =
|
||||
existing ??
|
||||
(await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey) as BufferSource,
|
||||
}));
|
||||
|
||||
await storeReminderMessage(message);
|
||||
|
||||
const raw = subscription.toJSON() as { endpoint?: string; keys?: { p256dh?: string; auth?: string } };
|
||||
const response = await fetch("/api/push", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "subscribe",
|
||||
endpoint: raw.endpoint,
|
||||
keys: raw.keys,
|
||||
locale,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Nothing persisted it, so don't leave a browser subscription that will
|
||||
// never receive anything.
|
||||
await subscription.unsubscribe().catch(() => undefined);
|
||||
return "granted";
|
||||
}
|
||||
|
||||
return "subscribed";
|
||||
}
|
||||
@@ -21,6 +21,31 @@ export function localeUrl(locale: Locale): string {
|
||||
return `${BASE_URL}${localePath(locale)}`;
|
||||
}
|
||||
|
||||
export const ALL_LOCALES: Locale[] = ["en", ...INTL_LOCALES];
|
||||
|
||||
// Localized sub-paths. English keeps the bare paths so existing links and any
|
||||
// already-indexed URL stay valid.
|
||||
export function archivePath(locale: Locale): string {
|
||||
return locale === "en" ? "/archive" : `/${locale}/archive`;
|
||||
}
|
||||
|
||||
export function puzzlePath(locale: Locale, puzzleNumber: number): string {
|
||||
return locale === "en" ? `/puzzle/${puzzleNumber}` : `/${locale}/puzzle/${puzzleNumber}`;
|
||||
}
|
||||
|
||||
export function playPath(locale: Locale, puzzleNumber: number): string {
|
||||
return locale === "en" ? `/play/${puzzleNumber}` : `/${locale}/play/${puzzleNumber}`;
|
||||
}
|
||||
|
||||
function languagesFor(pathFor: (locale: Locale) => string): Record<string, string> {
|
||||
const languages: Record<string, string> = {};
|
||||
ALL_LOCALES.forEach((locale) => {
|
||||
languages[locale] = `${BASE_URL}${pathFor(locale)}`;
|
||||
});
|
||||
languages["x-default"] = `${BASE_URL}${pathFor("en")}`;
|
||||
return languages;
|
||||
}
|
||||
|
||||
export function hreflangLanguages(): Record<string, string> {
|
||||
return {
|
||||
en: localeUrl("en"),
|
||||
@@ -33,6 +58,14 @@ export function hreflangLanguages(): Record<string, string> {
|
||||
};
|
||||
}
|
||||
|
||||
export function archiveHreflang(): Record<string, string> {
|
||||
return languagesFor(archivePath);
|
||||
}
|
||||
|
||||
export function puzzleHreflang(puzzleNumber: number): Record<string, string> {
|
||||
return languagesFor((locale) => puzzlePath(locale, puzzleNumber));
|
||||
}
|
||||
|
||||
export type SeoCopy = {
|
||||
metaTitle: string;
|
||||
metaDescription: string;
|
||||
|
||||
+34
-6
@@ -22,6 +22,9 @@ export type SharePayload = {
|
||||
dateKey: string;
|
||||
puzzleNumber: number;
|
||||
difficulties: Record<Difficulty, ShareDifficultySummary>;
|
||||
// Optional display name of whoever shared the result. Free text typed by the
|
||||
// player, so every consumer sanitises it before rendering.
|
||||
name?: string;
|
||||
};
|
||||
|
||||
function attemptRow(progressAttempt: { slots: { position: string; feedback: keyof typeof emoji }[] }): string {
|
||||
@@ -80,18 +83,40 @@ export function buildGlobalStatsShareText(stats: GlobalStats, t: Dictionary): st
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildChallengeShareText(dateKey: string, progress: DailyProgress, t: Dictionary): string {
|
||||
return [t.challengeShareLead, buildShareText(dateKey, progress, t)].join("\n");
|
||||
export function buildChallengeShareText(
|
||||
dateKey: string,
|
||||
progress: DailyProgress,
|
||||
t: Dictionary,
|
||||
name?: string,
|
||||
): string {
|
||||
const cleanName = name ? sanitizeShareName(name) : "";
|
||||
const lead = cleanName ? t.challengeShareLeadNamed(cleanName) : t.challengeShareLead;
|
||||
return [lead, buildShareText(dateKey, progress, t)].join("\n");
|
||||
}
|
||||
|
||||
export function buildShareUrl(_dateKey: string, _progress: DailyProgress, origin = "https://hidden11.app"): string {
|
||||
return origin.replace(/\/$/, "");
|
||||
export const MAX_SHARE_NAME_LENGTH = 20;
|
||||
|
||||
// Strips anything that could turn a name into markup or a second line of text,
|
||||
// and caps the length so the share code stays short.
|
||||
export function sanitizeShareName(raw: string): string {
|
||||
return raw
|
||||
.replace(/[<>&"'`\r\n\t]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, MAX_SHARE_NAME_LENGTH);
|
||||
}
|
||||
|
||||
export function buildShareResultUrl(dateKey: string, progress: DailyProgress, origin = "https://hidden11.app"): string {
|
||||
export function buildShareResultUrl(
|
||||
dateKey: string,
|
||||
progress: DailyProgress,
|
||||
origin = "https://hidden11.app",
|
||||
name?: string,
|
||||
): string {
|
||||
const cleanName = name ? sanitizeShareName(name) : "";
|
||||
const payload: SharePayload = {
|
||||
dateKey,
|
||||
puzzleNumber: getPuzzleNumber(dateKey),
|
||||
...(cleanName ? { name: cleanName } : {}),
|
||||
difficulties: DIFFICULTIES.reduce<Record<Difficulty, ShareDifficultySummary>>((acc, difficulty) => {
|
||||
const item = progress[difficulty];
|
||||
acc[difficulty] = {
|
||||
@@ -120,7 +145,10 @@ export function parseSharePayload(shareCode: string): SharePayload | null {
|
||||
if (!payload?.dateKey || !payload?.puzzleNumber || !payload?.difficulties) {
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
// The share code is attacker-controlled: re-sanitise the name on the way in
|
||||
// rather than trusting whatever was encoded.
|
||||
const name = typeof payload.name === "string" ? sanitizeShareName(payload.name) : "";
|
||||
return name ? { ...payload, name } : { ...payload, name: undefined };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ export type Player = {
|
||||
championsLeagueTitles?: number;
|
||||
leagueTitles?: number;
|
||||
leagueTitleCountries?: string[];
|
||||
ballonDOr?: number;
|
||||
goldenBoot?: number;
|
||||
worldCupWinner?: boolean;
|
||||
copaAmericaWinner?: boolean;
|
||||
eurocupWinner?: boolean;
|
||||
};
|
||||
|
||||
export type Puzzle = {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
21259b3f13afc0f590c2e2d6611d64d9
|
||||
@@ -0,0 +1,74 @@
|
||||
// hidden11 service worker.
|
||||
//
|
||||
// Scope is deliberately narrow: it exists to deliver the daily reminder, not to
|
||||
// cache the app. The push messages are sent WITHOUT a payload (the sender only
|
||||
// needs a VAPID header, no content encryption), so the notification copy is
|
||||
// written into the Cache API when the user subscribes and read back here — a
|
||||
// service worker cannot reach localStorage.
|
||||
|
||||
const MESSAGE_CACHE = "hidden11-push";
|
||||
const MESSAGE_KEY = "/__push-message";
|
||||
|
||||
const FALLBACK = {
|
||||
title: "hidden11",
|
||||
body: "Today's hidden XI is live.",
|
||||
url: "/",
|
||||
};
|
||||
|
||||
async function reminderMessage() {
|
||||
try {
|
||||
const cache = await caches.open(MESSAGE_CACHE);
|
||||
const stored = await cache.match(MESSAGE_KEY);
|
||||
if (!stored) return FALLBACK;
|
||||
const data = await stored.json();
|
||||
return {
|
||||
title: data.title || FALLBACK.title,
|
||||
body: data.body || FALLBACK.body,
|
||||
url: data.url || FALLBACK.url,
|
||||
};
|
||||
} catch {
|
||||
return FALLBACK;
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const message = await reminderMessage();
|
||||
await self.registration.showNotification(message.title, {
|
||||
body: message.body,
|
||||
icon: "/icon-192.png",
|
||||
badge: "/icon-192.png",
|
||||
tag: "hidden11-daily",
|
||||
renotify: true,
|
||||
data: { url: message.url },
|
||||
});
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const target = event.notification.data?.url || FALLBACK.url;
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const allClients = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
const existing = allClients.find((client) => client.url.includes(self.location.origin));
|
||||
if (existing) {
|
||||
await existing.focus();
|
||||
if ("navigate" in existing) await existing.navigate(target);
|
||||
return;
|
||||
}
|
||||
await self.clients.openWindow(target);
|
||||
})(),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
// Submits the sitemap URLs to IndexNow (Bing, Yandex, Seznam, Naver).
|
||||
//
|
||||
// Google dropped its sitemap ping endpoint in 2023, so the Search Console
|
||||
// submission stays a one-off manual step; IndexNow is the part that can run
|
||||
// unattended, which is why the daily n8n job calls this.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/submit-indexnow.mjs # every URL in the sitemap
|
||||
// node scripts/submit-indexnow.mjs --today # only today-sensitive URLs
|
||||
// INDEXNOW_KEY=... SITE_URL=... node scripts/...
|
||||
|
||||
const SITE_URL = (process.env.SITE_URL ?? "https://hidden11.app").replace(/\/$/, "");
|
||||
const KEY = process.env.INDEXNOW_KEY;
|
||||
const ENDPOINT = "https://api.indexnow.org/indexnow";
|
||||
|
||||
if (!KEY) {
|
||||
console.error("INDEXNOW_KEY is required (see pass show hidden11/indexnow-key)");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function sitemapUrls() {
|
||||
const response = await fetch(`${SITE_URL}/sitemap.xml`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`sitemap.xml responded ${response.status}`);
|
||||
}
|
||||
const xml = await response.text();
|
||||
return [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => match[1]);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const all = await sitemapUrls();
|
||||
|
||||
// The daily run only needs the surfaces that changed overnight: the home and
|
||||
// archive pages, plus the answer pages for the day that just became public.
|
||||
const puzzleNumber = (url) => {
|
||||
const match = url.match(/\/puzzle\/(\d+)$/);
|
||||
return match ? Number(match[1]) : null;
|
||||
};
|
||||
const newest = Math.max(0, ...all.map(puzzleNumber).filter((value) => value !== null));
|
||||
const urls = process.argv.includes("--today")
|
||||
? all.filter((url) => {
|
||||
const number = puzzleNumber(url);
|
||||
return number === null || number === newest;
|
||||
})
|
||||
: all;
|
||||
|
||||
if (urls.length === 0) {
|
||||
console.log("nothing to submit");
|
||||
return;
|
||||
}
|
||||
|
||||
// IndexNow caps a batch at 10 000 URLs.
|
||||
const batches = [];
|
||||
for (let index = 0; index < urls.length; index += 10000) {
|
||||
batches.push(urls.slice(index, index + 10000));
|
||||
}
|
||||
|
||||
for (const [index, batch] of batches.entries()) {
|
||||
const response = await fetch(ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify({
|
||||
host: new URL(SITE_URL).host,
|
||||
key: KEY,
|
||||
keyLocation: `${SITE_URL}/${KEY}.txt`,
|
||||
urlList: batch,
|
||||
}),
|
||||
});
|
||||
|
||||
// 200 = accepted, 202 = accepted but key still being validated.
|
||||
console.log(`batch ${index + 1}/${batches.length}: ${batch.length} urls → ${response.status}`);
|
||||
if (!response.ok && response.status !== 202) {
|
||||
console.error(await response.text());
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user