Favorite team: star a side, get its next match pinned on Live
A star on the team page (localStorage, no accounts) pins that team's next or running match in a My team section at the top of the Live tab. EN/DE strings + aria-pressed on the toggle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { Radio } from 'lucide-react';
|
|||||||
import { PageHeader } from '@/components/ui/PageHeader';
|
import { PageHeader } from '@/components/ui/PageHeader';
|
||||||
import { MatchCard } from '@/components/MatchCard';
|
import { MatchCard } from '@/components/MatchCard';
|
||||||
import { useTournamentStore } from '@/stores/tournamentStore';
|
import { useTournamentStore } from '@/stores/tournamentStore';
|
||||||
|
import { useFavoriteStore } from '@/stores/favoriteStore';
|
||||||
import { isToday } from '@/lib/format';
|
import { isToday } from '@/lib/format';
|
||||||
import { upcomingByDay, type DayGroup } from '@/lib/fixtures';
|
import { upcomingByDay, type DayGroup } from '@/lib/fixtures';
|
||||||
import { fmt, useFormat } from '@/lib/i18n';
|
import { fmt, useFormat } from '@/lib/i18n';
|
||||||
@@ -24,8 +25,17 @@ function Section({ title, fixtures }: { title: string; fixtures: Fixture[] }) {
|
|||||||
|
|
||||||
export function LivePage() {
|
export function LivePage() {
|
||||||
const snapshot = useTournamentStore((s) => s.snapshot);
|
const snapshot = useTournamentStore((s) => s.snapshot);
|
||||||
|
const favorite = useFavoriteStore((s) => s.favorite);
|
||||||
const { t, kickoffDay } = useFormat();
|
const { t, kickoffDay } = useFormat();
|
||||||
|
|
||||||
|
// The starred team's next (or running) match, pinned on top.
|
||||||
|
const favMatch = useMemo(() => {
|
||||||
|
if (!favorite || !snapshot) return null;
|
||||||
|
return snapshot.fixtures
|
||||||
|
.filter((f) => f.status !== 'finished' && (f.home.team === favorite || f.away.team === favorite))
|
||||||
|
.sort((a, b) => a.kickoff.localeCompare(b.kickoff))[0] ?? null;
|
||||||
|
}, [favorite, snapshot]);
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
const fixtures = snapshot?.fixtures ?? [];
|
const fixtures = snapshot?.fixtures ?? [];
|
||||||
const live = fixtures.filter((f) => f.status === 'live').sort((a, b) => a.kickoff.localeCompare(b.kickoff));
|
const live = fixtures.filter((f) => f.status === 'live').sort((a, b) => a.kickoff.localeCompare(b.kickoff));
|
||||||
@@ -66,6 +76,15 @@ export function LivePage() {
|
|||||||
subtitle={fmt(t.live.subtitle, { season: snapshot.season })}
|
subtitle={fmt(t.live.subtitle, { season: snapshot.season })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{favMatch && (
|
||||||
|
<section className="mb-7">
|
||||||
|
<h2 className="smallcaps mb-2.5">★ {t.live.myTeam}</h2>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<MatchCard f={favMatch} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<Section title={t.live.liveNowHeading} fixtures={groups.live} />
|
<Section title={t.live.liveNowHeading} fixtures={groups.live} />
|
||||||
<Section title={t.live.todayHeading} fixtures={groups.today} />
|
<Section title={t.live.todayHeading} fixtures={groups.today} />
|
||||||
{groups.upcoming.map((g, i) => (
|
{groups.upcoming.map((g, i) => (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useParams } from '@tanstack/react-router';
|
import { Link, useParams } from '@tanstack/react-router';
|
||||||
import { ArrowLeft, Shield } from 'lucide-react';
|
import { ArrowLeft, Shield, Star } from 'lucide-react';
|
||||||
|
import { useFavoriteStore } from '@/stores/favoriteStore';
|
||||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { teamFlag } from '@/lib/teams';
|
import { teamFlag } from '@/lib/teams';
|
||||||
@@ -31,6 +32,24 @@ function FixtureRow({ f, team }: { f: Fixture; team: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FavoriteStar({ team }: { team: string }) {
|
||||||
|
const t = useT();
|
||||||
|
const favorite = useFavoriteStore((s) => s.favorite);
|
||||||
|
const toggle = useFavoriteStore((s) => s.toggleFavorite);
|
||||||
|
const active = favorite === team;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(team)}
|
||||||
|
aria-label={active ? t.team.unfollow : t.team.follow}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={`grid h-9 w-9 place-items-center rounded-md transition-colors ${active ? 'text-gold' : 'text-faint hover:text-ink'}`}
|
||||||
|
>
|
||||||
|
<Star size={20} fill={active ? 'currentColor' : 'none'} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function TeamProfilePage() {
|
export function TeamProfilePage() {
|
||||||
const { name } = useParams({ strict: false }) as { name: string };
|
const { name } = useParams({ strict: false }) as { name: string };
|
||||||
const t = useT();
|
const t = useT();
|
||||||
@@ -62,7 +81,10 @@ export function TeamProfilePage() {
|
|||||||
<CardBody className="flex flex-wrap items-center gap-4">
|
<CardBody className="flex flex-wrap items-center gap-4">
|
||||||
<span aria-hidden className="text-5xl">{teamFlag(profile.team)}</span>
|
<span aria-hidden className="text-5xl">{teamFlag(profile.team)}</span>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-display text-2xl font-extrabold text-ink">{profile.team}</h1>
|
<h1 className="flex items-center gap-2 font-display text-2xl font-extrabold text-ink">
|
||||||
|
{profile.team}
|
||||||
|
<FavoriteStar team={profile.team} />
|
||||||
|
</h1>
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm">
|
<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>}
|
{profile.group && <Badge tone="neutral">{fmt(t.common.group, { group: profile.group })}</Badge>}
|
||||||
<Badge tone="muted">{fmt(t.team.eloBadge, { rating: profile.elo })}</Badge>
|
<Badge tone="muted">{fmt(t.team.eloBadge, { rating: profile.elo })}</Badge>
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export const de: Dict = {
|
|||||||
nextUp: "Als Nächstes",
|
nextUp: "Als Nächstes",
|
||||||
nextUpDay: "Als Nächstes · {day}",
|
nextUpDay: "Als Nächstes · {day}",
|
||||||
comingUp: "Demnächst",
|
comingUp: "Demnächst",
|
||||||
|
myTeam: "Mein Team",
|
||||||
latestResults: "Letzte Ergebnisse",
|
latestResults: "Letzte Ergebnisse",
|
||||||
empty: "Der Spielplan ist geladen. Sobald das Turnier läuft, tauchen die Spiele hier auf.",
|
empty: "Der Spielplan ist geladen. Sobald das Turnier läuft, tauchen die Spiele hier auf.",
|
||||||
},
|
},
|
||||||
@@ -371,6 +372,8 @@ export const de: Dict = {
|
|||||||
team: {
|
team: {
|
||||||
unknown: "Unbekanntes Team.",
|
unknown: "Unbekanntes Team.",
|
||||||
allTeams: "Alle Teams",
|
allTeams: "Alle Teams",
|
||||||
|
follow: "Diesem Team folgen",
|
||||||
|
unfollow: "Diesem Team nicht mehr folgen",
|
||||||
eloBadge: "Elo {rating}",
|
eloBadge: "Elo {rating}",
|
||||||
titleOddsBadge: "Titel {pct}",
|
titleOddsBadge: "Titel {pct}",
|
||||||
advanceOddsBadge: "Weiterkommen {pct}",
|
advanceOddsBadge: "Weiterkommen {pct}",
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export const en = {
|
|||||||
nextUp: "Next up",
|
nextUp: "Next up",
|
||||||
nextUpDay: "Next up · {day}",
|
nextUpDay: "Next up · {day}",
|
||||||
comingUp: "Coming up",
|
comingUp: "Coming up",
|
||||||
|
myTeam: "My team",
|
||||||
latestResults: "Latest results",
|
latestResults: "Latest results",
|
||||||
empty: "The schedule is ready. Matches will appear here once the tournament kicks off.",
|
empty: "The schedule is ready. Matches will appear here once the tournament kicks off.",
|
||||||
},
|
},
|
||||||
@@ -370,6 +371,8 @@ export const en = {
|
|||||||
team: {
|
team: {
|
||||||
unknown: "Unknown team.",
|
unknown: "Unknown team.",
|
||||||
allTeams: "All teams",
|
allTeams: "All teams",
|
||||||
|
follow: "Follow this team",
|
||||||
|
unfollow: "Unfollow this team",
|
||||||
eloBadge: "Elo {rating}",
|
eloBadge: "Elo {rating}",
|
||||||
titleOddsBadge: "Title {pct}",
|
titleOddsBadge: "Title {pct}",
|
||||||
advanceOddsBadge: "Advance {pct}",
|
advanceOddsBadge: "Advance {pct}",
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'cup26:favorite';
|
||||||
|
|
||||||
|
function readFavorite(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FavoriteState {
|
||||||
|
favorite: string | null;
|
||||||
|
toggleFavorite: (team: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One starred team — its next match gets pinned on the Live tab. */
|
||||||
|
export const useFavoriteStore = create<FavoriteState>((set, get) => ({
|
||||||
|
favorite: readFavorite(),
|
||||||
|
toggleFavorite: (team) => {
|
||||||
|
const next = get().favorite === team ? null : team;
|
||||||
|
try {
|
||||||
|
if (next) localStorage.setItem(STORAGE_KEY, next);
|
||||||
|
else localStorage.removeItem(STORAGE_KEY);
|
||||||
|
} catch { /* private mode */ }
|
||||||
|
set({ favorite: next });
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user