Bygg PWA-installasjonsoppfordring (Android beforeinstallprompt + iOS instruksjonsbanner) Design og bygg flere flighter i én frittstående runde (løs gruppering, retning 1) Design og bygg scramble/greensome-statistikk (valgt utslag per spiller) Bygg push-varsler til telefonens OS (VAPID, push_subscription, service worker) Scratch-verifiser backend for alle fire (27/27 sjekker gjort) Browserverifiser alle fire i nettleser mot scratch-backend Rull ut mot ekte systemer (migrasjoner + containere), med bekreftelse Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md Alle fire er ferdigstilt, verifisert og live. Kort oppsummert: PWA-installasjonsoppfordring — ekte beforeinstallprompt-fanging, Android-knapp + iOS-instruksjonsbanner, vist på dashbordet. Bekreftet at Chrome faktisk fyrte eventet i testøkten. Flere flighter i én frittstående runde — løs gruppering av separate runder (delt UUID), "+ Legg til en flight til" gjenbruker hele opprett-flyten forhåndsutfylt, nytt kombinert leaderboard på tvers av flighter. Testet fra knappetrykk til ferdig runde, bekreftet i database. Scramble/greensome: valgt utslag per spiller — nytt felt i hull-registreringen (helt valgfritt), "Utslag brukt"-oppsummering på statistikksiden. Bygget for frittstående runder; org-turneringer bevisst utenfor omfang denne runden (notert i backloggen). Push-varsler til telefonens OS — Web Push/VAPID, ny av/på-bryter i kontoinnstillinger, dekker alle fire varseltyper automatisk. VAPID-nøkler generert og lagt i .env uten å vises i chatten. Én ærlig begrensning: ekte OS-varsel-levering med innvilget tillatelse kunne ikke testes i denne automatiserte nettleserøkten (tillatelsen var forhåndsblokkert av miljøet) — verdt å teste selv på en ekte enhet. 27/27 scratch-sjekker, full nettleser-gjennomgang uten konsollfeil, migrasjoner 035–037 kjørt mot ekte teecup_db, begge containere redeployet og verifisert, teeoff.no upåvirket. Alle tre .md-filene er oppdatert med full detalj.
80 lines
3.3 KiB
TypeScript
80 lines
3.3 KiB
TypeScript
// Push-varsler til telefonens OS (Web Push/VAPID, 2026-07-28). Ren
|
|
// klientkode -- selve abonnementet lagres server-side via /push/subscribe.
|
|
// VIKTIG plattformbegrensning (se FEATURE_BACKLOG.md): iOS Safari støtter
|
|
// Web Push KUN når PWA-en er lagt til på hjemskjermen (installert som
|
|
// "standalone") -- en vanlig Safari-fane kan aldri abonnere, uansett
|
|
// tillatelse. isPushSupported() dekker dette.
|
|
|
|
function urlBase64ToUint8Array(base64: string): Uint8Array {
|
|
const padding = "=".repeat((4 - (base64.length % 4)) % 4)
|
|
const base64Safe = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/")
|
|
const raw = atob(base64Safe)
|
|
const output = new Uint8Array(raw.length)
|
|
for (let i = 0; i < raw.length; i++) output[i] = raw.charCodeAt(i)
|
|
return output
|
|
}
|
|
|
|
export function isPushSupported(): boolean {
|
|
if (typeof window === "undefined") return false
|
|
if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) return false
|
|
const nav = window.navigator as Navigator & { standalone?: boolean }
|
|
const isIOS = /iphone|ipad|ipod/i.test(window.navigator.userAgent)
|
|
const isStandalone = window.matchMedia("(display-mode: standalone)").matches || nav.standalone === true
|
|
// iOS krever installasjon til hjemskjermen -- alle andre støttede
|
|
// nettlesere (Chrome/Edge/Firefox-familien) trenger det ikke.
|
|
return !isIOS || isStandalone
|
|
}
|
|
|
|
export async function getExistingSubscription(): Promise<PushSubscription | null> {
|
|
if (!("serviceWorker" in navigator)) return null
|
|
const registration = await navigator.serviceWorker.ready
|
|
return registration.pushManager.getSubscription()
|
|
}
|
|
|
|
export async function subscribeToPush(): Promise<{ ok: boolean; reason?: string }> {
|
|
if (!isPushSupported()) {
|
|
return { ok: false, reason: "unsupported" }
|
|
}
|
|
const permission = await Notification.requestPermission()
|
|
if (permission !== "granted") {
|
|
return { ok: false, reason: "denied" }
|
|
}
|
|
const keyRes = await fetch("/push/vapid-public-key", { credentials: "include" })
|
|
if (!keyRes.ok) return { ok: false, reason: "server-error" }
|
|
const { public_key, configured }: { public_key: string | null; configured: boolean } = await keyRes.json()
|
|
if (!configured || !public_key) return { ok: false, reason: "not-configured" }
|
|
|
|
const registration = await navigator.serviceWorker.ready
|
|
const subscription = await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: urlBase64ToUint8Array(public_key),
|
|
})
|
|
|
|
const json = subscription.toJSON()
|
|
const subscribeRes = await fetch("/push/subscribe", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({
|
|
endpoint: json.endpoint,
|
|
keys: { p256dh: json.keys?.p256dh, auth: json.keys?.auth },
|
|
}),
|
|
})
|
|
if (!subscribeRes.ok) {
|
|
return { ok: false, reason: "server-error" }
|
|
}
|
|
return { ok: true }
|
|
}
|
|
|
|
export async function unsubscribeFromPush(): Promise<void> {
|
|
const subscription = await getExistingSubscription()
|
|
if (!subscription) return
|
|
const endpoint = subscription.endpoint
|
|
await subscription.unsubscribe()
|
|
await fetch("/push/subscribe", {
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({ endpoint }),
|
|
}).catch(() => {})
|
|
}
|