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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user