7dc66e50bf
Build & Deploy Tippspiel / build (push) Successful in 50s
48 country flags downloaded from flagcdn.com (320px PNG, ~55KB total)
stored in frontend/public/flags/{iso-code}.png.
New utility getFlagUrl() maps team names to local flag files.
Applied to MatchCard, DashboardPage, and TipModal.
Falls back to original crest URL if no mapping exists (e.g. TBD).
No external API calls at runtime — all flags served statically.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
155 lines
4.9 KiB
TypeScript
155 lines
4.9 KiB
TypeScript
import { useState } from 'react';
|
||
import { Match, api } from '../api/client';
|
||
import { getFlagUrl } from '../utils/flagUrl';
|
||
import styles from './TipModal.module.css';
|
||
|
||
interface Props {
|
||
match: Match;
|
||
onClose: () => void;
|
||
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);
|
||
const [away, setAway] = useState(existing?.away ?? 0);
|
||
const [saving, setSaving] = useState(false);
|
||
const [showSuccess, setShowSuccess] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
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);
|
||
setError(null);
|
||
try {
|
||
await api.submitTip(match.id, home, away);
|
||
setShowSuccess(true);
|
||
if (navigator.vibrate) navigator.vibrate(50); // haptic feedback
|
||
setTimeout(() => {
|
||
setShowSuccess(false);
|
||
onSaved(match.id, home, away);
|
||
onClose();
|
||
}, 1200);
|
||
} catch (e) {
|
||
setError((e as Error).message);
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className={styles.overlay} onClick={onClose}>
|
||
<div className={styles.sheet} onClick={e => e.stopPropagation()}>
|
||
|
||
{/* Drag handle */}
|
||
<div className={styles.handle} />
|
||
|
||
{/* Teams mit Flaggen */}
|
||
<div className={styles.teamsRow}>
|
||
<div className={styles.teamBlock}>
|
||
<div className={styles.flagLarge}>
|
||
{match.homeTeam.name
|
||
? <img src={getFlagUrl(match.homeTeam.name, match.homeTeam.crest)} alt={match.homeTeam.name} className={styles.flagImg} />
|
||
: <span className={styles.flagEmoji}>🏳️</span>
|
||
}
|
||
</div>
|
||
<span className={styles.teamName}>{match.homeTeam.name}</span>
|
||
</div>
|
||
|
||
<div className={styles.vsBlock} />
|
||
|
||
<div className={styles.teamBlock}>
|
||
<div className={styles.flagLarge}>
|
||
{match.awayTeam.name
|
||
? <img src={getFlagUrl(match.awayTeam.name, match.awayTeam.crest)} alt={match.awayTeam.name} className={styles.flagImg} />
|
||
: <span className={styles.flagEmoji}>🏳️</span>
|
||
}
|
||
</div>
|
||
<span className={styles.teamName}>{match.awayTeam.name}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Score Picker */}
|
||
<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.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>}
|
||
|
||
{showSuccess && (
|
||
<div className={styles.successOverlay}>
|
||
<div className={styles.successCheck}>✓</div>
|
||
<div className={styles.successText}>Dein Tipp ist drin! 🎯</div>
|
||
</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>
|
||
);
|
||
}
|
||
|
||
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))}
|
||
aria-label="Erhöhen"
|
||
>
|
||
+
|
||
</button>
|
||
<span className={styles.pickerValue}>{value}</span>
|
||
<button
|
||
className={styles.pickerBtn}
|
||
onClick={() => onChange(Math.max(0, value - 1))}
|
||
aria-label="Verringern"
|
||
>
|
||
−
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|