0de85cbc51
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
110 lines
3.5 KiB
TypeScript
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";
|
|
}
|