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:
@@ -2,27 +2,88 @@ import { useState, useEffect } from 'react';
|
||||
import { api, UserStats } from '../api/client';
|
||||
import styles from './ProfilePage.module.css';
|
||||
|
||||
function initials(name: string) {
|
||||
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const [stats, setStats] = useState<UserStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stats, setStats] = useState<UserStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [teamEdit, setTeamEdit] = useState(false);
|
||||
const [teamValue, setTeamValue] = useState('');
|
||||
const [teamSaving, setTeamSaving] = useState(false);
|
||||
const [teamMsg, setTeamMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.getMyStats().then(setStats).finally(() => setLoading(false));
|
||||
api.getMyStats().then(s => {
|
||||
setStats(s);
|
||||
setTeamValue(s.team ?? '');
|
||||
}).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const saveTeam = async () => {
|
||||
if (!teamValue.trim()) return;
|
||||
setTeamSaving(true);
|
||||
setTeamMsg(null);
|
||||
try {
|
||||
const res = await api.updateTeam(teamValue);
|
||||
setStats(prev => prev ? { ...prev, team: res.team } : prev);
|
||||
setTeamValue(res.team);
|
||||
setTeamEdit(false);
|
||||
setTeamMsg({ ok: true, text: 'Team gespeichert' });
|
||||
} catch (e) {
|
||||
setTeamMsg({ ok: false, text: (e as Error).message });
|
||||
} finally {
|
||||
setTeamSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className={styles.loading}><div className={styles.spinner} /></div>;
|
||||
if (!stats) return <div className={styles.empty}>Profil nicht verfügbar.</div>;
|
||||
if (!stats) return <div className={styles.empty}>Profil nicht verfügbar.</div>;
|
||||
|
||||
const evaluated = stats.exactCount + stats.tendencyCount + stats.wrongCount;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
|
||||
{/* Hero card */}
|
||||
<div className={`card ${styles.heroCard}`}>
|
||||
<div className={styles.avatar}>{stats.fullName.charAt(0).toUpperCase()}</div>
|
||||
<div className={styles.avatar}>{initials(stats.fullName)}</div>
|
||||
<div className={styles.heroInfo}>
|
||||
<h1 className={`font-display ${styles.name}`}>{stats.fullName}</h1>
|
||||
{stats.rank && (
|
||||
<div className={styles.rankBadge}>🏆 Platz {stats.rank}</div>
|
||||
{stats.rank && <div className={styles.rankBadge}>🏆 Platz {stats.rank}</div>}
|
||||
|
||||
{/* Team-Feld */}
|
||||
<div className={styles.teamRow}>
|
||||
{teamEdit ? (
|
||||
<div className={styles.teamEditRow}>
|
||||
<input
|
||||
className={styles.teamInput}
|
||||
value={teamValue}
|
||||
onChange={e => setTeamValue(e.target.value)}
|
||||
placeholder="z. B. Vertrieb Süd"
|
||||
maxLength={80}
|
||||
autoFocus
|
||||
onKeyDown={e => { if (e.key === 'Enter') saveTeam(); if (e.key === 'Escape') setTeamEdit(false); }}
|
||||
/>
|
||||
<button className={styles.teamSaveBtn} onClick={saveTeam} disabled={teamSaving}>
|
||||
{teamSaving ? <span className={styles.spinnerSm} /> : '✓'}
|
||||
</button>
|
||||
<button className={styles.teamCancelBtn} onClick={() => { setTeamEdit(false); setTeamValue(stats.team ?? ''); }}>✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className={styles.teamBtn} onClick={() => setTeamEdit(true)}>
|
||||
{stats.team
|
||||
? <><span className={styles.teamName}>{stats.team}</span><span className={styles.teamEditHint}>bearbeiten</span></>
|
||||
: <span className={styles.teamPlaceholder}>+ Team hinzufügen</span>
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{teamMsg && (
|
||||
<div className={`${styles.teamMsg} ${teamMsg.ok ? styles.teamMsgOk : styles.teamMsgErr}`}>
|
||||
{teamMsg.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.heroPoints}>
|
||||
@@ -31,6 +92,7 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className={styles.statsGrid}>
|
||||
<div className={`card ${styles.statCard}`}>
|
||||
<span className={`font-display ${styles.statVal} text-gold`}>{stats.exactCount}</span>
|
||||
@@ -50,6 +112,7 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accuracy bar */}
|
||||
{evaluated > 0 && (
|
||||
<div className={`card ${styles.accuracyCard}`}>
|
||||
<div className={styles.accuracyHeader}>
|
||||
|
||||
Reference in New Issue
Block a user