Fan Tier 2: venue pages, attack zones, broadcasts, push prefs, squads + UI/UX polish
Features (all from already-stored data):
- Venue pages: /venues hub + /venue/:name with each stadium's schedule,
altitude badge (>1000 m), local timezone. Linked from the match hero,
nav More sheet, and footer.
- Attacking-zones card on the match Stats tab (left/center/right share per
side, from the stored FotMob payload).
- US broadcast networks on the pre-match hero (SUMMARY_V 6).
- Notification preferences: follow multiple teams + an all-matches feed
('*'); push_subs keyed by (endpoint, team) with a safe one-time migration;
device drops its browser subscription only when the last follow is removed.
- FIFA tournament squad card on team pages (26-man list, position groups).
- Player match logs now show DNP rows for unused-squad appearances.
- OG/share meta for /player and /venue.
Polish (adversarial audit: 25 confirmed findings, all fixed):
- Mobile horizontal-scroll eliminated on /teams and /venues (grid-item
min-w-0 blowout); two-line venue fixture rows.
- Truncation actually fires now across team fixtures, road-to-final,
recent-form, H2H meetings, venue rows (min-w-0 on flex children).
- Team hero no longer detaches the standing block on wrap; bell denied-state
no longer overflows the h1.
- AllMatchesBell: 36px touch target, aria-label, own i18n key, even rhythm.
- Server hardening: cached player index (one cross-fixture scan, not per
request), migration rollback guard, 400 (not 500) on malformed name URLs,
all-zero zones render nothing, tidyName fixes shouted particles (DE→De).
- German polish: removed stray English ("anytime", "Build-Geocode"), clearer
squad note.
Gates: 170 tests, build, typecheck:server — all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -192,6 +192,9 @@ export function MatchPreviewPage() {
|
||||
const infoText = rich?.info && rich.info.stadium && rich.info.attendance != null && rich.info.referee && f.status === 'scheduled'
|
||||
? fmt(t.match.attendanceLine, { stadium: rich.info.stadium, n: rich.info.attendance.toLocaleString(locale), ref: rich.info.referee })
|
||||
: null;
|
||||
const tvText = !finished && preview.broadcasts.length > 0
|
||||
? fmt(t.match.tvLine, { channels: preview.broadcasts.join(' · ') })
|
||||
: null;
|
||||
|
||||
const heroChips: { label: string; value: string; gold?: boolean }[] = [];
|
||||
if (hasScore) {
|
||||
@@ -215,7 +218,7 @@ export function MatchPreviewPage() {
|
||||
// Tabs only appear when their data exists (e.g. no Timeline before kickoff).
|
||||
const tabs: TabKey[] = ['summary'];
|
||||
if (preview.events.length > 0) tabs.push('timeline');
|
||||
if (preview.liveStats || (rich && ((rich.fullStats?.length ?? 0) > 0 || rich.shots.length >= 3 || rich.momentum.length >= 10))) tabs.push('stats');
|
||||
if (preview.liveStats || (rich && ((rich.fullStats?.length ?? 0) > 0 || rich.zones != null || rich.shots.length >= 3 || rich.momentum.length >= 10))) tabs.push('stats');
|
||||
tabs.push('lineups');
|
||||
const tab: TabKey = tabs.includes(activeTab) ? activeTab : 'summary';
|
||||
|
||||
@@ -250,7 +253,12 @@ export function MatchPreviewPage() {
|
||||
{f.group ? fmt(t.common.group, { group: f.group }) : t.stage[f.stage]}
|
||||
<span className="text-line-strong"> · </span>{kickoffDay(f.kickoff)}
|
||||
<span className="text-line-strong"> · </span>{kickoffTime(f.kickoff)}
|
||||
<span className="text-line-strong"> · </span>{preview.venue}
|
||||
<span className="text-line-strong"> · </span>
|
||||
{f.venue ? (
|
||||
<Link to="/venue/$name" params={{ name: f.venue }} className="whitespace-nowrap transition-colors hover:text-accent">{preview.venue}</Link>
|
||||
) : (
|
||||
<span className="whitespace-nowrap">{preview.venue}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-[1fr_auto_1fr] items-center gap-2 md:gap-6">
|
||||
<Link to="/team/$name" params={{ name: f.home.team ?? '' }} className="flex flex-col items-center gap-1.5 hover:opacity-80">
|
||||
@@ -278,7 +286,7 @@ export function MatchPreviewPage() {
|
||||
{heroChips.map((c) => <HeroChip key={c.label} label={c.label} value={c.value} gold={c.gold ?? false} />)}
|
||||
</div>
|
||||
)}
|
||||
{(weatherText || infoText) && (
|
||||
{(weatherText || infoText || tvText) && (
|
||||
<div className="mt-5 space-y-1 border-t border-line pt-3 text-center text-xs text-muted">
|
||||
{weatherText && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
@@ -287,6 +295,7 @@ export function MatchPreviewPage() {
|
||||
</div>
|
||||
)}
|
||||
{infoText && <div>{infoText}</div>}
|
||||
{tvText && <div>{tvText}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -511,6 +520,35 @@ export function MatchPreviewPage() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
{rich?.zones && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.zonesTitle}</span></CardHeader>
|
||||
<CardBody className="space-y-3">
|
||||
{([
|
||||
['home', homeName, rich.zones.home, ['bg-accent/40', 'bg-accent/70', 'bg-accent']],
|
||||
['away', awayName, rich.zones.away, ['bg-info/40', 'bg-info/70', 'bg-info']],
|
||||
] as const).map(([side, name, z, tones]) => {
|
||||
const total = z.left + z.center + z.right || 1;
|
||||
return (
|
||||
<div key={side}>
|
||||
<div className="mb-1 text-sm font-medium text-ink">{name}</div>
|
||||
<div className="flex h-3 overflow-hidden rounded-full bg-elevated">
|
||||
<div className={tones[0]} style={{ width: `${(z.left / total) * 100}%` }} />
|
||||
<div className={tones[1]} style={{ width: `${(z.center / total) * 100}%` }} />
|
||||
<div className={tones[2]} style={{ width: `${(z.right / total) * 100}%` }} />
|
||||
</div>
|
||||
<div className="mt-0.5 flex justify-between text-[10px] text-faint">
|
||||
<span>{t.match.zones.left} {z.left}%</span>
|
||||
<span>{t.match.zones.center} {z.center}%</span>
|
||||
<span>{t.match.zones.right} {z.right}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-xs text-faint">{t.match.zonesNote}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
{rich && rich.momentum.length >= 10 && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.momentumTitle}</span></CardHeader>
|
||||
@@ -601,7 +639,7 @@ export function MatchPreviewPage() {
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader className="flex items-center gap-2"><Shirt size={16} className="text-accent" /><span className="font-display font-bold text-ink">{preview.lineups ? t.match.lineups : t.match.keyPlayers}</span></CardHeader>
|
||||
<CardBody className="grid grid-cols-2 gap-6">
|
||||
<CardBody className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6">
|
||||
{preview.lineups ? (
|
||||
<><LineupCol team={homeName} players={preview.lineups.home} /><LineupCol team={awayName} players={preview.lineups.away} /></>
|
||||
) : (
|
||||
@@ -615,8 +653,8 @@ export function MatchPreviewPage() {
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.recentForm}</span></CardHeader>
|
||||
<CardBody className="space-y-3">
|
||||
<div className="flex items-center justify-between"><span className="flex items-center gap-2 text-sm font-medium text-ink">{f.home.team && teamFlag(f.home.team)} {homeName}</span><FormChips form={preview.form.home} /></div>
|
||||
<div className="flex items-center justify-between"><span className="flex items-center gap-2 text-sm font-medium text-ink">{f.away.team && teamFlag(f.away.team)} {awayName}</span><FormChips form={preview.form.away} /></div>
|
||||
<div className="flex items-center justify-between gap-2"><span className="flex min-w-0 items-center gap-2 text-sm font-medium text-ink"><span className="shrink-0">{f.home.team && teamFlag(f.home.team)}</span> <span className="truncate">{homeName}</span></span><FormChips form={preview.form.home} /></div>
|
||||
<div className="flex items-center justify-between gap-2"><span className="flex min-w-0 items-center gap-2 text-sm font-medium text-ink"><span className="shrink-0">{f.away.team && teamFlag(f.away.team)}</span> <span className="truncate">{awayName}</span></span><FormChips form={preview.form.away} /></div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
@@ -633,7 +671,7 @@ export function MatchPreviewPage() {
|
||||
<div className="mb-1 text-[11px] uppercase tracking-wide text-faint">{t.match.h2h.recentMeetings}</div>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{preview.h2h.last.map((m, i) => (
|
||||
<li key={i} className="flex items-center justify-between text-ink-soft"><span className="text-faint">{m.date.slice(0, 10)}</span><span>{m.home} <span className="tnum font-semibold text-ink">{m.homeScore}–{m.awayScore}</span> {m.away}</span></li>
|
||||
<li key={i} className="flex items-center justify-between gap-2 text-ink-soft"><span className="shrink-0 text-faint">{m.date.slice(0, 10)}</span><span className="min-w-0 truncate text-right">{m.home} <span className="tnum font-semibold text-ink">{m.homeScore}–{m.awayScore}</span> {m.away}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user