Web Push: goal, kickoff and full-time alerts for a followed team
Fully progressive: without VAPID keys in the environment the API says 404, the bell never renders, and the service worker addition (a push handler via workbox importScripts — no SW strategy change) is inert. With keys: a bell on each team page requests permission, subscribes (one team per device) and the server diffs fixture states on every broadcast to push Kickoff / Goal / Full time to that team's followers. Dead endpoints (404/410) self-clean. Round-trip verified locally with dev VAPID keys; disabled mode verified too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useParams } from '@tanstack/react-router';
|
||||
import { ArrowLeft, Shield, Star } from 'lucide-react';
|
||||
import { ArrowLeft, Bell, BellRing, Shield, Star } from 'lucide-react';
|
||||
import { EloHistoryChart } from '@/components/EloHistoryChart';
|
||||
import { pushSupported, serverPushKey, subscribeTeam, unsubscribePush } from '@/lib/push';
|
||||
import { useFavoriteStore } from '@/stores/favoriteStore';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
@@ -51,6 +52,61 @@ function FavoriteStar({ team }: { team: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const PUSH_TEAM_KEY = 'cup26:push-team';
|
||||
|
||||
/** Goal/kickoff alerts for this team — shown only when the server has push
|
||||
* enabled and the browser supports it. One followed team per device. */
|
||||
function NotifyBell({ team }: { team: string }) {
|
||||
const t = useT();
|
||||
const [avail, setAvail] = useState(false);
|
||||
const [denied, setDenied] = useState(false);
|
||||
const [subTeam, setSubTeam] = useState<string | null>(() => {
|
||||
try { return localStorage.getItem(PUSH_TEAM_KEY); } catch { return null; }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void serverPushKey().then((k) => alive && setAvail(Boolean(k) && pushSupported()));
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
if (!avail) return null;
|
||||
const active = subTeam === team;
|
||||
|
||||
const toggle = async (): Promise<void> => {
|
||||
if (active) {
|
||||
await unsubscribePush();
|
||||
try { localStorage.removeItem(PUSH_TEAM_KEY); } catch { /* ok */ }
|
||||
setSubTeam(null);
|
||||
return;
|
||||
}
|
||||
const res = await subscribeTeam(team);
|
||||
if (res === 'subscribed') {
|
||||
try { localStorage.setItem(PUSH_TEAM_KEY, team); } catch { /* ok */ }
|
||||
setSubTeam(team);
|
||||
setDenied(false);
|
||||
} else if (res === 'denied') {
|
||||
setDenied(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggle()}
|
||||
aria-label={active ? t.team.notifyOff : t.team.notifyOn}
|
||||
aria-pressed={active}
|
||||
title={denied ? t.team.notifyDenied : active ? t.team.notifyOff : t.team.notifyOn}
|
||||
className={`grid h-9 w-9 place-items-center rounded-md transition-colors ${active ? 'text-accent' : 'text-faint hover:text-ink'}`}
|
||||
>
|
||||
{active ? <BellRing size={18} /> : <Bell size={18} />}
|
||||
</button>
|
||||
{denied && <span className="text-[11px] text-faint">{t.team.notifyDenied}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function TeamProfilePage() {
|
||||
const { name } = useParams({ strict: false }) as { name: string };
|
||||
const t = useT();
|
||||
@@ -82,9 +138,10 @@ export function TeamProfilePage() {
|
||||
<CardBody className="flex flex-wrap items-center gap-4">
|
||||
<span aria-hidden className="text-5xl">{teamFlag(profile.team)}</span>
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 font-display text-2xl font-extrabold text-ink">
|
||||
{profile.team}
|
||||
<h1 className="flex items-center gap-1 font-display text-2xl font-extrabold text-ink">
|
||||
<span className="mr-1">{profile.team}</span>
|
||||
<FavoriteStar team={profile.team} />
|
||||
<NotifyBell team={profile.team} />
|
||||
</h1>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm">
|
||||
{profile.group && <Badge tone="neutral">{fmt(t.common.group, { group: profile.group })}</Badge>}
|
||||
|
||||
Reference in New Issue
Block a user