feat: tip confirmation animation with haptic feedback
Success overlay with animated checkmark and 'Dein Tipp ist drin! 🎯' message. Haptic vibration on mobile. Auto-closes after 1.2s. - Add showSuccess state to TipModal - Trigger vibration feedback on successful submit - Display success overlay with popIn animation for checkmark - Auto-close modal after success animation completes - Add CSS animations (fadeIn, popIn) to TipModal.module.css Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
||||
import { Check, TrendingUp, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import styles from './MatchCard.module.css';
|
||||
function getCardState(match) {
|
||||
if (match.status === 'IN_PLAY' || match.status === 'PAUSED')
|
||||
return 'live';
|
||||
if (match.status === 'FINISHED') {
|
||||
return match.userTip ? 'finished' : 'missed';
|
||||
}
|
||||
// SCHEDULED or TIMED
|
||||
return match.userTip ? 'tipped' : 'open';
|
||||
}
|
||||
function useCountdown(minutesUntilKickoff) {
|
||||
const [remaining, setRemaining] = useState(minutesUntilKickoff);
|
||||
useEffect(() => {
|
||||
if (minutesUntilKickoff > 60)
|
||||
return; // only active for <1h
|
||||
setRemaining(minutesUntilKickoff);
|
||||
const interval = setInterval(() => {
|
||||
setRemaining(r => Math.max(0, r - 1 / 60));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [minutesUntilKickoff]);
|
||||
return remaining;
|
||||
}
|
||||
const STATUS_LABELS = {
|
||||
SCHEDULED: 'Geplant',
|
||||
TIMED: 'Terminiert',
|
||||
IN_PLAY: 'Live',
|
||||
PAUSED: 'Pause',
|
||||
FINISHED: 'Beendet',
|
||||
POSTPONED: 'Verschoben',
|
||||
CANCELLED: 'Abgesagt',
|
||||
};
|
||||
function formatKickoff(utcDate) {
|
||||
return new Date(utcDate).toLocaleString('de-DE', {
|
||||
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin',
|
||||
}) + ' Uhr';
|
||||
}
|
||||
function CountdownBadge({ minutes }) {
|
||||
if (minutes <= 0)
|
||||
return null;
|
||||
if (minutes < 60)
|
||||
return _jsxs("span", { className: styles.badgeUrgent, children: ["\u26A1 in ", minutes, " Min."] });
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
if (h < 24)
|
||||
return _jsxs("span", { className: styles.badge, children: ["in ", h, "h", m > 0 ? ` ${m}m` : ''] });
|
||||
const d = Math.floor(h / 24);
|
||||
return _jsxs("span", { className: styles.badge, children: ["in ", d, " Tag", d > 1 ? 'en' : ''] });
|
||||
}
|
||||
function FlagBox({ crest, name }) {
|
||||
return (_jsx("div", { className: styles.flagBox, children: crest
|
||||
? _jsx("img", { className: styles.crest, src: crest, alt: name })
|
||||
: _jsx("span", { style: { fontSize: 18 }, children: "\uD83C\uDFF3\uFE0F" }) }));
|
||||
}
|
||||
export default function MatchCard({ match, onTip }) {
|
||||
const state = getCardState(match);
|
||||
const remaining = useCountdown(match.minutesUntilKickoff);
|
||||
const remainingMins = Math.ceil(remaining);
|
||||
const isFinished = state === 'finished' || state === 'missed';
|
||||
const isLive = state === 'live';
|
||||
const hasTip = !!match.userTip;
|
||||
const points = match.userTip?.points ?? null;
|
||||
const resultClass = points === 3 ? styles.exact :
|
||||
points === 1 ? styles.tendency :
|
||||
(points === 0 && isFinished) ? styles.wrong : '';
|
||||
const glowClass = isFinished && points === 3 ? styles.glowExact :
|
||||
isFinished && points === 1 ? styles.glowTendency :
|
||||
isFinished && points === 0 ? styles.glowWrong : '';
|
||||
return (_jsxs("div", { className: `card ${styles.card} ${styles[`card_${state}`]} ${isLive ? styles.live : ''} ${glowClass}`, children: [_jsxs("div", { className: styles.topRow, children: [_jsxs("span", { className: `${styles.status} ${isLive ? styles.statusLive : ''}`, children: [isLive && _jsx("span", { className: styles.liveDot }), STATUS_LABELS[match.status] ?? match.status] }), match.group && (_jsx("span", { className: styles.group, children: match.group.replace('GROUP_', 'Gruppe ') })), (state === 'open' || state === 'tipped') && match.tippable && (match.minutesUntilKickoff < 60 ? (_jsxs("span", { className: `${styles.countdown} ${remainingMins < 5 ? styles.countdownUrgent : ''}`, children: ["Noch ", remainingMins, " Min!"] })) : (_jsx(CountdownBadge, { minutes: match.minutesUntilKickoff })))] }), _jsxs("div", { className: styles.matchRow, children: [_jsxs("div", { className: styles.teamHome, children: [_jsx("span", { className: styles.teamName, children: match.homeTeam.name }), _jsx(FlagBox, { crest: match.homeTeam.crest, name: match.homeTeam.name })] }), _jsx("div", { className: styles.scoreBox, children: isFinished || isLive ? (_jsxs("div", { className: styles.scoreStack, children: [_jsxs("span", { className: styles.score, children: [match.score.home ?? '–', "\u00A0:\u00A0", match.score.away ?? '–'] }), isLive && hasTip && (_jsxs("span", { className: styles.liveTipCompare, children: ["Tipp: ", match.userTip.home, ":", match.userTip.away] }))] })) : (_jsx("div", { className: styles.kickoffCenter, children: _jsx("span", { className: styles.kickoffCenterTime, children: formatKickoff(match.utcDate) }) })) }), _jsxs("div", { className: styles.teamAway, children: [_jsx(FlagBox, { crest: match.awayTeam.crest, name: match.awayTeam.name }), _jsx("span", { className: styles.teamName, children: match.awayTeam.name })] })] }), _jsx("div", { className: `${styles.tipRow} ${hasTip && points !== null ? `${styles.resultBanner} ${resultClass}` : ''}`, children: state === 'missed' ? (
|
||||
/* ── Missed: no tip, match finished ── */
|
||||
_jsx("span", { className: styles.missedLabel, children: "Nicht getippt" })) : state === 'live' ? (
|
||||
/* ── Live: no tip input, locked ── */
|
||||
hasTip ? (_jsxs("div", { className: styles.tipDisplay, children: [_jsx("div", { className: styles.tipLeft }), _jsxs("div", { className: styles.tipCenter, children: [_jsx("span", { className: styles.tipLabel, children: "DEIN TIPP" }), _jsxs("span", { className: styles.tipScore, children: [match.userTip.home, " : ", match.userTip.away] })] }), _jsx("div", { className: styles.tipRight })] })) : (_jsx("span", { className: styles.noTip, children: "Kein Tipp abgegeben" }))) : hasTip ? (points !== null ? (
|
||||
/* ── Auswertungs-Banner ── */
|
||||
_jsxs("div", { className: styles.tipDisplay, children: [_jsxs("div", { className: `${styles.tipLeft} ${styles.bannerLeft}`, children: [_jsx("span", { className: styles.resultIcon, children: points === 3 ? _jsx(Check, { size: 14, strokeWidth: 3 }) :
|
||||
points === 1 ? _jsx(TrendingUp, { size: 14, strokeWidth: 2.5 }) :
|
||||
_jsx(X, { size: 14, strokeWidth: 3 }) }), _jsx("span", { className: styles.resultLabel, children: points === 3 ? 'Exakt' : points === 1 ? 'Tendenz' : 'Falsch' })] }), _jsx("div", { className: styles.tipCenter, children: _jsxs("span", { className: styles.tipScoreBanner, children: [match.userTip.home, " : ", match.userTip.away] }) }), _jsx("div", { className: styles.tipRight, children: _jsx("span", { className: `${styles.pointsBadge} ${points === 3 ? styles.pointsBadge_exact :
|
||||
points === 1 ? styles.pointsBadge_tendency :
|
||||
styles.pointsBadge_wrong}`, children: points === 0 ? '0 Pkt.' : `+${points} Pkt.` }) })] })) : (
|
||||
/* ── Tipp vorhanden, noch nicht ausgewertet (tipped state) ── */
|
||||
_jsxs("div", { className: styles.tipDisplay, children: [_jsx("div", { className: styles.tipLeft, children: match.tippable && (_jsx("button", { className: styles.changeBtn, onClick: onTip, children: "\u00C4ndern" })) }), _jsxs("div", { className: styles.tipCenter, children: [!match.tippable && _jsx("span", { className: styles.tipLabel, children: "DEIN TIPP" }), _jsxs("span", { className: styles.tipDisplay_score, children: [_jsx(Check, { size: 13, strokeWidth: 3, style: { color: 'var(--success)', flexShrink: 0 } }), _jsxs("span", { className: styles.tipScore, children: [match.userTip.home, " : ", match.userTip.away] })] })] }), _jsx("div", { className: styles.tipRight })] }))) : match.tippable ? (_jsx("button", { className: `btn-primary ${styles.tipBtn}`, onClick: onTip, children: "Tipp abgeben" })) : (_jsx("span", { className: styles.noTip, children: "Kein Tipp abgegeben" })) })] }));
|
||||
}
|
||||
Reference in New Issue
Block a user