feat: Stadium Elite Design, Rangliste, Profil-Team, User-Upsert & n8n Cronjob

- MatchCard + TipModal: Uhrzeit statt VS zwischen Flaggen, Gruppe zentriert
- LeaderboardPage: Podium (2./1./3.), DU-Badge, Trend-Pfeile, Team-Zeile, CTA-Card
- AdminPage: Stadium Elite Redesign mit Result-Bar und Inline-Spinner
- ProfilePage: Team-Feld inline editierbar (PATCH /api/profile/team)
- User-Upsert beim ersten App-Aufruf (Matches-Route) statt erst beim Tipp
- DB Migration 002: team-Spalte in users, Leaderboard View aktualisiert
- Leaderboard-Refresh automatisch nach Tipps-Auswertung
- n8n Workflow angelegt: stündlicher Sync + Auswertung (ID: t3SDspIGDXwkfOt3)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ronny Mueller
2026-04-03 23:37:38 +02:00
parent e967f36f6c
commit e27a62a37b
20 changed files with 1515 additions and 297 deletions
+103 -34
View File
@@ -8,6 +8,14 @@ interface Props {
onSaved: (matchId: number, home: number, away: number) => void;
}
type Tendency = 'home' | 'draw' | 'away';
function getTendency(home: number, away: number): Tendency {
if (home > away) return 'home';
if (away > home) return 'away';
return 'draw';
}
export default function TipModal({ match, onClose, onSaved }: Props) {
const existing = match.userTip;
const [home, setHome] = useState(existing?.home ?? 0);
@@ -15,9 +23,15 @@ export default function TipModal({ match, onClose, onSaved }: Props) {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const adjust = (setter: React.Dispatch<React.SetStateAction<number>>, val: number, delta: number) => {
setter(Math.max(0, Math.min(20, val + delta)));
};
const tendency = getTendency(home, away);
const tendencyLabel =
tendency === 'home' ? match.homeTeam.shortName || match.homeTeam.name :
tendency === 'away' ? match.awayTeam.shortName || match.awayTeam.name :
'Unentschieden';
const tendencyColor =
tendency === 'home' ? 'var(--primary)' :
tendency === 'away' ? 'var(--cyan)' :
'var(--gold)';
const handleSave = async () => {
setSaving(true);
@@ -31,54 +45,97 @@ export default function TipModal({ match, onClose, onSaved }: Props) {
}
};
// Tendenz-Anzeige
const tendency = home > away ? match.homeTeam.shortName :
away > home ? match.awayTeam.shortName : 'Unentschieden';
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
{/* Header */}
<div className={styles.header}>
<h2 className={styles.title}>Tipp abgeben</h2>
<button className={styles.closeBtn} onClick={onClose}></button>
<div className={styles.sheet} onClick={e => e.stopPropagation()}>
{/* Drag handle */}
<div className={styles.handle} />
{/* Match info header */}
<div className={styles.matchHeader}>
{match.group && (
<span className={styles.groupBadge}>
{match.group.replace('GROUP_', 'Gruppe ')}
</span>
)}
</div>
{/* Teams */}
{/* Teams mit Flaggen */}
<div className={styles.teamsRow}>
<div className={styles.team}>
{match.homeTeam.crest && <img className={styles.crest} src={match.homeTeam.crest} alt="" />}
<div className={styles.teamBlock}>
<div className={styles.flagLarge}>
{match.homeTeam.crest
? <img src={match.homeTeam.crest} alt={match.homeTeam.name} className={styles.flagImg} />
: <span className={styles.flagEmoji}>🏳</span>
}
</div>
<span className={styles.teamName}>{match.homeTeam.name}</span>
<span className={styles.teamShort}>{match.homeTeam.shortName}</span>
</div>
<span className={styles.vs}>vs</span>
<div className={`${styles.team} ${styles.teamRight}`}>
<div className={styles.vsBlock}>
<div className={styles.kickoffBlock}>
<span className={styles.kickoffDate}>
{new Date(match.utcDate).toLocaleString('de-DE', {
weekday: 'short', day: 'numeric', month: 'short',
timeZone: 'Europe/Berlin'
})}
</span>
<span className={styles.kickoffTime}>
{new Date(match.utcDate).toLocaleString('de-DE', {
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin'
})} Uhr
</span>
</div>
</div>
<div className={styles.teamBlock}>
<div className={styles.flagLarge}>
{match.awayTeam.crest
? <img src={match.awayTeam.crest} alt={match.awayTeam.name} className={styles.flagImg} />
: <span className={styles.flagEmoji}>🏳</span>
}
</div>
<span className={styles.teamName}>{match.awayTeam.name}</span>
{match.awayTeam.crest && <img className={styles.crest} src={match.awayTeam.crest} alt="" />}
<span className={styles.teamShort}>{match.awayTeam.shortName}</span>
</div>
</div>
{/* Score Picker */}
<div className={styles.pickerRow}>
<ScorePicker value={home} onChange={v => setHome(v)} />
<div className={styles.colon}>:</div>
<ScorePicker value={away} onChange={v => setAway(v)} />
<div className={styles.pickerSection}>
<p className={styles.pickerLabel}>Dein Tipp</p>
<div className={styles.pickerRow}>
<ScorePicker value={home} onChange={setHome} />
<div className={styles.pickerColon}>:</div>
<ScorePicker value={away} onChange={setAway} />
</div>
</div>
{/* Tendenz */}
<div className={styles.tendencyRow}>
<span className={styles.tendencyLabel}>Tendenz:</span>
<span className={styles.tendencyValue}>{tendency}</span>
<div className={styles.tendencyBar} style={{ '--tendency-color': tendencyColor } as React.CSSProperties}>
<span className={styles.tendencyIcon}>
{tendency === 'draw' ? '🤝' : tendency === 'home' ? '🏠' : '✈️'}
</span>
<span className={styles.tendencyText}>
Tendenz: <strong>{tendencyLabel}</strong>
</span>
</div>
{error && <div className={styles.error}>{error}</div>}
{/* Buttons */}
<div className={styles.actions}>
<button className="btn-ghost" onClick={onClose}>Abbrechen</button>
<button className="btn-primary" onClick={handleSave} disabled={saving}>
{saving ? 'Wird gespeichert…' : '✓ Tipp speichern'}
</button>
</div>
{/* CTA */}
<button
className={`btn-primary ${styles.saveBtn}`}
onClick={handleSave}
disabled={saving}
>
{saving ? '⏳ Wird gespeichert…' : '✓ Tipp bestätigen'}
</button>
<button className={styles.cancelBtn} onClick={onClose}>
Abbrechen
</button>
</div>
</div>
);
@@ -87,9 +144,21 @@ export default function TipModal({ match, onClose, onSaved }: Props) {
function ScorePicker({ value, onChange }: { value: number; onChange: (v: number) => void }) {
return (
<div className={styles.picker}>
<button className={styles.pickerBtn} onClick={() => onChange(Math.min(20, value + 1))}>+</button>
<button
className={styles.pickerBtn}
onClick={() => onChange(Math.min(20, value + 1))}
aria-label="Erhöhen"
>
+
</button>
<span className={styles.pickerValue}>{value}</span>
<button className={styles.pickerBtn} onClick={() => onChange(Math.max(0, value - 1))}></button>
<button
className={styles.pickerBtn}
onClick={() => onChange(Math.max(0, value - 1))}
aria-label="Verringern"
>
</button>
</div>
);
}