Compare commits

...

82 Commits

Author SHA1 Message Date
Ronny a521f5656c chore: suppress markdownlint warnings in spec docs
Build & Deploy Tippspiel / build (push) Successful in 40s
2026-04-12 19:39:53 +02:00
Ronny aa0c065bd6 fix: show pending tips in profile, count all tips not just evaluated
- Profile tip history now shows all tips (not just evaluated)
- Pending tips display  badge in blue instead of hiding
- Fun Facts "Tipps abgegeben" counts all tips (from tips API)
- Added .badgePending CSS style

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:42:26 +02:00
Ronny 786b3586be fix: CSP allows Google Fonts for Material Symbols icons 2026-04-12 18:13:44 +02:00
Ronny edf33fa932 feat: premium achievement badges with Material Symbols icons
Backend:
- New /api/achievements endpoint calculating 6 badges:
  Scharfschütze, Serien-Tipper, Tabellenführer, Frühtipper,
  Globetrotter, Diamant
- Each with progress tracking (current/target)

Frontend:
- AchievementBadge component with Stitch-inspired design
- Material Symbols Outlined font (filled icons)
- Unlocked: colored icon with glow + drop-shadow, rank label
- Locked: grayscale, lock overlay, progress bar
- ProfilePage: real badges replacing emoji placeholders
- Progress bar showing X/6 collected
- Mobile: 2-col grid, Desktop: 6-col grid

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:09:25 +02:00
Ronny d370558174 feat: logo links to home/dashboard 2026-04-12 17:34:00 +02:00
Ronny 132ea4f7d0 feat: desktop layout optimization
Dashboard: 2-column layout (hero left 60%, stats+nudges right 40%)
Spielplan: 2-column grid for match cards on desktop
Profile: wider max-width (900px), 6-column achievement grid
Header: Admin link back in desktop nav, max-width 1200px
All via CSS media queries (min-width: 768px), no HTML restructuring.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:26:20 +02:00
Ronny 1be1cdba2f feat: local country flags replacing team crests
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>
2026-04-12 16:31:24 +02:00
Ronny 2414fc04d9 feat: three-way theme toggle — Dark / Light / System
Cycles: Sun (→ Light) → Monitor (→ System) → Moon (→ Dark)
System mode follows OS prefers-color-scheme and updates live.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:17:57 +02:00
Ronny ab09b92050 style: more spacing between Tippspiel and subtitle 2026-04-12 16:14:03 +02:00
Ronny 1addb3940d style: 'FIFA World Cup 2026' as golden subtitle under 'Tippspiel' 2026-04-12 16:11:01 +02:00
Ronny c2195aa02f style: Lucide TrendingUp/Down icons for rank change arrows 2026-04-12 16:07:02 +02:00
Ronny d38f650261 fix: move useEffect before early returns (React hooks rule violation) 2026-04-12 16:02:42 +02:00
Ronny fe777a2bab feat: achievement badge placeholders on profile page (Phase 2 prep)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 15:57:32 +02:00
Ronny 1999ef8a21 feat: rank change arrow (↑/↓) in dashboard stats tile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 15:56:59 +02:00
Ronny 9c19188a7e feat: WM 2026 trophy SVG without text in header
Extracted trophy graphic from unofficial logo SVG, removed
"FIFA WORLD CUP", "2026", and country names text.
Works in both dark and light mode (colored on transparent).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:39:33 +02:00
Ronny 9b78c4a46c feat: FIFA WM 2026 unofficial logo in header (PNG)
Unofficial logo works in both dark and light mode (colored on transparent).
Official and white variants also included as alternatives.
Removed broken SVG files (Cloudflare blocked downloads).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:32:48 +02:00
Ronny f88f2ac6bc feat: replace trophy emoji with official FIFA WM 2026 logo
White SVG logo for dark mode, colored SVG for light mode.
Logo switches automatically based on theme.
Sourced from football-logos.cc (SVG, ~4.5KB each).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:20:49 +02:00
Ronny f8a6d12db1 style: comprehensive Light Mode overrides
Dashboard:
- Hero card: white glass (rgba 255,255,255,0.75) instead of dark glass
- Reduced shadows (no more extreme 40px shadow)
- LED digits: dark gold (#9A6500) with subtle shadow
- Countdown/tip badges: dark gold on light background
- Flag boxes: lighter shadows, no dark glow aura

MatchCards:
- LED time: dark gold for contrast on light bg
- Flag boxes: lighter shadows

BottomNav:
- White background with subtle top border
- Inactive tabs: darker for readability

Global:
- text-muted: 0.45 opacity (was 0.35) — better readability
- text-secondary: 0.65 (was 0.60)
- Gold: #B8740A (darker for light bg contrast)
- shadow-card: much lighter (was too heavy)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:11:57 +02:00
Ronny 048bf15a5e feat: move Admin to bottom nav, remove from header 2026-04-12 15:02:58 +02:00
Ronny 07f6a8ae13 style: card polish — badge spacing, no separator line, wider tip row
- Badges: more margin below (10px), better text centering (line-height:1)
- Removed border-top separator between flags and tip area
- Tipped row: full width to align with flag edges
- Reduced card padding for compactness

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:53:34 +02:00
Ronny a7485da0e9 style: dashboard uses parent padding — same width as Spielplan 2026-04-12 14:36:44 +02:00
Ronny d1189d5d6e style: fix badge centering over flags, plain theme toggle icon
- badgeRow now uses same gap (10px) as matchRow for exact alignment
- Theme toggle: removed button box, plain icon like admin gear
- Both icons use text-secondary color, consistent style

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:29:52 +02:00
Ronny d44927ec23 style: badges as row above flags, centered over each team
Badge row uses same flex proportions as match row (flex:1 | spacer | flex:1)
so left badge centers over left flag, right badge over right flag.
Full team names restored (no more truncation from 5-column layout).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:18:22 +02:00
Ronny 799239dcc1 style: badges centered to flags, wider dashboard, mobile header actions
MatchCard:
- Badges (group, countdown, LIVE, BEENDET) now vertically centered
  to flag height, positioned left/right of the teams
- Removed separate topRow — all in one matchBlock flex layout

Dashboard:
- max-width increased to 800px (matches spielplan width)

Header:
- Theme toggle + admin link moved to headerActions (always visible)
- Theme toggle icon in gold color (was too dark in dark mode)
- Admin link brighter (text-secondary instead of text-muted)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:55:00 +02:00
Ronny 0e1675fe90 style: dashboard LED smaller, TipModal flags fullbleed + subtle picker buttons
- Dashboard: LED digits 20px (was 26px) — less crowded between flags
- MatchCard: topRow margins for better badge alignment
- TipModal: flags now object-fit:cover (fullbleed)
- TipModal: picker buttons less glossy — reduced shine from 55% to 12%,
  smaller shadow, subtler hover effect

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:37:57 +02:00
Ronny 44199b0a90 style: unified flag+time design across Dashboard and Spielplan
Dashboard:
- Flags aligned to top (flex-start) — consistent height regardless of name length
- LED time centered to flag height (64px)

Match Cards:
- Flags fullbleed (object-fit: cover), larger (56px), glassmorphism shine
- LED kickoff time replaces dash separator between flags
- Score appears at same position when Live/Finished
- Removed separate kickoffRow — cards are more compact
- scoreBox height matches flag height for vertical centering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:22:13 +02:00
Ronny 91ea1f4dc3 style: hero card curvature shine, smaller fullbleed flags, tighter LED
- Hero: added glossy ::before shine gradient (top 45%) for curvature/Wölbung
- Flags: 64px, object-fit:cover (flag fills entire box), glassmorphism shine
- LED: slightly smaller (26px), tighter digit spacing (18px), stronger glow
- Backdrop blur increased to 24px

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:09:05 +02:00
Ronny 27f93f76f9 fix: LED digits fixed 20px width for uniform spacing 2026-04-12 12:46:05 +02:00
Ronny 75d69191fa fix: LED digits individually spaced — fixes '2 1:00' gap in DSEG7 font 2026-04-12 12:42:47 +02:00
Ronny b2ca2c733a style: stronger glow, bigger LED, fix vertical alignment, tighter spacing 2026-04-12 12:39:23 +02:00
Ronny c6c167abb3 style: remove hero border, LED time between flags like Design 2
- Removed visible blue border on hero card (now near-invisible white/6%)
- LED kickoff time moved between the two flags (replacing VS text)
- Layout: flag — LED time — flag centered vertically

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:35:38 +02:00
Ronny 8503592c7b style: match Stitch glassmorphism — backdrop-blur, flag glow auras, deeper shadows
Hero card now uses glassmorphism (rgba + backdrop-blur) from Stitch design.
Each flag has individual blur-glow aura behind it.
Rounded corners increased to 2rem. Shadow depth increased.
Stats tiles and nudges also refined with rounder corners.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:08:29 +02:00
Ronny 23116a847a style: premium Dashboard redesign inspired by Stitch mockups
Hero Card:
- Dramatic gradient background (navy → deep blue)
- Radial glow effect behind team flags (stadium atmosphere)
- LED kickoff time with golden glow
- Larger flag icons (72px) as app-icon style boxes
- Countdown as golden badge with pulsing dot
- Bigger CTA button with gradient and shadow

Bottom Nav:
- Filled/solid SVG icons (home, soccer ball, trophy, person)
  instead of Lucide outline icons — more premium feel

Nudges:
- Icon + text layout with hover animation
- Better spacing and visual hierarchy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:56:29 +02:00
Ronny 8b7b31826a feat: stronger visual scoring differentiation + streak fix
- Exakt cards: gold glow border, gold banner, trophy emoji, larger animated badge
- Tendency: green accent (was blue), clearer differentiation from Exakt
- Falsch: muted gray, reduced opacity — clearly "lost"
- Profile tip history: solid gold/green/gray badges with distinct borders
- Streak: remove utc_date <= NOW() filter so dev-finished matches count; handle PostgreSQL boolean serialization

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 09:31:15 +02:00
Ronny 1eaec75901 style: BEENDET badge right-aligned, bigger score, score aligned to flag center
- BEENDET as subtle badge on the right (same position as LIVE/countdown)
- Score: 26px default, 32px for live matches
- Score vertically centered to flag height (52px), not flag+name

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:11:36 +02:00
Ronny 0f70a1913c style: LIVE badge right-aligned, bigger live score, remove redundant tip
- LIVE: pulsing red badge with dot, right-aligned in header (replaces countdown position)
- Live score: 28px instead of 22px for better visibility
- Removed duplicate tip display under live score (tip only shown in footer)
- BEENDET status stays left in header

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:00:55 +02:00
Ronny e9143d6ebe fix: countdown badge back in header row (right-aligned), fix LED spacing
- Countdown badge in header: Gruppe A ... in 60 Tagen (right side)
- Urgent countdown also in header: Gruppe A ... Noch 9 Min!
- Removed separate countdownRow (was misplaced below kickoff)
- Reduced DSEG7 letter-spacing to fix "2 1:00" gap issue

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:22:11 +02:00
Ronny 92f847c075 style: countdown badge left-aligned, separate from header
Countdown badge now in its own row, left-aligned to match
the left edge of the flags. Removed from header top row.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:16:17 +02:00
Ronny 9cd55f8e28 style: stronger LED glow on kickoff time (4-layer text-shadow) 2026-04-11 23:10:35 +02:00
Ronny 3a1d99a92f style: reduce LED kickoff size, center vertically between header and flags 2026-04-11 23:07:54 +02:00
Ronny b7068ea2b0 style: real LED segment display font for kickoff time
- Added DSEG7 font (stadium scoreboard segment display)
- Kickoff time centered in card, no box, just glowing LED digits
- Gold color with double text-shadow glow for authentic look

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:56:56 +02:00
Ronny 6be9bcdc1b style: stadium scoreboard kickoff display in card header
Kickoff time styled as LED scoreboard: monospace font, dark background,
gold text with glow effect. Placed in card header next to group badge.
Cards are more compact without the separate kickoff row.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:50:12 +02:00
Ronny 137e14b3d1 fix: kickoff time centered without 'Uhr', unified countdown badge, DevPanel close button
- Kickoff: centered "21:00" above flags (no 'Uhr' suffix)
- Countdown: always rendered as badge (was unstyled span for <60min)
- DevPanel: added close button (✕) in panel header for reliable closing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:46:53 +02:00
Ronny e1b9f03d60 style: move kickoff time to card header, better date header spacing
- Kickoff time now in header row: "Gruppe A · 04:00 Uhr · in 61 Tagen"
  instead of centered between flags (avoids visual misalignment)
- Date timeline headers: more top padding for balanced spacing
  between cards

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:37:25 +02:00
Ronny d39ec7a579 style: Spielplan — glassmorphism stats card, timeline date headers
- Stats: glass card with 2/104 progress + Punkte/Exakt/Offen details
- Date sections: timeline divider with centered label + lines
  instead of accordion (no more broken border-radius)
- Past matches: simple toggle button, separate from timeline
- Match list: clean vertical flow without section containers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:27:58 +02:00
Ronny a7ce8141a3 style: redesign Spielplan — date grouping, compact stats, less glossy
- Replace 3 large stat tiles with compact "2 von 104 getippt" line
- Remove phase dropdown (not useful for daily tipping)
- Group matches by actual date (Mi, 11. Juni / Do, 12. Juni)
  instead of generic "Demnächst"
- First 3 date sections open by default
- Reduce TipModal flag glossy to match MatchCard flags
- Past matches in own collapsed section

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:09:38 +02:00
Ronny 4fe4d45270 style: align group badge left in card header 2026-04-11 21:59:44 +02:00
Ronny 6a40d71634 style: Match-Cards — names below flags, less glossy, cleaner header
- Team names centered below flags (vertical layout)
- Reduced flag box glossy effect (20% shine vs 55%)
- Removed "Terminiert" status from header (only show Live/Beendet)
- Dash separator instead of colon for upcoming matches
- Flex layout instead of grid for better centering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:44:42 +02:00
Ronny 8bc00f12aa style: redesign Match-Cards — balanced layout, better tipped state
- Move kickoff time above teams row instead of between flags
- Center separator: slim ":" instead of 100px time block
- Use shortName for teams (prevents overflow on mobile)
- Tipped state: clean green bar with icon, label, score, edit hint
  instead of scattered checkmark + underline link

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:29:18 +02:00
Ronny 5af41a8a2c fix: accuracy 1000% bug, Spiele nav icon, light mode polish
- Backend: parseInt for leaderboard values from PostgreSQL
  (string concatenation "1"+"0"="10" caused 1000% accuracy)
- BottomNav: Swords icon instead of emoji for Spiele tab
- Light mode: stronger card shadows and shine gradient

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:05:23 +02:00
Ronny e10aeadb6b fix: accuracy display, Spiele nav icon, light mode card polish
- Trefferquote: accuracy already displayed correctly as raw value (no fix needed, was already `{stats.accuracy}%`)
- BottomNav: replace  emoji with Lucide Swords icon for Spiele tab (emoji rendered as gear on some systems); remove unused .emojiIcon CSS class
- Light mode: stronger card shadows (0 4px 20px + 0 1px 4px) and brighter card shine (0.85)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 21:03:40 +02:00
Ronny 676ed9c1b3 fix: improve tipping user journey
- Dashboard "Jetzt tippen" opens TipModal directly instead of
  navigating to /spiele (no more dead-end spielplan)
- After tipping, dashboard updates to show "Dein Tipp: X:Y ✓"
- Spielplan auto-opens all sections when only 1-2 exist
  (no more collapsed "Demnächst" as only section)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:51:36 +02:00
Ronny 57bae63b68 chore: cleanup — remove compiled .js files and .superpowers artifacts
Added frontend/src/**/*.js and .superpowers/ to .gitignore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:43:42 +02:00
Ronny 7bb35ecf65 fix: simplify CI pipeline YAML to avoid parsing errors
Removed env: block and complex inline heredocs.
Compose file written to temp file instead of inline Python string.
2026-04-11 20:38:10 +02:00
Ronny addff8f0cc ci: switch to Gitea Container Registry for reliable deploys
Previous pipeline built images locally via Portainer Docker API,
but Docker layer caching produced identical images. Now:
- Build with nocache=1
- Push to Gitea registry (git.home.rm-warpstation.de)
- Compose uses image: from registry instead of build:
- Redeploy with pullImage: true forces fresh container

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:18:34 +02:00
Ronny dd65f7c4fe fix: StatsRing NaN with no data, Toast showing repeatedly, timer stability
- StatsRing: compute all without || 1 fallback, guard segments/legend behind hasData, use seg.count in legend to avoid NaN
- useRankChange: skip toast if already shown this session via sessionStorage
- Toast: use ref for onDismiss to prevent timer reset on every render

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 19:54:32 +02:00
Ronny 77ee3f9a45 fix: restore glassmorphism card effects on new components
Apply global .card class to Dashboard, Profile, ConfettiReveal, and
Toast components for consistent glossy card appearance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 19:45:54 +02:00
Ronny 7b19f3db98 feat: Phase 1 — Engagement & UX-Polish
Dashboard als Startseite, Bottom Nav, Smart Sections,
zustandsbasierte Match-Cards, Konfetti-Reveal, Streak-Tracker,
Rich Profile, Tipp-Animation, Rang-Toast.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:17:19 +02:00
Ronny f1b4b63324 style: CSS polish, light mode verification, build fixes
- Add --primary-rgb, --transition-fast, --transition-normal CSS tokens to :root
- Add --primary-rgb override in [data-theme="light"] section
- Fix TS error: remove unused devUser prop from Route elements in App.tsx (API patching via window._devUser makes props redundant)
- Fix TS error: remove unused 'api' import from DevPanel.tsx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 19:16:13 +02:00
Ronny 2dc55f29db feat: rich profile page with stats ring, tip history, fun stats
Donut chart showing exact/tendency/wrong distribution.
Scrollable tip history with point badges.
Fun stats: favorite tip, home win percentage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 19:14:41 +02:00
Ronny a304ceeff5 feat: rank change toast + streak milestone icons
Toast notification on rank change (up/down).
Streak display with milestones: 🔥 at 3, 🔥🔥 at 10,  at 20.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 19:12:02 +02:00
Ronny f6ab2c719d feat: Punkte-Reveal with confetti animation
Shows animated reveal overlay for unseen match results.
Exact match (3pts) triggers confetti explosion.
Each reveal shown only once (localStorage tracking).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 19:10:22 +02:00
Ronny 89046a2e29 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>
2026-04-11 19:08:39 +02:00
Ronny 1ed64078b4 feat: zustandsbasierte Match-Cards (open/tipped/live/finished/missed)
Each card state has distinct visual treatment:
- Open: standard with countdown timer when <1h
- Tipped: green accent with tip display
- Live: pulsing red dot
- Finished: points badge (gold/green/gray)
- Missed: grayed out

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 19:07:21 +02:00
Ronny 9a9b85a269 feat: smart sections in Spielplan (Heute/Morgen/Woche/Vergangen)
Matches grouped by time relevance with collapsible accordion.
Heute and Morgen open by default. Stage filter simplified to dropdown.
2026-04-11 19:05:09 +02:00
Ronny d27881c1c2 feat: add Dashboard as new startseite
Hero card with next match + countdown, stats tiles (rank, points, streak),
and contextual nudges. Replaces match list as landing page.
2026-04-11 19:02:53 +02:00
Ronny b10f0f6ad4 feat: add /api/dashboard endpoint with hero match, stats, streak, nudges
Returns next tippable match (hero), user rank/points from leaderboard,
consecutive-tip streak, and up to 3 contextual nudges in one request.
Mounts at /api/dashboard; adds getDashboard() + DashboardData type to
the frontend API client.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 19:01:01 +02:00
Ronny cb095126ef feat: add bottom navigation bar (mobile-first)
Fixed bottom nav with Home/Spiele/Rangliste/Profil tabs.
Desktop keeps header nav. Admin hidden behind gear icon.
Main content padded to avoid overlap with bottom nav.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 18:59:00 +02:00
Ronny 4f148811f0 refactor: simplify TipModal — remove Expertenblick and redundant header
Strips all insight/agent state, fetchInsight() SSE function, audio playback
logic, and the insightWrapper JSX block that called /api/agent/* routes.
Also removes the matchHeader/groupBadge and kickoffBlock from the modal
(info already visible on the match card). Cleans all corresponding CSS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 18:56:48 +02:00
Ronny 62aeda1395 refactor: remove KI-Agent chat widget and backend route 2026-04-11 18:54:50 +02:00
Ronny 69585cfac1 docs: Phase 1 implementation plan — 14 tasks
Step-by-step plan covering:
- Cleanup (remove AgentChat, simplify TipModal)
- Bottom Nav, Dashboard, Smart Sections
- Match Card states, animations, confetti
- Streak tracker, rank toasts, rich profile

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:34:24 +02:00
Ronny ea5b7b19fa docs: Phase 1 Design-Spec — Engagement & UX-Polish
Umfassendes Design-Dokument für das App-Redesign:
- Dashboard als neue Startseite (Hero + Stats + Nudges)
- Bottom Navigation Bar (Mobile-First)
- Smart Sections im Spielplan
- 5 emotionale Momente (Konfetti, Countdown, Streak etc.)
- Zustandsbasierte Match-Cards
- Reiches Profil mit Stats-Ring und Tipp-Historie
- Cleanup: KI-Agent entfernen, Modal verschlanken

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:26:32 +02:00
Ronny e0462c5ba4 ci: trigger rebuild for devUser fix
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:16:08 +02:00
Ronny 7bbbe01a03 fix: devUser-Parameter an alle API-Calls weiterleiten
Im Dev-Modus wurde der devUser Query-Parameter aus der Browser-URL
nicht an die Backend-API-Calls weitergegeben. Dadurch liefen alle
Requests immer als devUser=1 (Ronny), unabhängig vom gewählten User.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:03:11 +02:00
Ronny 7c9b7344aa fix: Health-Check vereinfacht – kein DB-Query mehr bei /health
Verhindert unnötige SELECT 1 Abfragen gegen Supabase alle 30s.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:45:39 +02:00
Ronny e0c4beadb1 style: Lucide Icons für Theme-Toggle + stärkerer Glossy-Effekt auf Flag-Boxen
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:09:36 +02:00
Ronny 7dac1befe7 style: stärkerer Glossy-Effekt auf Flag-Boxen im Light Mode
Glanz-Overlay auf 55% angehoben, inset-Highlight und Border ergänzt
für MatchCard flagBox, TipModal flagLarge und pickerBtn.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 14:02:21 +02:00
Ronny 01d7c10719 chore: add Claude Code config and local docs to .gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:00:27 +02:00
Ronny f56ecb724b feat: Light Mode mit Theme-Toggle
Fügt vollständigen Light Mode hinzu – umschaltbar per ☀️/🌙-Button
im Header, Auswahl wird in localStorage persistiert.

- index.css: Light-Mode-Variablen unter [data-theme="light"], neue Tokens --border-subtle, --shadow-card, --card-shine
- App.tsx: Theme-State + useEffect setzt data-theme auf <html>
- App.module.css: Toggle-Button gestylt, Header-Background auf CSS-Var umgestellt
- Komponenten-CSS: Hardcodierte rgba-Werte auf CSS-Variablen migriert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 12:50:04 +02:00
Ronny 2127ebceeb ci: Credentials aus Workflow entfernen, Portainer-Config dynamisch lesen
Statt Credentials im Workflow hardcoden:
- Compose-File und Env-Vars werden zur Laufzeit aus Portainer gelesen
- Einziges Secret im Workflow: PORTAINER_TOKEN
- Keine sensiblen Daten mehr in git-versionierten Dateien

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 13:41:19 +02:00
Ronny d7cd558caf ci: curl via apk installieren (Alpine runner hat kein curl)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 11:48:35 +02:00
Ronny 94be5620a6 fix: Gitea CI/CD und Blank-Page-Fehler behoben
- Helmet CSP: upgrade-insecure-requests und HSTS für HTTP-Deployments deaktiviert
  (war die Ursache der leeren Seite - Browser versuchte JS über HTTPS zu laden)
- Backend: statische Dateien werden jetzt in allen NODE_ENV-Modi serviert
- Frontend: IS_DEV erkennt auch VITE_TEST_MODE=true (Build-Zeit Variable)
- Dockerfile: VITE_TEST_MODE=true beim Vite-Build, NODE_ENV=development
- docker-compose.yml: NODE_ENV=development, CORS_ORIGIN=*
- Gitea Workflow: Auth-Token für git clone, Build via Portainer API statt lokaler Docker CLI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 11:46:56 +02:00
105 changed files with 5504 additions and 2470 deletions
+128 -4
View File
@@ -8,15 +8,139 @@ jobs:
build:
runs-on: self-hosted
steps:
- name: Setup tools
run: apk add --no-cache curl python3
- name: Checkout
run: |
rm -rf workspace && mkdir workspace
git clone --depth 1 --branch main http://gitea:3000/mwf975_git/tippspiel.git workspace
GIT_TERMINAL_PROMPT=0 git clone \
--depth 1 \
--branch main \
http://x-token:${{ secrets.DEPLOY_TOKEN }}@gitea:3000/mwf975_git/tippspiel.git \
workspace
- name: Build Docker Image
- name: Create build context
run: |
cd workspace
docker build -t wm2026-tippspiel:latest -t wm2026-tippspiel:${GITHUB_SHA:-latest} .
tar cf /tmp/tippspiel-ci.tar \
--exclude='.git' \
--exclude='node_modules' \
--exclude='*.docx' \
--exclude='prototyp_*.html' \
.
echo "Build context size: $(du -sh /tmp/tippspiel-ci.tar | cut -f1)"
- name: Build Docker Image via Portainer
run: |
REGISTRY="git.home.rm-warpstation.de"
IMAGE_TAG="${REGISTRY}/mwf975_git/tippspiel:latest"
echo "Building image: $IMAGE_TAG"
curl -s -k -X POST \
"https://192.168.1.60:9444/api/endpoints/2/docker/build?t=${IMAGE_TAG}&dockerfile=./Dockerfile&nocache=1" \
-H "X-API-Key: ${{ secrets.PORTAINER_TOKEN }}" \
-H "Content-Type: application/x-tar" \
--data-binary @/tmp/tippspiel-ci.tar \
--max-time 600 \
| tail -5
echo "Build completed."
- name: Push to Gitea Registry
run: |
REGISTRY="git.home.rm-warpstation.de"
IMAGE_TAG="${REGISTRY}/mwf975_git/tippspiel:latest"
DEPLOY_TOKEN="${{ secrets.DEPLOY_TOKEN }}"
AUTH_HEADER=$(python3 -c "
import base64, json
auth = json.dumps({'username': 'mwf975_git', 'password': '${DEPLOY_TOKEN}', 'serveraddress': 'https://${REGISTRY}'})
print(base64.urlsafe_b64encode(auth.encode()).decode())
")
echo "Pushing $IMAGE_TAG..."
curl -s -k -X POST \
"https://192.168.1.60:9444/api/endpoints/2/docker/images/${IMAGE_TAG}/push" \
-H "X-API-Key: ${{ secrets.PORTAINER_TOKEN }}" \
-H "X-Registry-Auth: $AUTH_HEADER" \
--max-time 300
echo ""
echo "Push completed."
- name: Redeploy Stack via Portainer
run: |
REGISTRY="git.home.rm-warpstation.de"
# Compose-File als separate Datei schreiben
cat > /tmp/compose-deploy.yml << 'COMPOSE_EOF'
services:
tippspiel:
image: git.home.rm-warpstation.de/mwf975_git/tippspiel:latest
container_name: wm2026-tippspiel
restart: unless-stopped
ports:
- "3301:3001"
environment:
- NODE_ENV=${NODE_ENV}
- PORT=${PORT}
- DATABASE_URL=${DATABASE_URL}
- SUPABASE_URL=${SUPABASE_URL}
- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- FOOTBALL_API_KEY=${FOOTBALL_API_KEY}
- FOOTBALL_API_BASE_URL=${FOOTBALL_API_BASE_URL}
- ELEVENLABS_API_KEY=${ELEVENLABS_API_KEY}
- CORS_ORIGIN=${CORS_ORIGIN}
- STAFFBASE_PUBLIC_KEY=${STAFFBASE_PUBLIC_KEY:-}
- STAFFBASE_PLUGIN_ID=${STAFFBASE_PLUGIN_ID:-}
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3001/health"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
networks:
- main-network
networks:
main-network:
external: true
COMPOSE_EOF
# Env-Vars aus Portainer lesen
ENV_VARS=$(curl -s -k \
"https://192.168.1.60:9444/api/stacks/115" \
-H "X-API-Key: ${{ secrets.PORTAINER_TOKEN }}" \
| python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin).get('Env', [])))")
# Stack-File lesen und Payload bauen
STACK_CONTENT=$(cat /tmp/compose-deploy.yml)
PAYLOAD=$(python3 -c "
import json, sys
compose = open('/tmp/compose-deploy.yml').read()
env_vars = json.loads(sys.argv[1])
print(json.dumps({
'stackFileContent': compose,
'env': env_vars,
'prune': True,
'pullImage': True
}))
" "$ENV_VARS")
echo "Redeploying stack..."
curl -s -k -X PUT \
"https://192.168.1.60:9444/api/stacks/115?endpointId=2" \
-H "X-API-Key: ${{ secrets.PORTAINER_TOKEN }}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
| python3 -c "import sys,json; d=json.load(sys.stdin); print('Stack:', d.get('Name'), '| Status:', d.get('Status'))" \
|| echo "Stack redeploy triggered."
- name: Verify deployment
run: |
sleep 20
STATUS=$(curl -s http://192.168.1.60:3301/health | python3 -c "import sys,json; print(d:=json.load(sys.stdin), d.get('status'))" 2>/dev/null || echo "unreachable")
echo "Health check: $STATUS"
- name: Cleanup
run: rm -rf workspace
if: always()
run: rm -rf workspace /tmp/tippspiel-ci.tar /tmp/compose-deploy.yml
+14
View File
@@ -18,3 +18,17 @@ backend/public/
# Environment
.env
.env.*
# Claude Code
.claude/
.mcp.json
# Docs / local tools
DEPLOY_DOCKER_TEST.md
tools.yaml
# Compiled JS in TypeScript source
frontend/src/**/*.js
# Superpowers brainstorm artifacts
.superpowers/
+1
View File
@@ -0,0 +1 @@
docs/superpowers/**
+2 -1
View File
@@ -7,6 +7,7 @@ WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
ENV VITE_TEST_MODE=true
RUN npx vite build
# ============================================================
@@ -41,7 +42,7 @@ COPY --from=build-frontend /app/backend/public ./public
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
ENV NODE_ENV=production
ENV NODE_ENV=development
ENV PORT=3001
EXPOSE 3001
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

+31 -25
View File
@@ -6,7 +6,6 @@ import rateLimit from 'express-rate-limit';
import { staffbaseAuth as createStaffbaseAuth } from './middleware/staffbaseAuth';
import { devAuth } from './middleware/devAuth';
import { checkDbConnection } from './db/client';
import { logger } from './services/logger';
import matchesRouter from './routes/matches';
@@ -15,7 +14,8 @@ import leaderboardRouter from './routes/leaderboard';
import adminRouter from './routes/admin';
import profileRouter from './routes/profile';
import devRouter from './routes/dev';
import agentRouter from './routes/agent';
import dashboardRouter from './routes/dashboard';
import achievementsRouter from './routes/achievements';
const app = express();
const PORT = parseInt(process.env.PORT ?? '3001');
@@ -28,13 +28,19 @@ app.use(
// Staffbase lädt das Plugin in einem iFrame
// X-Frame-Options muss daher SAMEORIGIN oder ALLOW-FROM erlauben
frameguard: false,
// HSTS nur aktivieren wenn wir über HTTPS laufen (z.B. hinter einem Reverse Proxy)
hsts: process.env.NODE_ENV === 'production' && process.env.PLUGIN_BASE_URL?.startsWith('https')
? { maxAge: 15552000, includeSubDomains: true }
: false,
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // für inline styles im Frontend
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'https://cdn.jsdelivr.net'],
imgSrc: ["'self'", 'data:', 'https://crests.football-data.org'],
frameAncestors: ['https://app.staffbase.com', 'https://*.staffbase.com'],
upgradeInsecureRequests: process.env.PLUGIN_BASE_URL?.startsWith('https') ? [] : null,
},
},
})
@@ -48,6 +54,10 @@ const allowedOrigins = (process.env.CORS_ORIGIN ?? 'https://app.staffbase.com')
app.use(
cors({
origin: (origin, callback) => {
// Wildcard: alle Origins erlauben
if (allowedOrigins.includes('*')) {
return callback(null, true);
}
// Requests ohne Origin (z.B. direkte API-Calls) in Development erlauben
if (!origin && process.env.NODE_ENV !== 'production') {
return callback(null, true);
@@ -87,24 +97,12 @@ app.use(
})
);
// Rate limit für Agent-Chat (Claude API Calls sind kostenpflichtig)
app.use(
'/api/agent',
rateLimit({
windowMs: 60 * 1000, // 1 Minute
max: 10, // 10 Chat-Nachrichten pro User/Minute
message: { error: 'Too many agent requests, bitte kurz warten.' },
})
);
// ============================================================
// Health Check (ohne Auth für Railway/Render/Uptime-Monitoring)
// ============================================================
app.get('/health', async (_req, res) => {
const dbOk = await checkDbConnection();
res.status(dbOk ? 200 : 503).json({
status: dbOk ? 'ok' : 'degraded',
db: dbOk ? 'connected' : 'unreachable',
app.get('/health', (_req, res) => {
res.status(200).json({
status: 'ok',
timestamp: new Date().toISOString(),
version: process.env.npm_package_version ?? '1.0.0',
});
@@ -130,22 +128,30 @@ app.use('/api/tips', tipsRouter);
app.use('/api/leaderboard', leaderboardRouter);
app.use('/api/admin', adminRouter);
app.use('/api/profile', profileRouter);
app.use('/api/agent', agentRouter);
app.use('/api/dashboard', dashboardRouter);
app.use('/api/achievements', achievementsRouter);
if (process.env.NODE_ENV === 'development') {
app.use('/api/dev', devRouter);
}
// ============================================================
// Frontend (React Build) statisches Serving
// In Production liefert das Backend auch das Frontend aus
// Wird in allen Modi aktiviert, wenn ein public-Ordner existiert
// (Docker-Container liefern Frontend immer über das Backend aus)
// ============================================================
if (process.env.NODE_ENV === 'production') {
{
// eslint-disable-next-line @typescript-eslint/no-var-requires
const path = require('path') as typeof import('path');
app.use(express.static(path.join(process.cwd(), 'public')));
app.get('*', (_req, res) => {
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
});
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require('fs') as typeof import('fs');
const publicDir = path.join(process.cwd(), 'public');
if (fs.existsSync(publicDir)) {
logger.info('Serving frontend static files from', { publicDir });
app.use(express.static(publicDir));
app.get('*', (_req, res) => {
res.sendFile(path.join(publicDir, 'index.html'));
});
}
}
// ============================================================
+169
View File
@@ -0,0 +1,169 @@
import { Router, Request, Response } from 'express';
import { query } from '../db/client';
import { logger } from '../services/logger';
const router = Router();
export interface Achievement {
id: string;
name: string;
description: string;
icon: string; // Material Symbol name
color: string; // CSS color for the glow
rankLabel: string; // e.g. "Gold-Rang", "On Fire"
unlocked: boolean;
progress: number; // 0-100 percentage
current: number; // current value
target: number; // target value
}
router.get('/', async (req: Request, res: Response): Promise<void> => {
const userId = req.staffbaseUser?.sub;
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
try {
// Get user stats
const statsResult = await query<any>(
`SELECT total_points, tips_count, exact_count, tendency_count, rank
FROM leaderboard WHERE user_id = $1`,
[userId]
);
const stats = statsResult[0];
const exactCount = parseInt(String(stats?.exact_count ?? '0'));
const tipsCount = parseInt(String(stats?.tips_count ?? '0'));
const rank = stats?.rank ? parseInt(String(stats.rank)) : null;
// Calculate streak (same logic as dashboard)
const pastMatches = await query<{ has_tip: boolean }>(
`SELECT CASE WHEN t.id IS NOT NULL THEN true ELSE false END AS has_tip
FROM matches m
LEFT JOIN tips t ON t.match_id = m.id AND t.user_id = $1
WHERE m.status IN ('FINISHED', 'IN_PLAY')
ORDER BY m.utc_date DESC`,
[userId]
);
let maxStreak = 0;
let currentStreak = 0;
for (const m of pastMatches) {
if (m.has_tip === true || (m.has_tip as unknown) === 't') {
currentStreak++;
if (currentStreak > maxStreak) maxStreak = currentStreak;
} else {
currentStreak = 0;
}
}
// Check early tipper: tips submitted >24h before kickoff
const earlyTips = await query<{ count: string }>(
`SELECT COUNT(*) AS count FROM tips t
JOIN matches m ON m.id = t.match_id
WHERE t.user_id = $1
AND m.status = 'FINISHED'
AND t.created_at < m.utc_date - interval '24 hours'`,
[userId]
);
const earlyCount = parseInt(earlyTips[0]?.count || '0');
const finishedWithTips = await query<{ count: string }>(
`SELECT COUNT(*) AS count FROM tips t
JOIN matches m ON m.id = t.match_id
WHERE t.user_id = $1 AND m.status = 'FINISHED'`,
[userId]
);
const finishedTipCount = parseInt(finishedWithTips[0]?.count || '0');
// Check globetrotter: tips in all groups
const groupsTipped = await query<{ count: string }>(
`SELECT COUNT(DISTINCT m.group_name) AS count
FROM tips t JOIN matches m ON m.id = t.match_id
WHERE t.user_id = $1 AND m.stage = 'GROUP_STAGE' AND m.group_name IS NOT NULL`,
[userId]
);
const groupCount = parseInt(groupsTipped[0]?.count || '0');
const totalGroups = 12; // WM 2026 has 12 groups (A-L)
// Build achievements
const achievements: Achievement[] = [
{
id: 'sharpshooter',
name: 'Scharfschütze',
description: '5 exakte Tipps',
icon: 'target',
color: '#f5ce53',
rankLabel: 'Gold-Rang',
unlocked: exactCount >= 5,
progress: Math.min(100, (exactCount / 5) * 100),
current: exactCount,
target: 5,
},
{
id: 'streak_master',
name: 'Serien-Tipper',
description: '10er Tipp-Serie',
icon: 'local_fire_department',
color: '#ff716c',
rankLabel: 'On Fire',
unlocked: maxStreak >= 10,
progress: Math.min(100, (maxStreak / 10) * 100),
current: maxStreak,
target: 10,
},
{
id: 'league_leader',
name: 'Tabellenführer',
description: 'Platz 1 erreicht',
icon: 'crown',
color: '#e6c047',
rankLabel: 'Prestige',
unlocked: rank === 1,
progress: rank === 1 ? 100 : 0,
current: rank === 1 ? 1 : 0,
target: 1,
},
{
id: 'early_bird',
name: 'Frühtipper',
description: 'Tipps 24h vor Anpfiff',
icon: 'alarm',
color: '#4BB7F8',
rankLabel: 'Speedster',
unlocked: finishedTipCount > 0 && earlyCount === finishedTipCount,
progress: finishedTipCount > 0 ? Math.min(100, (earlyCount / finishedTipCount) * 100) : 0,
current: earlyCount,
target: finishedTipCount || 1,
},
{
id: 'globetrotter',
name: 'Globetrotter',
description: 'Alle Gruppenspiele',
icon: 'public',
color: '#4BB7F8',
rankLabel: 'Reisender',
unlocked: groupCount >= totalGroups,
progress: Math.min(100, (groupCount / totalGroups) * 100),
current: groupCount,
target: totalGroups,
},
{
id: 'diamond',
name: 'Diamant',
description: '20 exakte Tipps',
icon: 'diamond',
color: '#a066ff',
rankLabel: 'Legendär',
unlocked: exactCount >= 20,
progress: Math.min(100, (exactCount / 20) * 100),
current: exactCount,
target: 20,
},
];
const unlockedCount = achievements.filter(a => a.unlocked).length;
res.json({ achievements, unlockedCount, total: achievements.length });
} catch (error) {
logger.error('Achievements failed', { error });
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;
-411
View File
@@ -1,411 +0,0 @@
import { Router, Request, Response } from 'express';
import Anthropic from '@anthropic-ai/sdk';
import { query } from '../db/client';
import { logger } from '../services/logger';
const router = Router();
// ============================================================
// Anthropic Client (lazy-initialized, Key aus .env)
// ============================================================
let anthropic: Anthropic | null = null;
function getClient(): Anthropic {
if (!anthropic) {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) throw new Error('ANTHROPIC_API_KEY not set');
anthropic = new Anthropic({ apiKey });
}
return anthropic;
}
// ============================================================
// Kontext: aktuelle Spiele aus der DB holen
// ============================================================
async function getMatchContext(): Promise<string> {
try {
const rows = await query<{
utc_date: Date;
status: string;
stage: string;
group_name: string | null;
home_team_name: string;
away_team_name: string;
score_home: number | null;
score_away: number | null;
}>(
`SELECT utc_date, status, stage, group_name,
home_team_name, away_team_name, score_home, score_away
FROM matches
ORDER BY utc_date ASC
LIMIT 200`
);
const now = new Date();
const upcoming = rows.filter(
(r) => new Date(r.utc_date) > now && (r.status === 'SCHEDULED' || r.status === 'TIMED')
);
const finished = rows.filter((r) => r.status === 'FINISHED');
const inPlay = rows.filter((r) => r.status === 'IN_PLAY' || r.status === 'PAUSED');
const lines: string[] = [];
if (inPlay.length > 0) {
lines.push('=== LIVE JETZT ===');
inPlay.forEach((m) => {
lines.push(
`${m.home_team_name} ${m.score_home ?? '?'} : ${m.score_away ?? '?'} ${m.away_team_name} (LIVE)`
);
});
}
if (upcoming.length > 0) {
lines.push('\n=== NÄCHSTE SPIELE (WM 2026) ===');
upcoming.slice(0, 20).forEach((m) => {
const d = new Date(m.utc_date);
const dateStr = d.toLocaleDateString('de-DE', {
weekday: 'short', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
});
const groupStr = m.group_name ? ` [${m.group_name}]` : ` [${m.stage}]`;
lines.push(`${dateStr}${groupStr}: ${m.home_team_name} vs. ${m.away_team_name}`);
});
}
if (finished.length > 0) {
lines.push('\n=== ZULETZT GESPIELT ===');
finished.slice(-10).forEach((m) => {
lines.push(
`${m.home_team_name} ${m.score_home} : ${m.score_away} ${m.away_team_name} (Ergebnis)`
);
});
}
return lines.join('\n');
} catch (err) {
logger.error('Agent: Fehler beim Laden des Match-Kontexts', { err });
return '(Keine Spieldaten verfügbar)';
}
}
// ============================================================
// System-Prompt: Der Fußball-Experte
// ============================================================
const NETZER_STYLE =
'DEIN STIL - Günther Netzer:\n' +
'Du bist Günther Netzer, ARD-Fußballexperte von 1997-2010. Trocken. Direkt. Elitär. Nostalgisch.\n' +
'Du maßt das aktuelle Geschehen stets an idealistischen Maßstäben - und an deiner eigenen Karriere.\n\n' +
'TYPISCHE PHRASEN die du verwendest:\n' +
'- "Aus der Tiefe des Raumes"\n' +
'- "Das sind fundamentale Dinge"\n' +
'- "Das ist ein Minimalisten-Dasein"\n' +
'- "Mir hat hier heute noch gar nichts gefallen"\n' +
'- "Das hat mit Spitzenfußball nichts zu tun"\n' +
'- "Das war dezent" (wenn eine Leistung mäßig war)\n' +
'- "Was bleibt mir noch übrig jetzt zu sagen..."\n' +
'- Gelegentlich ironisches Lob: "Das ist wirklich eine sehr kluge Beobachtung..."\n\n' +
'EIGENHEITEN:\n' +
'- Du vergleichst fast alles mit Beckenbauer, Müller, Cruyff oder deiner eigenen Zeit\n' +
'- Taktik-Geschwafel lehnst du ab: "Das nennen die heutzutage Ballbesitzfußball. Früher nannte man das Angst."\n' +
'- Du bist von Mannschaften prinzipiell enttäuscht, außer die Leistung ist absolut unstrittig\n' +
'- Kurze Sätze. Kein "mega", kein "Wahnsinn". Kein übertriebenes Lob.\n\n';
const DELLING_STYLE =
'Die Rolle von Gerhard Delling (dein Moderator-Pendant, NUR im Dialog-Modus):\n' +
'- Trocken, skeptisch, stichelt gerne\n' +
'- Typische Phrasen: "Nun könnte man sagen, seien wir doch mal großzügig...", "Fanden Sie nicht, dass immerhin..."\n' +
'- Verteidigt absichtlich die schwächere Mannschaft um Netzer zu provozieren\n' +
'- Stichelt gegen Netzers Vergangenheit (Laufbereitschaft, Frisur)\n' +
'- Bleibt immer ruhig, lässt sich von Netzers Arroganz nicht erschüttern\n' +
'- Er und Netzer siezen sich stets, obwohl sie Freunde sind\n\n';
const SYSTEM_PROMPT_BASE =
'Du bist der Fußball-Experte im WM 2026 Tippspiel von GEALAN. Du kennst WM und EM in- und auswendig: Ergebnisse, Rekorde, Taktiken, Legenden - von 1930 bis heute.\n\n' +
NETZER_STYLE +
DELLING_STYLE +
'GESPRÄCHSMODUS: Wenn der Nutzer dich direkt anschreibt, antwortest du als Netzer allein. Wenn du eine Analyse oder Einschätzung gibst, kannst du gelegentlich einen kurzen Einwurf von Delling einfließen lassen - im Format:\n' +
'**Delling:** "..."\n' +
'**Netzer:** "..."\n\n' +
'TIPP-EMPFEHLUNGEN: Wenn jemand allgemein fragt, stelle zuerst eine Rückfrage. Haenge einen CHOICES-Block an mit den naechsten 5 Spielen. Erst nach Auswahl gibst du Empfehlungen - maximal 3 auf einmal.\n\n' +
'CHOICES-FORMAT (nur fuer Tipp-Rückfragen):\n' +
'[CHOICES]\nHeimteam vs. Gastteam\n[/CHOICES]\n' +
'Exakte Teamnamen aus dem Kontext. Kein Datum, keine Emojis im Block.\n\n' +
'FORMATIERUNG: Kompakt (max. 4 Absaetze). **fett** fuer Namen/Scores. ## fuer Überschriften. --- als Trennlinie. Kein # H1. Kein Emoji-Overload.\n\n' +
'SPIELPLAN-KONTEXT:\n';
// ============================================================
// POST /api/agent/chat
// Body: { messages: [{role, content}][], quickAction?: string }
// ============================================================
router.post('/chat', async (req: Request, res: Response): Promise<void> => {
const { messages, quickAction } = req.body as {
messages?: Array<{ role: 'user' | 'assistant'; content: string }>;
quickAction?: string;
};
// Validierung
if (!messages && !quickAction) {
res.status(400).json({ error: 'messages oder quickAction erforderlich' });
return;
}
const chatMessages: Array<{ role: 'user' | 'assistant'; content: string }> =
messages ?? [];
// Quick-Action als User-Nachricht hinzufügen
if (quickAction) {
chatMessages.push({ role: 'user', content: quickAction });
}
if (chatMessages.length === 0) {
res.status(400).json({ error: 'Keine Nachrichten vorhanden' });
return;
}
try {
const client = getClient();
const matchContext = await getMatchContext();
const systemPrompt = SYSTEM_PROMPT_BASE + matchContext;
// Streaming-Antwort via SSE
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const stream = await client.messages.stream({
model: 'claude-sonnet-4-6',
max_tokens: 1024,
system: systemPrompt,
messages: chatMessages.slice(-10), // max. 10 Nachrichten Kontext
});
for await (const chunk of stream) {
if (
chunk.type === 'content_block_delta' &&
chunk.delta.type === 'text_delta'
) {
const data = JSON.stringify({ text: chunk.delta.text });
res.write(`data: ${data}\n\n`);
}
}
res.write('data: [DONE]\n\n');
res.end();
logger.info('Agent: Chat-Anfrage beantwortet', {
userId: req.staffbaseUser?.sub,
messageCount: chatMessages.length,
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Agent: Fehler', { error: message });
if (!res.headersSent) {
res.status(500).json({ error: 'Agent nicht verfügbar', detail: message });
} else {
res.write(`data: ${JSON.stringify({ error: message })}\n\n`);
res.end();
}
}
});
// ============================================================
// POST /api/agent/insight
// Kompakte Einschätzung für genau ein Spiel (für TipModal)
// Body: { homeTeam: string, awayTeam: string, stage: string, group?: string }
// ============================================================
router.post('/insight', async (req: Request, res: Response): Promise<void> => {
const { homeTeam, awayTeam, stage, group } = req.body as {
homeTeam: string;
awayTeam: string;
stage?: string;
group?: string;
};
if (!homeTeam || !awayTeam) {
res.status(400).json({ error: 'homeTeam und awayTeam erforderlich' });
return;
}
const stageLabel = group
? group.replace('GROUP_', 'Gruppe ')
: stage ?? 'WM 2026';
const insightPrompt =
NETZER_STYLE +
DELLING_STYLE +
'Schreibe einen kurzen Expertenblick als Dialog zwischen Delling und Netzer über das folgende Spiel.\n\n' +
'WICHTIG: Das Spiel hat noch NICHT stattgefunden. Es ist eine Vorschau, keine Nachbetrachtung.\n' +
'Verwende ausschließlich Zukunftsformen und Konjunktiv: "wird", "könnte", "dürfte", "ist zu erwarten".\n' +
'Du darfst auf vergangene Begegnungen, Qualifikation oder historische Statistiken referenzieren - aber nur als Argument für die Prognose.\n' +
'VERBOTEN: Phrasen wie "hat mir nicht gefallen", "da war nichts", "das war" bezogen auf das aktuelle Spiel.\n\n' +
'BEISPIELE (Vorschau-Ton - diese Authentizität ist entscheidend):\n\n' +
'Beispiel 1 (Gruppenspiel mit klarem Favoriten):\n' +
'**Delling:** "Nun, Herr Netzer, wir haben hier ja doch einen veritablen Favoriten. Könnte der Außenseiter nicht von der Qualifikationsform profitieren?"\n' +
'**Netzer:** "Nein. Das waren Qualifikationsspiele. Das wird mit dem hier nichts zu tun haben. Das sind fundamentale Dinge."\n' +
'**Delling:** "Seien wir doch mal großzügig - auch der Außenseiter hat Qualitäten, die sich zeigen könnten."\n' +
'**Netzer:** "Das nennen Sie Qualitäten. Ich nenne das ein Minimalisten-Dasein. Ich tippe auf einen klaren Sieg des Favoriten."\n\n' +
'Beispiel 2 (Ausgeglichenes Spiel):\n' +
'**Delling:** "Herr Netzer, das könnte ja ein enges Spiel werden. Beide Mannschaften liegen nah beieinander."\n' +
'**Netzer:** "Das ist dezent ausgedrückt. Beiden fehlt, was Beckenbauer damals selbstverständlich war - diese Überlegenheit. Aus der Tiefe des Raumes heraus, verstehen Sie?"\n' +
'**Delling:** "Ich glaube, die Spieler würden sich bedanken, wenn Sie ihnen das vor dem Anpfiff erläutern könnten."\n' +
'**Netzer:** "Was bleibt mir noch übrig jetzt zu sagen. Ich tippe auf ein 1:1."\n\n' +
'JETZT das echte Spiel:\n' +
'Spiel: **' + homeTeam + '** vs. **' + awayTeam + '** (' + stageLabel + ')\n\n' +
'Schreibe genau 4 Wechselreden (Delling, Netzer, Delling, Netzer). Netzer gibt am Ende seinen konkreten Tipp mit Score. Kein Emoji. Siezen. Kurze Sätze bei Netzer.';
try {
const client = getClient();
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const stream = await client.messages.stream({
model: 'claude-haiku-4-5-20251001',
max_tokens: 512,
messages: [{ role: 'user', content: insightPrompt }],
});
for await (const chunk of stream) {
if (
chunk.type === 'content_block_delta' &&
chunk.delta.type === 'text_delta'
) {
res.write('data: ' + JSON.stringify({ text: chunk.delta.text }) + '\n\n');
}
}
res.write('data: [DONE]\n\n');
res.end();
logger.info('Agent: Insight geliefert', {
userId: req.staffbaseUser?.sub,
match: homeTeam + ' vs ' + awayTeam,
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Agent: Insight-Fehler', { error: message });
if (!res.headersSent) {
res.status(500).json({ error: 'Insight nicht verfügbar' });
} else {
res.write('data: ' + JSON.stringify({ error: message }) + '\n\n');
res.end();
}
}
});
// ============================================================
// POST /api/agent/insight-audio
// Body: { dialogText: string }
// Gibt eine MP3 zurück (Delling + Netzer als Dialog, 2 Stimmen)
// ============================================================
// ElevenLabs Voice-IDs (kostenlose Standard-Voices)
// Netzer: "Adam" tief, ruhig, autoritär
// Delling: "Antoni" etwas heller, sachlicher
const ELEVENLABS_VOICE_NETZER = process.env.ELEVENLABS_VOICE_NETZER ?? 'pNInz6obpgDQGcFmaJgB'; // Adam
const ELEVENLABS_VOICE_DELLING = process.env.ELEVENLABS_VOICE_DELLING ?? 'ErXwobaYiN019PkySvjV'; // Antoni
async function synthesizeTurn(
text: string,
voiceId: string,
apiKey: string
): Promise<Buffer> {
const res = await fetch(
`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,
{
method: 'POST',
headers: {
'xi-api-key': apiKey,
'Content-Type': 'application/json',
Accept: 'audio/mpeg',
},
body: JSON.stringify({
text,
model_id: 'eleven_multilingual_v2',
voice_settings: { stability: 0.55, similarity_boost: 0.75 },
}),
}
);
if (!res.ok) {
const err = await res.text();
throw new Error(`ElevenLabs error ${res.status}: ${err}`);
}
const arrayBuf = await res.arrayBuffer();
return Buffer.from(arrayBuf);
}
// Parst **Delling:** "..." / **Netzer:** "..." Zeilen aus dem Dialog-Text
function parseDialogTurns(
dialogText: string
): Array<{ speaker: 'Delling' | 'Netzer'; text: string }> {
const turns: Array<{ speaker: 'Delling' | 'Netzer'; text: string }> = [];
const lines = dialogText.split('\n');
for (const line of lines) {
const m = line.match(/^\*\*(Delling|Netzer):\*\*\s*[„""]?(.+?)["""]?\s*$/);
if (m) {
turns.push({
speaker: m[1] as 'Delling' | 'Netzer',
text: m[2].trim(),
});
}
}
return turns;
}
router.post('/insight-audio', async (req: Request, res: Response): Promise<void> => {
const { dialogText } = req.body as { dialogText?: string };
if (!dialogText) {
res.status(400).json({ error: 'dialogText erforderlich' });
return;
}
const apiKey = process.env.ELEVENLABS_API_KEY;
if (!apiKey) {
res.status(503).json({ error: 'ELEVENLABS_API_KEY nicht konfiguriert' });
return;
}
const turns = parseDialogTurns(dialogText);
logger.info('Audio: Dialog geparst', { turns: turns.length, preview: dialogText.slice(0, 200) });
if (turns.length === 0) {
res.status(400).json({ error: 'Kein Dialog-Format erkannt' });
return;
}
try {
// Turns sequenziell synthetisieren (Free Tier: max 2 concurrent)
const audioBuffers: Buffer[] = [];
for (const turn of turns) {
const buf = await synthesizeTurn(
turn.text,
turn.speaker === 'Netzer' ? ELEVENLABS_VOICE_NETZER : ELEVENLABS_VOICE_DELLING,
apiKey
);
audioBuffers.push(buf);
}
// MP3-Chunks zusammenführen (einfaches Aneinanderhängen reicht für MP3)
const combined = Buffer.concat(audioBuffers);
res.setHeader('Content-Type', 'audio/mpeg');
res.setHeader('Content-Length', combined.length);
res.setHeader('Cache-Control', 'no-store');
res.send(combined);
logger.info('Agent: Insight-Audio generiert', {
userId: req.staffbaseUser?.sub,
turns: turns.length,
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Agent: Insight-Audio-Fehler', { error: message });
if (!res.headersSent) {
res.status(500).json({ error: 'Audio-Generierung fehlgeschlagen' });
}
}
});
export default router;
+123
View File
@@ -0,0 +1,123 @@
import { Router, Request, Response } from 'express';
import { query } from '../db/client';
import { logger } from '../services/logger';
const router = Router();
router.get('/', async (req: Request, res: Response): Promise<void> => {
const userId = req.staffbaseUser?.sub;
if (!userId) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
try {
// 1. Hero: next upcoming match
const heroResult = await query<any>(
`SELECT m.id, m.utc_date, m.status,
m.home_team_name, m.home_team_short, m.home_team_crest,
m.away_team_name, m.away_team_short, m.away_team_crest,
t.tip_home, t.tip_away,
EXTRACT(EPOCH FROM (m.utc_date - NOW())) / 60 AS minutes_until
FROM matches m
LEFT JOIN tips t ON t.match_id = m.id AND t.user_id = $1
WHERE m.status IN ('SCHEDULED', 'TIMED')
ORDER BY m.utc_date ASC
LIMIT 1`,
[userId]
);
const h = heroResult[0];
const hero = h ? {
match: {
id: h.id,
homeTeam: { name: h.home_team_name, shortName: h.home_team_short, crest: h.home_team_crest },
awayTeam: { name: h.away_team_name, shortName: h.away_team_short, crest: h.away_team_crest },
utcDate: h.utc_date,
status: h.status,
minutesUntilKickoff: Math.round(parseFloat(h.minutes_until)),
},
userTip: h.tip_home != null ? { home: h.tip_home, away: h.tip_away } : null,
tippable: parseFloat(h.minutes_until) > 5,
} : null;
// 2. Stats from leaderboard
const statsResult = await query<any>(
`SELECT rank, total_points FROM leaderboard WHERE user_id = $1`,
[userId]
);
const s = statsResult[0];
// 3. Streak: consecutive tipped matches (most recent backward)
const pastMatches = await query<{ has_tip: boolean }>(
`SELECT CASE WHEN t.id IS NOT NULL THEN true ELSE false END AS has_tip
FROM matches m
LEFT JOIN tips t ON t.match_id = m.id AND t.user_id = $1
WHERE m.status IN ('FINISHED', 'IN_PLAY')
ORDER BY m.utc_date DESC`,
[userId]
);
let streak = 0;
for (const m of pastMatches) {
if (m.has_tip === true || (m.has_tip as unknown) === 't' || (m.has_tip as unknown) === '1') streak++;
else break;
}
// 4. Nudges
const nudges: Array<{ type: string; text: string; matchId?: number }> = [];
const untipped = await query<{ count: string }>(
`SELECT COUNT(*) AS count FROM matches m
LEFT JOIN tips t ON t.match_id = m.id AND t.user_id = $1
WHERE m.utc_date::date = CURRENT_DATE
AND m.status IN ('SCHEDULED', 'TIMED')
AND t.id IS NULL`,
[userId]
);
const untippedCount = parseInt(untipped[0]?.count || '0');
if (untippedCount > 0) {
nudges.push({
type: 'untipped',
text: `📅 Heute noch ${untippedCount} ${untippedCount === 1 ? 'Spiel' : 'Spiele'} ohne Tipp`,
});
}
const leader = await query<{ full_name: string; total_points: string }>(
`SELECT full_name, total_points FROM leaderboard ORDER BY rank ASC LIMIT 1`
);
if (leader[0]) {
nudges.push({ type: 'leader', text: `🏆 ${leader[0].full_name} führt mit ${leader[0].total_points} Punkten` });
}
const latest = await query<any>(
`SELECT m.home_team_short, m.away_team_short, m.score_home, m.score_away, t.points, m.id AS match_id
FROM tips t JOIN matches m ON m.id = t.match_id
WHERE t.user_id = $1 AND t.points IS NOT NULL
ORDER BY m.utc_date DESC LIMIT 1`,
[userId]
);
if (latest[0]) {
const r = latest[0];
nudges.push({
type: 'result',
text: `🎯 Letzte Auswertung: ${r.points} Punkte für ${r.home_team_short} ${r.score_home}:${r.score_away} ${r.away_team_short}`,
matchId: r.match_id,
});
}
res.json({
hero,
stats: {
rank: s ? parseInt(s.rank) : null,
totalPoints: s ? parseInt(s.total_points) : 0,
streak,
},
nudges,
});
} catch (error) {
logger.error('Dashboard failed', { error });
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;
+8 -10
View File
@@ -101,24 +101,22 @@ router.get('/me', async (req: Request, res: Response): Promise<void> => {
const evaluatedCount = parseInt(tipsStats?.count ?? '0');
const wrongCount = parseInt(tipsStats?.wrong_count ?? '0');
const exactCount = parseInt(String(lb?.exact_count ?? '0'));
const tendencyCount = parseInt(String(lb?.tendency_count ?? '0'));
const accuracy =
evaluatedCount > 0
? Math.round(
(((lb?.exact_count ?? 0) + (lb?.tendency_count ?? 0)) /
evaluatedCount) *
100
)
? Math.round(((exactCount + tendencyCount) / evaluatedCount) * 100)
: 0;
const response: UserStatsResponse = {
userId,
fullName: lb?.full_name ?? req.staffbaseUser!.name ?? 'Unbekannt',
team: lb?.team ?? null,
totalPoints: lb?.total_points ?? 0,
rank: lb?.rank ?? null,
tipsCount: lb?.tips_count ?? 0,
exactCount: lb?.exact_count ?? 0,
tendencyCount: lb?.tendency_count ?? 0,
totalPoints: parseInt(String(lb?.total_points ?? '0')),
rank: lb?.rank ? parseInt(String(lb.rank)) : null,
tipsCount: parseInt(String(lb?.tips_count ?? '0')),
exactCount,
tendencyCount,
wrongCount,
accuracy,
};
+3 -5
View File
@@ -1,14 +1,12 @@
services:
tippspiel:
build:
context: .
dockerfile: Dockerfile
image: git.home.rm-warpstation.de/mwf975_git/tippspiel:latest
container_name: wm2026-tippspiel
restart: unless-stopped
ports:
- "3301:3001"
environment:
- NODE_ENV=production
- NODE_ENV=development
- PORT=3001
- DATABASE_URL=${DATABASE_URL}
- SUPABASE_URL=${SUPABASE_URL}
@@ -17,7 +15,7 @@ services:
- FOOTBALL_API_KEY=${FOOTBALL_API_KEY}
- FOOTBALL_API_BASE_URL=https://api.football-data.org/v4
- ELEVENLABS_API_KEY=${ELEVENLABS_API_KEY}
- CORS_ORIGIN=${CORS_ORIGIN:-https://app.staffbase.com,http://localhost:5173}
- CORS_ORIGIN=*
- STAFFBASE_PUBLIC_KEY=${STAFFBASE_PUBLIC_KEY:-}
- STAFFBASE_PLUGIN_ID=${STAFFBASE_PLUGIN_ID:-}
healthcheck:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,352 @@
<!-- markdownlint-disable -->
# Phase 1: Engagement & UX-Polish — Design Spec
**Datum:** 2026-04-11
**Projekt:** WM 2026 Tippspiel
**Ziel:** Die App von "funktional" zu "macht Spaß" transformieren. Mitarbeiter sollen beim Öffnen lächeln und täglich zurückkommen.
**Zielplattform:** Mobile-First (Staffbase-App auf Smartphone), Desktop als Bonus
**Launch:** Anfang Juni 2026 (Intranet-Post), WM-Start 11. Juni 2026
---
## 1. Neue Startseite: Dashboard
Die aktuelle Startseite zeigt 104 Spiele als endlose Liste. Die neue Startseite wird ein persönliches WM-Cockpit.
### Layout: "Hero + Stats + Nudges"
**Hero-Bereich (oberes Drittel):**
- Nächstes tippbares Spiel prominent angezeigt mit Team-Flaggen und Countdown-Timer
- Wenn bereits getippt: Tipp anzeigen mit Häkchen ("Dein Tipp: 2:1 ✓")
- Wenn noch nicht getippt: "Jetzt tippen"-Button
- Wenn kein Spiel ansteht: Nächstes Spiel mit Datum ("Nächstes Spiel: Morgen 21:00")
**Stats-Kacheln (mittleres Drittel):**
- Drei kompakte Kacheln nebeneinander:
- **Dein Rang** (Platzierung mit Pfeil hoch/runter seit letzter Änderung)
- **Punkte** (Gesamtpunktzahl)
- **Streak** (🔥-Counter, aktuelle Serie an abgegebenen Tipps)
**Nudge-Bereich (unteres Drittel):**
- Kontextabhängige Handlungsaufforderungen:
- "📅 Heute noch 2 Spiele ohne Tipp" → Link zum Spielplan
- "🏆 Max führt mit 15 Punkten" → Link zur Rangliste
- "🎯 Letzte Auswertung: 3 Punkte für Brazil vs Serbia!" → Link zum Ergebnis
- Maximal 2-3 Nudges, priorisiert nach Relevanz
**Navigationsänderung:** Der volle Spielplan wird zur eigenen Seite "Spiele" in der Navigation. Dashboard ist die neue Startseite.
---
## 2. Bottom Navigation Bar
Die Header-Navigation wird durch eine feste Bottom Navigation ersetzt (Mobile-First-Standard).
**4 Haupttabs:**
- 🏠 **Home** — Dashboard (neue Startseite)
-**Spiele** — Spielplan mit Smart Sections
- 🏆 **Rangliste** — Leaderboard
- 👤 **Profil** — Persönliche Statistiken
**Header:** Wird schlank — nur Logo ("WM 2026 Tippspiel"), optional Notification-Badge, Theme-Toggle.
**Admin:** Nicht mehr in der Hauptnavigation. Nur für Editoren sichtbar als Zahnrad-Icon im Header. Viewer sehen es nicht.
**Desktop:** Bottom Nav wird zur Standard-Sidebar oder bleibt als Header-Nav — sekundäre Priorität, Mobile-Erlebnis bestimmt das Design.
---
## 3. Smart Sections im Spielplan
Die 104-Spiele-Liste wird intelligent in Zeitabschnitte gruppiert.
**Abschnitte (automatisch basierend auf aktuellem Datum):**
| Abschnitt | Default-Zustand | Inhalt |
|-----------|----------------|--------|
| **Heute** | Expandiert, farbig hervorgehoben | Alle Spiele des Tages |
| **Morgen** | Expandiert | Alle Spiele des Folgetags |
| **Diese Woche** | Kollapsiert, Anzahl angezeigt | Restliche Spiele der Woche |
| **Nächste Woche+** | Kollapsiert | Spätere Spiele |
| **Vergangene Spiele** | Kollapsiert | Letzte Ergebnisse + eigene Punkte |
**Verhalten:**
- Abschnitte sind auf-/zuklappbar (Accordion)
- "Heute" scrollt automatisch in den Viewport beim Öffnen
- Leerer Abschnitt wird ausgeblendet (z.B. "Heute" wenn kein Spiel)
- Stage-Filter (Gruppenphase, Achtelfinale...) wird zum optionalen Dropdown statt Button-Leiste
---
## 4. Zustandsbasierte Match-Cards
Jede Match-Card sieht visuell anders aus je nach Spielstatus. Die aktuellen Cards sind identisch unabhängig vom Zustand.
**5 visuelle Zustände:**
### ⏳ Offen (tippbar)
- Standard-Design mit Team-Flaggen und Kick-off-Zeit
- "Tipp abgeben"-Button prominent
- Countdown wenn <24h bis Anpfiff
- Pulsierender roter Countdown wenn <1h
### ✅ Getippt
- Grüner linker Rand / Akzent
- Tipp prominent angezeigt ("Dein Tipp: 2:1")
- "Ändern"-Link statt "Tipp abgeben"
- Häkchen-Icon
### 🔴 Live (in Spielgang)
- Pulsierender roter Punkt neben "LIVE"
- Aktueller Spielstand (wenn über Sync verfügbar)
- Tipp daneben zum Vergleich
- Tippfenster geschlossen — Schloss-Icon
### 🏁 Beendet (ausgewertet)
- Endergebnis prominent
- Dein Tipp + Punkte-Badge:
- 🥇 **Gold-Badge (3 Pkt):** Exakter Treffer, goldener Schimmer-Effekt
-**Grüner Badge (1 Pkt):** Richtige Tendenz
-**Grauer Badge (0 Pkt):** Falsch getippt
### 🔒 Verpasst (nicht getippt, Spiel vorbei)
- Ausgegraut / reduzierte Opacity
- "Nicht getippt" Label
- Leichter visueller "Shame-Effekt" als Motivation fürs nächste Mal
---
## 5. Emotionale Momente & Animationen
5 Schlüsselmomente, die die App lebendig machen.
### 5.1 Tipp-Bestätigung
- **Trigger:** User klickt "Tipp bestätigen"
- **Animation:** Card pulsiert kurz grün, animiertes Häkchen fliegt rein
- **Text:** "Dein Tipp ist drin! 🎯" als kurze Success-Message
- **Mobile:** Subtiles Haptic-Feedback (Vibration via `navigator.vibrate`)
- **Dauer:** ~1 Sekunde, dann Modal schließt
### 5.2 Live-Countdown vor Anpfiff
- **Trigger:** Spiel startet in <1 Stunde
- **Anzeige:** Pulsierender roter Countdown-Timer auf der Match-Card ("Noch 12 Min!")
- **Urgency-Stufen:**
- <24h: Countdown in Stunden ("in 5h")
- <1h: Pulsierender roter Countdown in Minuten
- <5min: Schnelleres Pulsieren
- **Nach Anpfiff:** "Tippfenster geschlossen" mit Schloss-Icon, sanfte Transition
### 5.3 Punkte-Reveal nach Spielende
- **Trigger:** User öffnet App / navigiert zu einem ausgewerteten Spiel, das noch nicht "gesehen" wurde
- **Sequenz (gestaffelt, ~2-3 Sekunden):**
1. Endergebnis einblenden (z.B. "Brazil 3:1 Serbia")
2. "Dein Tipp war: 3:1" einblenden
3. Punkte-Counter animiert hochzählen
- **Varianten nach Ergebnis:**
- **Exakt (3 Pkt):** 🎉 Konfetti-Explosion, goldener "EXAKT!"-Badge, Celebratory-Puls
- **Tendenz (1 Pkt):** 👏 Grüner Puls-Effekt, "Richtige Tendenz!"
- **Falsch (0 Pkt):** 😅 Kurzes Kopfschütteln-Emoji, "Knapp daneben..."
- **"Gesehen"-Flag:** Jeder Reveal wird nur einmal gezeigt (localStorage-basiert)
### 5.4 Ranglistenveränderung
- **Trigger:** Rang hat sich seit letztem Besuch geändert
- **Anzeige:** Toast-Notification beim App-Öffnen:
- Aufstieg: "⬆️ Du bist auf Platz 3 aufgestiegen!"
- Abstieg: "⬇️ Du bist auf Platz 7 gerutscht — hol dir die Punkte zurück!"
- Knapper Verfolger: "⚠️ Anna ist nur noch 1 Punkt hinter dir!"
- **Speicherung:** Letzter bekannter Rang in localStorage, Vergleich bei jedem Laden
### 5.5 Streak-Tracker
- **Definition:** Anzahl aufeinanderfolgender Spiele, für die ein Tipp abgegeben wurde (nicht Korrektheit, nur Teilnahme)
- **Anzeige:** 🔥-Counter auf dem Dashboard und im Profil
- **Meilensteine:**
- 3er-Streak: 🔥 Feuer-Icon erscheint
- 10er-Streak: 🔥🔥 Doppel-Feuer
- 20er-Streak: ⚡ Blitz-Icon
- **Streak-Bruch:** "Deine 7er-Serie ist gerissen! Starte eine neue." als Nudge auf dem Dashboard
- **Backend:** Streak wird bei Tipp-Abgabe berechnet (aufeinanderfolgende Spiele nach Kick-off-Datum)
---
## 6. Profil-Seite: Reiches Profil
Die aktuelle Profil-Seite zeigt Name, Rang und 4 Stat-Boxen. Das Redesign macht sie persönlich und interessant.
### Layout (von oben nach unten):
**Header-Card:**
- Initialen-Avatar (bestehendes Design)
- Name + Rang-Badge ("🏆 Platz 5")
- Lieblingsteam mit Flagge (bestehende "Team hinzufügen"-Funktion, prominenter platziert)
**Stats-Ring:**
- Kreisdiagramm (Donut-Chart) mit Verteilung: Exakt / Tendenz / Falsch
- Gesamtpunktzahl in der Mitte des Rings
- Legende darunter
**Tipp-Historie:**
- Scrollbare Liste: "Deine letzten 10 Tipps"
- Jeder Eintrag: Teams, dein Tipp, Ergebnis, Punkte-Badge
- Expandierbar auf alle Tipps
**Fun-Stats:**
- "Dein Lieblings-Tipp: 1:0 (5x getippt)"
- "Du tippst 70% Heimsiege"
- "Längste Streak: 🔥12"
- Rotiert oder zeigt 2-3 gleichzeitig
**Achievement-Platzhalter (Vorgriff Phase 2):**
- Badge-Leiste am unteren Rand
- Grau/locked wenn noch nicht erreicht, farbig wenn freigeschaltet
- Phase 1: Platzhalter-UI, Logik kommt in Phase 2
---
## 7. Entfernte / Vereinfachte Features
### KI-Agent / Expertenblick — ENTFERNT
- Chat-Widget (Fußball-Icon unten rechts) wird entfernt
- "Expertenblick"-Accordion im Tipp-Modal wird entfernt
- Dateien: `AgentChat.tsx`, Agent-Route im Backend (`/api/agent`) können entfernt oder deaktiviert werden
- Begründung: Nice-to-have, lenkt vom Kernflow ab, spart Platz auf Mobile
### Tipp-Modal — VERSCHLANKT
- **Entfernt:** Gruppeninfo-Header, Kick-off-Datum (steht schon auf der Card), Expertenblick-Accordion
- **Bleibt:** Team-Flaggen + Namen, Score-Picker (+/-), Tendenz-Anzeige, "Tipp bestätigen"-Button
- **Neu:** Erfolgsanimation nach Bestätigung (siehe 5.1)
### Stage-Filter — VEREINFACHT
- Aktuell: 8 horizontale Filter-Buttons
- Neu: Dropdown/Select im Spielplan-Header
- Smart Sections übernehmen die Hauptnavigation im Spielplan
### Admin-Navigation — VERSTECKT
- Nicht mehr als Haupttab sichtbar
- Nur für User mit Editor-Rolle: Zahnrad-Icon im Header
- Viewer sehen keine Admin-UI
---
## 8. Technische Hinweise
### Frontend
- **Framework:** React + Vite + TypeScript (bestehend)
- **Animationen:** CSS-Animationen + `framer-motion` für komplexere Sequenzen (Konfetti, Punkte-Reveal)
- **Konfetti:** Lightweight-Library wie `canvas-confetti` (~3KB)
- **Charts:** Donut-Chart für Profil — entweder SVG-basiert selbst gebaut oder leichtgewichtige Library
- **Haptic:** `navigator.vibrate(50)` für Mobile (Progressive Enhancement, kein Fallback nötig)
- **State:** localStorage für Streak, gesehene Reveals, letzter Rang
### Backend
- **Streak-Berechnung:** Neue Query oder Service-Funktion, die aufeinanderfolgende Tipps zählt
- **Dashboard-Daten:** Neuer Endpoint `/api/dashboard` der Hero-Spiel, Stats und Nudges gebündelt liefert
- **Cleanup:** Agent-Route kann deaktiviert werden (Feature-Flag oder entfernen)
### Kein Breaking Change
- Bestehende API-Endpoints bleiben kompatibel
- Datenbank-Schema braucht keine Migration (Streak wird berechnet, nicht gespeichert)
- Bestehende Tipps und Punkte bleiben erhalten
---
## 9. Phasen-Übersicht
| Phase | Inhalt | Zeitrahmen |
|-------|--------|------------|
| **Phase 1** (dieses Dokument) | Dashboard, Bottom Nav, Smart Sections, Emotionale Momente, Match-Cards, Profil, Cleanup | Vor Launch (bis Ende Mai) |
| **Phase 2** | Badges & Achievements, Wochenwertung, Streak-Meilenstein-Rewards | Zum Launch / erste WM-Woche |
| **Phase 3** | Tipps anderer sehen, Reaktionen/Emojis, Abteilungs-Challenge | Während der WM, iterativ |
+19
View File
@@ -8,12 +8,14 @@
"name": "wm2026-tippspiel-frontend",
"version": "1.0.0",
"dependencies": {
"canvas-confetti": "^1.9.4",
"lucide-react": "^1.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.0"
},
"devDependencies": {
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
@@ -1155,6 +1157,13 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/canvas-confetti": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz",
"integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1279,6 +1288,16 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvas-confetti": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz",
"integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==",
"license": "ISC",
"funding": {
"type": "donate",
"url": "https://www.paypal.me/kirilvatev"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+2
View File
@@ -8,12 +8,14 @@
"preview": "vite preview"
},
"dependencies": {
"canvas-confetti": "^1.9.4",
"lucide-react": "^1.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.0"
},
"devDependencies": {
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<ns0:svg xmlns:ns0="http://www.w3.org/2000/svg" viewBox="140 -10 340 470"><ns0:path fill="#e30613" d="M216.85 142.13a102.3 102.3 0 0 1-3.35-83.69c4.14-8.29 19.84-37.17 54.49-51.13a103.1 103.1 0 0 1 49.2-6.72 38.93 38.93 0 0 1 25.18 69.48c-13.68 14.17-26.69 20.66-35.74 24-14.71 5.35-34.28 6.84-53.75 8.58-15.55 1.4-21.43 1.21-27.12 6.1-9.72 8.32-9.81 23.73-8.91 33.38" data-source="https://football-logos.cc" /><ns0:path fill="#fff" d="m292.8 80.84 12.21-5.29-6.14-18.71h19.47a18.85 18.85 0 0 1 1.3-12.22 19.2 19.2 0 0 1 11.4-9.77l-4.07-4.08q.6-5.49 1.22-11l-12.63 6.92-3.66-5.7-16.29 11q-2.65-10.57-5.3-21.17l-6.51 9.31-11-4.48v11l-15.88-2.44a35.5 35.5 0 0 1 10.18 10.18 35 35 0 0 1 4.07 8.55l-15.47-.41c-.14 2.58-.27 5.16-.41 7.74l-14 5.28 5.81 4.9-1.62 5.3a28 28 0 0 1 13.84.81 27.4 27.4 0 0 1 7.33 3.67l2.45 7.73a40.3 40.3 0 0 1 10.58-11.4 39.8 39.8 0 0 1 10.12-5.29z" /><ns0:path fill="#077947" d="M357.17 16.06a107.5 107.5 0 0 1 29.25 31.26A52.9 52.9 0 0 1 395.8 69c2.12 6.82 9.22 32.46-3.14 60.67a87 87 0 0 1-33.1 38.3c-1.28.44-11.62 3.86-21.59-2.45-9.7-6.13-11.22-16.51-11.4-17.92a83.75 83.75 0 0 1 .41-34.21c8.27-36.26 38.79-49.92 36.65-78.19a43 43 0 0 0-6.46-19.14" /><ns0:ellipse cx="362.26" cy="216.51" fill="#fff" rx="34.92" ry="21.31" transform="rotate(-69.12 295.198 173.757)" /><ns0:ellipse cx="360.61" cy="217.01" fill="#e30613" rx="29.44" ry="15.86" transform="rotate(-73.7 299.202 174.037)" /><ns0:ellipse cx="360.72" cy="221.24" fill="#fff" rx="18.94" ry="9.27" transform="rotate(-73.79 299.064 178.285)" /><ns0:path fill="#0a3047" d="M364.43 172.84a125.45 125.45 0 0 1-61.42 20.38c-10.45-.21-33.27-2-53.36-15.73-5.26-3.59-12.73-8.9-18.59-18.69a61.2 61.2 0 0 1-7.32-20.42 63.3 63.3 0 0 1-.73-15.93 24.2 24.2 0 0 1 6.11-9.47c13.89-12.94 42.77-5.46 60 5.7a88.8 88.8 0 0 1 21.18 20c7.4 10 7.52 15 16.29 24 5.09 5.24 10.72 10.88 19.67 12.56a31.14 31.14 0 0 0 18.17-2.4" /><ns0:path fill="#fff" d="M258.95 128.06c1.5-2.12 3.12-4.3 4.89-6.52q2.06-2.58 4.07-4.89l4.89 19.55 20.95 11-15.65 6.92a36.18 36.18 0 0 0 6.1 24 32.8 32.8 0 0 1-15.47-7.74 32.4 32.4 0 0 1-7.74-10.54l-17.51 1.22 5.7-13.64-10.18-23z" /><ns0:path fill="#132e49" d="M256.1 196.84a250.71 250.71 0 0 1 6.11 211.36 145.5 145.5 0 0 0 107.11 0 511 511 0 0 0-48.46-120.54 510.3 510.3 0 0 0-64.76-90.82" /><ns0:path fill="#0d7247" d="m256.1 420.46-10.58 26.07a173.78 173.78 0 0 0 135.2-2l-7.33-24a188.18 188.18 0 0 1-117.29 0z" data-source="https://football-logos.cc" /><ns0:path fill="#e30613" d="m314.04 243.71 32.68 62.31a215 215 0 0 1 12.34-112.79 101.23 101.23 0 0 0-45 50.48z" /></ns0:svg>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 940 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 989 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 789 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 932 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 681 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

+79 -5
View File
@@ -5,9 +5,9 @@
}
.header {
background: rgba(10,14,26,0.92);
background: color-mix(in srgb, var(--bg-deep) 92%, transparent);
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(75,183,248,0.1);
border-bottom: 1px solid rgba(75,183,248,0.12);
position: sticky;
top: 0;
z-index: 100;
@@ -27,9 +27,25 @@
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: inherit;
}
.logoFlag { font-size: 22px; }
.logoExtra {
margin-left: 8px;
}
.logoImg {
height: 32px;
width: auto;
object-fit: contain;
}
.logoTextBlock {
display: flex;
flex-direction: column;
gap: 3px;
}
.logoText {
font-family: 'Plus Jakarta Sans', sans-serif;
@@ -39,6 +55,15 @@
letter-spacing: -0.3px;
}
.logoSub {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 9px;
font-weight: 600;
color: var(--gold);
letter-spacing: 0.5px;
text-transform: uppercase;
}
.devBadge {
font-size: 10px;
font-weight: 700;
@@ -66,17 +91,66 @@
color: var(--text-secondary);
}
.navLink:hover { color: var(--text-primary); background: var(--surface-mid); }
.navLink:hover { color: var(--text-primary); background: var(--surface-high); }
.navLinkActive {
color: var(--primary);
background: var(--primary-dim);
}
.themeToggle {
background: none;
border: none;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
cursor: pointer;
transition: color 0.2s;
flex-shrink: 0;
color: var(--text-secondary);
}
.themeToggle:hover { color: var(--text-primary); }
.themeToggle:active { transform: scale(0.92); }
.main {
flex: 1;
max-width: 1100px;
max-width: 1200px;
margin: 0 auto;
padding: 32px 24px;
padding-bottom: 70px;
width: 100%;
}
@media (min-width: 768px) {
.main {
padding-bottom: 32px;
}
}
/* Header actions — always visible (theme toggle + admin) */
.headerActions {
display: flex;
align-items: center;
gap: 4px;
}
/* Hide header nav on mobile, keep actions */
@media (max-width: 767px) {
.nav {
display: none;
}
}
/* Admin link: icon only */
.adminLink {
display: flex;
align-items: center;
padding: 4px;
color: var(--text-secondary);
text-decoration: none;
transition: color 0.2s;
}
.adminLink:hover {
color: var(--text-secondary);
}
+75 -14
View File
@@ -1,26 +1,67 @@
import { useState, useEffect } from 'react';
import { Routes, Route, NavLink } from 'react-router-dom';
import { Sun, Moon, Monitor } from 'lucide-react';
import DashboardPage from './pages/DashboardPage';
import MatchesPage from './pages/MatchesPage';
import LeaderboardPage from './pages/LeaderboardPage';
import ProfilePage from './pages/ProfilePage';
import AdminPage from './pages/AdminPage';
import AgentChat from './components/AgentChat';
import BottomNav from './components/BottomNav';
import Toast from './components/Toast';
import { useRankChange } from './hooks/useRankChange';
import styles from './App.module.css';
const IS_DEV = import.meta.env.DEV;
const IS_DEV = import.meta.env.DEV || import.meta.env.VITE_TEST_MODE === 'true';
// Lazy-load DevPanel nur in Development
// Lazy-load DevPanel in Development/Test-Mode
let DevPanel: React.ComponentType<any> | null = null;
if (IS_DEV) {
// Dynamic import — kein Bundle-Impact in Production
import('./components/DevPanel').then(m => { DevPanel = m.default; });
// VITE_TEST_MODE wird erst zur Laufzeit geprüft, daher Import immer einbinden
import('./components/DevPanel').then(m => { DevPanel = m.default; }).catch(() => {});
type ThemeSetting = 'dark' | 'light' | 'system';
function getInitialSetting(): ThemeSetting {
try {
const stored = localStorage.getItem('theme') as ThemeSetting | null;
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored;
} catch {}
return 'dark';
}
function resolveTheme(setting: ThemeSetting): 'dark' | 'light' {
if (setting === 'system') {
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
return setting;
}
export default function App() {
const [themeSetting, setThemeSetting] = useState<ThemeSetting>(getInitialSetting);
const theme = resolveTheme(themeSetting);
const { message: rankMsg, dismiss: dismissRank } = useRankChange();
const [devUser, setDevUser] = useState(1);
const [devMatches, setDevMatches] = useState<any[]>([]);
const [refreshKey, setRefreshKey] = useState(0);
// Theme auf <html> setzen und in localStorage speichern
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('theme', themeSetting); } catch {}
}, [theme, themeSetting]);
// Listen for OS theme changes when in system mode
useEffect(() => {
if (themeSetting !== 'system') return;
const mq = window.matchMedia('(prefers-color-scheme: light)');
const handler = () => setRefreshKey(k => k + 1); // force re-render to re-resolve
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [themeSetting]);
function toggleTheme() {
setThemeSetting(t => t === 'dark' ? 'light' : t === 'light' ? 'system' : 'dark');
}
// DevUser als Query-Parameter im API-Fetch setzen
useEffect(() => {
if (!IS_DEV) return;
@@ -52,15 +93,24 @@ export default function App() {
<div className={styles.app}>
<header className={styles.header}>
<div className={styles.headerInner}>
<div className={styles.logo}>
<span className={styles.logoFlag}>🏆</span>
<span className={styles.logoText}>WM 2026 Tippspiel</span>
<NavLink to="/" className={styles.logo}>
<img
src="/assets/wm2026-trophy.svg"
alt="FIFA WM 2026"
className={styles.logoImg}
/>
<div className={styles.logoTextBlock}>
<span className={styles.logoText}>Tippspiel</span>
<span className={styles.logoSub}>FIFA World Cup 2026</span>
</div>
</NavLink>
<div className={styles.logoExtra}>
{IS_DEV && (
<span className={styles.devBadge}>DEV · User {devUser}</span>
)}
</div>
<nav className={styles.nav}>
<NavLink to="/" end className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
<NavLink to="/spiele" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
Spielplan
</NavLink>
<NavLink to="/rangliste" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
@@ -73,18 +123,32 @@ export default function App() {
Admin
</NavLink>
</nav>
<div className={styles.headerActions}>
<button
className={styles.themeToggle}
onClick={toggleTheme}
title={themeSetting === 'dark' ? 'Light Mode' : themeSetting === 'light' ? 'System' : 'Dark Mode'}
aria-label="Theme wechseln"
>
{themeSetting === 'dark' ? <Sun size={16} /> : themeSetting === 'light' ? <Monitor size={16} /> : <Moon size={16} />}
</button>
</div>
</div>
</header>
<main className={styles.main}>
<Routes>
<Route path="/" element={<MatchesPage key={refreshKey} devUser={devUser} />} />
<Route path="/" element={<DashboardPage key={refreshKey} />} />
<Route path="/spiele" element={<MatchesPage key={refreshKey} />} />
<Route path="/rangliste" element={<LeaderboardPage key={refreshKey} />} />
<Route path="/profil" element={<ProfilePage key={refreshKey} />} />
<Route path="/admin" element={<AdminPage />} />
</Routes>
</main>
{rankMsg && <Toast message={rankMsg} onDismiss={dismissRank} />}
<BottomNav />
{IS_DEV && DevPanel && (
<DevPanel
currentUser={devUser}
@@ -93,9 +157,6 @@ export default function App() {
onRefresh={handleDevRefresh}
/>
)}
{/* Fußball-Experte Chat-Widget immer sichtbar */}
<AgentChat />
</div>
);
}
+51 -1
View File
@@ -1,7 +1,14 @@
const BASE = '/api';
function withDevUser(path: string): string {
const devUser = new URLSearchParams(window.location.search).get('devUser');
if (!devUser) return path;
const sep = path.includes('?') ? '&' : '?';
return `${path}${sep}devUser=${devUser}`;
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
const res = await fetch(`${BASE}${withDevUser(path)}`, {
...options,
headers: {
'Content-Type': 'application/json',
@@ -46,6 +53,12 @@ export const api = {
body: JSON.stringify({ team }),
}),
// Dashboard
getDashboard: () => request<DashboardData>('/dashboard'),
// Achievements
getAchievements: () => request<AchievementsData>('/achievements'),
// Admin
syncMatches: () =>
request<{ success: boolean; total: number; created: number; updated: number }>(
@@ -60,6 +73,24 @@ export const api = {
};
// Types (gespiegelt vom Backend)
export interface DashboardData {
hero: {
match: {
id: number;
homeTeam: { name: string; shortName: string; crest: string | null };
awayTeam: { name: string; shortName: string; crest: string | null };
utcDate: string;
status: string;
minutesUntilKickoff: number;
};
userTip: { home: number; away: number } | null;
tippable: boolean;
} | null;
stats: { rank: number | null; totalPoints: number; streak: number };
nudges: Array<{ type: string; text: string; matchId?: number }>;
}
export interface Match {
id: number;
externalId: number;
@@ -123,3 +154,22 @@ export interface UserStats {
wrongCount: number;
accuracy: number;
}
export interface Achievement {
id: string;
name: string;
description: string;
icon: string;
color: string;
rankLabel: string;
unlocked: boolean;
progress: number;
current: number;
target: number;
}
export interface AchievementsData {
achievements: Achievement[];
unlockedCount: number;
total: number;
}
@@ -0,0 +1,167 @@
/* ═══ Achievement Badge — Premium Gaming Style ═══ */
.badge {
position: relative;
background: var(--surface-mid);
border-radius: var(--radius-md);
padding: 20px 12px 16px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
transition: transform 0.2s;
}
.badge:active {
transform: scale(0.97);
}
/* ── Unlocked ── */
.unlocked {
border-color: rgba(255, 255, 255, 0.08);
}
.glow {
position: absolute;
inset: -4px;
border-radius: inherit;
filter: blur(20px);
opacity: 0.5;
pointer-events: none;
z-index: 0;
}
/* ── Locked ── */
.locked {
opacity: 0.45;
filter: grayscale(0.7);
}
.lockOverlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.lockCircle {
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.7);
}
/* ── Icon ── */
.iconWrap {
position: relative;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
z-index: 1;
}
.iconGlow {
position: absolute;
inset: -8px;
border-radius: 50%;
filter: blur(16px);
pointer-events: none;
}
.icon {
font-size: 42px;
position: relative;
z-index: 1;
}
/* ── Text ── */
.name {
font-size: 0.7rem;
font-weight: 700;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 2px;
position: relative;
z-index: 1;
}
.desc {
font-size: 0.6rem;
color: var(--text-muted);
margin-bottom: 8px;
position: relative;
z-index: 1;
}
/* ── Rank Badge (unlocked) ── */
.rankBadge {
font-size: 0.55rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 3px 10px;
border-radius: 20px;
border: 1px solid;
position: relative;
z-index: 1;
}
/* ── Progress (locked) ── */
.progress {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
position: relative;
z-index: 1;
}
.progressBar {
flex: 1;
height: 4px;
background: var(--surface-high);
border-radius: 2px;
overflow: hidden;
}
.progressFill {
height: 100%;
border-radius: 2px;
transition: width 0.5s ease;
}
.progressText {
font-size: 0.55rem;
color: var(--text-muted);
font-weight: 600;
white-space: nowrap;
}
/* ── Light Mode ── */
:global([data-theme="light"]) .badge {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
:global([data-theme="light"]) .locked {
opacity: 0.35;
}
:global([data-theme="light"]) .lockCircle {
background: rgba(255, 255, 255, 0.8);
color: rgba(0, 0, 0, 0.4);
}
@@ -0,0 +1,70 @@
import { Achievement } from '../api/client';
import styles from './AchievementBadge.module.css';
interface Props {
achievement: Achievement;
}
export default function AchievementBadge({ achievement }: Props) {
const { name, description, icon, color, rankLabel, unlocked, current, target } = achievement;
return (
<div className={`${styles.badge} ${unlocked ? styles.unlocked : styles.locked}`}>
{/* Glow background for unlocked */}
{unlocked && (
<div className={styles.glow} style={{ background: `${color}20` }} />
)}
{/* Lock overlay for locked */}
{!unlocked && (
<div className={styles.lockOverlay}>
<div className={styles.lockCircle}>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>lock</span>
</div>
</div>
)}
{/* Icon */}
<div className={styles.iconWrap}>
{unlocked && (
<div className={styles.iconGlow} style={{ background: `${color}30` }} />
)}
<span
className={`material-symbols-outlined ${styles.icon}`}
style={{
color: unlocked ? color : 'var(--text-muted)',
fontVariationSettings: "'FILL' 1",
filter: unlocked ? `drop-shadow(0 0 10px ${color}cc)` : 'none',
}}
>
{icon}
</span>
</div>
{/* Name + Description */}
<h3 className={styles.name}>{name}</h3>
<p className={styles.desc}>{description}</p>
{/* Progress or Rank label */}
{unlocked ? (
<div className={styles.rankBadge} style={{
background: `${color}15`,
borderColor: `${color}40`,
color: color,
}}>
{rankLabel}
</div>
) : (
<div className={styles.progress}>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${achievement.progress}%`, background: color }}
/>
</div>
<span className={styles.progressText}>{current}/{target}</span>
</div>
)}
</div>
);
}
@@ -1,466 +0,0 @@
/* ============================================================
AgentChat Fußball-Experte Chat-Widget
Stadium Elite Design
============================================================ */
/* ---- Floating Button ---- */
.trigger {
position: fixed;
bottom: 28px;
right: 28px;
z-index: 500;
width: 60px;
height: 60px;
border-radius: 50%;
border: none;
background: transparent;
box-shadow: none;
cursor: pointer;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
line-height: 1;
transition: transform 0.2s;
color: #fff;
padding: 0;
}
.trigger.open {
background: rgba(28, 38, 64, 0.9);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
.trigger:hover {
transform: scale(1.08);
box-shadow: 0 6px 28px rgba(75, 183, 248, 0.6);
}
.trigger.open {
background: linear-gradient(135deg, #1C2640 0%, #111827 100%);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
/* Puls-Ring wenn geschlossen */
.trigger:not(.open)::after {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
border: 2px solid rgba(75, 183, 248, 0.4);
animation: pulse 2.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.15); opacity: 0; }
}
/* ---- Panel ---- */
.panel {
position: fixed;
bottom: 100px;
right: 28px;
z-index: 499;
width: 380px;
max-height: 560px;
display: flex;
flex-direction: column;
background: var(--surface-mid);
border-radius: var(--radius-lg);
border: 1px solid rgba(75, 183, 248, 0.15);
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.07);
overflow: hidden;
animation: slideUp 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(16px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* ---- Header ---- */
.header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
background: linear-gradient(135deg, rgba(75, 183, 248, 0.1) 0%, rgba(33, 150, 243, 0.05) 100%);
border-bottom: 1px solid rgba(75, 183, 248, 0.12);
flex-shrink: 0;
}
.headerIcon {
font-size: 22px;
line-height: 1;
}
.headerTitle {
font-family: 'Plus Jakarta Sans', sans-serif;
font-weight: 700;
font-size: 15px;
color: var(--text-primary);
flex: 1;
}
.headerSub {
font-size: 11px;
color: var(--text-secondary);
margin-top: 1px;
}
.headerOnline {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 6px var(--success);
flex-shrink: 0;
}
/* ---- Messages Area ---- */
.messages {
flex: 1;
overflow-y: auto;
padding: 16px 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* ---- Quick Action Chips ---- */
.quickActions {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 14px 14px;
}
.quickActionsLabel {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.07em;
font-weight: 600;
}
.quickChips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
padding: 6px 12px;
border-radius: 20px;
border: 1px solid rgba(75, 183, 248, 0.25);
background: rgba(75, 183, 248, 0.07);
color: var(--primary);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.chip:hover {
background: rgba(75, 183, 248, 0.15);
border-color: rgba(75, 183, 248, 0.4);
transform: translateY(-1px);
}
/* ---- Message Bubbles ---- */
.message {
display: flex;
flex-direction: column;
max-width: 88%;
}
.message.user {
align-self: flex-end;
align-items: flex-end;
}
.message.assistant {
align-self: flex-start;
align-items: flex-start;
}
.bubble {
padding: 10px 14px;
border-radius: var(--radius-md);
font-size: 14px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.message.user .bubble {
background: linear-gradient(135deg, var(--primary) 0%, #2196f3 100%);
color: #fff;
border-bottom-right-radius: 4px;
}
.message.assistant .bubble {
background: var(--surface-high);
color: var(--text-primary);
border: 1px solid rgba(255, 255, 255, 0.06);
border-bottom-left-radius: 4px;
}
/* Markdown-Inhalt in Assistenten-Bubbles */
.markdownBody {
font-size: 13.5px;
line-height: 1.6;
}
.markdownBody strong {
color: var(--text-primary);
font-weight: 700;
}
.markdownBody em {
color: var(--text-secondary);
font-style: italic;
}
.markdownBody ul {
padding-left: 1.2em;
margin: 4px 0;
}
.markdownBody li {
margin-bottom: 3px;
color: var(--text-primary);
}
.messageTime {
font-size: 10px;
color: var(--text-muted);
margin-top: 4px;
padding: 0 4px;
}
/* ---- Typing Indicator ---- */
.typing {
display: flex;
align-items: center;
gap: 4px;
padding: 10px 14px;
background: var(--surface-high);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: var(--radius-md);
border-bottom-left-radius: 4px;
width: fit-content;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-secondary);
animation: bounce 1.2s ease-in-out infinite;
}
.dot:nth-child(2) { animation-delay: 0.2s; }
.dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
30% { transform: translateY(-5px); opacity: 1; }
}
/* ---- Input Area ---- */
.inputArea {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 12px 14px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
background: var(--surface-low);
flex-shrink: 0;
}
.input {
flex: 1;
background: var(--surface-mid);
border: 1px solid rgba(75, 183, 248, 0.2);
border-radius: var(--radius-sm);
padding: 10px 13px;
color: var(--text-primary);
font-size: 14px;
font-family: 'Inter', sans-serif;
resize: none;
min-height: 42px;
max-height: 120px;
outline: none;
transition: border-color 0.15s;
line-height: 1.4;
}
.input::placeholder { color: var(--text-muted); }
.input:focus {
border-color: rgba(75, 183, 248, 0.5);
box-shadow: 0 0 0 2px rgba(75, 183, 248, 0.1);
}
.sendBtn {
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
border: none;
background: linear-gradient(135deg, var(--primary) 0%, #2196f3 100%);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: opacity 0.15s, transform 0.1s;
box-shadow: 0 2px 10px rgba(75, 183, 248, 0.3);
}
.sendBtn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); }
.sendBtn:disabled { opacity: 0.4; cursor: not-allowed; }
/* ---- Choice Selector ---- */
.choiceSelector {
margin-top: 10px;
border-top: 1px solid rgba(75, 183, 248, 0.12);
padding-top: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.choiceList {
display: flex;
flex-direction: column;
gap: 5px;
}
.choiceItem {
display: flex;
align-items: center;
gap: 9px;
padding: 8px 12px;
border-radius: var(--radius-sm);
border: 1px solid rgba(75, 183, 248, 0.18);
background: rgba(75, 183, 248, 0.05);
color: var(--text-primary);
font-size: 13px;
font-family: 'Inter', sans-serif;
cursor: pointer;
text-align: left;
transition: all 0.15s;
}
.choiceItem:hover:not(:disabled) {
background: rgba(75, 183, 248, 0.12);
border-color: rgba(75, 183, 248, 0.35);
}
.choiceItem:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.choiceItemSelected {
background: rgba(75, 183, 248, 0.15);
border-color: var(--primary);
color: var(--primary);
font-weight: 500;
}
.choiceCheckbox {
width: 16px;
height: 16px;
border-radius: 4px;
border: 1.5px solid rgba(75, 183, 248, 0.4);
background: transparent;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
flex-shrink: 0;
color: var(--primary);
font-weight: 700;
transition: all 0.12s;
}
.choiceItemSelected .choiceCheckbox {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.choiceActions {
display: flex;
gap: 6px;
}
.choiceConfirm {
flex: 1;
padding: 8px 14px;
border-radius: var(--radius-sm);
border: none;
background: linear-gradient(135deg, var(--primary) 0%, #2196f3 100%);
color: #fff;
font-size: 13px;
font-weight: 600;
font-family: 'Plus Jakarta Sans', sans-serif;
cursor: pointer;
transition: opacity 0.15s;
box-shadow: 0 2px 10px rgba(75, 183, 248, 0.25);
}
.choiceConfirm:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.choiceConfirm:hover:not(:disabled) {
opacity: 0.9;
}
.choiceAll {
padding: 8px 14px;
border-radius: var(--radius-sm);
border: 1px solid rgba(75, 183, 248, 0.25);
background: transparent;
color: var(--text-secondary);
font-size: 13px;
font-family: 'Inter', sans-serif;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.choiceAll:hover:not(:disabled) {
color: var(--text-primary);
border-color: rgba(75, 183, 248, 0.4);
background: rgba(75, 183, 248, 0.07);
}
.choiceAll:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ---- Responsive ---- */
@media (max-width: 440px) {
.panel {
right: 12px;
left: 12px;
width: auto;
bottom: 88px;
}
.trigger {
right: 16px;
bottom: 20px;
}
}
-488
View File
@@ -1,488 +0,0 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import styles from './AgentChat.module.css';
import netzerpng from '../assets/guenther_icon.png';
// ============================================================
// Choices Parser
// Extrahiert [CHOICES]...[/CHOICES] aus Agent-Antwort
// ============================================================
function parseChoices(text: string): { before: string; choices: string[]; hasChoices: boolean } {
const match = text.match(/\[CHOICES\]([\s\S]*?)\[\/CHOICES\]/);
if (!match) return { before: text, choices: [], hasChoices: false };
const before = text.slice(0, match.index).trimEnd();
const choices = match[1]
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0);
return { before, choices, hasChoices: true };
}
// ============================================================
// ChoiceSelector Komponente
// ============================================================
function ChoiceSelector({
choices,
onConfirm,
disabled,
}: {
choices: string[];
onConfirm: (selected: string[]) => void;
disabled: boolean;
}) {
const [selected, setSelected] = useState<Set<string>>(new Set());
function toggle(choice: string) {
setSelected((prev) => {
const next = new Set(prev);
next.has(choice) ? next.delete(choice) : next.add(choice);
return next;
});
}
function handleConfirm() {
if (selected.size === 0) return;
onConfirm([...selected]);
}
function handleAll() {
onConfirm(choices);
}
return (
<div className={styles.choiceSelector}>
<div className={styles.choiceList}>
{choices.map((c) => (
<button
key={c}
className={`${styles.choiceItem} ${selected.has(c) ? styles.choiceItemSelected : ''}`}
onClick={() => toggle(c)}
disabled={disabled}
>
<span className={styles.choiceCheckbox}>
{selected.has(c) ? '✓' : ''}
</span>
{c}
</button>
))}
</div>
<div className={styles.choiceActions}>
<button
className={styles.choiceConfirm}
onClick={handleConfirm}
disabled={disabled || selected.size === 0}
>
{selected.size > 0 ? `${selected.size} Spiel${selected.size > 1 ? 'e' : ''} analysieren` : 'Auswahl treffen'}
</button>
<button
className={styles.choiceAll}
onClick={handleAll}
disabled={disabled}
>
Alle
</button>
</div>
</div>
);
}
// ============================================================
// Mini Markdown Renderer
// Unterstützt: ## Header, **bold**, *italic*, --- Trennlinie, - Listen
// ============================================================
function renderMarkdown(text: string): React.ReactNode[] {
const lines = text.split('\n');
const result: React.ReactNode[] = [];
let listBuffer: string[] = [];
let keyCounter = 0;
const k = () => keyCounter++;
function flushList() {
if (listBuffer.length === 0) return;
result.push(
<ul key={k()} style={{ paddingLeft: '1.2em', margin: '4px 0', listStyleType: 'disc' }}>
{listBuffer.map((item, i) => (
<li key={i} style={{ marginBottom: '2px' }}>{inlineFormat(item)}</li>
))}
</ul>
);
listBuffer = [];
}
function inlineFormat(line: string): React.ReactNode {
// **bold** und *italic* inline parsen
const parts: React.ReactNode[] = [];
const regex = /(\*\*(.+?)\*\*|\*(.+?)\*)/g;
let last = 0;
let match;
while ((match = regex.exec(line)) !== null) {
if (match.index > last) parts.push(line.slice(last, match.index));
if (match[2]) parts.push(<strong key={match.index}>{match[2]}</strong>);
else if (match[3]) parts.push(<em key={match.index}>{match[3]}</em>);
last = match.index + match[0].length;
}
if (last < line.length) parts.push(line.slice(last));
return parts.length === 1 ? parts[0] : parts;
}
for (const line of lines) {
// Trennlinie ---
if (/^---+$/.test(line.trim())) {
flushList();
result.push(
<hr key={k()} style={{ border: 'none', borderTop: '1px solid rgba(255,255,255,0.1)', margin: '8px 0' }} />
);
continue;
}
// ## H2
if (line.startsWith('## ')) {
flushList();
result.push(
<div key={k()} style={{ fontFamily: "'Plus Jakarta Sans', sans-serif", fontWeight: 700, fontSize: '13px', color: 'var(--primary)', marginTop: '10px', marginBottom: '3px', letterSpacing: '-0.1px' }}>
{inlineFormat(line.slice(3))}
</div>
);
continue;
}
// # H1
if (line.startsWith('# ')) {
flushList();
result.push(
<div key={k()} style={{ fontFamily: "'Plus Jakarta Sans', sans-serif", fontWeight: 800, fontSize: '14px', color: 'var(--gold)', marginTop: '8px', marginBottom: '4px' }}>
{inlineFormat(line.slice(2))}
</div>
);
continue;
}
// Listenpunkt - oder *
if (/^[-*]\s/.test(line)) {
listBuffer.push(line.slice(2));
continue;
}
// Leere Zeile
if (line.trim() === '') {
flushList();
result.push(<div key={k()} style={{ height: '6px' }} />);
continue;
}
// Normaler Absatz
flushList();
result.push(
<div key={k()} style={{ marginBottom: '2px' }}>{inlineFormat(line)}</div>
);
}
flushList();
return result;
}
// ============================================================
// Types
// ============================================================
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
}
// ============================================================
// Quick-Action Chips
// ============================================================
const QUICK_ACTIONS = [
{ label: '🎯 Tipp-Empfehlung', prompt: 'Gib mir eine Tipp-Empfehlung für die nächsten Spiele der WM 2026!' },
{ label: '📊 Head-to-Head', prompt: 'Zeig mir ein interessantes Head-to-Head zwischen zwei WM-Teams!' },
{ label: '⚡ Fun Fact', prompt: 'Erzähl mir einen kuriosen oder legendären Fun Fact aus der WM-Geschichte!' },
{ label: '🏆 WM-Rekorde', prompt: 'Was sind die spektakulärsten Rekorde aller WM-Turniere?' },
];
// ============================================================
// API: Streaming Chat-Anfrage
// ============================================================
async function sendMessage(
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
onChunk: (text: string) => void,
onDone: () => void,
onError: (err: string) => void
) {
try {
const res = await fetch('/api/agent/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
onError(data.error ?? `HTTP ${res.status}`);
return;
}
const reader = res.body?.getReader();
if (!reader) { onError('Stream nicht verfügbar'); return; }
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6).trim();
if (payload === '[DONE]') { onDone(); return; }
try {
const parsed = JSON.parse(payload);
if (parsed.text) onChunk(parsed.text);
if (parsed.error) { onError(parsed.error); return; }
} catch {
// ignore parse errors
}
}
}
onDone();
} catch (err) {
onError(err instanceof Error ? err.message : 'Netzwerkfehler');
}
}
// ============================================================
// AgentChat Component
// ============================================================
export default function AgentChat() {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showQuickActions, setShowQuickActions] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const streamingIdRef = useRef<string | null>(null);
// Auto-scroll bei neuen Nachrichten
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Fokus ins Eingabefeld wenn Panel öffnet
useEffect(() => {
if (isOpen) {
setTimeout(() => inputRef.current?.focus(), 300);
}
}, [isOpen]);
const formatTime = (date: Date) =>
date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
const handleSend = useCallback(
async (text?: string) => {
const messageText = (text ?? input).trim();
if (!messageText || isLoading) return;
setInput('');
setShowQuickActions(false);
// User-Nachricht hinzufügen
const userMsg: Message = {
id: Date.now().toString(),
role: 'user',
content: messageText,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMsg]);
setIsLoading(true);
// Leere Assistenten-Nachricht für Streaming vorbereiten
const assistantId = (Date.now() + 1).toString();
streamingIdRef.current = assistantId;
const assistantMsg: Message = {
id: assistantId,
role: 'assistant',
content: '',
timestamp: new Date(),
};
setMessages((prev) => [...prev, assistantMsg]);
// Chat-History für API aufbauen (nur role + content)
const history = [
...messages.map((m) => ({ role: m.role, content: m.content })),
{ role: 'user' as const, content: messageText },
];
await sendMessage(
history,
// onChunk: Text an laufende Nachricht anhängen
(chunk) => {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: m.content + chunk } : m
)
);
},
// onDone
() => {
setIsLoading(false);
streamingIdRef.current = null;
},
// onError
(err) => {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, content: `⚠️ Fehler: ${err}` }
: m
)
);
setIsLoading(false);
streamingIdRef.current = null;
}
);
},
[input, isLoading, messages]
);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleQuickAction = (prompt: string) => {
handleSend(prompt);
};
return (
<>
{/* ---- Floating Button ---- */}
<button
className={`${styles.trigger} ${isOpen ? styles.open : ''}`}
onClick={() => setIsOpen((o) => !o)}
aria-label={isOpen ? 'Chat schließen' : 'Günther öffnen'}
title="Günther Fußball-Experte"
>
{isOpen ? '✕' : <img src={netzerpng} alt="Günther" style={{ width: '52px', height: '52px', objectFit: 'contain' }} />}
</button>
{/* ---- Chat Panel ---- */}
{isOpen && (
<div className={styles.panel} role="dialog" aria-label="Fußball-Experte Chat">
{/* Header */}
<div className={styles.header}>
<span className={styles.headerIcon}><img src={netzerpng} alt="Günther" style={{ width: '28px', height: '28px', objectFit: 'contain' }} /></span>
<div style={{ flex: 1 }}>
<div className={styles.headerTitle}>Günther</div>
<div className={styles.headerSub}>Statistiken · Tipps · Fun Facts</div>
</div>
<div className={styles.headerOnline} title="Online" />
</div>
{/* Messages */}
<div className={styles.messages}>
{messages.length === 0 && (
<div style={{ color: 'var(--text-secondary)', fontSize: 13, textAlign: 'center', paddingTop: 8 }}>
Frag mich alles rund um Fußball, WM &amp; EM!
</div>
)}
{messages.map((msg, idx) => {
const isLastAssistant =
msg.role === 'assistant' && idx === messages.length - 1;
const isStreaming = isLoading && streamingIdRef.current === msg.id;
const { before, choices, hasChoices } =
msg.role === 'assistant' && !isStreaming
? parseChoices(msg.content)
: { before: msg.content, choices: [], hasChoices: false };
return (
<div key={msg.id} className={`${styles.message} ${styles[msg.role]}`}>
<div className={styles.bubble}>
{msg.role === 'assistant' ? (
<>
{/* Typing Indicator solange Inhalt noch leer */}
{msg.content === '' && isStreaming ? (
<div className={styles.typing}>
<div className={styles.dot} />
<div className={styles.dot} />
<div className={styles.dot} />
</div>
) : (
<div className={styles.markdownBody}>
{renderMarkdown(hasChoices ? before : msg.content)}
</div>
)}
{/* Choice-Selector: nur bei letzter Assistenten-Nachricht */}
{hasChoices && isLastAssistant && (
<ChoiceSelector
choices={choices}
disabled={isLoading}
onConfirm={(selected) => {
const text =
selected.length === choices.length
? 'Analysiere bitte alle Spiele.'
: 'Analysiere bitte: ' + selected.join(', ');
handleSend(text);
}}
/>
)}
</>
) : (
msg.content
)}
</div>
<div className={styles.messageTime}>{formatTime(msg.timestamp)}</div>
</div>
);
})}
<div ref={messagesEndRef} />
</div>
{/* Quick Actions (nur beim ersten Öffnen, solange kein Chat läuft) */}
{showQuickActions && messages.length === 0 && (
<div className={styles.quickActions}>
<div className={styles.quickActionsLabel}>Schnellauswahl</div>
<div className={styles.quickChips}>
{QUICK_ACTIONS.map((qa) => (
<button
key={qa.label}
className={styles.chip}
onClick={() => handleQuickAction(qa.prompt)}
disabled={isLoading}
>
{qa.label}
</button>
))}
</div>
</div>
)}
{/* Input Area */}
<div className={styles.inputArea}>
<textarea
ref={inputRef}
className={styles.input}
placeholder="Frag den Experten…"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isLoading}
rows={1}
/>
<button
className={styles.sendBtn}
onClick={() => handleSend()}
disabled={!input.trim() || isLoading}
aria-label="Senden"
>
</button>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,56 @@
.bottomNav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-around;
align-items: center;
height: 60px;
padding-bottom: env(safe-area-inset-bottom, 0px);
background: color-mix(in srgb, var(--bg-deep) 92%, transparent);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid rgba(75, 183, 248, 0.15);
z-index: 100;
}
.tab, .tabActive {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 6px 16px;
font-size: 11px;
text-decoration: none;
color: var(--text-muted);
transition: color 0.2s;
}
.tabActive {
color: var(--primary);
}
.tab:hover {
color: var(--text-secondary);
}
/* Light mode */
:global([data-theme="light"]) .bottomNav {
background: rgba(255, 255, 255, 0.9);
border-top: 1px solid rgba(0, 0, 0, 0.08);
}
:global([data-theme="light"]) .tab {
color: rgba(13, 21, 38, 0.4);
}
:global([data-theme="light"]) .tabActive {
color: var(--primary);
}
@media (min-width: 768px) {
.bottomNav {
display: none;
}
}
+32
View File
@@ -0,0 +1,32 @@
import { NavLink } from 'react-router-dom';
import styles from './BottomNav.module.css';
export default function BottomNav() {
const linkClass = ({ isActive }: { isActive: boolean }) =>
isActive ? styles.tabActive : styles.tab;
return (
<nav className={styles.bottomNav}>
<NavLink to="/" end className={linkClass}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M12 3l9 8-1.4 1.3L18 10.8V20h-5v-6H11v6H6v-9.2l-1.6 1.5L3 11l9-8z"/></svg>
<span>Home</span>
</NavLink>
<NavLink to="/spiele" className={linkClass}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 3.3l1.35-.95c1.82.56 3.37 1.76 4.38 3.34l-.39 1.34-1.35.46L13 6.7V5.3zm-3.35-.95L11 5.3v1.4L7.01 9.49l-1.35-.46-.39-1.34a9.972 9.972 0 014.38-3.34zM7.08 17.11l-1.14.1C4.73 15.81 4 13.99 4 12c0-.12.01-.23.02-.35l1-.73 1.38.48 1.46 4.34-.78 1.37zm7.42 2.48c-.79.26-1.63.41-2.5.41s-1.71-.15-2.5-.41l-.69-1.49.64-1.1h5.11l.64 1.11-.7 1.48zM14.27 15H9.73l-1.35-4.02L12 8.44l3.63 2.54L14.27 15zm3.79 2.21l-1.14-.1-.78-1.37 1.46-4.34 1.38-.48 1 .73c.01.12.02.23.02.35 0 1.99-.73 3.81-1.94 5.21z"/></svg>
<span>Spiele</span>
</NavLink>
<NavLink to="/rangliste" className={linkClass}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M19 5h-2V3H7v2H5c-1.1 0-2 .9-2 2v1c0 2.55 1.92 4.63 4.39 4.94A5.01 5.01 0 0011 15.9V19H7v2h10v-2h-4v-3.1a5.01 5.01 0 003.61-2.96C19.08 12.63 21 10.55 21 8V7c0-1.1-.9-2-2-2zM5 8V7h2v3.82C5.84 10.4 5 9.3 5 8zm14 0c0 1.3-.84 2.4-2 2.82V7h2v1z"/></svg>
<span>Rangliste</span>
</NavLink>
<NavLink to="/profil" className={linkClass}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
<span>Profil</span>
</NavLink>
<NavLink to="/admin" className={linkClass}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
<span>Admin</span>
</NavLink>
</nav>
);
}
@@ -0,0 +1,91 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
animation: fadeIn 0.3s ease;
}
.card {
background: var(--surface-mid);
border-radius: var(--radius-lg);
padding: 32px 24px;
text-align: center;
max-width: 320px;
width: 90%;
animation: scaleIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.result {
font-size: 1.3rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 8px;
}
.tipLine {
font-size: 0.95rem;
color: var(--text-secondary);
margin-bottom: 16px;
}
.badge {
display: inline-block;
padding: 8px 20px;
border-radius: 20px;
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 8px;
}
.badgeExact {
background: linear-gradient(135deg, var(--gold), #FFD700);
color: #1a1a1a;
animation: shimmer 2s ease-in-out;
}
.badgeTendency {
background: var(--success);
color: #1a1a1a;
}
.badgeWrong {
background: var(--text-muted);
color: var(--bg-deep);
}
.label {
font-size: 1rem;
color: var(--text-primary);
margin-bottom: 20px;
}
.dismissBtn {
background: var(--primary);
color: white;
border: none;
border-radius: var(--radius-sm);
padding: 10px 32px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleIn {
from { transform: scale(0.8); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes shimmer {
0% { box-shadow: 0 0 0 rgba(254, 174, 50, 0); }
50% { box-shadow: 0 0 20px rgba(254, 174, 50, 0.6); }
100% { box-shadow: 0 0 0 rgba(254, 174, 50, 0); }
}
@@ -0,0 +1,43 @@
import { useEffect, useRef } from 'react';
import confetti from 'canvas-confetti';
import { Match } from '../api/client';
import styles from './ConfettiReveal.module.css';
interface Props {
match: Match;
onDismiss: () => void;
}
export default function ConfettiReveal({ match, onDismiss }: Props) {
const didFire = useRef(false);
const tip = match.userTip!;
const points = tip.points!;
useEffect(() => {
if (points === 3 && !didFire.current) {
didFire.current = true;
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } });
}
}, [points]);
const resultLabel = points === 3 ? 'EXAKT! 🎉' : points === 1 ? 'Richtige Tendenz! 👏' : 'Knapp daneben... 😅';
const badgeClass = points === 3 ? styles.badgeExact : points === 1 ? styles.badgeTendency : styles.badgeWrong;
return (
<div className={styles.overlay} onClick={onDismiss}>
<div className={`card ${styles.card}`} onClick={e => e.stopPropagation()}>
<div className={styles.result}>
{match.homeTeam.shortName} {match.score.home}:{match.score.away} {match.awayTeam.shortName}
</div>
<div className={styles.tipLine}>
Dein Tipp: {tip.home}:{tip.away}
</div>
<div className={`${styles.badge} ${badgeClass}`}>
{points} {points === 1 ? 'Punkt' : 'Punkte'}
</div>
<div className={styles.label}>{resultLabel}</div>
<button className={styles.dismissBtn} onClick={onDismiss}>Weiter</button>
</div>
</div>
);
}
@@ -46,6 +46,23 @@
padding: 12px 16px;
background: rgba(254,174,50,0.08);
border-bottom: 1px solid rgba(254,174,50,0.15);
display: flex;
align-items: center;
justify-content: space-between;
}
.closeBtn {
background: none;
border: none;
color: var(--text-muted);
font-size: 16px;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
}
.closeBtn:hover {
background: rgba(255,255,255,0.1);
color: var(--text-primary);
}
.panelTitle {
+2 -1
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { api, Match } from '../api/client';
import { Match } from '../api/client';
import styles from './DevPanel.module.css';
const DEV_USERS = [
@@ -131,6 +131,7 @@ export default function DevPanel({ currentUser, onUserChange, matches, onRefresh
<div className={styles.panel}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>🧪 Simulations-Modus</span>
<button className={styles.closeBtn} onClick={() => setOpen(false)}></button>
</div>
{/* User Switcher */}
+312 -67
View File
@@ -1,7 +1,7 @@
/* MatchCard — Stadium Elite Style */
.card {
padding: 20px 24px;
padding: 16px 20px;
transition: box-shadow 0.2s, transform 0.15s;
cursor: default;
}
@@ -21,12 +21,24 @@
inset 0 1px 0 rgba(255,255,255,0.07) !important;
}
/* Top row */
.topRow {
/* Badge row — mirrors matchRow layout for centered alignment */
.badgeRow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 18px;
gap: 10px;
margin-bottom: 10px;
}
.badgeSlot {
flex: 1;
display: flex;
justify-content: center;
min-width: 0;
}
.badgeSpacer {
min-width: 60px; /* matches scoreBox min-width */
flex-shrink: 0;
}
.status {
@@ -39,6 +51,34 @@
.statusLive {
color: var(--error);
}
/* BEENDET badge — right-aligned in header */
.finishedBadge {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
background: var(--surface-high);
padding: 3px 10px;
border-radius: 20px;
}
/* LIVE badge — right-aligned in header */
.liveBadge {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 800;
color: var(--error);
text-transform: uppercase;
letter-spacing: 0.08em;
background: rgba(248, 113, 113, 0.12);
padding: 3px 10px;
border-radius: 20px;
border: 1px solid rgba(248, 113, 113, 0.25);
animation: pulse 1.5s ease-in-out infinite;
}
@@ -48,22 +88,23 @@
}
.badge, .badgeUrgent {
.countdownBadge {
font-size: 11px;
font-weight: 700;
padding: 3px 9px;
padding: 4px 10px;
border-radius: 20px;
}
.badge {
background: var(--surface-high);
color: var(--text-secondary);
line-height: 1;
display: inline-flex;
align-items: center;
}
.badgeUrgent {
.countdownUrgent {
background: rgba(254,174,50,0.12);
color: var(--gold);
border: 1px solid rgba(254,174,50,0.2);
animation: pulse 1.5s ease-in-out infinite;
}
.group {
@@ -71,44 +112,65 @@
font-weight: 600;
color: var(--primary);
background: var(--primary-dim);
padding: 3px 9px;
padding: 4px 10px;
border-radius: 20px;
border: 1px solid rgba(75,183,248,0.15);
margin-left: auto;
line-height: 1;
display: inline-flex;
align-items: center;
}
/* Match row — Teams + Score */
/* LED time between flags */
.kickoffLED {
font-family: 'DSEG7', 'Courier New', monospace;
font-size: 16px;
color: #FECC4C;
letter-spacing: 0.02em;
text-shadow:
0 0 3px rgba(254, 174, 50, 0.9),
0 0 8px rgba(254, 174, 50, 0.5),
0 0 16px rgba(254, 174, 50, 0.25);
}
/* Match row — Teams + time/score */
.matchRow {
display: grid;
grid-template-columns: 1fr 100px 1fr;
align-items: center;
gap: 12px;
margin-bottom: 18px;
display: flex;
align-items: flex-start;
justify-content: center;
gap: 10px;
margin-bottom: 16px;
}
/* Teams */
.teamHome {
/* Teams — flag on top, name below */
.teamHome, .teamAway {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
/* Center: Score or LED time, vertically centered to flag */
.scoreBox {
display: flex;
align-items: center;
gap: 12px;
justify-content: flex-end;
justify-content: center;
align-self: flex-start;
height: 56px; /* match flag height for vertical centering */
min-width: 60px;
}
.teamAway {
display: flex;
align-items: center;
gap: 12px;
}
/* Flag box — glossy square */
/* Flag box — fullbleed icon style */
.flagBox {
width: 56px;
height: 56px;
border-radius: 13px;
border-radius: 14px;
background: var(--surface-high);
box-shadow:
0 4px 12px rgba(0,0,0,0.3),
inset 0 1px 0 rgba(255,255,255,0.12);
0 4px 12px rgba(0,0,0,0.25),
inset 0 1px 0 rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.08);
display: flex;
align-items: center;
justify-content: center;
@@ -121,43 +183,46 @@
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 50%;
background: linear-gradient(180deg, rgba(255,255,255,0.08) 0%, transparent 100%);
height: 40%;
background: linear-gradient(180deg, rgba(255,255,255,0.12) 0%, transparent 100%);
pointer-events: none;
z-index: 1;
}
.crest {
width: 38px;
height: 38px;
object-fit: contain;
width: 100%;
height: 100%;
object-fit: cover;
position: relative;
z-index: 0;
}
.teamName {
font-family: 'Plus Jakarta Sans', sans-serif;
font-weight: 700;
font-size: 15px;
font-weight: 600;
font-size: 13px;
color: var(--text-primary);
line-height: 1.2;
text-align: center;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Score / VS center */
.scoreBox {
display: flex;
align-items: center;
justify-content: center;
}
.score {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 22px;
font-size: 26px;
font-weight: 800;
color: var(--text-primary);
letter-spacing: 4px;
}
.scoreLive {
font-size: 32px;
}
.kickoffCenter {
display: flex;
flex-direction: column;
@@ -175,8 +240,7 @@
/* Tipp area */
.tipRow {
border-top: 1px solid rgba(255,255,255,0.05);
padding-top: 14px;
padding-top: 8px;
display: flex;
align-items: center;
justify-content: center;
@@ -282,48 +346,51 @@
/* Farb-Varianten Banner */
.exact {
background: linear-gradient(90deg, rgba(52,211,153,0.18) 0%, rgba(52,211,153,0.08) 100%);
color: #4ade80;
border-top: 1px solid rgba(52,211,153,0.20);
background: linear-gradient(90deg, rgba(254,174,50,0.22) 0%, rgba(254,174,50,0.09) 100%);
color: #FECC4C;
border-top: 1px solid rgba(254,174,50,0.30);
}
.tendency {
background: linear-gradient(90deg, rgba(75,183,248,0.18) 0%, rgba(75,183,248,0.08) 100%);
color: var(--primary);
border-top: 1px solid rgba(75,183,248,0.20);
background: linear-gradient(90deg, rgba(52,211,153,0.18) 0%, rgba(52,211,153,0.08) 100%);
color: #4ade80;
border-top: 1px solid rgba(52,211,153,0.22);
}
.wrong {
background: linear-gradient(90deg, rgba(248,113,113,0.15) 0%, rgba(248,113,113,0.06) 100%);
color: var(--error);
border-top: 1px solid rgba(248,113,113,0.18);
background: linear-gradient(90deg, rgba(148,163,184,0.12) 0%, rgba(148,163,184,0.05) 100%);
color: var(--text-muted);
border-top: 1px solid rgba(148,163,184,0.15);
}
/* Card-Glow je Ergebnis */
.glowExact {
box-shadow:
0 0 0 1px rgba(52,211,153,0.18),
0 10px 30px rgba(52,211,153,0.07),
inset 0 1px 0 rgba(255,255,255,0.07) !important;
0 0 0 1.5px rgba(254,174,50,0.40),
0 0 20px rgba(254,174,50,0.18),
0 10px 30px rgba(254,174,50,0.10),
inset 0 1px 0 rgba(255,255,255,0.09) !important;
border-color: rgba(254,174,50,0.35) !important;
}
.glowTendency {
box-shadow:
0 0 0 1px rgba(75,183,248,0.18),
0 10px 30px rgba(75,183,248,0.07),
0 0 0 1px rgba(52,211,153,0.28),
0 10px 30px rgba(52,211,153,0.10),
inset 0 1px 0 rgba(255,255,255,0.07) !important;
}
.glowWrong {
box-shadow:
0 0 0 1px rgba(248,113,113,0.15),
0 10px 30px rgba(248,113,113,0.05),
inset 0 1px 0 rgba(255,255,255,0.07) !important;
0 0 0 1px rgba(148,163,184,0.15),
0 10px 20px rgba(0,0,0,0.08),
inset 0 1px 0 rgba(255,255,255,0.05) !important;
opacity: 0.85;
}
.editBtn {
background: transparent;
border: 1px solid rgba(255,255,255,0.1);
border: 1px solid var(--border-subtle);
color: var(--text-muted);
padding: 4px 12px;
border-radius: 20px;
@@ -339,3 +406,181 @@
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Tipped row — clean compact display */
.tippedRow {
display: flex;
align-items: center;
gap: 10px;
background: rgba(52, 211, 153, 0.08);
border: 1px solid rgba(52, 211, 153, 0.2);
border-radius: var(--radius-sm);
padding: 10px 14px;
cursor: pointer;
transition: background 0.2s;
width: 100%;
}
.tippedRow:hover {
background: rgba(52, 211, 153, 0.14);
}
.tippedIcon {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--success);
color: white;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.tippedLabel {
font-size: 12px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
}
.tippedScore {
font-size: 16px;
font-weight: 800;
color: var(--text-primary);
margin-left: auto;
}
.tippedEdit {
font-size: 11px;
color: var(--primary);
font-weight: 600;
opacity: 0.7;
}
/* ── State-based card variants ──────────────────────────────────── */
.card_open { /* default — no extra styling needed */ }
.card_tipped {
border-left: 3px solid var(--success);
}
.card_live {
border-left: 3px solid var(--error);
}
.card_finished { /* glow classes already applied via JS */ }
.card_missed {
opacity: 0.45;
pointer-events: none;
}
/* Live pulsing dot */
.liveDot {
width: 8px;
height: 8px;
background: var(--error);
border-radius: 50%;
display: inline-block;
margin-right: 6px;
vertical-align: middle;
animation: pulse 1.5s ease-in-out infinite;
}
/* Points badge */
.pointsBadge {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 700;
}
.pointsBadge_exact {
background: linear-gradient(135deg, #FEAE32, #FFD700, #FECC4C);
color: #1a1a1a;
font-size: 0.9rem;
padding: 5px 12px;
box-shadow: 0 0 12px rgba(254,174,50,0.45);
animation: shimmer 2.5s ease-in-out infinite;
}
.pointsBadge_tendency {
background: linear-gradient(135deg, var(--success), #22c55e);
color: #0a2a1a;
}
.pointsBadge_wrong {
background: var(--surface-high);
color: var(--text-muted);
border: 1px solid var(--border-subtle);
}
/* Missed label */
.missedLabel {
font-size: 0.75rem;
color: var(--text-muted);
font-style: italic;
}
/* Countdown (replaces badge when < 60 min) */
.countdown {
color: var(--error);
font-weight: 700;
font-size: 0.8rem;
}
.countdownUrgent {
animation: pulse 0.8s ease-in-out infinite;
}
/* Tipped state: checkmark + score inline */
.tipDisplay_score {
display: flex;
align-items: center;
gap: 6px;
}
/* Change button for tipped state */
.changeBtn {
background: none;
border: none;
color: var(--primary);
cursor: pointer;
font-size: 0.8rem;
text-decoration: underline;
padding: 0;
}
.changeBtn:hover {
color: var(--primary);
opacity: 0.75;
}
@keyframes shimmer {
0% { box-shadow: 0 0 0 rgba(254, 174, 50, 0); }
50% { box-shadow: 0 0 16px rgba(254, 174, 50, 0.5); }
100% { box-shadow: 0 0 0 rgba(254, 174, 50, 0); }
}
/* ═══ Light Mode Overrides ═══ */
:global([data-theme="light"]) .kickoffLED {
color: #9A6500;
text-shadow:
0 0 2px rgba(154, 101, 0, 0.3),
0 0 6px rgba(154, 101, 0, 0.1);
}
:global([data-theme="light"]) .flagBox {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
border-color: rgba(0, 0, 0, 0.08);
}
:global([data-theme="light"]) .flagBox::before {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.25) 0%, transparent 100%);
}
+109 -63
View File
@@ -1,5 +1,7 @@
import { Check, TrendingUp, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Match } from '../api/client';
import { getFlagUrl } from '../utils/flagUrl';
import styles from './MatchCard.module.css';
interface Props {
@@ -7,37 +9,44 @@ interface Props {
onTip: () => void;
}
const STATUS_LABELS: Record<string, string> = {
SCHEDULED: 'Geplant',
TIMED: 'Terminiert',
IN_PLAY: 'Live',
PAUSED: 'Pause',
FINISHED: 'Beendet',
POSTPONED: 'Verschoben',
CANCELLED: 'Abgesagt',
};
type CardState = 'open' | 'tipped' | 'live' | 'finished' | 'missed';
function getCardState(match: Match): CardState {
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: number) {
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;
}
function formatKickoff(utcDate: string): string {
return new Date(utcDate).toLocaleString('de-DE', {
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin',
}) + ' Uhr';
}
function CountdownBadge({ minutes }: { minutes: number }) {
if (minutes <= 0) return null;
if (minutes < 60) return <span className={styles.badgeUrgent}> in {minutes} Min.</span>;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (h < 24) return <span className={styles.badge}>in {h}h{m > 0 ? ` ${m}m` : ''}</span>;
const d = Math.floor(h / 24);
return <span className={styles.badge}>in {d} Tag{d > 1 ? 'en' : ''}</span>;
});
}
function FlagBox({ crest, name }: { crest: string | null; name: string }) {
const src = getFlagUrl(name, crest);
return (
<div className={styles.flagBox}>
{crest
? <img className={styles.crest} src={crest} alt={name} />
{src
? <img className={styles.crest} src={src} alt={name} />
: <span style={{ fontSize: 18 }}>🏳</span>
}
</div>
@@ -45,10 +54,15 @@ function FlagBox({ crest, name }: { crest: string | null; name: string }) {
}
export default function MatchCard({ match, onTip }: Props) {
const isFinished = match.status === 'FINISHED';
const isLive = match.status === 'IN_PLAY' || match.status === 'PAUSED';
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 :
@@ -60,52 +74,89 @@ export default function MatchCard({ match, onTip }: Props) {
isFinished && points === 0 ? styles.glowWrong : '';
return (
<div className={`card ${styles.card} ${isLive ? styles.live : ''} ${glowClass}`}>
<div className={`card ${styles.card} ${styles[`card_${state}`]} ${isLive ? styles.live : ''} ${glowClass}`}>
{/* Top row: Status / Kickoff / Badges */}
<div className={styles.topRow}>
<span className={`${styles.status} ${isLive ? styles.statusLive : ''}`}>
{isLive && '● '}{STATUS_LABELS[match.status] ?? match.status}
{/* Badge row — centered above each flag */}
<div className={styles.badgeRow}>
<span className={styles.badgeSlot}>
{match.group && (
<span className={styles.group}>
{match.group.replace('GROUP_', 'Gruppe ')}
</span>
)}
</span>
<span className={styles.badgeSpacer} />
<span className={styles.badgeSlot}>
{isLive && (
<span className={styles.liveBadge}>
<span className={styles.liveDot} />
LIVE
</span>
)}
{isFinished && (
<span className={styles.finishedBadge}>Beendet</span>
)}
{(state === 'open' || state === 'tipped') && match.tippable && (
<span className={`${styles.countdownBadge} ${remainingMins < 60 ? styles.countdownUrgent : ''}`}>
{match.minutesUntilKickoff < 60
? `Noch ${remainingMins} Min!`
: (() => {
const h = Math.floor(match.minutesUntilKickoff / 60);
if (h < 24) return `in ${h}h`;
const d = Math.floor(h / 24);
return `in ${d} Tag${d > 1 ? 'en' : ''}`;
})()
}
</span>
)}
</span>
{match.group && (
<span className={styles.group}>
{match.group.replace('GROUP_', 'Gruppe ')}
</span>
)}
{match.tippable && <CountdownBadge minutes={match.minutesUntilKickoff} />}
</div>
{/* Teams + Score */}
{/* Teams + Center (time or score) */}
<div className={styles.matchRow}>
{/* Home */}
<div className={styles.teamHome}>
<span className={styles.teamName}>{match.homeTeam.name}</span>
<FlagBox crest={match.homeTeam.crest} name={match.homeTeam.name} />
<span className={styles.teamName}>{match.homeTeam.shortName}</span>
</div>
{/* Score / Kickoff time */}
<div className={styles.scoreBox}>
{isFinished || isLive ? (
<span className={styles.score}>
<span className={`${styles.score} ${isLive ? styles.scoreLive : ''}`}>
{match.score.home ?? ''}&nbsp;:&nbsp;{match.score.away ?? ''}
</span>
) : (
<div className={styles.kickoffCenter}>
<span className={styles.kickoffCenterTime}>{formatKickoff(match.utcDate)}</span>
</div>
<span className={styles.kickoffLED}>{formatKickoff(match.utcDate)}</span>
)}
</div>
{/* Away */}
<div className={styles.teamAway}>
<FlagBox crest={match.awayTeam.crest} name={match.awayTeam.name} />
<span className={styles.teamName}>{match.awayTeam.name}</span>
<span className={styles.teamName}>{match.awayTeam.shortName}</span>
</div>
</div>
{/* Tipp area — wird zum farbigen Banner wenn Punkte ausgewertet */}
<div className={`${styles.tipRow} ${hasTip && points !== null ? `${styles.resultBanner} ${resultClass}` : ''}`}>
{hasTip ? (
{state === 'missed' ? (
/* ── Missed: no tip, match finished ── */
<span className={styles.missedLabel}>Nicht getippt</span>
) : state === 'live' ? (
/* ── Live: no tip input, locked ── */
hasTip ? (
<div className={styles.tipDisplay}>
<div className={styles.tipLeft} />
<div className={styles.tipCenter}>
<span className={styles.tipLabel}>DEIN TIPP</span>
<span className={styles.tipScore}>
{match.userTip!.home} : {match.userTip!.away}
</span>
</div>
<div className={styles.tipRight} />
</div>
) : (
<span className={styles.noTip}>Kein Tipp abgegeben</span>
)
) : hasTip ? (
points !== null ? (
/* ── Auswertungs-Banner ── */
<div className={styles.tipDisplay}>
@@ -117,7 +168,7 @@ export default function MatchCard({ match, onTip }: Props) {
<X size={14} strokeWidth={3} />}
</span>
<span className={styles.resultLabel}>
{points === 3 ? 'Exakt' : points === 1 ? 'Tendenz' : 'Falsch'}
{points === 3 ? '🏆 Exakt' : points === 1 ? 'Tendenz' : 'Falsch'}
</span>
</div>
@@ -130,27 +181,22 @@ export default function MatchCard({ match, onTip }: Props) {
{/* Rechts: Punkte */}
<div className={styles.tipRight}>
<span className={styles.resultPoints}>
<span className={`${styles.pointsBadge} ${
points === 3 ? styles.pointsBadge_exact :
points === 1 ? styles.pointsBadge_tendency :
styles.pointsBadge_wrong
}`}>
{points === 0 ? '0 Pkt.' : `+${points} Pkt.`}
</span>
</div>
</div>
) : (
/* ── Tipp vorhanden, noch nicht ausgewertet ── */
<div className={styles.tipDisplay}>
<div className={styles.tipLeft}>
{match.tippable && (
<button className={styles.editBtn} onClick={onTip}>Ändern</button>
)}
</div>
<div className={styles.tipCenter}>
{/* Label nur zeigen wenn kein Ändern-Button da ist, sonst fluchtet der Button nicht */}
{!match.tippable && <span className={styles.tipLabel}>DEIN TIPP</span>}
<span className={styles.tipScore}>
{match.userTip!.home} : {match.userTip!.away}
</span>
</div>
<div className={styles.tipRight} />
/* ── Tipp vorhanden, noch nicht ausgewertet (tipped state) ── */
<div className={styles.tippedRow} onClick={match.tippable ? onTip : undefined}>
<span className={styles.tippedIcon}></span>
<span className={styles.tippedLabel}>Dein Tipp</span>
<span className={styles.tippedScore}>{match.userTip!.home} : {match.userTip!.away}</span>
{match.tippable && <span className={styles.tippedEdit}>Ändern</span>}
</div>
)
) : match.tippable ? (
@@ -0,0 +1,35 @@
.ring {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 0;
}
.svg {
width: 160px;
height: 160px;
}
.legend {
display: flex;
gap: 16px;
margin-top: 12px;
font-size: 0.8rem;
color: var(--text-secondary);
flex-wrap: wrap;
justify-content: center;
}
.legendItem {
display: flex;
align-items: center;
gap: 4px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
+68
View File
@@ -0,0 +1,68 @@
import styles from './StatsRing.module.css';
interface Props {
exact: number;
tendency: number;
wrong: number;
total: number;
}
export default function StatsRing({ exact, tendency, wrong, total }: Props) {
const radius = 55;
const circumference = 2 * Math.PI * radius;
const all = exact + tendency + wrong;
const hasData = all > 0;
const segments = hasData ? [
{ value: exact / all, color: 'var(--gold)', label: 'Exakt', count: exact },
{ value: tendency / all, color: 'var(--success)', label: 'Tendenz', count: tendency },
{ value: wrong / all, color: 'var(--error)', label: 'Falsch', count: wrong },
] : [];
let offset = 0;
return (
<div className={styles.ring}>
<svg viewBox="0 0 140 140" className={styles.svg}>
{/* Background circle */}
<circle cx="70" cy="70" r={radius} fill="none" stroke="var(--surface-high)" strokeWidth="12" />
{segments.map((seg, i) => {
if (seg.value === 0) return null;
const dashArray = `${seg.value * circumference} ${circumference}`;
const rotation = offset * 360 - 90;
offset += seg.value;
return (
<circle
key={i}
cx="70" cy="70" r={radius}
fill="none"
stroke={seg.color}
strokeWidth="12"
strokeDasharray={dashArray}
transform={`rotate(${rotation} 70 70)`}
strokeLinecap="round"
/>
);
})}
<text x="70" y="65" textAnchor="middle" dominantBaseline="central"
fill="var(--text-primary)" fontSize="28" fontWeight="700">
{total}
</text>
<text x="70" y="85" textAnchor="middle"
fill="var(--text-secondary)" fontSize="11">
{hasData ? 'Punkte' : 'Keine Tipps'}
</text>
</svg>
{hasData && (
<div className={styles.legend}>
{segments.map((seg, i) => (
<span key={i} className={styles.legendItem}>
<span className={styles.dot} style={{ background: seg.color }} />
{seg.label}: {seg.count}
</span>
))}
</div>
)}
</div>
);
}
+61 -332
View File
@@ -52,32 +52,11 @@
.handle {
width: 40px;
height: 4px;
background: rgba(255,255,255,0.15);
background: var(--surface-high);
border-radius: 2px;
margin: 0 auto 20px;
}
/* Match header */
.matchHeader {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
}
.groupBadge {
font-size: 11px;
font-weight: 700;
color: var(--primary);
background: var(--primary-dim);
padding: 3px 10px;
border-radius: 20px;
border: 1px solid rgba(75,183,248,0.2);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Teams row */
.teamsRow {
display: grid;
@@ -98,12 +77,10 @@
.flagLarge {
width: 72px;
height: 72px;
border-radius: 18px;
border-radius: 16px;
background: var(--surface-high);
box-shadow:
0 8px 24px rgba(0,0,0,0.35),
inset 0 1px 0 rgba(255,255,255,0.12),
inset 1px 0 0 rgba(255,255,255,0.06);
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
border: 1px solid var(--border-subtle);
display: flex;
align-items: center;
justify-content: center;
@@ -115,16 +92,16 @@
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 50%;
background: linear-gradient(180deg, rgba(255,255,255,0.08) 0%, transparent 100%);
height: 40%;
background: linear-gradient(180deg, rgba(255,255,255,0.2) 0%, transparent 100%);
pointer-events: none;
z-index: 1;
}
.flagImg {
width: 48px;
height: 48px;
object-fit: contain;
width: 100%;
height: 100%;
object-fit: cover;
position: relative;
z-index: 0;
}
@@ -140,13 +117,6 @@
line-height: 1.2;
}
.teamShort {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.vsBlock {
display: flex;
align-items: center;
@@ -154,31 +124,6 @@
height: 72px;
}
.kickoffBlock {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.kickoffDate {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 11px;
font-weight: 700;
color: var(--text-secondary);
text-align: center;
white-space: nowrap;
}
.kickoffTime {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 12px;
font-weight: 800;
color: var(--primary);
text-align: center;
white-space: nowrap;
}
/* Picker section */
.pickerSection {
margin-bottom: 20px;
@@ -219,13 +164,13 @@
}
.pickerBtn {
width: 56px;
height: 56px;
width: 48px;
height: 48px;
background: var(--surface-high);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 16px;
border: 1px solid var(--border-subtle);
border-radius: 14px;
color: var(--text-primary);
font-size: 24px;
font-size: 22px;
font-weight: 300;
cursor: pointer;
display: flex;
@@ -234,31 +179,23 @@
transition: all 0.15s;
position: relative;
overflow: hidden;
box-shadow:
0 6px 16px rgba(0,0,0,0.35),
inset 0 1px 0 rgba(255,255,255,0.12),
inset 1px 0 0 rgba(255,255,255,0.06);
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
}
/* Glossy sheen gleich wie flagLarge */
.pickerBtn::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 50%;
background: linear-gradient(180deg, rgba(255,255,255,0.1) 0%, transparent 100%);
height: 40%;
background: linear-gradient(180deg, rgba(255,255,255,0.12) 0%, transparent 100%);
pointer-events: none;
border-radius: 16px 16px 0 0;
border-radius: 14px 14px 0 0;
}
.pickerBtn:hover {
background: rgba(75,183,248,0.12);
border-color: rgba(75,183,248,0.35);
background: rgba(75,183,248,0.08);
border-color: rgba(75,183,248,0.2);
color: var(--primary);
box-shadow:
0 6px 20px rgba(75,183,248,0.2),
inset 0 1px 0 rgba(75,183,248,0.15),
inset 1px 0 0 rgba(75,183,248,0.08);
}
.pickerBtn:active {
@@ -287,7 +224,7 @@
background: var(--surface-high);
border-radius: 12px;
margin-bottom: 20px;
border: 1px solid rgba(255,255,255,0.08);
border: 1px solid var(--border-subtle);
position: relative;
overflow: hidden;
box-shadow:
@@ -296,7 +233,7 @@
inset 1px 0 0 rgba(255,255,255,0.05);
}
/* Glossy sheen gleich wie flagLarge und pickerBtn */
/* Glossy sheen */
.tendencyBar::before {
content: '';
position: absolute;
@@ -318,253 +255,6 @@
font-weight: 700;
}
/* ---- Expertenblick ---- */
.insightWrapper {
margin-bottom: 20px;
}
/* Toggle-Zeile mit Play-Button */
.insightToggleRow {
display: flex;
align-items: center;
gap: 8px;
}
.insightToggleRow .insightToggle {
flex: 1;
}
/* Audio-Play-Button */
.audioBtn {
display: flex;
align-items: center;
gap: 5px;
padding: 9px 12px;
background: linear-gradient(135deg, rgba(75,183,248,0.12) 0%, rgba(75,183,248,0.05) 100%);
border: 1px solid rgba(75,183,248,0.3);
border-radius: 12px;
color: var(--cyan);
font-size: 12px;
font-weight: 700;
font-family: 'Plus Jakarta Sans', sans-serif;
cursor: pointer;
transition: all 0.18s;
white-space: nowrap;
flex-shrink: 0;
}
.audioBtn:hover:not(:disabled) {
background: linear-gradient(135deg, rgba(75,183,248,0.2) 0%, rgba(75,183,248,0.08) 100%);
border-color: rgba(75,183,248,0.5);
}
.audioBtn:disabled {
opacity: 0.6;
cursor: default;
}
/* Dialog-Format: Delling / Netzer */
.dialogLine {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 12px;
border-radius: 10px;
margin-bottom: 6px;
animation: insightFadeIn 0.2s ease;
}
.dialogLine:last-child {
margin-bottom: 0;
}
.speakerDelling {
background: rgba(148, 163, 184, 0.06);
border-left: 2px solid rgba(148,163,184,0.4);
align-items: flex-start;
}
.speakerNetzer {
background: rgba(254, 174, 50, 0.06);
border-left: 2px solid rgba(254,174,50,0.5);
align-items: flex-start;
}
.dialogSpeaker {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 2px;
}
.speakerDelling .dialogSpeaker {
color: rgba(148,163,184,0.8);
}
.speakerNetzer .dialogSpeaker {
color: var(--gold);
}
.dialogText {
font-size: 13px;
line-height: 1.55;
color: var(--text-primary);
font-style: italic;
}
.insightToggle {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 11px 16px;
background: linear-gradient(135deg, rgba(254,174,50,0.12) 0%, rgba(254,174,50,0.05) 100%);
border: 1px solid rgba(254,174,50,0.3);
border-radius: 12px;
color: var(--gold);
font-size: 13px;
font-weight: 700;
font-family: 'Plus Jakarta Sans', sans-serif;
cursor: pointer;
transition: all 0.18s;
text-align: left;
letter-spacing: 0.02em;
position: relative;
overflow: hidden;
box-shadow:
0 2px 12px rgba(254,174,50,0.1),
inset 0 1px 0 rgba(254,174,50,0.15);
}
/* Glossy sheen */
.insightToggle::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 50%;
background: linear-gradient(180deg, rgba(255,255,255,0.06) 0%, transparent 100%);
pointer-events: none;
}
.insightToggle:hover {
background: linear-gradient(135deg, rgba(254,174,50,0.2) 0%, rgba(254,174,50,0.08) 100%);
border-color: rgba(254,174,50,0.5);
box-shadow:
0 4px 20px rgba(254,174,50,0.2),
inset 0 1px 0 rgba(254,174,50,0.2);
}
.insightIcon {
color: var(--gold);
flex-shrink: 0;
filter: drop-shadow(0 0 4px rgba(254,174,50,0.5));
}
.insightChevron {
margin-left: auto;
color: rgba(254,174,50,0.6);
transition: transform 0.2s;
}
.insightPanel {
margin-top: 8px;
padding: 14px 16px;
background: var(--surface-high);
border-radius: 12px;
border: 1px solid rgba(254,174,50,0.1);
box-shadow: inset 0 1px 0 rgba(254,174,50,0.05);
animation: insightFadeIn 0.2s ease;
}
@keyframes insightFadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.insightLoading {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 13px;
}
.insightSpinner {
color: var(--primary);
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.insightText {
display: flex;
flex-direction: column;
gap: 0;
}
.insightLine {
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.insightLine:last-child {
border-bottom: none;
padding-bottom: 0;
}
.insightLine:first-child {
padding-top: 0;
}
.insightLabel {
font-family: 'Plus Jakarta Sans', sans-serif;
font-weight: 700;
font-size: 10px;
color: var(--primary);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.insightValue {
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary);
}
.insightValue strong {
color: var(--text-primary);
font-weight: 700;
}
.insightCursor {
display: inline-block;
width: 2px;
height: 14px;
background: var(--primary);
border-radius: 1px;
margin-left: 2px;
vertical-align: middle;
animation: blink 0.8s ease-in-out infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.insightErrorMsg {
font-size: 13px;
color: var(--text-muted);
text-align: center;
}
/* Error */
.error {
color: var(--error);
@@ -599,3 +289,42 @@
}
.cancelBtn:hover { color: var(--text-secondary); }
/* Success overlay animation */
.successOverlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--bg-deep) 92%, transparent);
border-radius: inherit;
animation: fadeIn 0.3s ease;
z-index: 10;
}
.successCheck {
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--success);
color: white;
font-size: 32px;
display: flex;
align-items: center;
justify-content: center;
animation: popIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.successText {
margin-top: 12px;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
}
@keyframes popIn {
0% { transform: scale(0); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
+22 -235
View File
@@ -1,6 +1,6 @@
import { useState, useRef } from 'react';
import { Sparkles, ChevronDown, ChevronUp, Loader2, Volume2, Square } from 'lucide-react';
import { useState } from 'react';
import { Match, api } from '../api/client';
import { getFlagUrl } from '../utils/flagUrl';
import styles from './TipModal.module.css';
interface Props {
@@ -17,132 +17,14 @@ function getTendency(home: number, away: number): Tendency {
return 'draw';
}
// Streaming-Fetch für /api/agent/insight
async function fetchInsight(
homeTeam: string,
awayTeam: string,
stage: string,
group: string | null,
onChunk: (text: string) => void,
onDone: () => void,
onError: () => void
) {
try {
const res = await fetch('/api/agent/insight', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ homeTeam, awayTeam, stage, group }),
});
if (!res.ok) { onError(); return; }
const reader = res.body?.getReader();
if (!reader) { onError(); return; }
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6).trim();
if (payload === '[DONE]') { onDone(); return; }
try {
const parsed = JSON.parse(payload);
if (parsed.text) onChunk(parsed.text);
if (parsed.error) { onError(); return; }
} catch { /* ignore */ }
}
}
onDone();
} catch {
onError();
}
}
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);
// Expertenblick State
const [insightOpen, setInsightOpen] = useState(false);
const [insightText, setInsightText] = useState('');
const [insightLoading, setInsightLoading] = useState(false);
const [insightError, setInsightError] = useState(false);
const insightFetched = useRef(false);
// Audio State
const [audioLoading, setAudioLoading] = useState(false);
const [audioPlaying, setAudioPlaying] = useState(false);
const [audioError, setAudioError] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
async function handlePlayAudio() {
// Stop wenn gerade läuft
if (audioPlaying && audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
setAudioPlaying(false);
return;
}
setAudioError(false);
setAudioLoading(true);
try {
const res = await fetch('/api/agent/insight-audio', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dialogText: insightText }),
});
if (!res.ok) throw new Error('Audio-Generierung fehlgeschlagen');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audioRef.current = audio;
audio.onended = () => {
setAudioPlaying(false);
URL.revokeObjectURL(url);
};
audio.onerror = () => {
setAudioPlaying(false);
setAudioError(true);
};
setAudioLoading(false);
setAudioPlaying(true);
await audio.play();
} catch {
setAudioLoading(false);
setAudioError(true);
}
}
function handleToggleInsight() {
const opening = !insightOpen;
setInsightOpen(opening);
// Nur einmal laden
if (opening && !insightFetched.current) {
insightFetched.current = true;
setInsightLoading(true);
setInsightText('');
setInsightError(false);
fetchInsight(
match.homeTeam.name,
match.awayTeam.name,
match.stage,
match.group,
(chunk) => setInsightText((t) => t + chunk),
() => setInsightLoading(false),
() => { setInsightLoading(false); setInsightError(true); }
);
}
}
const tendency = getTendency(home, away);
const tendencyLabel =
tendency === 'home' ? match.homeTeam.shortName || match.homeTeam.name :
@@ -158,7 +40,13 @@ export default function TipModal({ match, onClose, onSaved }: Props) {
setError(null);
try {
await api.submitTip(match.id, home, away);
onSaved(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);
@@ -172,47 +60,24 @@ export default function TipModal({ match, onClose, onSaved }: Props) {
{/* 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 mit Flaggen */}
<div className={styles.teamsRow}>
<div className={styles.teamBlock}>
<div className={styles.flagLarge}>
{match.homeTeam.crest
? <img src={match.homeTeam.crest} alt={match.homeTeam.name} className={styles.flagImg} />
{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.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.vsBlock} />
<div className={styles.teamBlock}>
<div className={styles.flagLarge}>
{match.awayTeam.crest
? <img src={match.awayTeam.crest} alt={match.awayTeam.name} className={styles.flagImg} />
{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>
@@ -240,93 +105,15 @@ export default function TipModal({ match, onClose, onSaved }: Props) {
</span>
</div>
{/* Expertenblick */}
<div className={styles.insightWrapper}>
<div className={styles.insightToggleRow}>
<button className={styles.insightToggle} onClick={handleToggleInsight}>
<Sparkles size={14} className={styles.insightIcon} />
<span>Expertenblick</span>
{insightOpen
? <ChevronUp size={14} className={styles.insightChevron} />
: <ChevronDown size={14} className={styles.insightChevron} />
}
</button>
{/* Play-Button nur wenn Dialog fertig geladen */}
{insightOpen && !insightLoading && !insightError && insightText && (
<button
className={styles.audioBtn}
onClick={handlePlayAudio}
disabled={audioLoading}
title={audioPlaying ? 'Stop' : 'Dialog anhören'}
>
{audioLoading
? <Loader2 size={13} className={styles.insightSpinner} />
: audioPlaying
? <Square size={13} />
: <Volume2 size={13} />
}
<span>{audioPlaying ? 'Stop' : 'Anhören'}</span>
</button>
)}
</div>
{insightOpen && (
<div className={styles.insightPanel}>
{insightLoading && insightText === '' ? (
<div className={styles.insightLoading}>
<Loader2 size={15} className={styles.insightSpinner} />
<span>Analyse läuft</span>
</div>
) : insightError ? (
<div className={styles.insightErrorMsg}>
Einschätzung gerade nicht verfügbar.
</div>
) : (
<div className={styles.insightText}>
{insightText.split('\n').filter(l => l.trim()).map((line, i) => {
// Dialog-Format: **Delling:** "..." oder **Netzer:** "..."
const dialogMatch = line.match(/^\*\*(Delling|Netzer):\*\*\s*["„]?(.+?)[""]?\s*$/);
if (dialogMatch) {
const speaker = dialogMatch[1] as 'Delling' | 'Netzer';
return (
<div key={i} className={`${styles.dialogLine} ${styles[`speaker${speaker}`]}`}>
<span className={styles.dialogSpeaker}>{speaker}</span>
<span className={styles.dialogText}>{dialogMatch[2].replace(/^["„]|[""]$/g, '')}"</span>
</div>
);
}
// Fallback: **Label:** Value (alter Stil)
const labelMatch = line.match(/^\*\*(.+?):\*\*\s*(.*)$/);
if (labelMatch) {
const valueParts = labelMatch[2].split(/(\*\*.+?\*\*)/g).map((part, j) =>
part.startsWith('**') && part.endsWith('**')
? <strong key={j}>{part.slice(2, -2)}</strong>
: part
);
return (
<div key={i} className={styles.insightLine}>
<span className={styles.insightLabel}>{labelMatch[1]}</span>
<span className={styles.insightValue}>{valueParts}</span>
</div>
);
}
return <div key={i} className={styles.insightLine}>{line}</div>;
})}
{insightLoading && <span className={styles.insightCursor} />}
{audioError && (
<div className={styles.insightErrorMsg} style={{ marginTop: '0.5rem' }}>
Audio nicht verfügbar.
</div>
)}
</div>
)}
</div>
)}
</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}`}
+25
View File
@@ -0,0 +1,25 @@
.toast {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
background: color-mix(in srgb, var(--surface-high) 95%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: var(--text-primary);
padding: 12px 20px;
border-radius: var(--radius-md);
font-size: 0.9rem;
font-weight: 500;
z-index: 300;
cursor: pointer;
animation: slideDown 0.3s ease;
border: 1px solid rgba(75, 183, 248, 0.15);
max-width: 90%;
text-align: center;
}
@keyframes slideDown {
from { transform: translateX(-50%) translateY(-100%); opacity: 0; }
to { transform: translateX(-50%) translateY(0); opacity: 1; }
}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect, useRef } from 'react';
import styles from './Toast.module.css';
interface Props {
message: string;
onDismiss: () => void;
duration?: number;
}
export default function Toast({ message, onDismiss, duration = 5000 }: Props) {
const onDismissRef = useRef(onDismiss);
onDismissRef.current = onDismiss;
useEffect(() => {
const timer = setTimeout(() => onDismissRef.current(), duration);
return () => clearTimeout(timer);
}, [duration]);
return (
<div className={`card ${styles.toast}`} onClick={onDismiss}>
{message}
</div>
);
}
+14
View File
@@ -1,3 +1,17 @@
interface Window {
_devUser?: number;
}
interface ImportMetaEnv {
readonly VITE_TEST_MODE?: string;
readonly DEV: boolean;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare module '*.module.css' {
const classes: { readonly [key: string]: string };
export default classes;
}
+31
View File
@@ -0,0 +1,31 @@
import { useState, useEffect } from 'react';
import { api } from '../api/client';
const RANK_KEY = 'tippspiel_last_rank';
const SHOWN_KEY = 'tippspiel_rank_toast_shown';
export function useRankChange() {
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
// Only show once per session
if (sessionStorage.getItem(SHOWN_KEY)) return;
api.getMyStats().then(stats => {
if (!stats.rank) return;
const lastRank = parseInt(localStorage.getItem(RANK_KEY) || '0');
if (lastRank > 0 && lastRank !== stats.rank) {
if (stats.rank < lastRank) {
setMessage(`⬆️ Du bist auf Platz ${stats.rank} aufgestiegen!`);
} else {
setMessage(`⬇️ Du bist auf Platz ${stats.rank} gerutscht — hol dir die Punkte zurück!`);
}
sessionStorage.setItem(SHOWN_KEY, '1');
}
localStorage.setItem(RANK_KEY, String(stats.rank));
}).catch(() => {});
}, []);
function dismiss() { setMessage(null); }
return { message, dismiss };
}
+36
View File
@@ -0,0 +1,36 @@
import { useState, useEffect } from 'react';
import { Match } from '../api/client';
const SEEN_KEY = 'tippspiel_seen_results';
function getSeenIds(): Set<number> {
try {
return new Set(JSON.parse(localStorage.getItem(SEEN_KEY) || '[]'));
} catch { return new Set(); }
}
function markSeen(matchId: number) {
const seen = getSeenIds();
seen.add(matchId);
localStorage.setItem(SEEN_KEY, JSON.stringify([...seen]));
}
export function useRevealQueue(matches: Match[]) {
const [queue, setQueue] = useState<Match[]>([]);
useEffect(() => {
const seen = getSeenIds();
const unseen = matches.filter(
m => m.status === 'FINISHED' && m.userTip && m.userTip.points !== null && !seen.has(m.id)
);
setQueue(unseen);
}, [matches]);
function dismissCurrent() {
if (queue.length === 0) return;
markSeen(queue[0].id);
setQueue(q => q.slice(1));
}
return { current: queue[0] || null, remaining: queue.length, dismissCurrent };
}
+66 -23
View File
@@ -2,26 +2,69 @@
WM 2026 Tippspiel — Stadium Elite Design System
============================================================ */
/* Material Symbols — filled icons for badges */
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@400,1&display=swap');
/* Stadium LED Segment Display Font */
@font-face {
font-family: 'DSEG7';
src: url('https://cdn.jsdelivr.net/npm/dseg@0.46.0/fonts/DSEG7-Classic/DSEG7Classic-Regular.woff2') format('woff2');
font-weight: 400;
font-display: swap;
}
/* --- Dark Mode (Standard) --- */
:root {
--bg-deep: #0A0E1A;
--bg-mid: #0F1628;
--surface-low: #111827;
--surface-mid: #151D30;
--surface-high: #1C2640;
--primary: #4BB7F8;
--primary-dim: rgba(75,183,248,0.12);
--gold: #FEAE32;
--gold-glow: rgba(254,174,50,0.4);
--cyan: #69DAFF;
--text-primary: #F0F4FF;
--bg-deep: #0A0E1A;
--bg-mid: #0F1628;
--surface-low: #111827;
--surface-mid: #151D30;
--surface-high: #1C2640;
--border-subtle: rgba(255,255,255,0.07);
--primary: #4BB7F8;
--primary-dim: rgba(75,183,248,0.12);
--gold: #FEAE32;
--gold-glow: rgba(254,174,50,0.4);
--cyan: #69DAFF;
--text-primary: #F0F4FF;
--text-secondary: rgba(240,244,255,0.55);
--text-muted: rgba(240,244,255,0.3);
--success: #34D399;
--error: #F87171;
--radius-sm: 8px;
--radius-md: 14px;
--radius-lg: 20px;
--radius-xl: 28px;
--text-muted: rgba(240,244,255,0.3);
--success: #34D399;
--error: #F87171;
--radius-sm: 8px;
--radius-md: 14px;
--radius-lg: 20px;
--radius-xl: 28px;
--shadow-card: 0 10px 25px rgba(0,0,0,0.25);
--card-shine: rgba(255,255,255,0.04);
--scrollbar-bg: var(--surface-high);
--primary-rgb: 75, 183, 248;
--transition-fast: 0.15s ease;
--transition-normal: 0.3s ease;
}
/* --- Light Mode --- */
[data-theme="light"] {
--bg-deep: #F0F4FA;
--bg-mid: #E8EEF7;
--surface-low: #FFFFFF;
--surface-mid: #FFFFFF;
--surface-high: #E4EAF2;
--border-subtle: rgba(0,0,0,0.1);
--primary: #1A8FE3;
--primary-dim: rgba(26,143,227,0.10);
--gold: #B8740A;
--gold-glow: rgba(184,116,10,0.3);
--cyan: #0080C6;
--text-primary: #0D1526;
--text-secondary: rgba(13,21,38,0.65);
--text-muted: rgba(13,21,38,0.45);
--success: #168A5C;
--error: #C92A1F;
--shadow-card: 0 2px 8px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04);
--card-shine: rgba(255,255,255,0.9);
--scrollbar-bg: var(--surface-high);
--primary-rgb: 26, 143, 227;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -38,7 +81,7 @@ html, body, #root {
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--surface-high); border-radius: 3px; }
::-webkit-scrollbar-thumb { background: var(--scrollbar-bg); border-radius: 3px; }
/* Utility */
.font-display { font-family: 'Plus Jakarta Sans', sans-serif; }
@@ -50,10 +93,10 @@ html, body, #root {
.card {
background: var(--surface-mid);
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
box-shadow:
0 10px 25px rgba(0,0,0,0.25),
inset 0 1px 0 rgba(255,255,255,0.07),
inset 1px 0 0 rgba(255,255,255,0.04);
var(--shadow-card),
inset 0 1px 0 var(--border-subtle);
position: relative;
overflow: hidden;
}
@@ -61,7 +104,7 @@ html, body, #root {
content: '';
position: absolute;
top: 0; left: 0; right: 0; height: 50%;
background: linear-gradient(180deg, rgba(255,255,255,0.04) 0%, transparent 100%);
background: linear-gradient(180deg, var(--card-shine) 0%, transparent 100%);
pointer-events: none;
}
+2 -2
View File
@@ -171,8 +171,8 @@
.spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border: 2px solid var(--surface-high);
border-top-color: var(--text-primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
+477
View File
@@ -0,0 +1,477 @@
.dashboard {
max-width: 1100px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 14px;
}
/* Mobile: single column */
.dashboardGrid {
display: flex;
flex-direction: column;
gap: 14px;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 14px;
}
/* Desktop: 2-column layout */
@media (min-width: 768px) {
.dashboardGrid {
flex-direction: row;
gap: 20px;
align-items: flex-start;
}
.hero {
flex: 3;
min-width: 0;
}
.sidebar {
flex: 2;
position: sticky;
top: 80px;
}
}
/* ═══════════════════════════════════════
HERO CARD — Stadium atmosphere
═══════════════════════════════════════ */
.hero {
position: relative;
border-radius: 2rem;
padding: 28px 22px 22px;
cursor: pointer;
overflow: hidden;
/* Glassmorphism with curvature shine */
background: rgba(49, 52, 66, 0.45);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow:
0 40px 80px -15px rgba(0, 0, 0, 0.6),
0 8px 24px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.12);
transition: transform 0.2s;
}
/* Glossy curvature shine — top highlight */
.hero::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 45%;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, transparent 100%);
pointer-events: none;
z-index: 0;
border-radius: 2rem 2rem 0 0;
}
.hero:hover {
transform: scale(1.01);
}
/* Radial glow behind the teams — strong stadium light */
.heroGlow {
position: absolute;
top: 50%;
left: 50%;
width: 400px;
height: 350px;
transform: translate(-50%, -50%);
background: radial-gradient(circle at center, rgba(75, 183, 248, 0.2) 0%, rgba(75, 183, 248, 0.08) 35%, transparent 65%);
pointer-events: none;
z-index: 0;
}
.heroHeader {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
z-index: 1;
margin-bottom: 12px;
}
.heroLabel {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-muted);
font-weight: 600;
}
.heroCountdown {
display: flex;
align-items: center;
gap: 6px;
color: var(--gold);
font-weight: 700;
font-size: 0.75rem;
letter-spacing: 0.5px;
background: rgba(254, 174, 50, 0.1);
padding: 4px 10px;
border-radius: 20px;
border: 1px solid rgba(254, 174, 50, 0.2);
}
.countdownDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--gold);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* Center column: LED time between flags, vertically centered to flag height */
.heroCenter {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 64px; /* matches flag box height */
position: relative;
z-index: 1;
}
.heroLED {
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.ledDigit, .ledColon {
font-family: 'DSEG7', 'Courier New', monospace;
font-size: 20px;
color: #FECC4C;
display: inline-block;
text-align: center;
text-shadow:
0 0 3px rgba(254, 174, 50, 1),
0 0 8px rgba(254, 174, 50, 0.7),
0 0 20px rgba(254, 174, 50, 0.4),
0 0 40px rgba(254, 174, 50, 0.15);
}
.ledDigit {
width: 14px;
}
.ledColon {
width: 6px;
}
.heroGroup {
font-size: 0.6rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
}
/* Teams */
.heroTeams {
display: flex;
justify-content: center;
align-items: flex-start;
gap: 8px;
margin: 16px 0 20px;
position: relative;
z-index: 1;
}
.heroTeam {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
flex: 1;
}
.heroCrestBox {
position: relative;
width: 64px;
height: 64px;
border-radius: 16px;
background: var(--surface-high);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* Glossy shine on flag box */
.heroCrestBox::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 40%;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.15) 0%, transparent 100%);
pointer-events: none;
z-index: 2;
border-radius: 16px 16px 0 0;
}
/* Glow aura behind each flag */
.heroCrestBox::before {
content: '';
position: absolute;
inset: -12px;
background: rgba(75, 183, 248, 0.18);
filter: blur(20px);
border-radius: 50%;
z-index: -1;
}
/* Flag fills the entire box */
.heroCrest {
width: 100%;
height: 100%;
object-fit: cover;
}
.heroCrestFallback {
font-size: 32px;
}
.heroTeamName {
font-size: 0.85rem;
font-weight: 700;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: 0.5px;
text-align: center;
}
/* VS is now replaced by heroCenter with LED time */
.heroVs { display: none; }
/* CTA / Tip */
.heroTipBtn {
display: block;
width: 100%;
padding: 14px;
border: none;
border-radius: var(--radius-md);
background: linear-gradient(135deg, var(--primary) 0%, #2196f3 100%);
color: white;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
position: relative;
z-index: 1;
box-shadow: 0 4px 20px rgba(75, 183, 248, 0.3);
transition: transform 0.15s, box-shadow 0.15s;
letter-spacing: 0.3px;
}
.heroTipBtn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 24px rgba(75, 183, 248, 0.4);
}
.heroTipBtn:active {
transform: scale(0.98);
}
.heroTip {
border-radius: var(--radius-sm);
padding: 10px 16px;
text-align: center;
color: var(--gold);
font-weight: 600;
font-size: 0.9rem;
background: rgba(254, 174, 50, 0.08);
border: 1px solid rgba(254, 174, 50, 0.15);
position: relative;
z-index: 1;
}
.heroEmpty {
text-align: center;
color: var(--text-muted);
margin: 24px 0;
position: relative;
z-index: 1;
}
/* ═══════════════════════════════════════
STATS ROW
═══════════════════════════════════════ */
.statsRow {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
}
.statTile {
padding: 18px 8px;
text-align: center;
border-radius: 1.5rem !important;
border: 1px solid rgba(255, 255, 255, 0.05) !important;
}
.statValue {
display: block;
font-size: 1.6rem;
font-weight: 800;
color: var(--text-primary);
line-height: 1;
}
.statGold {
color: var(--gold);
}
.rankUp {
font-size: 0.7rem;
color: var(--success);
margin-left: 4px;
font-weight: 700;
}
.rankDown {
font-size: 0.7rem;
color: var(--error);
margin-left: 4px;
font-weight: 700;
}
.statLabel {
display: block;
font-size: 0.65rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.8px;
margin-top: 6px;
font-weight: 600;
}
/* ═══════════════════════════════════════
NUDGES
═══════════════════════════════════════ */
.nudges {
display: flex;
flex-direction: column;
gap: 8px;
}
.nudge {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
cursor: pointer;
transition: all 0.2s;
border-radius: 1.25rem !important;
border: 1px solid rgba(255, 255, 255, 0.05) !important;
}
.nudge:hover {
transform: translateX(2px);
}
.nudgeIcon {
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(75, 183, 248, 0.1);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
flex-shrink: 0;
}
.nudgeText {
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.4;
}
.nudgeStreak {
border-left: 3px solid var(--error) !important;
}
/* ═══════════════════════════════════════
STATES
═══════════════════════════════════════ */
/* ═══════════════════════════════════════
LIGHT MODE OVERRIDES
═══════════════════════════════════════ */
:global([data-theme="light"]) .hero {
background: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
:global([data-theme="light"]) .hero::before {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.5) 0%, transparent 100%);
}
:global([data-theme="light"]) .heroGlow {
background: radial-gradient(circle at center, rgba(26, 143, 227, 0.08) 0%, transparent 65%);
}
:global([data-theme="light"]) .heroLED,
:global([data-theme="light"]) .ledDigit,
:global([data-theme="light"]) .ledColon {
color: #9A6500;
text-shadow:
0 0 3px rgba(154, 101, 0, 0.4),
0 0 8px rgba(154, 101, 0, 0.15);
}
:global([data-theme="light"]) .heroCrestBox {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(0, 0, 0, 0.08);
}
:global([data-theme="light"]) .heroCrestBox::before {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.3) 0%, transparent 100%);
}
:global([data-theme="light"]) .heroCrestBox::after {
display: none;
}
:global([data-theme="light"]) .heroTip {
background: rgba(184, 116, 10, 0.08);
border: 1px solid rgba(184, 116, 10, 0.2);
color: #8B5E00;
}
:global([data-theme="light"]) .heroCountdown {
background: rgba(184, 116, 10, 0.08);
border-color: rgba(184, 116, 10, 0.2);
color: #8B5E00;
}
:global([data-theme="light"]) .statTile {
border: 1px solid rgba(0, 0, 0, 0.06) !important;
}
.loading,
.error {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
}
+233
View File
@@ -0,0 +1,233 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { TrendingUp, TrendingDown } from 'lucide-react';
import { api, DashboardData, Match } from '../api/client';
import { getFlagUrl } from '../utils/flagUrl';
import TipModal from '../components/TipModal';
import styles from './DashboardPage.module.css';
interface Props {
devUser?: number;
}
function formatStreak(streak: number): string {
if (streak >= 20) return `${streak}`;
if (streak >= 10) return `🔥🔥${streak}`;
if (streak >= 3) return `🔥${streak}`;
if (streak > 0) return String(streak);
return '0';
}
function formatKickoff(utcDate: string): string {
return new Date(utcDate).toLocaleString('de-DE', {
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin',
});
}
function formatCountdown(minutes: number): string {
if (minutes < 60) return `in ${Math.round(minutes)} Min`;
if (minutes < 60 * 24) return `in ${Math.floor(minutes / 60)}h`;
const days = Math.floor(minutes / (60 * 24));
return `in ${days} ${days === 1 ? 'Tag' : 'Tagen'}`;
}
export default function DashboardPage(_props: Props) {
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [tipMatch, setTipMatch] = useState<Match | null>(null);
const navigate = useNavigate();
useEffect(() => {
setLoading(true);
setError(false);
api.getDashboard()
.then(d => { setData(d); setLoading(false); })
.catch(() => { setError(true); setLoading(false); });
}, []);
// Keep stored streak up to date (hook must be before early returns)
const STREAK_KEY = 'tippspiel_last_streak';
useEffect(() => {
if (data && data.stats.streak > 0) {
localStorage.setItem(STREAK_KEY, String(data.stats.streak));
}
}, [data]);
if (loading) return <div className={styles.loading}>Laden...</div>;
if (error || !data) return <div className={styles.error}>Dashboard konnte nicht geladen werden.</div>;
const { hero, stats, nudges } = data;
const lastRank = parseInt(localStorage.getItem('tippspiel_last_rank') || '0');
const rankDiff = lastRank > 0 && stats.rank !== null ? lastRank - stats.rank : 0;
// Streak break detection via localStorage
const lastStreak = parseInt(localStorage.getItem(STREAK_KEY) || '0');
const streakBroken = lastStreak >= 3 && stats.streak === 0;
return (
<div className={styles.dashboard}>
<div className={styles.dashboardGrid}>
{/* Hero Card */}
<div className={styles.hero} onClick={() => navigate('/spiele')}>
{/* Radial glow background effect */}
<div className={styles.heroGlow} />
<div className={styles.heroHeader}>
<span className={styles.heroLabel}>Nächstes Spiel</span>
{hero && (
<span className={styles.heroCountdown}>
<span className={styles.countdownDot} />
{formatCountdown(hero.match.minutesUntilKickoff).toUpperCase()}
</span>
)}
</div>
{hero ? (
<>
{/* Teams with LED time in center */}
<div className={styles.heroTeams}>
<div className={styles.heroTeam}>
<div className={styles.heroCrestBox}>
{hero.match.homeTeam.name ? (
<img src={getFlagUrl(hero.match.homeTeam.name, hero.match.homeTeam.crest)} alt={hero.match.homeTeam.name} className={styles.heroCrest} />
) : (
<span className={styles.heroCrestFallback}>🏳</span>
)}
</div>
<span className={styles.heroTeamName}>{hero.match.homeTeam.shortName}</span>
</div>
{/* Center: LED time — each digit fixed-width for even spacing */}
<div className={styles.heroCenter}>
<span className={styles.heroLED}>
{formatKickoff(hero.match.utcDate).split('').map((ch, i) => (
<span key={i} className={ch === ':' ? styles.ledColon : styles.ledDigit}>{ch}</span>
))}
</span>
</div>
<div className={styles.heroTeam}>
<div className={styles.heroCrestBox}>
{hero.match.awayTeam.name ? (
<img src={getFlagUrl(hero.match.awayTeam.name, hero.match.awayTeam.crest)} alt={hero.match.awayTeam.name} className={styles.heroCrest} />
) : (
<span className={styles.heroCrestFallback}>🏳</span>
)}
</div>
<span className={styles.heroTeamName}>{hero.match.awayTeam.shortName}</span>
</div>
</div>
{/* CTA or Tip Display */}
{hero.userTip ? (
<div className={styles.heroTip}>
Dein Tipp: {hero.userTip.home}:{hero.userTip.away}
</div>
) : hero.tippable ? (
<button
className={styles.heroTipBtn}
onClick={e => {
e.stopPropagation();
setTipMatch({
id: hero.match.id,
externalId: 0,
utcDate: hero.match.utcDate,
status: hero.match.status,
stage: '',
group: null,
homeTeam: hero.match.homeTeam,
awayTeam: hero.match.awayTeam,
score: { home: null, away: null },
userTip: null,
minutesUntilKickoff: hero.match.minutesUntilKickoff,
tippable: true,
});
}}
>
Jetzt tippen
</button>
) : null}
</>
) : (
<p className={styles.heroEmpty}>Keine anstehenden Spiele</p>
)}
</div>
{/* Sidebar: Stats + Nudges */}
<div className={styles.sidebar}>
<div className={styles.statsRow}>
<div className={`card ${styles.statTile}`}>
<span className={styles.statValue}>
{stats.rank !== null ? stats.rank : '—'}
{rankDiff > 0 && <span className={styles.rankUp}><TrendingUp size={14} strokeWidth={2.5} /></span>}
{rankDiff < 0 && <span className={styles.rankDown}><TrendingDown size={14} strokeWidth={2.5} /></span>}
</span>
<span className={styles.statLabel}>Dein Rang</span>
</div>
<div className={`card ${styles.statTile}`}>
<span className={`${styles.statValue} ${styles.statGold}`}>{stats.totalPoints}</span>
<span className={styles.statLabel}>Punkte</span>
</div>
<div className={`card ${styles.statTile}`}>
<span className={styles.statValue}>{formatStreak(stats.streak)}</span>
<span className={styles.statLabel}>Streak</span>
</div>
</div>
{/* Nudges */}
{(streakBroken || nudges.length > 0) && (
<div className={styles.nudges}>
{streakBroken && (
<div
className={`card ${styles.nudge} ${styles.nudgeStreak}`}
onClick={() => {
localStorage.removeItem(STREAK_KEY);
navigate('/spiele');
}}
>
<span className={styles.nudgeIcon}>💔</span>
<span className={styles.nudgeText}>
Deine {lastStreak}er-Serie ist gerissen! Starte eine neue.
</span>
</div>
)}
{nudges.map((nudge, i) => (
<div
key={i}
className={`card ${styles.nudge}`}
onClick={() => {
if (nudge.type === 'untipped') navigate('/spiele');
else if (nudge.type === 'leader') navigate('/rangliste');
}}
>
<span className={styles.nudgeIcon}>
{nudge.type === 'untipped' ? '📅' : nudge.type === 'leader' ? '🏆' : '🎯'}
</span>
<span className={styles.nudgeText}>{nudge.text}</span>
</div>
))}
</div>
)}
</div>{/* /sidebar */}
</div>{/* /dashboardGrid */}
{tipMatch && (
<TipModal
match={tipMatch}
onClose={() => setTipMatch(null)}
onSaved={(_matchId, tipHome, tipAway) => {
setTipMatch(null);
if (data && data.hero) {
setData({
...data,
hero: { ...data.hero, userTip: { home: tipHome, away: tipAway } },
});
}
}}
/>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More