diff --git a/.gitignore b/.gitignore
index c83e883..81b73ff 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,9 @@ backend/public/
# Docs / local tools
DEPLOY_DOCKER_TEST.md
tools.yaml
+
+# Compiled JS in TypeScript source
+frontend/src/**/*.js
+
+# Superpowers brainstorm artifacts
+.superpowers/
diff --git a/.superpowers/brainstorm/4751-1775923699/content/01-dashboard-concept.html b/.superpowers/brainstorm/4751-1775923699/content/01-dashboard-concept.html
deleted file mode 100644
index 809ff8d..0000000
--- a/.superpowers/brainstorm/4751-1775923699/content/01-dashboard-concept.html
+++ /dev/null
@@ -1,87 +0,0 @@
-
Neue Startseite: Dashboard statt Spielliste
-Aktuell sieht man 104 Spiele als endlose Liste. Die neue Startseite soll sofort zeigen: Was ist relevant für MICH, JETZT?
-
-
-
-
-
-
-
-
Nächstes Spiel · in 2h 14min
-
-
- Dein Tipp: 2:1 ✓
-
-
-
-
-
📅 Heute noch 2 Spiele ohne Tipp
-
🏆 Max führt mit 15 Punkten
-
-
-
-
-
A: Hero + Stats + Nudges
-
Großes "Nächstes Spiel" oben, darunter persönliche Stats (Rang, Punkte, Streak), unten Handlungsaufforderungen und Social-Info. Kompakt, alles Wichtige auf einen Blick.
-
-
-
-
-
-
-
-
Hallo Ronny!
Platz 5 · 12 Punkte
-
3🔥
-
-
⏰ Jetzt tippen
-
-
- 🇲🇽 Mexico vs S. Africa 🇿🇦
- 2h 14m
-
-
-
-
- 🇰🇷 S. Korea vs Croatia 🇭🇷
- 5h 14m
-
-
-
📊 Ergebnisse
-
-
- 🇧🇷 Brazil 3:1 Serbia 🇷🇸
- +3 🎯
-
-
-
-
-
-
B: Feed-Style Timeline
-
Persönliche Begrüßung oben, dann chronologisch: erst offene Tipps (dringendste zuerst mit Countdown), dann letzte Ergebnisse mit Punkte-Badges. Wie ein persönlicher WM-Feed.
-
-
-
-
-
-
-
Beide Varianten ersetzen die 104-Spiele-Liste als Startseite
-
Der komplette Spielplan bleibt erreichbar über "Alle Spiele" in der Navigation. Die Startseite wird zum persönlichen WM-Cockpit.
-
-
\ No newline at end of file
diff --git a/.superpowers/brainstorm/4751-1775923699/content/02-emotional-moments.html b/.superpowers/brainstorm/4751-1775923699/content/02-emotional-moments.html
deleted file mode 100644
index 06c3efa..0000000
--- a/.superpowers/brainstorm/4751-1775923699/content/02-emotional-moments.html
+++ /dev/null
@@ -1,50 +0,0 @@
-Emotionale Momente: Wo die App "lebendig" wird
-5 Schlüsselmomente, in denen die App ein Lächeln erzeugen soll. Welche sind dir am wichtigsten?
-
-
-
-
-
1
-
-
Tipp-Bestätigung
-
Nach "Tipp bestätigen" kurze Erfolgsanimation: Card pulsiert grün, Häkchen fliegt rein, subtiles Haptic-Feedback (Vibration auf Mobile). Statt nur Modal schließen → "Dein Tipp ist drin! 🎯" mit sanfter Animation.
-
-
-
-
-
2
-
-
Live-Countdown vor Anpfiff
-
Wenn ein Spiel in <1 Stunde startet: pulsierender roter Countdown auf der Match-Card. "Noch 12 Minuten zum Tippen!" — erzeugt Dringlichkeit und FOMO. Nach Anpfiff: "Tippfenster geschlossen" mit Schloss-Icon.
-
-
-
-
-
3
-
-
Punkte-Reveal nach Spielende
-
Der stärkste Moment: Wenn ein Spiel auf FINISHED geht, Punkte nicht einfach anzeigen — enthüllen. Kurzsequenz: Ergebnis einblenden → "Dein Tipp war..." → Punkte-Zähler animiert hochzählen. Bei exaktem Treffer: 🎉 Konfetti-Explosion + goldener "EXAKT!"-Badge. Bei Tendenz: 👏 grüner Puls. Bei falsch: 😅 kurzes Kopfschütteln-Emoji.
-
-
-
-
-
4
-
-
Ranglistenveränderung
-
Wenn sich dein Rang ändert: "Du bist auf Platz 3 aufgestiegen! ⬆️" als Toast-Notification beim nächsten App-Öffnen. Oder: "Achtung, Anna ist nur noch 1 Punkt hinter dir!" — erzeugt freundschaftliche Rivalität.
-
-
-
-
-
5
-
-
Streak-Tracker
-
Visueller Feuer-Counter: 🔥3 wenn du 3 Spiele in Folge getippt hast (nicht korrekt, nur abgegeben). Motiviert zum Dranbleiben. Bei 10er-Streak: besonderes Icon. Wenn Streak bricht: "Deine 7er-Serie ist gerissen! Starte eine neue."
-
-
-
-
-
-
-
Klicke auf alle Momente, die du umsetzen möchtest (Mehrfachauswahl möglich). Oder sag mir im Terminal, wenn du alle willst oder Änderungswünsche hast.
-
\ No newline at end of file
diff --git a/.superpowers/brainstorm/4751-1775923699/content/03-spielplan-navigation.html b/.superpowers/brainstorm/4751-1775923699/content/03-spielplan-navigation.html
deleted file mode 100644
index b0164a8..0000000
--- a/.superpowers/brainstorm/4751-1775923699/content/03-spielplan-navigation.html
+++ /dev/null
@@ -1,61 +0,0 @@
-Spielplan & Navigation: 104 Spiele im Griff
-Aktuell: endlose Liste mit Stage-Filter oben. Wie machen wir das besser für Mobile?
-
-
-
Spielplan-Struktur
-
-
-
-
-
-
A
-
-
Smart Sections (Empfehlung)
-
Spielplan automatisch in Abschnitte unterteilt:
-
"Heute" (expandiert, farbig hervorgehoben)
-
"Morgen" (expandiert)
-
"Diese Woche" (kollapsiert, Anzahl angezeigt)
-
"Vergangene Spiele" (kollapsiert, zeigt letzte Ergebnisse + deine Punkte)
-
Kein manuelles Filtern nötig — die App weiß, was jetzt relevant ist. Stage-Filter (Gruppenphase, Achtelfinale...) bleiben als optionaler Zweitmodus.
-
-
-
-
-
B
-
-
Tab-basiert
-
Drei Haupt-Tabs am oberen Rand:
-
"Offen" — Spiele, die noch getippt werden können (nach Kick-off sortiert)
-
"Live" — Laufende Spiele mit Echtzeit-Status
-
"Ergebnisse" — Abgeschlossene Spiele mit Punkten
-
Klar getrennt, aber weniger zeitlicher Kontext ("heute/morgen").
-
-
-
-
-
-
-
Mobile Navigation
-
-
-
-
-
-
C
-
-
Bottom Navigation Bar (Empfehlung)
-
Feste Leiste am unteren Bildschirmrand mit Icons + Label:
-
🏠 Home (Dashboard) · ⚽ Spiele · 🏆 Rangliste · 👤 Profil
-
Standard-Pattern für Mobile-Apps (Staffbase, Instagram, etc.). Header wird schlank — nur Logo + evtl. Notification-Badge. Admin nur für Editoren sichtbar als Extra-Icon.
-
-
-
-
-
D
-
-
Header-Nav beibehalten
-
Bestehende Navigation oben lassen, aber scrollbar machen auf Mobile (horizontal swipe). Weniger Aufwand, aber der Header nimmt auf kleinen Screens viel Platz ein und ist weniger daumenfreundlich.
-
-
-
-
\ No newline at end of file
diff --git a/.superpowers/brainstorm/4751-1775923699/content/04-profil-matchcards.html b/.superpowers/brainstorm/4751-1775923699/content/04-profil-matchcards.html
deleted file mode 100644
index 7798ec3..0000000
--- a/.superpowers/brainstorm/4751-1775923699/content/04-profil-matchcards.html
+++ /dev/null
@@ -1,64 +0,0 @@
-Profil-Seite & Match-Cards aufwerten
-Zwei Bereiche, die aktuell etwas leblos wirken
-
-
-
Profil-Seite: Von Nullen zu Persönlichkeit
-
Aktuell: Name, Rang, 4 Stat-Boxen mit Nullen. Kein Grund hier wiederzukommen.
-
-
-
-
-
-
A
-
-
Reiches Profil (Empfehlung)
-
- Header: Avatar (Initialen-Circle wie jetzt), Name, Rang-Badge, Lieblingsteam mit Flagge
-
Stats-Ring: Kreisdiagramm mit Exakt/Tendenz/Falsch-Verteilung statt 4 separate Boxen
-
Tipp-Historie: Scrollbare Liste der letzten Tipps mit Ergebnis + Punkte — "Deine letzten 10 Tipps"
-
Achievements: Badge-Leiste (grau wenn noch nicht erreicht, farbig wenn freigeschaltet) — Vorgriff auf Phase 2
-
Fun-Stat: "Dein Lieblings-Tipp: 1:0 (5x getippt)" oder "Du tippst 70% Heimsiege"
-
-
-
-
-
-
B
-
-
Minimal-Upgrade
-
Bestehende Struktur beibehalten, aber: Stat-Boxen mit Animationen beim Laden, Tipp-Historie als einfache Liste darunter, Lieblingsteam-Auswahl prominenter platzieren. Weniger Aufwand, aber auch weniger Wow.
-
-
-
-
-
-
-
Match-Cards: Mehr Status, mehr Emotion
-
Aktuell: Team-Flaggen + Uhrzeit + "Tipp abgeben"-Button. Funktional, aber alle Karten sehen gleich aus.
-
-
-
-
-
-
C
-
-
Zustandsbasierte Cards (Empfehlung)
-
Jede Card sieht anders aus je nach Zustand:
-
⏳ Offen: "Tipp abgeben"-Button, Countdown wenn <24h
-
✅ Getippt: Dein Tipp prominent angezeigt, grüner Rand, "Ändern"-Link
-
🔴 Live: Pulsierender roter Punkt, aktueller Spielstand (wenn verfügbar)
-
🏁 Beendet: Ergebnis + dein Tipp + Punkte-Badge (Gold/Grün/Grau), bei exaktem Treffer: goldener Schimmer
-
🔒 Verpasst: Ausgegraut, "Nicht getippt" — leichter Shame-Effekt als Motivation
-
-
-
-
-
-
D
-
-
Einfache Verbesserung
-
Bestehende Cards beibehalten, nur Farb-Akzente für Status hinzufügen (grüner Rand = getippt, grau = verpasst). Kein grundlegender Redesign. Weniger Aufwand, aber auch weniger visuell differenziert.
-
-
-
-
\ No newline at end of file
diff --git a/.superpowers/brainstorm/4751-1775923699/content/05-cleanup-scope.html b/.superpowers/brainstorm/4751-1775923699/content/05-cleanup-scope.html
deleted file mode 100644
index 0209200..0000000
--- a/.superpowers/brainstorm/4751-1775923699/content/05-cleanup-scope.html
+++ /dev/null
@@ -1,67 +0,0 @@
-Aufräumen: Was fällt weg, was ändert sich?
-Damit die App schlanker und fokussierter wird
-
-
-
-
-
1
-
-
KI-Agent / Expertenblick entfernen
-
Der Chat-Widget (Fußball-Icon unten rechts) und "Expertenblick" im Tipp-Modal. Wurde als Nice-to-have eingestuft. Spart Platz auf Mobile und reduziert Ablenkung vom Kernflow. Der Platz unten rechts wird frei für den Dev-Button (nur Dev-Mode).
-
-
-
-
-
2
-
-
Stage-Filter vereinfachen
-
Aktuell: 8 Filter-Buttons (Alle, Gruppenphase, Runde der 32, Achtelfinale...). Wird durch Smart Sections ersetzt. Stage-Filter nur noch als Dropdown im Spielplan-View, nicht mehr als Button-Leiste. Weniger visueller Lärm.
-
-
-
-
-
3
-
-
Admin aus der Hauptnav entfernen
-
Aktuell sieht jeder "Admin" in der Navigation (auch Viewer). Besser: Admin nur für Editoren sichtbar, und zwar als kleines Zahnrad-Icon — nicht als vollwertiger Nav-Punkt. Hält die Navigation sauber für normale User.
-
-
-
-
-
4
-
-
Tipp-Modal verschlanken
-
Aktuell enthält das Modal: Gruppeninfo, Kick-off-Datum, Picker, Tendenz-Anzeige, Expertenblick-Accordion, Bestätigen, Abbrechen. Vereinfachen auf: Teams + Flaggen, Picker, Tendenz, Bestätigen. Kompakter, schnellerer Flow.
-
-
-
-
-
-
-
Zusammenfassung Phase 1
-
-
-
Neu
-
- - Dashboard als Startseite (Hero + Stats + Nudges)
- - Bottom Navigation Bar
- - Smart Sections im Spielplan
- - 5 emotionale Momente (Animationen, Konfetti, Streaks)
- - Zustandsbasierte Match-Cards
- - Reiches Profil mit Stats-Ring + Historie
-
-
-
-
Phase 2 & 3 (später)
-
- - Badges & Achievements
- - Wochenwertung
- - Tipps anderer sehen
- - Reaktionen / Emojis
- - Abteilungs-Challenge
-
-
-
-
-
-Wähle aus, was wegfallen soll. Dann schreibe ich das komplette Design-Dokument.
\ No newline at end of file
diff --git a/.superpowers/brainstorm/4751-1775923699/content/waiting.html b/.superpowers/brainstorm/4751-1775923699/content/waiting.html
deleted file mode 100644
index d990a0e..0000000
--- a/.superpowers/brainstorm/4751-1775923699/content/waiting.html
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
Design-Dokument wird geschrieben... weiter im Terminal.
-
\ No newline at end of file
diff --git a/.superpowers/brainstorm/4751-1775923699/state/server-stopped b/.superpowers/brainstorm/4751-1775923699/state/server-stopped
deleted file mode 100644
index 795ce5c..0000000
--- a/.superpowers/brainstorm/4751-1775923699/state/server-stopped
+++ /dev/null
@@ -1 +0,0 @@
-{"reason":"idle timeout","timestamp":1775926519574}
diff --git a/.superpowers/brainstorm/4751-1775923699/state/server.log b/.superpowers/brainstorm/4751-1775923699/state/server.log
deleted file mode 100644
index 108608f..0000000
--- a/.superpowers/brainstorm/4751-1775923699/state/server.log
+++ /dev/null
@@ -1,38 +0,0 @@
-{"type":"server-started","port":52250,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:52250","screen_dir":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content","state_dir":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/state"}
-{"type":"screen-added","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/01-dashboard-concept.html"}
-{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/01-dashboard-concept.html"}
-{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/01-dashboard-concept.html"}
-{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/01-dashboard-concept.html"}
-{"source":"user-event","type":"click","text":"Nächstes Spiel · in 2h 14min\n \n 🇲🇽Mexico\n vs\n 🇿🇦S. Africa\n \n \n Dein Tipp: 2:1 ✓\n \n \n \n \n 5.\n Dein Rang\n \n \n 12\n Punkte\n \n \n 3🔥\n Streak\n \n \n \n 📅 Heute noch 2 Spiele ohne Tipp\n 🏆 Max führt mit 15 Punkten\n \n \n \n \n A: Hero + Stats + Nudges\n Großes \"Nächstes Spiel\" oben, darunter persönliche Stats (Rang, Punkte, Streak), unten Handlungsaufforderungen und Social-Info. Kompakt, alles Wichtige auf einen Blick.","choice":"a","id":null,"timestamp":1775924301735}
-{"type":"screen-added","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/02-emotional-moments.html"}
-{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/02-emotional-moments.html"}
-{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/02-emotional-moments.html"}
-{"source":"user-event","type":"click","text":"1\n \n Tipp-Bestätigung\n Nach \"Tipp bestätigen\" kurze Erfolgsanimation: Card pulsiert grün, Häkchen fliegt rein, subtiles Haptic-Feedback (Vibration auf Mobile). Statt nur Modal schließen → \"Dein Tipp ist drin! 🎯\" mit sanfter Animation.","choice":"tipp-confirm","id":null,"timestamp":1775924389765}
-{"source":"user-event","type":"click","text":"2\n \n Live-Countdown vor Anpfiff\n Wenn ein Spiel in <1 Stunde startet: pulsierender roter Countdown auf der Match-Card. \"Noch 12 Minuten zum Tippen!\" — erzeugt Dringlichkeit und FOMO. Nach Anpfiff: \"Tippfenster geschlossen\" mit Schloss-Icon.","choice":"countdown","id":null,"timestamp":1775924391183}
-{"source":"user-event","type":"click","text":"3\n \n Punkte-Reveal nach Spielende\n Der stärkste Moment: Wenn ein Spiel auf FINISHED geht, Punkte nicht einfach anzeigen — enthüllen. Kurzsequenz: Ergebnis einblenden → \"Dein Tipp war...\" → Punkte-Zähler animiert hochzählen. Bei exaktem Treffer: 🎉 Konfetti-Explosion + goldener \"EXAKT!\"-Badge. Bei Tendenz: 👏 grüner Puls. Bei falsch: 😅 kurzes Kopfschütteln-Emoji.","choice":"punkte-reveal","id":null,"timestamp":1775924392631}
-{"source":"user-event","type":"click","text":"4\n \n Ranglistenveränderung\n Wenn sich dein Rang ändert: \"Du bist auf Platz 3 aufgestiegen! ⬆️\" als Toast-Notification beim nächsten App-Öffnen. Oder: \"Achtung, Anna ist nur noch 1 Punkt hinter dir!\" — erzeugt freundschaftliche Rivalität.","choice":"rang-change","id":null,"timestamp":1775924394132}
-{"source":"user-event","type":"click","text":"5\n \n Streak-Tracker\n Visueller Feuer-Counter: 🔥3 wenn du 3 Spiele in Folge getippt hast (nicht korrekt, nur abgegeben). Motiviert zum Dranbleiben. Bei 10er-Streak: besonderes Icon. Wenn Streak bricht: \"Deine 7er-Serie ist gerissen! Starte eine neue.\"","choice":"streak","id":null,"timestamp":1775924395415}
-{"type":"screen-added","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/03-spielplan-navigation.html"}
-{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/03-spielplan-navigation.html"}
-{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/03-spielplan-navigation.html"}
-{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/03-spielplan-navigation.html"}
-{"source":"user-event","type":"click","text":"A\n \n Smart Sections (Empfehlung)\n Spielplan automatisch in Abschnitte unterteilt:\n \"Heute\" (expandiert, farbig hervorgehoben)\n \"Morgen\" (expandiert)\n \"Diese Woche\" (kollapsiert, Anzahl angezeigt)\n \"Vergangene Spiele\" (kollapsiert, zeigt letzte Ergebnisse + deine Punkte)\n Kein manuelles Filtern nötig — die App weiß, was jetzt relevant ist. Stage-Filter (Gruppenphase, Achtelfinale...) bleiben als optionaler Zweitmodus.","choice":"smart-sections","id":null,"timestamp":1775924457903}
-{"source":"user-event","type":"click","text":"C\n \n Bottom Navigation Bar (Empfehlung)\n Feste Leiste am unteren Bildschirmrand mit Icons + Label:\n 🏠 Home (Dashboard) · ⚽ Spiele · 🏆 Rangliste · 👤 Profil\n Standard-Pattern für Mobile-Apps (Staffbase, Instagram, etc.). Header wird schlank — nur Logo + evtl. Notification-Badge. Admin nur für Editoren sichtbar als Extra-Icon.","choice":"bottom-nav","id":null,"timestamp":1775924462017}
-{"type":"screen-added","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/04-profil-matchcards.html"}
-{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/04-profil-matchcards.html"}
-{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/04-profil-matchcards.html"}
-{"source":"user-event","type":"click","text":"A\n \n Reiches Profil (Empfehlung)\n \n Header: Avatar (Initialen-Circle wie jetzt), Name, Rang-Badge, Lieblingsteam mit Flagge\n Stats-Ring: Kreisdiagramm mit Exakt/Tendenz/Falsch-Verteilung statt 4 separate Boxen\n Tipp-Historie: Scrollbare Liste der letzten Tipps mit Ergebnis + Punkte — \"Deine letzten 10 Tipps\"\n Achievements: Badge-Leiste (grau wenn noch nicht erreicht, farbig wenn freigeschaltet) — Vorgriff auf Phase 2\n Fun-Stat: \"Dein Lieblings-Tipp: 1:0 (5x getippt)\" oder \"Du tippst 70% Heimsiege\"","choice":"profil-rich","id":null,"timestamp":1775924529062}
-{"source":"user-event","type":"click","text":"C\n \n Zustandsbasierte Cards (Empfehlung)\n Jede Card sieht anders aus je nach Zustand:\n ⏳ Offen: \"Tipp abgeben\"-Button, Countdown wenn <24h\n ✅ Getippt: Dein Tipp prominent angezeigt, grüner Rand, \"Ändern\"-Link\n 🔴 Live: Pulsierender roter Punkt, aktueller Spielstand (wenn verfügbar)\n 🏁 Beendet: Ergebnis + dein Tipp + Punkte-Badge (Gold/Grün/Grau), bei exaktem Treffer: goldener Schimmer\n 🔒 Verpasst: Ausgegraut, \"Nicht getippt\" — leichter Shame-Effekt als Motivation","choice":"cards-states","id":null,"timestamp":1775924530413}
-{"type":"screen-added","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/05-cleanup-scope.html"}
-{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/05-cleanup-scope.html"}
-{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/05-cleanup-scope.html"}
-{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/05-cleanup-scope.html"}
-{"source":"user-event","type":"click","text":"4\n \n Tipp-Modal verschlanken\n Aktuell enthält das Modal: Gruppeninfo, Kick-off-Datum, Picker, Tendenz-Anzeige, Expertenblick-Accordion, Bestätigen, Abbrechen. Vereinfachen auf: Teams + Flaggen, Picker, Tendenz, Bestätigen. Kompakter, schnellerer Flow.","choice":"simplify-tip-modal","id":null,"timestamp":1775924611669}
-{"source":"user-event","type":"click","text":"3\n \n Admin aus der Hauptnav entfernen\n Aktuell sieht jeder \"Admin\" in der Navigation (auch Viewer). Besser: Admin nur für Editoren sichtbar, und zwar als kleines Zahnrad-Icon — nicht als vollwertiger Nav-Punkt. Hält die Navigation sauber für normale User.","choice":"admin-hidden","id":null,"timestamp":1775924612378}
-{"source":"user-event","type":"click","text":"2\n \n Stage-Filter vereinfachen\n Aktuell: 8 Filter-Buttons (Alle, Gruppenphase, Runde der 32, Achtelfinale...). Wird durch Smart Sections ersetzt. Stage-Filter nur noch als Dropdown im Spielplan-View, nicht mehr als Button-Leiste. Weniger visueller Lärm.","choice":"remove-stage-filter","id":null,"timestamp":1775924613128}
-{"source":"user-event","type":"click","text":"1\n \n KI-Agent / Expertenblick entfernen\n Der Chat-Widget (Fußball-Icon unten rechts) und \"Expertenblick\" im Tipp-Modal. Wurde als Nice-to-have eingestuft. Spart Platz auf Mobile und reduziert Ablenkung vom Kernflow. Der Platz unten rechts wird frei für den Dev-Button (nur Dev-Mode).","choice":"remove-agent","id":null,"timestamp":1775924613861}
-{"type":"screen-added","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/waiting.html"}
-{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/waiting.html"}
-{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/waiting.html"}
-{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/waiting.html"}
-{"type":"server-stopped","reason":"idle timeout"}
diff --git a/.superpowers/brainstorm/4751-1775923699/state/server.pid b/.superpowers/brainstorm/4751-1775923699/state/server.pid
deleted file mode 100644
index f9aa984..0000000
--- a/.superpowers/brainstorm/4751-1775923699/state/server.pid
+++ /dev/null
@@ -1 +0,0 @@
-4760
diff --git a/frontend/src/App.js b/frontend/src/App.js
deleted file mode 100644
index d9a5ead..0000000
--- a/frontend/src/App.js
+++ /dev/null
@@ -1,72 +0,0 @@
-import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
-import { useState, useEffect } from 'react';
-import { Routes, Route, NavLink } from 'react-router-dom';
-import { Sun, Moon, Settings } 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 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 || import.meta.env.VITE_TEST_MODE === 'true';
-// Lazy-load DevPanel in Development/Test-Mode
-let DevPanel = null;
-// VITE_TEST_MODE wird erst zur Laufzeit geprüft, daher Import immer einbinden
-import('./components/DevPanel').then(m => { DevPanel = m.default; }).catch(() => { });
-function getInitialTheme() {
- try {
- const stored = localStorage.getItem('theme');
- if (stored === 'light' || stored === 'dark')
- return stored;
- }
- catch { }
- return 'dark';
-}
-export default function App() {
- const [theme, setTheme] = useState(getInitialTheme);
- const { message: rankMsg, dismiss: dismissRank } = useRankChange();
- const [devUser, setDevUser] = useState(1);
- const [devMatches, setDevMatches] = useState([]);
- const [refreshKey, setRefreshKey] = useState(0);
- // Theme auf setzen und in localStorage speichern
- useEffect(() => {
- document.documentElement.setAttribute('data-theme', theme);
- try {
- localStorage.setItem('theme', theme);
- }
- catch { }
- }, [theme]);
- function toggleTheme() {
- setTheme(t => t === 'dark' ? 'light' : 'dark');
- }
- // DevUser als Query-Parameter im API-Fetch setzen
- useEffect(() => {
- if (!IS_DEV)
- return;
- // Patch fetch für Dev-Mode: devUser Query-Param anhängen
- const origFetch = window.fetch;
- window._devUser = devUser;
- window.fetch = (input, init) => {
- if (typeof input === 'string' && input.startsWith('/api')) {
- const url = new URL(input, window.location.origin);
- url.searchParams.set('devUser', String(window._devUser ?? 1));
- return origFetch(url.toString(), init);
- }
- return origFetch(input, init);
- };
- return () => { window.fetch = origFetch; };
- }, [devUser]);
- // Matches für DevPanel laden
- useEffect(() => {
- if (!IS_DEV)
- return;
- fetch('/api/matches').then(r => r.json()).then(d => setDevMatches(d.matches ?? [])).catch(() => { });
- }, [refreshKey, devUser]);
- function handleDevRefresh() {
- setRefreshKey(k => k + 1);
- }
- return (_jsxs("div", { className: styles.app, children: [_jsx("header", { className: styles.header, children: _jsxs("div", { className: styles.headerInner, children: [_jsxs("div", { className: styles.logo, children: [_jsx("span", { className: styles.logoFlag, children: "\uD83C\uDFC6" }), _jsx("span", { className: styles.logoText, children: "WM 2026 Tippspiel" }), IS_DEV && (_jsxs("span", { className: styles.devBadge, children: ["DEV \u00B7 User ", devUser] }))] }), _jsxs("nav", { className: styles.nav, children: [_jsx(NavLink, { to: "/spiele", className: ({ isActive }) => isActive ? styles.navLinkActive : styles.navLink, children: "Spielplan" }), _jsx(NavLink, { to: "/rangliste", className: ({ isActive }) => isActive ? styles.navLinkActive : styles.navLink, children: "Rangliste" }), _jsx(NavLink, { to: "/profil", className: ({ isActive }) => isActive ? styles.navLinkActive : styles.navLink, children: "Mein Profil" }), _jsx(NavLink, { to: "/admin", className: styles.adminLink, title: "Admin", children: _jsx(Settings, { size: 16 }) }), _jsx("button", { className: styles.themeToggle, onClick: toggleTheme, title: theme === 'dark' ? 'Light Mode aktivieren' : 'Dark Mode aktivieren', "aria-label": "Theme wechseln", children: theme === 'dark' ? _jsx(Sun, { size: 16 }) : _jsx(Moon, { size: 16 }) })] })] }) }), _jsx("main", { className: styles.main, children: _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(DashboardPage, {}, refreshKey) }), _jsx(Route, { path: "/spiele", element: _jsx(MatchesPage, {}, refreshKey) }), _jsx(Route, { path: "/rangliste", element: _jsx(LeaderboardPage, {}, refreshKey) }), _jsx(Route, { path: "/profil", element: _jsx(ProfilePage, {}, refreshKey) }), _jsx(Route, { path: "/admin", element: _jsx(AdminPage, {}) })] }) }), rankMsg && _jsx(Toast, { message: rankMsg, onDismiss: dismissRank }), _jsx(BottomNav, {}), IS_DEV && DevPanel && (_jsx(DevPanel, { currentUser: devUser, onUserChange: (u) => { setDevUser(u); setRefreshKey(k => k + 1); }, matches: devMatches, onRefresh: handleDevRefresh }))] }));
-}
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
deleted file mode 100644
index c375e65..0000000
--- a/frontend/src/api/client.js
+++ /dev/null
@@ -1,48 +0,0 @@
-const BASE = '/api';
-function withDevUser(path) {
- 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(path, options) {
- const res = await fetch(`${BASE}${withDevUser(path)}`, {
- ...options,
- headers: {
- 'Content-Type': 'application/json',
- ...options?.headers,
- },
- });
- if (!res.ok) {
- const err = await res.json().catch(() => ({ error: res.statusText }));
- throw new Error(err.message || err.error || 'Request failed');
- }
- return res.json();
-}
-export const api = {
- // Matches
- getMatches: (params) => {
- const q = new URLSearchParams(params).toString();
- return request(`/matches${q ? '?' + q : ''}`);
- },
- // Tips
- submitTip: (matchId, tipHome, tipAway) => request('/tips', {
- method: 'POST',
- body: JSON.stringify({ matchId, tipHome, tipAway }),
- }),
- getMyTips: () => request('/tips'),
- // Leaderboard
- getLeaderboard: () => request('/leaderboard'),
- getMyStats: () => request('/leaderboard/me'),
- // Profile
- updateTeam: (team) => request('/profile/team', {
- method: 'PATCH',
- body: JSON.stringify({ team }),
- }),
- // Dashboard
- getDashboard: () => request('/dashboard'),
- // Admin
- syncMatches: () => request('/admin/sync', { method: 'POST' }),
- evaluateTips: () => request('/admin/evaluate', { method: 'POST' }),
-};
diff --git a/frontend/src/components/BottomNav.js b/frontend/src/components/BottomNav.js
deleted file mode 100644
index 1606780..0000000
--- a/frontend/src/components/BottomNav.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
-import { NavLink } from 'react-router-dom';
-import { Home, Trophy, User } from 'lucide-react';
-import styles from './BottomNav.module.css';
-export default function BottomNav() {
- const linkClass = ({ isActive }) => isActive ? styles.tabActive : styles.tab;
- return (_jsxs("nav", { className: styles.bottomNav, children: [_jsxs(NavLink, { to: "/", end: true, className: linkClass, children: [_jsx(Home, { size: 20 }), _jsx("span", { children: "Home" })] }), _jsxs(NavLink, { to: "/spiele", className: linkClass, children: [_jsx("span", { className: styles.emojiIcon, children: "\u26BD" }), _jsx("span", { children: "Spiele" })] }), _jsxs(NavLink, { to: "/rangliste", className: linkClass, children: [_jsx(Trophy, { size: 20 }), _jsx("span", { children: "Rangliste" })] }), _jsxs(NavLink, { to: "/profil", className: linkClass, children: [_jsx(User, { size: 20 }), _jsx("span", { children: "Profil" })] })] }));
-}
diff --git a/frontend/src/components/ConfettiReveal.js b/frontend/src/components/ConfettiReveal.js
deleted file mode 100644
index ea76484..0000000
--- a/frontend/src/components/ConfettiReveal.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
-import { useEffect, useRef } from 'react';
-import confetti from 'canvas-confetti';
-import styles from './ConfettiReveal.module.css';
-export default function ConfettiReveal({ match, onDismiss }) {
- 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 (_jsx("div", { className: styles.overlay, onClick: onDismiss, children: _jsxs("div", { className: `card ${styles.card}`, onClick: e => e.stopPropagation(), children: [_jsxs("div", { className: styles.result, children: [match.homeTeam.shortName, " ", match.score.home, ":", match.score.away, " ", match.awayTeam.shortName] }), _jsxs("div", { className: styles.tipLine, children: ["Dein Tipp: ", tip.home, ":", tip.away] }), _jsxs("div", { className: `${styles.badge} ${badgeClass}`, children: [points, " ", points === 1 ? 'Punkt' : 'Punkte'] }), _jsx("div", { className: styles.label, children: resultLabel }), _jsx("button", { className: styles.dismissBtn, onClick: onDismiss, children: "Weiter" })] }) }));
-}
diff --git a/frontend/src/components/DevPanel.js b/frontend/src/components/DevPanel.js
deleted file mode 100644
index 4afc5d6..0000000
--- a/frontend/src/components/DevPanel.js
+++ /dev/null
@@ -1,120 +0,0 @@
-import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
-import { useState } from 'react';
-import styles from './DevPanel.module.css';
-const DEV_USERS = [
- { id: 1, name: 'Ronny M.', role: 'Editor' },
- { id: 2, name: 'Max M.', role: 'Viewer' },
- { id: 3, name: 'Anna S.', role: 'Viewer' },
-];
-const TIME_PRESETS = [
- { label: 'In 2 Std.', minutes: 120 },
- { label: 'In 10 Min.', minutes: 10 },
- { label: 'Jetzt +1 Min.', minutes: 1 },
- { label: 'Läuft (−30)', minutes: -30 },
- { label: 'Beendet (−120)', minutes: -120 },
-];
-const STATUS_PRESETS = [
- { label: 'TIMED', status: 'TIMED', scoreHome: null, scoreAway: null },
- { label: 'LIVE', status: 'IN_PLAY', scoreHome: 0, scoreAway: 0 },
- { label: 'Pause', status: 'PAUSED', scoreHome: 1, scoreAway: 0 },
- { label: '2:1 Fertig', status: 'FINISHED', scoreHome: 2, scoreAway: 1 },
- { label: '0:0 Fertig', status: 'FINISHED', scoreHome: 0, scoreAway: 0 },
-];
-export default function DevPanel({ currentUser, onUserChange, matches, onRefresh }) {
- const [open, setOpen] = useState(false);
- const [selectedMatch, setSelectedMatch] = useState('');
- const [busy, setBusy] = useState(false);
- const [log, setLog] = useState([]);
- function addLog(msg) {
- setLog(prev => [`${new Date().toLocaleTimeString('de-DE')} ${msg}`, ...prev].slice(0, 8));
- }
- async function applyTime(minutes) {
- if (!selectedMatch)
- return;
- setBusy(true);
- try {
- await fetch(`/api/dev/match/${selectedMatch}/set-time`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ minutesFromNow: minutes }),
- });
- addLog(`✓ Spiel #${selectedMatch}: Zeit → ${minutes > 0 ? `+${minutes}` : minutes} Min.`);
- onRefresh();
- }
- catch (e) {
- addLog(`✗ Fehler: ${e.message}`);
- }
- finally {
- setBusy(false);
- }
- }
- async function applyStatus(status, scoreHome, scoreAway) {
- if (!selectedMatch)
- return;
- setBusy(true);
- try {
- await fetch(`/api/dev/match/${selectedMatch}/set-status`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ status, scoreHome, scoreAway }),
- });
- addLog(`✓ Spiel #${selectedMatch}: Status → ${status}`);
- onRefresh();
- }
- catch (e) {
- addLog(`✗ Fehler: ${e.message}`);
- }
- finally {
- setBusy(false);
- }
- }
- async function resetTips() {
- setBusy(true);
- try {
- await fetch('/api/dev/reset-tips', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ userId: `dev-user-00${currentUser}` }),
- });
- addLog(`✓ Tipps von User ${currentUser} gelöscht`);
- onRefresh();
- }
- catch (e) {
- addLog(`✗ Fehler: ${e.message}`);
- }
- finally {
- setBusy(false);
- }
- }
- async function resetMatch(all) {
- setBusy(true);
- try {
- const body = all ? {} : { matchId: selectedMatch };
- const res = await fetch('/api/dev/reset-match', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
- const data = await res.json();
- if (all) {
- addLog(`✓ ${data.count ?? 0} Spiele zurückgesetzt (TIMED)`);
- }
- else {
- addLog(`✓ Spiel #${selectedMatch} zurückgesetzt (TIMED)`);
- }
- onRefresh();
- }
- catch (e) {
- addLog(`✗ Fehler: ${e.message}`);
- }
- finally {
- setBusy(false);
- }
- }
- // Nur erste 20 Spiele zur Auswahl anbieten
- const selectableMatches = matches.slice(0, 20);
- return (_jsxs("div", { className: styles.wrap, children: [_jsxs("button", { className: styles.toggleBtn, onClick: () => setOpen(o => !o), children: [open ? '✕' : '🧪', " ", !open && _jsx("span", { className: styles.toggleLabel, children: "Dev" })] }), open && (_jsxs("div", { className: styles.panel, children: [_jsx("div", { className: styles.panelHeader, children: _jsx("span", { className: styles.panelTitle, children: "\uD83E\uDDEA Simulations-Modus" }) }), _jsxs("section", { className: styles.section, children: [_jsx("div", { className: styles.sectionLabel, children: "Aktiver User" }), _jsx("div", { className: styles.userButtons, children: DEV_USERS.map(u => (_jsxs("button", { className: `${styles.userBtn} ${currentUser === u.id ? styles.userBtnActive : ''}`, onClick: () => {
- onUserChange(u.id);
- addLog(`→ Wechsel zu User ${u.id}: ${u.name}`);
- }, children: [_jsx("span", { className: styles.userInitial, children: u.name.charAt(0) }), _jsx("span", { className: styles.userName, children: u.name }), _jsx("span", { className: styles.userRole, children: u.role })] }, u.id))) })] }), _jsxs("section", { className: styles.section, children: [_jsx("div", { className: styles.sectionLabel, children: "Spiel ausw\u00E4hlen" }), _jsxs("select", { className: styles.select, value: selectedMatch, onChange: e => setSelectedMatch(e.target.value ? parseInt(e.target.value) : ''), children: [_jsx("option", { value: "", children: "\u2014 Spiel w\u00E4hlen \u2014" }), selectableMatches.map(m => (_jsxs("option", { value: m.id, children: ["#", m.id, " ", m.homeTeam.shortName, " vs ", m.awayTeam.shortName] }, m.id)))] })] }), _jsxs("section", { className: `${styles.section} ${!selectedMatch ? styles.sectionDisabled : ''}`, children: [_jsx("div", { className: styles.sectionLabel, children: "Ansto\u00DFzeit setzen" }), _jsx("div", { className: styles.presetGrid, children: TIME_PRESETS.map(p => (_jsx("button", { className: styles.presetBtn, onClick: () => applyTime(p.minutes), disabled: !selectedMatch || busy, children: p.label }, p.label))) })] }), _jsxs("section", { className: `${styles.section} ${!selectedMatch ? styles.sectionDisabled : ''}`, children: [_jsx("div", { className: styles.sectionLabel, children: "Status setzen" }), _jsx("div", { className: styles.presetGrid, children: STATUS_PRESETS.map(p => (_jsx("button", { className: `${styles.presetBtn} ${p.status === 'FINISHED' ? styles.presetBtnDanger : p.status === 'IN_PLAY' ? styles.presetBtnLive : ''}`, onClick: () => applyStatus(p.status, p.scoreHome, p.scoreAway), disabled: !selectedMatch || busy, children: p.label }, p.label))) })] }), _jsxs("section", { className: styles.section, children: [_jsx("div", { className: styles.sectionLabel, children: "Zur\u00FCcksetzen" }), _jsxs("div", { className: styles.resetGrid, children: [_jsx("button", { className: styles.resetBtn, onClick: () => resetMatch(false), disabled: !selectedMatch || busy, title: "Ausgew\u00E4hltes Spiel auf TIMED zur\u00FCcksetzen", children: "\u21BA Spiel zur\u00FCcksetzen" }), _jsx("button", { className: `${styles.resetBtn} ${styles.resetBtnAll}`, onClick: () => resetMatch(true), disabled: busy, title: "Alle laufenden/beendeten Spiele zur\u00FCcksetzen", children: "\u21BA Alle Spiele" }), _jsx("button", { className: `${styles.resetBtn} ${styles.resetBtnTips}`, onClick: resetTips, disabled: busy, children: "\uD83D\uDDD1 Tipps l\u00F6schen" })] })] }), log.length > 0 && (_jsx("div", { className: styles.log, children: log.map((l, i) => _jsx("div", { className: styles.logLine, children: l }, i)) }))] }))] }));
-}
diff --git a/frontend/src/components/MatchCard.js b/frontend/src/components/MatchCard.js
deleted file mode 100644
index 29ea743..0000000
--- a/frontend/src/components/MatchCard.js
+++ /dev/null
@@ -1,85 +0,0 @@
-import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
-import { Check, TrendingUp, X } from 'lucide-react';
-import { useEffect, useState } from 'react';
-import styles from './MatchCard.module.css';
-function getCardState(match) {
- if (match.status === 'IN_PLAY' || match.status === 'PAUSED')
- return 'live';
- if (match.status === 'FINISHED') {
- return match.userTip ? 'finished' : 'missed';
- }
- // SCHEDULED or TIMED
- return match.userTip ? 'tipped' : 'open';
-}
-function useCountdown(minutesUntilKickoff) {
- const [remaining, setRemaining] = useState(minutesUntilKickoff);
- useEffect(() => {
- if (minutesUntilKickoff > 60)
- return; // only active for <1h
- setRemaining(minutesUntilKickoff);
- const interval = setInterval(() => {
- setRemaining(r => Math.max(0, r - 1 / 60));
- }, 1000);
- return () => clearInterval(interval);
- }, [minutesUntilKickoff]);
- return remaining;
-}
-const STATUS_LABELS = {
- SCHEDULED: 'Geplant',
- TIMED: 'Terminiert',
- IN_PLAY: 'Live',
- PAUSED: 'Pause',
- FINISHED: 'Beendet',
- POSTPONED: 'Verschoben',
- CANCELLED: 'Abgesagt',
-};
-function formatKickoff(utcDate) {
- return new Date(utcDate).toLocaleString('de-DE', {
- hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin',
- }) + ' Uhr';
-}
-function CountdownBadge({ minutes }) {
- if (minutes <= 0)
- return null;
- if (minutes < 60)
- return _jsxs("span", { className: styles.badgeUrgent, children: ["\u26A1 in ", minutes, " Min."] });
- const h = Math.floor(minutes / 60);
- const m = minutes % 60;
- if (h < 24)
- return _jsxs("span", { className: styles.badge, children: ["in ", h, "h", m > 0 ? ` ${m}m` : ''] });
- const d = Math.floor(h / 24);
- return _jsxs("span", { className: styles.badge, children: ["in ", d, " Tag", d > 1 ? 'en' : ''] });
-}
-function FlagBox({ crest, name }) {
- return (_jsx("div", { className: styles.flagBox, children: crest
- ? _jsx("img", { className: styles.crest, src: crest, alt: name })
- : _jsx("span", { style: { fontSize: 18 }, children: "\uD83C\uDFF3\uFE0F" }) }));
-}
-export default function MatchCard({ match, onTip }) {
- const state = getCardState(match);
- const remaining = useCountdown(match.minutesUntilKickoff);
- const remainingMins = Math.ceil(remaining);
- const isFinished = state === 'finished' || state === 'missed';
- const isLive = state === 'live';
- const hasTip = !!match.userTip;
- const points = match.userTip?.points ?? null;
- const resultClass = points === 3 ? styles.exact :
- points === 1 ? styles.tendency :
- (points === 0 && isFinished) ? styles.wrong : '';
- const glowClass = isFinished && points === 3 ? styles.glowExact :
- isFinished && points === 1 ? styles.glowTendency :
- isFinished && points === 0 ? styles.glowWrong : '';
- return (_jsxs("div", { className: `card ${styles.card} ${styles[`card_${state}`]} ${isLive ? styles.live : ''} ${glowClass}`, children: [_jsxs("div", { className: styles.topRow, children: [_jsxs("span", { className: `${styles.status} ${isLive ? styles.statusLive : ''}`, children: [isLive && _jsx("span", { className: styles.liveDot }), STATUS_LABELS[match.status] ?? match.status] }), match.group && (_jsx("span", { className: styles.group, children: match.group.replace('GROUP_', 'Gruppe ') })), (state === 'open' || state === 'tipped') && match.tippable && (match.minutesUntilKickoff < 60 ? (_jsxs("span", { className: `${styles.countdown} ${remainingMins < 5 ? styles.countdownUrgent : ''}`, children: ["Noch ", remainingMins, " Min!"] })) : (_jsx(CountdownBadge, { minutes: match.minutesUntilKickoff })))] }), _jsxs("div", { className: styles.matchRow, children: [_jsxs("div", { className: styles.teamHome, children: [_jsx("span", { className: styles.teamName, children: match.homeTeam.name }), _jsx(FlagBox, { crest: match.homeTeam.crest, name: match.homeTeam.name })] }), _jsx("div", { className: styles.scoreBox, children: isFinished || isLive ? (_jsxs("div", { className: styles.scoreStack, children: [_jsxs("span", { className: styles.score, children: [match.score.home ?? '–', "\u00A0:\u00A0", match.score.away ?? '–'] }), isLive && hasTip && (_jsxs("span", { className: styles.liveTipCompare, children: ["Tipp: ", match.userTip.home, ":", match.userTip.away] }))] })) : (_jsx("div", { className: styles.kickoffCenter, children: _jsx("span", { className: styles.kickoffCenterTime, children: formatKickoff(match.utcDate) }) })) }), _jsxs("div", { className: styles.teamAway, children: [_jsx(FlagBox, { crest: match.awayTeam.crest, name: match.awayTeam.name }), _jsx("span", { className: styles.teamName, children: match.awayTeam.name })] })] }), _jsx("div", { className: `${styles.tipRow} ${hasTip && points !== null ? `${styles.resultBanner} ${resultClass}` : ''}`, children: state === 'missed' ? (
- /* ── Missed: no tip, match finished ── */
- _jsx("span", { className: styles.missedLabel, children: "Nicht getippt" })) : state === 'live' ? (
- /* ── Live: no tip input, locked ── */
- hasTip ? (_jsxs("div", { className: styles.tipDisplay, children: [_jsx("div", { className: styles.tipLeft }), _jsxs("div", { className: styles.tipCenter, children: [_jsx("span", { className: styles.tipLabel, children: "DEIN TIPP" }), _jsxs("span", { className: styles.tipScore, children: [match.userTip.home, " : ", match.userTip.away] })] }), _jsx("div", { className: styles.tipRight })] })) : (_jsx("span", { className: styles.noTip, children: "Kein Tipp abgegeben" }))) : hasTip ? (points !== null ? (
- /* ── Auswertungs-Banner ── */
- _jsxs("div", { className: styles.tipDisplay, children: [_jsxs("div", { className: `${styles.tipLeft} ${styles.bannerLeft}`, children: [_jsx("span", { className: styles.resultIcon, children: points === 3 ? _jsx(Check, { size: 14, strokeWidth: 3 }) :
- points === 1 ? _jsx(TrendingUp, { size: 14, strokeWidth: 2.5 }) :
- _jsx(X, { size: 14, strokeWidth: 3 }) }), _jsx("span", { className: styles.resultLabel, children: points === 3 ? 'Exakt' : points === 1 ? 'Tendenz' : 'Falsch' })] }), _jsx("div", { className: styles.tipCenter, children: _jsxs("span", { className: styles.tipScoreBanner, children: [match.userTip.home, " : ", match.userTip.away] }) }), _jsx("div", { className: styles.tipRight, children: _jsx("span", { className: `${styles.pointsBadge} ${points === 3 ? styles.pointsBadge_exact :
- points === 1 ? styles.pointsBadge_tendency :
- styles.pointsBadge_wrong}`, children: points === 0 ? '0 Pkt.' : `+${points} Pkt.` }) })] })) : (
- /* ── Tipp vorhanden, noch nicht ausgewertet (tipped state) ── */
- _jsxs("div", { className: styles.tipDisplay, children: [_jsx("div", { className: styles.tipLeft, children: match.tippable && (_jsx("button", { className: styles.changeBtn, onClick: onTip, children: "\u00C4ndern" })) }), _jsxs("div", { className: styles.tipCenter, children: [!match.tippable && _jsx("span", { className: styles.tipLabel, children: "DEIN TIPP" }), _jsxs("span", { className: styles.tipDisplay_score, children: [_jsx(Check, { size: 13, strokeWidth: 3, style: { color: 'var(--success)', flexShrink: 0 } }), _jsxs("span", { className: styles.tipScore, children: [match.userTip.home, " : ", match.userTip.away] })] })] }), _jsx("div", { className: styles.tipRight })] }))) : match.tippable ? (_jsx("button", { className: `btn-primary ${styles.tipBtn}`, onClick: onTip, children: "Tipp abgeben" })) : (_jsx("span", { className: styles.noTip, children: "Kein Tipp abgegeben" })) })] }));
-}
diff --git a/frontend/src/components/StatsRing.js b/frontend/src/components/StatsRing.js
deleted file mode 100644
index 611e5fe..0000000
--- a/frontend/src/components/StatsRing.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
-import styles from './StatsRing.module.css';
-export default function StatsRing({ exact, tendency, wrong, total }) {
- 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 (_jsxs("div", { className: styles.ring, children: [_jsxs("svg", { viewBox: "0 0 140 140", className: styles.svg, children: [_jsx("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 (_jsx("circle", { cx: "70", cy: "70", r: radius, fill: "none", stroke: seg.color, strokeWidth: "12", strokeDasharray: dashArray, transform: `rotate(${rotation} 70 70)`, strokeLinecap: "round" }, i));
- }), _jsx("text", { x: "70", y: "65", textAnchor: "middle", dominantBaseline: "central", fill: "var(--text-primary)", fontSize: "28", fontWeight: "700", children: total }), _jsx("text", { x: "70", y: "85", textAnchor: "middle", fill: "var(--text-secondary)", fontSize: "11", children: hasData ? 'Punkte' : 'Keine Tipps' })] }), hasData && (_jsx("div", { className: styles.legend, children: segments.map((seg, i) => (_jsxs("span", { className: styles.legendItem, children: [_jsx("span", { className: styles.dot, style: { background: seg.color } }), seg.label, ": ", seg.count] }, i))) }))] }));
-}
diff --git a/frontend/src/components/TipModal.js b/frontend/src/components/TipModal.js
deleted file mode 100644
index 0c0ad05..0000000
--- a/frontend/src/components/TipModal.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
-import { useState } from 'react';
-import { api } from '../api/client';
-import styles from './TipModal.module.css';
-function getTendency(home, away) {
- if (home > away)
- return 'home';
- if (away > home)
- return 'away';
- return 'draw';
-}
-export default function TipModal({ match, onClose, onSaved }) {
- 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(null);
- const tendency = getTendency(home, away);
- const tendencyLabel = tendency === 'home' ? match.homeTeam.shortName || match.homeTeam.name :
- tendency === 'away' ? match.awayTeam.shortName || match.awayTeam.name :
- 'Unentschieden';
- const tendencyColor = tendency === 'home' ? 'var(--primary)' :
- tendency === 'away' ? 'var(--cyan)' :
- 'var(--gold)';
- const handleSave = async () => {
- setSaving(true);
- setError(null);
- try {
- await api.submitTip(match.id, home, away);
- setShowSuccess(true);
- if (navigator.vibrate)
- navigator.vibrate(50); // haptic feedback
- setTimeout(() => {
- setShowSuccess(false);
- onSaved(match.id, home, away);
- onClose();
- }, 1200);
- }
- catch (e) {
- setError(e.message);
- setSaving(false);
- }
- };
- return (_jsx("div", { className: styles.overlay, onClick: onClose, children: _jsxs("div", { className: styles.sheet, onClick: e => e.stopPropagation(), children: [_jsx("div", { className: styles.handle }), _jsxs("div", { className: styles.teamsRow, children: [_jsxs("div", { className: styles.teamBlock, children: [_jsx("div", { className: styles.flagLarge, children: match.homeTeam.crest
- ? _jsx("img", { src: match.homeTeam.crest, alt: match.homeTeam.name, className: styles.flagImg })
- : _jsx("span", { className: styles.flagEmoji, children: "\uD83C\uDFF3\uFE0F" }) }), _jsx("span", { className: styles.teamName, children: match.homeTeam.name })] }), _jsx("div", { className: styles.vsBlock }), _jsxs("div", { className: styles.teamBlock, children: [_jsx("div", { className: styles.flagLarge, children: match.awayTeam.crest
- ? _jsx("img", { src: match.awayTeam.crest, alt: match.awayTeam.name, className: styles.flagImg })
- : _jsx("span", { className: styles.flagEmoji, children: "\uD83C\uDFF3\uFE0F" }) }), _jsx("span", { className: styles.teamName, children: match.awayTeam.name })] })] }), _jsxs("div", { className: styles.pickerSection, children: [_jsx("p", { className: styles.pickerLabel, children: "Dein Tipp" }), _jsxs("div", { className: styles.pickerRow, children: [_jsx(ScorePicker, { value: home, onChange: setHome }), _jsx("div", { className: styles.pickerColon, children: ":" }), _jsx(ScorePicker, { value: away, onChange: setAway })] })] }), _jsxs("div", { className: styles.tendencyBar, style: { '--tendency-color': tendencyColor }, children: [_jsx("span", { className: styles.tendencyIcon, children: tendency === 'draw' ? '🤝' : tendency === 'home' ? '🏠' : '✈️' }), _jsxs("span", { className: styles.tendencyText, children: ["Tendenz: ", _jsx("strong", { children: tendencyLabel })] })] }), error && _jsx("div", { className: styles.error, children: error }), showSuccess && (_jsxs("div", { className: styles.successOverlay, children: [_jsx("div", { className: styles.successCheck, children: "\u2713" }), _jsx("div", { className: styles.successText, children: "Dein Tipp ist drin! \uD83C\uDFAF" })] })), _jsx("button", { className: `btn-primary ${styles.saveBtn}`, onClick: handleSave, disabled: saving, children: saving ? '⏳ Wird gespeichert…' : '✓ Tipp bestätigen' }), _jsx("button", { className: styles.cancelBtn, onClick: onClose, children: "Abbrechen" })] }) }));
-}
-function ScorePicker({ value, onChange }) {
- return (_jsxs("div", { className: styles.picker, children: [_jsx("button", { className: styles.pickerBtn, onClick: () => onChange(Math.min(20, value + 1)), "aria-label": "Erh\u00F6hen", children: "+" }), _jsx("span", { className: styles.pickerValue, children: value }), _jsx("button", { className: styles.pickerBtn, onClick: () => onChange(Math.max(0, value - 1)), "aria-label": "Verringern", children: "\u2212" })] }));
-}
diff --git a/frontend/src/components/Toast.js b/frontend/src/components/Toast.js
deleted file mode 100644
index 53787df..0000000
--- a/frontend/src/components/Toast.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import { jsx as _jsx } from "react/jsx-runtime";
-import { useEffect, useRef } from 'react';
-import styles from './Toast.module.css';
-export default function Toast({ message, onDismiss, duration = 5000 }) {
- const onDismissRef = useRef(onDismiss);
- onDismissRef.current = onDismiss;
- useEffect(() => {
- const timer = setTimeout(() => onDismissRef.current(), duration);
- return () => clearTimeout(timer);
- }, [duration]);
- return (_jsx("div", { className: `card ${styles.toast}`, onClick: onDismiss, children: message }));
-}
diff --git a/frontend/src/hooks/useRankChange.js b/frontend/src/hooks/useRankChange.js
deleted file mode 100644
index a7ca264..0000000
--- a/frontend/src/hooks/useRankChange.js
+++ /dev/null
@@ -1,29 +0,0 @@
-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(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 };
-}
diff --git a/frontend/src/hooks/useRevealQueue.js b/frontend/src/hooks/useRevealQueue.js
deleted file mode 100644
index b4215e4..0000000
--- a/frontend/src/hooks/useRevealQueue.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import { useState, useEffect } from 'react';
-const SEEN_KEY = 'tippspiel_seen_results';
-function getSeenIds() {
- try {
- return new Set(JSON.parse(localStorage.getItem(SEEN_KEY) || '[]'));
- }
- catch {
- return new Set();
- }
-}
-function markSeen(matchId) {
- const seen = getSeenIds();
- seen.add(matchId);
- localStorage.setItem(SEEN_KEY, JSON.stringify([...seen]));
-}
-export function useRevealQueue(matches) {
- const [queue, setQueue] = useState([]);
- 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 };
-}
diff --git a/frontend/src/main.js b/frontend/src/main.js
deleted file mode 100644
index c2170fc..0000000
--- a/frontend/src/main.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import { jsx as _jsx } from "react/jsx-runtime";
-import React from 'react';
-import ReactDOM from 'react-dom/client';
-import { BrowserRouter } from 'react-router-dom';
-import App from './App';
-import './index.css';
-ReactDOM.createRoot(document.getElementById('root')).render(_jsx(React.StrictMode, { children: _jsx(BrowserRouter, { children: _jsx(App, {}) }) }));
diff --git a/frontend/src/pages/AdminPage.js b/frontend/src/pages/AdminPage.js
deleted file mode 100644
index d70938f..0000000
--- a/frontend/src/pages/AdminPage.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
-import { useState } from 'react';
-import { api } from '../api/client';
-import styles from './AdminPage.module.css';
-export default function AdminPage() {
- const [syncResult, setSyncResult] = useState(null);
- const [evalResult, setEvalResult] = useState(null);
- const [refreshResult, setRefreshResult] = useState(null);
- const [syncing, setSyncing] = useState(false);
- const [evaluating, setEvaluating] = useState(false);
- const [refreshing, setRefreshing] = useState(false);
- const handleSync = async () => {
- setSyncing(true);
- setSyncResult(null);
- try {
- const res = await api.syncMatches();
- setSyncResult({ success: true, timestamp: new Date(), message: `${res.total} Spiele geladen — ${res.created} neu, ${res.updated} aktualisiert` });
- }
- catch (e) {
- setSyncResult({ success: false, timestamp: new Date(), message: e.message });
- }
- finally {
- setSyncing(false);
- }
- };
- const handleEvaluate = async () => {
- setEvaluating(true);
- setEvalResult(null);
- try {
- const res = await api.evaluateTips();
- setEvalResult({ success: true, timestamp: new Date(), message: `${res.matchesEvaluated} Spiele ausgewertet — ${res.tipsUpdated} Tipps bewertet` });
- }
- catch (e) {
- setEvalResult({ success: false, timestamp: new Date(), message: e.message });
- }
- finally {
- setEvaluating(false);
- }
- };
- const handleRefreshLeaderboard = async () => {
- setRefreshing(true);
- setRefreshResult(null);
- try {
- await fetch('/api/admin/refresh-leaderboard', { method: 'POST' });
- setRefreshResult({ success: true, timestamp: new Date(), message: 'Materialized View aktualisiert' });
- }
- catch (e) {
- setRefreshResult({ success: false, timestamp: new Date(), message: e.message });
- }
- finally {
- setRefreshing(false);
- }
- };
- function formatTime(d) {
- return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
- }
- return (_jsxs("div", { className: styles.page, children: [_jsxs("div", { className: styles.pageHeader, children: [_jsx("h1", { className: `font-display ${styles.title}`, children: "Administration" }), _jsx("div", { className: styles.roleBadge, children: "Editor" })] }), _jsx("p", { className: styles.hint, children: "Nur f\u00FCr Editoren sichtbar. Nach Staffbase-Freischaltung wird diese Seite durch Rollenpr\u00FCfung gesch\u00FCtzt." }), _jsxs("div", { className: styles.cards, children: [_jsx(ActionCard, { icon: "\u21BB", title: "Spiele synchronisieren", desc: "L\u00E4dt alle WM 2026-Spiele von football-data.org und aktualisiert die Datenbank.", result: syncResult, loading: syncing, loadingLabel: "Wird synchronisiert\u2026", actionLabel: "Jetzt synchronisieren", onAction: handleSync, formatTime: formatTime }), _jsx(ActionCard, { icon: "\u25C8", title: "Tipps auswerten", desc: "Berechnet Punkte f\u00FCr alle abgeschlossenen Spiele und aktualisiert die Rangliste.", result: evalResult, loading: evaluating, loadingLabel: "Wird ausgewertet\u2026", actionLabel: "Tipps auswerten", onAction: handleEvaluate, formatTime: formatTime }), _jsx(ActionCard, { icon: "\u27F3", title: "Rangliste aktualisieren", desc: "Aktualisiert die Materialized View manuell \u2014 normalerweise automatisch nach Auswertung.", result: refreshResult, loading: refreshing, loadingLabel: "Wird aktualisiert\u2026", actionLabel: "Rangliste neu berechnen", onAction: handleRefreshLeaderboard, formatTime: formatTime })] })] }));
-}
-/* ── Sub-component ── */
-function ActionCard({ icon, title, desc, result, loading, loadingLabel, actionLabel, onAction, formatTime, }) {
- return (_jsxs("div", { className: `card ${styles.actionCard}`, children: [_jsxs("div", { className: styles.cardTop, children: [_jsx("div", { className: styles.cardIcon, children: icon }), _jsxs("div", { children: [_jsx("div", { className: styles.cardTitle, children: title }), _jsx("div", { className: styles.cardDesc, children: desc })] })] }), result && (_jsxs("div", { className: `${styles.resultBar} ${result.success ? styles.resultSuccess : styles.resultError}`, children: [_jsx("span", { className: styles.resultDot }), _jsx("span", { className: styles.resultMsg, children: result.message }), _jsx("span", { className: styles.resultTime, children: formatTime(result.timestamp) })] })), _jsx("button", { className: `${styles.actionBtn} ${loading ? styles.actionBtnLoading : ''}`, onClick: onAction, disabled: loading, children: loading ? (_jsxs(_Fragment, { children: [_jsx("span", { className: styles.spinner }), loadingLabel] })) : actionLabel })] }));
-}
diff --git a/frontend/src/pages/DashboardPage.js b/frontend/src/pages/DashboardPage.js
deleted file mode 100644
index f07ace7..0000000
--- a/frontend/src/pages/DashboardPage.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
-import { useEffect, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { api } from '../api/client';
-import styles from './DashboardPage.module.css';
-function formatStreak(streak) {
- if (streak >= 20)
- return `⚡${streak}`;
- if (streak >= 10)
- return `🔥🔥${streak}`;
- if (streak >= 3)
- return `🔥${streak}`;
- if (streak > 0)
- return String(streak);
- return '0';
-}
-function formatCountdown(minutes) {
- if (minutes < 60)
- return `in ${minutes} Min`;
- if (minutes < 60 * 24)
- return `in ${Math.floor(minutes / 60)}h`;
- return `in ${Math.floor(minutes / (60 * 24))} Tagen`;
-}
-export default function DashboardPage(_props) {
- const [data, setData] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(false);
- const navigate = useNavigate();
- useEffect(() => {
- setLoading(true);
- setError(false);
- api.getDashboard()
- .then(d => { setData(d); setLoading(false); })
- .catch(() => { setError(true); setLoading(false); });
- }, []);
- if (loading)
- return _jsx("div", { className: styles.loading, children: "Laden..." });
- if (error || !data)
- return _jsx("div", { className: styles.error, children: "Dashboard konnte nicht geladen werden." });
- const { hero, stats, nudges } = data;
- return (_jsxs("div", { className: styles.dashboard, children: [_jsxs("div", { className: `card ${styles.hero}`, onClick: () => navigate('/spiele'), children: [_jsxs("div", { className: styles.heroLabel, children: [_jsx("span", { children: "N\u00E4chstes Spiel" }), hero && (_jsx("span", { className: styles.heroCountdown, children: formatCountdown(hero.match.minutesUntilKickoff) }))] }), hero ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: styles.heroTeams, children: [_jsxs("div", { className: styles.heroTeam, children: [hero.match.homeTeam.crest ? (_jsx("img", { src: hero.match.homeTeam.crest, alt: hero.match.homeTeam.name, className: styles.heroCrest })) : (_jsx("div", { className: styles.heroCrest })), _jsx("span", { children: hero.match.homeTeam.shortName })] }), _jsx("span", { className: styles.heroVs, children: "vs" }), _jsxs("div", { className: styles.heroTeam, children: [hero.match.awayTeam.crest ? (_jsx("img", { src: hero.match.awayTeam.crest, alt: hero.match.awayTeam.name, className: styles.heroCrest })) : (_jsx("div", { className: styles.heroCrest })), _jsx("span", { children: hero.match.awayTeam.shortName })] })] }), hero.userTip ? (_jsxs("div", { className: styles.heroTip, children: ["Dein Tipp: ", hero.userTip.home, ":", hero.userTip.away, " \u2713"] })) : hero.tippable ? (_jsx("button", { className: styles.heroTipBtn, onClick: e => { e.stopPropagation(); navigate('/spiele'); }, children: "Jetzt tippen" })) : null] })) : (_jsx("p", { style: { textAlign: 'center', color: 'var(--text-muted)', margin: '16px 0' }, children: "Keine anstehenden Spiele" }))] }), _jsxs("div", { className: styles.statsRow, children: [_jsxs("div", { className: `card ${styles.statTile}`, children: [_jsx("span", { className: styles.statValue, children: stats.rank !== null ? stats.rank : '—' }), _jsx("span", { className: styles.statLabel, children: "Dein Rang" })] }), _jsxs("div", { className: `card ${styles.statTile}`, children: [_jsx("span", { className: styles.statValue, children: stats.totalPoints }), _jsx("span", { className: styles.statLabel, children: "Punkte" })] }), _jsxs("div", { className: `card ${styles.statTile}`, children: [_jsx("span", { className: styles.statValue, children: formatStreak(stats.streak) }), _jsx("span", { className: styles.statLabel, children: "Streak" })] })] }), nudges.length > 0 && (_jsx("div", { className: styles.nudges, children: nudges.map((nudge, i) => (_jsx("div", { className: `card ${styles.nudge}`, onClick: () => {
- if (nudge.type === 'untipped')
- navigate('/spiele');
- else if (nudge.type === 'leader')
- navigate('/rangliste');
- }, children: nudge.text }, i))) }))] }));
-}
diff --git a/frontend/src/pages/LeaderboardPage.js b/frontend/src/pages/LeaderboardPage.js
deleted file mode 100644
index df29351..0000000
--- a/frontend/src/pages/LeaderboardPage.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
-import { useState, useEffect } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { api } from '../api/client';
-import styles from './LeaderboardPage.module.css';
-function initials(name) {
- return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
-}
-function TrendIcon({ entry, prev }) {
- if (!prev)
- return _jsx("span", { className: styles.trendNeutral, children: "\u2192" });
- if (entry.total_points > prev.total_points)
- return _jsx("span", { className: styles.trendUp, children: "\u2197" });
- if (entry.total_points < prev.total_points)
- return _jsx("span", { className: styles.trendDown, children: "\u2198" });
- return _jsx("span", { className: styles.trendNeutral, children: "\u2192" });
-}
-export default function LeaderboardPage() {
- const [data, setData] = useState(null);
- const [tippableCount, setTippableCount] = useState(0);
- const [loading, setLoading] = useState(true);
- const navigate = useNavigate();
- useEffect(() => {
- Promise.all([
- api.getLeaderboard(),
- api.getMatches(),
- ]).then(([lb, matches]) => {
- setData(lb);
- setTippableCount(matches.matches.filter(m => m.tippable && !m.userTip).length);
- }).finally(() => setLoading(false));
- }, []);
- if (loading)
- return (_jsx("div", { className: styles.loading, children: _jsx("div", { className: styles.spinner }) }));
- if (!data)
- return null;
- const { entries, currentUserId, currentUserRank, totalParticipants } = data;
- const top3 = entries.slice(0, 3);
- const rest = entries.slice(3);
- const podiumSlots = [];
- if (top3[1])
- podiumSlots.push({ entry: top3[1], rank: 2, medal: '🥈', colorClass: styles.silver, barHeight: '64px' });
- if (top3[0])
- podiumSlots.push({ entry: top3[0], rank: 1, medal: '🥇', colorClass: styles.gold, barHeight: '96px' });
- if (top3[2])
- podiumSlots.push({ entry: top3[2], rank: 3, medal: '🥉', colorClass: styles.bronze, barHeight: '48px' });
- return (_jsxs("div", { className: styles.page, children: [_jsxs("div", { className: styles.pageHeader, children: [_jsx("h1", { className: `font-display ${styles.title}`, children: "Rangliste" }), _jsxs("div", { className: styles.meta, children: [totalParticipants, " Teilnehmer", currentUserRank ? ` · Du: Platz ${currentUserRank}` : ''] })] }), entries.length === 0 ? (_jsx("div", { className: styles.empty, children: "Noch keine Punkte vergeben. Spiele m\u00FCssen erst abgeschlossen sein." })) : (_jsxs(_Fragment, { children: [top3.length > 0 && (_jsx("div", { className: styles.podiumWrap, children: podiumSlots.map(({ entry, rank, medal, colorClass, barHeight }) => {
- const isMe = entry.user_id === currentUserId;
- const isFirst = rank === 1;
- return (_jsxs("div", { className: `${styles.podiumCard} ${isFirst ? styles.podiumFirst : ''}`, children: [_jsx("div", { className: styles.podiumMedal, children: medal }), _jsx("div", { className: `${styles.podiumAvatar} ${colorClass} ${isFirst ? styles.podiumAvatarLarge : ''} ${isMe ? styles.podiumAvatarMe : ''}`, children: initials(entry.full_name) }), _jsxs("div", { className: `${styles.podiumName} ${isMe ? styles.nameMe : ''}`, children: [entry.full_name.split(' ')[0], isMe ? ' (Ich)' : ''] }), _jsxs("div", { className: `${styles.podiumPoints} ${colorClass}`, children: [entry.total_points.toLocaleString('de-DE'), _jsx("span", { className: styles.podiumPtLabel, children: " Pkt" })] }), _jsx("div", { className: `${styles.podiumBar} ${colorClass}Bar`, style: { height: barHeight } })] }, entry.user_id));
- }) })), rest.length > 0 && (_jsxs(_Fragment, { children: [_jsxs("div", { className: styles.listHeader, children: [_jsx("span", { children: "POS" }), _jsx("span", { children: "SPIELER" }), _jsx("span", { children: "TREND" }), _jsx("span", { children: "PUNKTE" })] }), _jsx("div", { className: styles.list, children: rest.map((entry, i) => {
- const isMe = entry.user_id === currentUserId;
- const prev = rest[i - 1];
- return (_jsxs("div", { className: `card ${styles.row} ${isMe ? styles.rowMe : ''}`, children: [_jsx("div", { className: styles.rankCol, children: _jsx("span", { className: styles.rankNum, children: entry.rank }) }), _jsx("div", { className: `${styles.avatarSmall} ${isMe ? styles.avatarSmallMe : ''}`, children: initials(entry.full_name) }), _jsxs("div", { className: styles.nameCol, children: [_jsx("div", { className: styles.nameRow, children: _jsxs("span", { className: `${styles.rowName} ${isMe ? styles.nameMe : ''}`, children: [entry.full_name, isMe ? ' (Ich)' : ''] }) }), entry.team && _jsx("div", { className: styles.rowTeam, children: entry.team }), isMe && _jsx("div", { className: styles.aufholjagd, children: "AUFHOLJAGD!" })] }), _jsx("div", { className: styles.trendCol, children: _jsx(TrendIcon, { entry: entry, prev: prev }) }), _jsx("div", { className: styles.pointsCol, children: entry.total_points.toLocaleString('de-DE') })] }, entry.user_id));
- }) })] })), tippableCount > 0 && (_jsxs("div", { className: `card ${styles.ctaCard}`, children: [_jsxs("div", { className: styles.ctaText, children: [_jsx("div", { className: styles.ctaTitle, children: "Punkte sichern!" }), _jsxs("div", { className: styles.ctaBody, children: [tippableCount, " Spiel", tippableCount !== 1 ? 'e' : '', " noch ohne Tipp \u2014 kletter nach oben."] })] }), _jsx("button", { className: `btn-primary ${styles.ctaBtn}`, onClick: () => navigate('/'), children: "TIPPEN" })] }))] }))] }));
-}
diff --git a/frontend/src/pages/MatchesPage.js b/frontend/src/pages/MatchesPage.js
deleted file mode 100644
index 583d386..0000000
--- a/frontend/src/pages/MatchesPage.js
+++ /dev/null
@@ -1,100 +0,0 @@
-import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
-import { useState, useEffect, useCallback } from 'react';
-import { api } from '../api/client';
-import MatchCard from '../components/MatchCard';
-import TipModal from '../components/TipModal';
-import { useRevealQueue } from '../hooks/useRevealQueue';
-import ConfettiReveal from '../components/ConfettiReveal';
-import styles from './MatchesPage.module.css';
-function groupIntoSections(matches) {
- const now = new Date();
- const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
- const tomorrowStart = new Date(todayStart);
- tomorrowStart.setDate(todayStart.getDate() + 1);
- const dayAfterTomorrow = new Date(todayStart);
- dayAfterTomorrow.setDate(todayStart.getDate() + 2);
- const weekEnd = new Date(todayStart);
- weekEnd.setDate(todayStart.getDate() + 7);
- const sections = [
- { key: 'today', label: 'Heute', matches: [], defaultOpen: true, highlight: true },
- { key: 'tomorrow', label: 'Morgen', matches: [], defaultOpen: true, highlight: false },
- { key: 'week', label: 'Diese Woche', matches: [], defaultOpen: false, highlight: false },
- { key: 'later', label: 'Demnächst', matches: [], defaultOpen: false, highlight: false },
- { key: 'past', label: 'Vergangene Spiele', matches: [], defaultOpen: false, highlight: false },
- ];
- for (const match of matches) {
- const d = new Date(match.utcDate);
- if (d < todayStart) {
- sections[4].matches.push(match); // past
- }
- else if (d < tomorrowStart) {
- sections[0].matches.push(match); // today
- }
- else if (d < dayAfterTomorrow) {
- sections[1].matches.push(match); // tomorrow
- }
- else if (d < weekEnd) {
- sections[2].matches.push(match); // this week
- }
- else {
- sections[3].matches.push(match); // later
- }
- }
- // Past matches: most recent first
- sections[4].matches.reverse();
- return sections.filter(s => s.matches.length > 0);
-}
-export default function MatchesPage() {
- const [allMatches, setAllMatches] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [stageFilter, setStageFilter] = useState('');
- const [selectedMatch, setSelectedMatch] = useState(null);
- const [openSections, setOpenSections] = useState(new Set());
- const loadMatches = useCallback(async () => {
- setLoading(true);
- setError(null);
- try {
- const res = await api.getMatches();
- setAllMatches(res.matches);
- }
- catch (e) {
- setError(e.message);
- }
- finally {
- setLoading(false);
- }
- }, []);
- useEffect(() => { loadMatches(); }, [loadMatches]);
- // Initialize open sections after initial load
- useEffect(() => {
- if (allMatches.length > 0) {
- const filteredMatches = allMatches.filter(m => !stageFilter || m.stage === stageFilter);
- const sections = groupIntoSections(filteredMatches);
- setOpenSections(new Set(sections.filter(s => s.defaultOpen).map(s => s.key)));
- }
- }, [allMatches]); // only on initial load
- const handleTipSaved = (matchId, tipHome, tipAway) => {
- setAllMatches(prev => prev.map(m => m.id === matchId
- ? { ...m, userTip: { home: tipHome, away: tipAway, points: null } }
- : m));
- setSelectedMatch(null);
- };
- function toggleSection(key) {
- setOpenSections(prev => {
- const next = new Set(prev);
- if (next.has(key))
- next.delete(key);
- else
- next.add(key);
- return next;
- });
- }
- const { current: revealMatch, dismissCurrent } = useRevealQueue(allMatches);
- const filteredMatches = allMatches.filter(m => !stageFilter || m.stage === stageFilter);
- const sections = groupIntoSections(filteredMatches);
- // Stats always over all matches (unfiltered)
- const tipped = allMatches.filter(m => m.userTip).length;
- const tippable = allMatches.filter(m => m.tippable && !m.userTip).length;
- return (_jsxs("div", { className: styles.page, children: [revealMatch && (_jsx(ConfettiReveal, { match: revealMatch, onDismiss: dismissCurrent })), _jsxs("div", { className: styles.statsRow, children: [_jsxs("div", { className: `card ${styles.statCard}`, children: [_jsx("span", { className: styles.statValue, children: allMatches.length }), _jsx("span", { className: styles.statLabel, children: "Spiele gesamt" })] }), _jsxs("div", { className: `card ${styles.statCard}`, children: [_jsx("span", { className: `${styles.statValue} text-primary`, children: tipped }), _jsx("span", { className: styles.statLabel, children: "Tipps abgegeben" })] }), _jsxs("div", { className: `card ${styles.statCard}`, children: [_jsx("span", { className: `${styles.statValue} text-gold`, children: tippable }), _jsx("span", { className: styles.statLabel, children: "Noch tippbar" })] })] }), _jsxs("select", { className: styles.stageFilter, value: stageFilter, onChange: e => setStageFilter(e.target.value), children: [_jsx("option", { value: "", children: "Alle Phasen" }), _jsx("option", { value: "GROUP_STAGE", children: "Gruppenphase" }), _jsx("option", { value: "ROUND_OF_32", children: "Runde der 32" }), _jsx("option", { value: "LAST_16", children: "Achtelfinale" }), _jsx("option", { value: "QUARTER_FINALS", children: "Viertelfinale" }), _jsx("option", { value: "SEMI_FINALS", children: "Halbfinale" }), _jsx("option", { value: "THIRD_PLACE", children: "Platz 3" }), _jsx("option", { value: "FINAL", children: "Finale" })] }), loading && (_jsxs("div", { className: styles.loadingState, children: [_jsx("div", { className: styles.spinner }), _jsx("span", { children: "Spiele werden geladen\u2026" })] })), error && (_jsxs("div", { className: styles.errorState, children: [_jsxs("span", { children: ["\u26A0\uFE0F ", error] }), _jsx("button", { className: "btn-ghost", onClick: loadMatches, children: "Erneut versuchen" })] })), !loading && !error && filteredMatches.length === 0 && (_jsxs("div", { className: styles.emptyState, children: [_jsx("span", { className: styles.emptyIcon, children: "\u26BD" }), _jsx("p", { children: "Noch keine Spiele vorhanden." }), _jsx("p", { className: styles.emptyHint, children: "Geh auf die Admin-Seite und klicke \"Spiele synchronisieren\"." })] })), !loading && !error && sections.map(section => (_jsxs("div", { className: `${styles.section} ${section.highlight ? styles.sectionHighlight : ''}`, children: [_jsxs("button", { className: styles.sectionHeader, onClick: () => toggleSection(section.key), children: [_jsx("span", { className: styles.sectionLabel, children: section.label }), _jsxs("span", { className: styles.sectionCount, children: [section.matches.length, " Spiele"] }), _jsx("span", { className: styles.sectionChevron, children: openSections.has(section.key) ? '▾' : '▸' })] }), openSections.has(section.key) && (_jsx("div", { className: styles.sectionContent, children: section.matches.map(match => (_jsx(MatchCard, { match: match, onTip: () => setSelectedMatch(match) }, match.id))) }))] }, section.key))), selectedMatch && (_jsx(TipModal, { match: selectedMatch, onClose: () => setSelectedMatch(null), onSaved: handleTipSaved }))] }));
-}
diff --git a/frontend/src/pages/ProfilePage.js b/frontend/src/pages/ProfilePage.js
deleted file mode 100644
index 02a9946..0000000
--- a/frontend/src/pages/ProfilePage.js
+++ /dev/null
@@ -1,102 +0,0 @@
-import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
-import { useState, useEffect } from 'react';
-import { api } from '../api/client';
-import StatsRing from '../components/StatsRing';
-import styles from './ProfilePage.module.css';
-function initials(name) {
- return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
-}
-function mostCommonTip(tips) {
- const counts = {};
- for (const t of tips) {
- const key = `${t.tip_home}:${t.tip_away}`;
- counts[key] = (counts[key] ?? 0) + 1;
- }
- let best = '';
- let max = 0;
- for (const [key, count] of Object.entries(counts)) {
- if (count > max) {
- max = count;
- best = key;
- }
- }
- return best ? `${best} (${max}x getippt)` : '—';
-}
-function homeWinPct(tips) {
- if (!tips.length)
- return 0;
- const homeWins = tips.filter(t => t.tip_home > t.tip_away).length;
- return Math.round((homeWins / tips.length) * 100);
-}
-export default function ProfilePage() {
- const [stats, setStats] = useState(null);
- const [tips, setTips] = useState([]);
- const [loading, setLoading] = useState(true);
- const [teamEdit, setTeamEdit] = useState(false);
- const [teamValue, setTeamValue] = useState('');
- const [teamSaving, setTeamSaving] = useState(false);
- const [teamMsg, setTeamMsg] = useState(null);
- useEffect(() => {
- Promise.all([
- api.getMyStats(),
- api.getMyTips(),
- ]).then(([s, t]) => {
- setStats(s);
- setTeamValue(s.team ?? '');
- setTips(t.tips);
- }).finally(() => setLoading(false));
- }, []);
- const saveTeam = async () => {
- if (!teamValue.trim())
- return;
- setTeamSaving(true);
- setTeamMsg(null);
- try {
- const res = await api.updateTeam(teamValue);
- setStats(prev => prev ? { ...prev, team: res.team } : prev);
- setTeamValue(res.team);
- setTeamEdit(false);
- setTeamMsg({ ok: true, text: 'Team gespeichert' });
- }
- catch (e) {
- setTeamMsg({ ok: false, text: e.message });
- }
- finally {
- setTeamSaving(false);
- }
- };
- if (loading)
- return _jsx("div", { className: styles.loading, children: _jsx("div", { className: styles.spinner }) });
- if (!stats)
- return _jsx("div", { className: styles.empty, children: "Profil nicht verf\u00FCgbar." });
- const evaluatedTips = tips.filter(t => t.points !== null);
- const recentTips = evaluatedTips.slice(0, 10);
- const favTip = mostCommonTip(tips);
- const homePct = homeWinPct(tips);
- function pointBadgeClass(points) {
- if (points === null)
- return '';
- if (points >= 3)
- return styles.badgeExact;
- if (points >= 1)
- return styles.badgeTendency;
- return styles.badgeWrong;
- }
- function pointLabel(points) {
- if (points === null)
- return '';
- if (points >= 3)
- return `${points} Pkt ✓✓`;
- if (points >= 1)
- return `${points} Pkt ✓`;
- return `${points} Pkt ✗`;
- }
- return (_jsxs("div", { className: styles.page, children: [_jsxs("div", { className: `card ${styles.heroCard}`, children: [_jsx("div", { className: styles.avatar, children: initials(stats.fullName) }), _jsxs("div", { className: styles.heroInfo, children: [_jsx("h1", { className: `font-display ${styles.name}`, children: stats.fullName }), stats.rank && _jsxs("div", { className: styles.rankBadge, children: ["\uD83C\uDFC6 Platz ", stats.rank] }), _jsx("div", { className: styles.teamRow, children: teamEdit ? (_jsxs("div", { className: styles.teamEditRow, children: [_jsx("input", { className: styles.teamInput, value: teamValue, onChange: e => setTeamValue(e.target.value), placeholder: "z. B. Vertrieb S\u00FCd", maxLength: 80, autoFocus: true, onKeyDown: e => {
- if (e.key === 'Enter')
- saveTeam();
- if (e.key === 'Escape')
- setTeamEdit(false);
- } }), _jsx("button", { className: styles.teamSaveBtn, onClick: saveTeam, disabled: teamSaving, children: teamSaving ? _jsx("span", { className: styles.spinnerSm }) : '✓' }), _jsx("button", { className: styles.teamCancelBtn, onClick: () => { setTeamEdit(false); setTeamValue(stats.team ?? ''); }, children: "\u2715" })] })) : (_jsx("button", { className: styles.teamBtn, onClick: () => setTeamEdit(true), children: stats.team
- ? _jsxs(_Fragment, { children: [_jsx("span", { className: styles.teamName, children: stats.team }), _jsx("span", { className: styles.teamEditHint, children: "bearbeiten" })] })
- : _jsx("span", { className: styles.teamPlaceholder, children: "+ Team hinzuf\u00FCgen" }) })) }), teamMsg && (_jsx("div", { className: `${styles.teamMsg} ${teamMsg.ok ? styles.teamMsgOk : styles.teamMsgErr}`, children: teamMsg.text }))] })] }), _jsxs("div", { className: `card ${styles.ringCard}`, children: [_jsx("h2", { className: styles.sectionTitle, children: "Tipp-Statistik" }), _jsx(StatsRing, { exact: stats.exactCount, tendency: stats.tendencyCount, wrong: stats.wrongCount, total: stats.totalPoints }), stats.accuracy > 0 && (_jsxs("div", { className: styles.accuracyRow, children: [_jsx("span", { className: styles.accuracyLabel, children: "Trefferquote" }), _jsxs("span", { className: `font-display ${styles.accuracyVal}`, children: [stats.accuracy, "%"] })] }))] }), recentTips.length > 0 && (_jsxs("div", { className: `card ${styles.historyCard}`, children: [_jsx("h2", { className: styles.sectionTitle, children: "Letzte Tipps" }), _jsx("ul", { className: styles.tipList, children: recentTips.map((tip, i) => (_jsxs("li", { className: `${styles.tipRow} ${i % 2 === 1 ? styles.tipRowAlt : ''}`, children: [_jsxs("span", { className: styles.tipMatch, children: [tip.home_team_short, " vs ", tip.away_team_short] }), _jsxs("span", { className: styles.tipScore, children: ["Tipp: ", tip.tip_home, ":", tip.tip_away] }), _jsx("span", { className: `${styles.pointBadge} ${pointBadgeClass(tip.points)}`, children: pointLabel(tip.points) })] }, tip.match_id))) })] })), tips.length > 0 && (_jsxs("div", { className: styles.funStats, children: [_jsx("h2", { className: styles.sectionTitle, children: "Fun Facts" }), _jsxs("div", { className: styles.funGrid, children: [_jsxs("div", { className: `card ${styles.funCard}`, children: [_jsx("span", { className: styles.funIcon, children: "\uD83C\uDFAF" }), _jsx("span", { className: styles.funLabel, children: "Lieblings-Tipp" }), _jsx("span", { className: styles.funValue, children: favTip })] }), _jsxs("div", { className: `card ${styles.funCard}`, children: [_jsx("span", { className: styles.funIcon, children: "\uD83C\uDFE0" }), _jsx("span", { className: styles.funLabel, children: "Heimsiege getippt" }), _jsxs("span", { className: styles.funValue, children: [homePct, "%"] })] }), _jsxs("div", { className: `card ${styles.funCard}`, children: [_jsx("span", { className: styles.funIcon, children: "\uD83D\uDCCA" }), _jsx("span", { className: styles.funLabel, children: "Tipps abgegeben" }), _jsx("span", { className: styles.funValue, children: stats.tipsCount })] })] })] }))] }));
-}