Files
claude-code 0de85cbc51
Build and push image / build (push) Failing after 15m20s
Localize the archive, add reminders, named challenges and a funnel
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
2026-08-26 12:41:28 +00:00

110 lines
3.5 KiB
TypeScript

// 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";
}