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>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
<h2>Neue Startseite: Dashboard statt Spielliste</h2>
|
||||
<p class="subtitle">Aktuell sieht man 104 Spiele als endlose Liste. Die neue Startseite soll sofort zeigen: Was ist relevant für MICH, JETZT?</p>
|
||||
|
||||
<div class="cards">
|
||||
|
||||
<div class="card" data-choice="a" onclick="toggleSelect(this)">
|
||||
<div class="card-image">
|
||||
<div style="background: linear-gradient(135deg, #0a1628, #1a2744); border-radius: 12px; padding: 16px; font-family: system-ui; color: #e0e8f0; font-size: 13px;">
|
||||
<div style="text-align:center; margin-bottom: 14px;">
|
||||
<div style="font-size: 11px; color: #64b5f6; text-transform: uppercase; letter-spacing: 1px;">Nächstes Spiel · in 2h 14min</div>
|
||||
<div style="display:flex; justify-content:center; align-items:center; gap: 20px; margin: 10px 0;">
|
||||
<div style="text-align:center"><div style="font-size: 24px;">🇲🇽</div><div style="font-size: 11px;">Mexico</div></div>
|
||||
<div style="font-size: 18px; font-weight: bold; color: #ffd54f;">vs</div>
|
||||
<div style="text-align:center"><div style="font-size: 24px;">🇿🇦</div><div style="font-size: 11px;">S. Africa</div></div>
|
||||
</div>
|
||||
<div style="background: #1e3a5f; border-radius: 8px; padding: 8px; margin-top: 6px;">
|
||||
<span style="color: #ffd54f; font-weight: bold;">Dein Tipp: 2:1</span> ✓
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 12px;">
|
||||
<div style="background: #1e3a5f; border-radius: 8px; padding: 8px; text-align:center;">
|
||||
<div style="font-size: 20px; font-weight: bold; color: #ffd54f;">5.</div>
|
||||
<div style="font-size: 10px; color: #90a4ae;">Dein Rang</div>
|
||||
</div>
|
||||
<div style="background: #1e3a5f; border-radius: 8px; padding: 8px; text-align:center;">
|
||||
<div style="font-size: 20px; font-weight: bold; color: #4fc3f7;">12</div>
|
||||
<div style="font-size: 10px; color: #90a4ae;">Punkte</div>
|
||||
</div>
|
||||
<div style="background: #1e3a5f; border-radius: 8px; padding: 8px; text-align:center;">
|
||||
<div style="font-size: 20px; font-weight: bold; color: #81c784;">3🔥</div>
|
||||
<div style="font-size: 10px; color: #90a4ae;">Streak</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: #90a4ae; border-top: 1px solid #1e3a5f; padding-top: 8px;">
|
||||
<div>📅 <strong>Heute noch 2 Spiele</strong> ohne Tipp</div>
|
||||
<div style="margin-top: 4px;">🏆 Max führt mit 15 Punkten</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>A: Hero + Stats + Nudges</h3>
|
||||
<p>Großes "Nächstes Spiel" oben, darunter persönliche Stats (Rang, Punkte, Streak), unten Handlungsaufforderungen und Social-Info. Kompakt, alles Wichtige auf einen Blick.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" data-choice="b" onclick="toggleSelect(this)">
|
||||
<div class="card-image">
|
||||
<div style="background: linear-gradient(135deg, #0a1628, #1a2744); border-radius: 12px; padding: 16px; font-family: system-ui; color: #e0e8f0; font-size: 13px;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 12px;">
|
||||
<div><span style="font-size: 16px; font-weight: bold;">Hallo Ronny!</span><br><span style="font-size: 11px; color: #90a4ae;">Platz 5 · 12 Punkte</span></div>
|
||||
<div style="background: #ffd54f; color: #0a1628; border-radius: 20px; padding: 4px 12px; font-size: 12px; font-weight: bold;">3🔥</div>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: #64b5f6; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px;">⏰ Jetzt tippen</div>
|
||||
<div style="background: #1e3a5f; border-radius: 10px; padding: 10px; margin-bottom: 6px;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<span>🇲🇽 Mexico vs S. Africa 🇿🇦</span>
|
||||
<span style="background: #e53935; color:white; border-radius: 12px; padding: 2px 8px; font-size: 10px;">2h 14m</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background: #1e3a5f; border-radius: 10px; padding: 10px; margin-bottom: 6px;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<span>🇰🇷 S. Korea vs Croatia 🇭🇷</span>
|
||||
<span style="background: #ff9800; color:white; border-radius: 12px; padding: 2px 8px; font-size: 10px;">5h 14m</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: #64b5f6; text-transform: uppercase; letter-spacing: 1px; margin: 10px 0 6px;">📊 Ergebnisse</div>
|
||||
<div style="background: #1e3a5f; border-radius: 10px; padding: 10px;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<span>🇧🇷 Brazil 3:1 Serbia 🇷🇸</span>
|
||||
<span style="background: #4caf50; color:white; border-radius: 12px; padding: 2px 8px; font-size: 10px;">+3 🎯</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>B: Feed-Style Timeline</h3>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="section" style="margin-top: 24px;">
|
||||
<h3>Beide Varianten ersetzen die 104-Spiele-Liste als Startseite</h3>
|
||||
<p>Der komplette Spielplan bleibt erreichbar über "Alle Spiele" in der Navigation. Die Startseite wird zum persönlichen WM-Cockpit.</p>
|
||||
</div></content>
|
||||
</invoke>
|
||||
@@ -0,0 +1,50 @@
|
||||
<h2>Emotionale Momente: Wo die App "lebendig" wird</h2>
|
||||
<p class="subtitle">5 Schlüsselmomente, in denen die App ein Lächeln erzeugen soll. Welche sind dir am wichtigsten?</p>
|
||||
|
||||
<div class="options" data-multiselect>
|
||||
|
||||
<div class="option" data-choice="tipp-confirm" onclick="toggleSelect(this)">
|
||||
<div class="letter">1</div>
|
||||
<div class="content">
|
||||
<h3>Tipp-Bestätigung</h3>
|
||||
<p>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 → <strong>"Dein Tipp ist drin! 🎯"</strong> mit sanfter Animation.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option" data-choice="countdown" onclick="toggleSelect(this)">
|
||||
<div class="letter">2</div>
|
||||
<div class="content">
|
||||
<h3>Live-Countdown vor Anpfiff</h3>
|
||||
<p>Wenn ein Spiel in <1 Stunde startet: pulsierender roter Countdown auf der Match-Card. <strong>"Noch 12 Minuten zum Tippen!"</strong> — erzeugt Dringlichkeit und FOMO. Nach Anpfiff: "Tippfenster geschlossen" mit Schloss-Icon.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option" data-choice="punkte-reveal" onclick="toggleSelect(this)">
|
||||
<div class="letter">3</div>
|
||||
<div class="content">
|
||||
<h3>Punkte-Reveal nach Spielende</h3>
|
||||
<p>Der stärkste Moment: Wenn ein Spiel auf FINISHED geht, <strong>Punkte nicht einfach anzeigen — enthüllen</strong>. Kurzsequenz: Ergebnis einblenden → "Dein Tipp war..." → Punkte-Zähler animiert hochzählen. Bei exaktem Treffer: 🎉 <strong>Konfetti-Explosion + goldener "EXAKT!"-Badge</strong>. Bei Tendenz: 👏 grüner Puls. Bei falsch: 😅 kurzes Kopfschütteln-Emoji.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option" data-choice="rang-change" onclick="toggleSelect(this)">
|
||||
<div class="letter">4</div>
|
||||
<div class="content">
|
||||
<h3>Ranglistenveränderung</h3>
|
||||
<p>Wenn sich dein Rang ändert: <strong>"Du bist auf Platz 3 aufgestiegen! ⬆️"</strong> als Toast-Notification beim nächsten App-Öffnen. Oder: <strong>"Achtung, Anna ist nur noch 1 Punkt hinter dir!"</strong> — erzeugt freundschaftliche Rivalität.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option" data-choice="streak" onclick="toggleSelect(this)">
|
||||
<div class="letter">5</div>
|
||||
<div class="content">
|
||||
<h3>Streak-Tracker</h3>
|
||||
<p>Visueller Feuer-Counter: <strong>🔥3</strong> wenn du 3 Spiele in Folge getippt hast (nicht korrekt, nur abgegeben). Motiviert zum Dranbleiben. Bei 10er-Streak: besonderes Icon. Wenn Streak bricht: <strong>"Deine 7er-Serie ist gerissen! Starte eine neue."</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="section" style="margin-top: 20px;">
|
||||
<p><strong>Klicke auf alle Momente, die du umsetzen möchtest</strong> (Mehrfachauswahl möglich). Oder sag mir im Terminal, wenn du alle willst oder Änderungswünsche hast.</p>
|
||||
</div>
|
||||
@@ -0,0 +1,61 @@
|
||||
<h2>Spielplan & Navigation: 104 Spiele im Griff</h2>
|
||||
<p class="subtitle">Aktuell: endlose Liste mit Stage-Filter oben. Wie machen wir das besser für Mobile?</p>
|
||||
|
||||
<div class="section">
|
||||
<h3>Spielplan-Struktur</h3>
|
||||
</div>
|
||||
|
||||
<div class="options">
|
||||
|
||||
<div class="option" data-choice="smart-sections" onclick="toggleSelect(this)">
|
||||
<div class="letter">A</div>
|
||||
<div class="content">
|
||||
<h3>Smart Sections (Empfehlung)</h3>
|
||||
<p>Spielplan automatisch in Abschnitte unterteilt:
|
||||
<br><strong>"Heute"</strong> (expandiert, farbig hervorgehoben)
|
||||
<br><strong>"Morgen"</strong> (expandiert)
|
||||
<br><strong>"Diese Woche"</strong> (kollapsiert, Anzahl angezeigt)
|
||||
<br><strong>"Vergangene Spiele"</strong> (kollapsiert, zeigt letzte Ergebnisse + deine Punkte)
|
||||
<br><br>Kein manuelles Filtern nötig — die App weiß, was jetzt relevant ist. Stage-Filter (Gruppenphase, Achtelfinale...) bleiben als optionaler Zweitmodus.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option" data-choice="tabbed" onclick="toggleSelect(this)">
|
||||
<div class="letter">B</div>
|
||||
<div class="content">
|
||||
<h3>Tab-basiert</h3>
|
||||
<p>Drei Haupt-Tabs am oberen Rand:
|
||||
<br><strong>"Offen"</strong> — Spiele, die noch getippt werden können (nach Kick-off sortiert)
|
||||
<br><strong>"Live"</strong> — Laufende Spiele mit Echtzeit-Status
|
||||
<br><strong>"Ergebnisse"</strong> — Abgeschlossene Spiele mit Punkten
|
||||
<br><br>Klar getrennt, aber weniger zeitlicher Kontext ("heute/morgen").</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="section" style="margin-top: 32px;">
|
||||
<h3>Mobile Navigation</h3>
|
||||
</div>
|
||||
|
||||
<div class="options">
|
||||
|
||||
<div class="option" data-choice="bottom-nav" onclick="toggleSelect(this)">
|
||||
<div class="letter">C</div>
|
||||
<div class="content">
|
||||
<h3>Bottom Navigation Bar (Empfehlung)</h3>
|
||||
<p>Feste Leiste am unteren Bildschirmrand mit Icons + Label:
|
||||
<br>🏠 <strong>Home</strong> (Dashboard) · ⚽ <strong>Spiele</strong> · 🏆 <strong>Rangliste</strong> · 👤 <strong>Profil</strong>
|
||||
<br><br>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.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option" data-choice="keep-header" onclick="toggleSelect(this)">
|
||||
<div class="letter">D</div>
|
||||
<div class="content">
|
||||
<h3>Header-Nav beibehalten</h3>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,64 @@
|
||||
<h2>Profil-Seite & Match-Cards aufwerten</h2>
|
||||
<p class="subtitle">Zwei Bereiche, die aktuell etwas leblos wirken</p>
|
||||
|
||||
<div class="section">
|
||||
<h3>Profil-Seite: Von Nullen zu Persönlichkeit</h3>
|
||||
<p>Aktuell: Name, Rang, 4 Stat-Boxen mit Nullen. Kein Grund hier wiederzukommen.</p>
|
||||
</div>
|
||||
|
||||
<div class="options">
|
||||
|
||||
<div class="option" data-choice="profil-rich" onclick="toggleSelect(this)">
|
||||
<div class="letter">A</div>
|
||||
<div class="content">
|
||||
<h3>Reiches Profil (Empfehlung)</h3>
|
||||
<p>
|
||||
<strong>Header:</strong> Avatar (Initialen-Circle wie jetzt), Name, Rang-Badge, Lieblingsteam mit Flagge
|
||||
<br><strong>Stats-Ring:</strong> Kreisdiagramm mit Exakt/Tendenz/Falsch-Verteilung statt 4 separate Boxen
|
||||
<br><strong>Tipp-Historie:</strong> Scrollbare Liste der letzten Tipps mit Ergebnis + Punkte — "Deine letzten 10 Tipps"
|
||||
<br><strong>Achievements:</strong> Badge-Leiste (grau wenn noch nicht erreicht, farbig wenn freigeschaltet) — Vorgriff auf Phase 2
|
||||
<br><strong>Fun-Stat:</strong> "Dein Lieblings-Tipp: 1:0 (5x getippt)" oder "Du tippst 70% Heimsiege"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option" data-choice="profil-minimal" onclick="toggleSelect(this)">
|
||||
<div class="letter">B</div>
|
||||
<div class="content">
|
||||
<h3>Minimal-Upgrade</h3>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="section" style="margin-top: 32px;">
|
||||
<h3>Match-Cards: Mehr Status, mehr Emotion</h3>
|
||||
<p>Aktuell: Team-Flaggen + Uhrzeit + "Tipp abgeben"-Button. Funktional, aber alle Karten sehen gleich aus.</p>
|
||||
</div>
|
||||
|
||||
<div class="options">
|
||||
|
||||
<div class="option" data-choice="cards-states" onclick="toggleSelect(this)">
|
||||
<div class="letter">C</div>
|
||||
<div class="content">
|
||||
<h3>Zustandsbasierte Cards (Empfehlung)</h3>
|
||||
<p>Jede Card sieht anders aus je nach Zustand:
|
||||
<br>⏳ <strong>Offen:</strong> "Tipp abgeben"-Button, Countdown wenn <24h
|
||||
<br>✅ <strong>Getippt:</strong> Dein Tipp prominent angezeigt, grüner Rand, "Ändern"-Link
|
||||
<br>🔴 <strong>Live:</strong> Pulsierender roter Punkt, aktueller Spielstand (wenn verfügbar)
|
||||
<br>🏁 <strong>Beendet:</strong> Ergebnis + dein Tipp + Punkte-Badge (Gold/Grün/Grau), bei exaktem Treffer: goldener Schimmer
|
||||
<br>🔒 <strong>Verpasst:</strong> Ausgegraut, "Nicht getippt" — leichter Shame-Effekt als Motivation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option" data-choice="cards-simple" onclick="toggleSelect(this)">
|
||||
<div class="letter">D</div>
|
||||
<div class="content">
|
||||
<h3>Einfache Verbesserung</h3>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,67 @@
|
||||
<h2>Aufräumen: Was fällt weg, was ändert sich?</h2>
|
||||
<p class="subtitle">Damit die App schlanker und fokussierter wird</p>
|
||||
|
||||
<div class="options" data-multiselect>
|
||||
|
||||
<div class="option" data-choice="remove-agent" onclick="toggleSelect(this)">
|
||||
<div class="letter">1</div>
|
||||
<div class="content">
|
||||
<h3>KI-Agent / Expertenblick entfernen</h3>
|
||||
<p>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).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option" data-choice="remove-stage-filter" onclick="toggleSelect(this)">
|
||||
<div class="letter">2</div>
|
||||
<div class="content">
|
||||
<h3>Stage-Filter vereinfachen</h3>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option" data-choice="admin-hidden" onclick="toggleSelect(this)">
|
||||
<div class="letter">3</div>
|
||||
<div class="content">
|
||||
<h3>Admin aus der Hauptnav entfernen</h3>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option" data-choice="simplify-tip-modal" onclick="toggleSelect(this)">
|
||||
<div class="letter">4</div>
|
||||
<div class="content">
|
||||
<h3>Tipp-Modal verschlanken</h3>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="section" style="margin-top: 24px;">
|
||||
<h3>Zusammenfassung Phase 1</h3>
|
||||
<div class="pros-cons">
|
||||
<div class="pros">
|
||||
<h4>Neu</h4>
|
||||
<ul>
|
||||
<li>Dashboard als Startseite (Hero + Stats + Nudges)</li>
|
||||
<li>Bottom Navigation Bar</li>
|
||||
<li>Smart Sections im Spielplan</li>
|
||||
<li>5 emotionale Momente (Animationen, Konfetti, Streaks)</li>
|
||||
<li>Zustandsbasierte Match-Cards</li>
|
||||
<li>Reiches Profil mit Stats-Ring + Historie</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cons">
|
||||
<h4>Phase 2 & 3 (später)</h4>
|
||||
<ul>
|
||||
<li>Badges & Achievements</li>
|
||||
<li>Wochenwertung</li>
|
||||
<li>Tipps anderer sehen</li>
|
||||
<li>Reaktionen / Emojis</li>
|
||||
<li>Abteilungs-Challenge</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="subtitle" style="margin-top: 16px;">Wähle aus, was wegfallen soll. Dann schreibe ich das komplette Design-Dokument.</p>
|
||||
@@ -0,0 +1,3 @@
|
||||
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
|
||||
<p class="subtitle">Design-Dokument wird geschrieben... weiter im Terminal.</p>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
{"reason":"idle timeout","timestamp":1775926519574}
|
||||
@@ -0,0 +1,38 @@
|
||||
{"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"}
|
||||
@@ -0,0 +1 @@
|
||||
4760
|
||||
+2
-12
@@ -14,7 +14,7 @@ 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';
|
||||
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT ?? '3001');
|
||||
@@ -96,16 +96,6 @@ 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)
|
||||
// ============================================================
|
||||
@@ -137,7 +127,7 @@ 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);
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
app.use('/api/dev', devRouter);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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.utc_date <= NOW() AND 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) 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;
|
||||
Generated
+19
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
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 <html> 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 }))] }));
|
||||
}
|
||||
@@ -96,5 +96,32 @@
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px;
|
||||
padding-bottom: 70px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.main {
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide header nav on mobile */
|
||||
@media (max-width: 767px) {
|
||||
.nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Admin link: icon only, subtle */
|
||||
.adminLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.adminLink:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
+14
-9
@@ -1,11 +1,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Routes, Route, NavLink } from 'react-router-dom';
|
||||
import { Sun, Moon } from 'lucide-react';
|
||||
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 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 || import.meta.env.VITE_TEST_MODE === 'true';
|
||||
@@ -27,6 +30,7 @@ function getInitialTheme(): Theme {
|
||||
|
||||
export default function App() {
|
||||
const [theme, setTheme] = useState<Theme>(getInitialTheme);
|
||||
const { message: rankMsg, dismiss: dismissRank } = useRankChange();
|
||||
const [devUser, setDevUser] = useState(1);
|
||||
const [devMatches, setDevMatches] = useState<any[]>([]);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
@@ -80,7 +84,7 @@ export default function App() {
|
||||
)}
|
||||
</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}>
|
||||
@@ -89,8 +93,8 @@ export default function App() {
|
||||
<NavLink to="/profil" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
|
||||
Mein Profil
|
||||
</NavLink>
|
||||
<NavLink to="/admin" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
|
||||
Admin
|
||||
<NavLink to="/admin" className={styles.adminLink} title="Admin">
|
||||
<Settings size={16} />
|
||||
</NavLink>
|
||||
<button
|
||||
className={styles.themeToggle}
|
||||
@@ -106,13 +110,17 @@ export default function App() {
|
||||
|
||||
<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}
|
||||
@@ -121,9 +129,6 @@ export default function App() {
|
||||
onRefresh={handleDevRefresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fußball-Experte Chat-Widget – immer sichtbar */}
|
||||
<AgentChat />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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' }),
|
||||
};
|
||||
@@ -53,6 +53,9 @@ export const api = {
|
||||
body: JSON.stringify({ team }),
|
||||
}),
|
||||
|
||||
// Dashboard
|
||||
getDashboard: () => request<DashboardData>('/dashboard'),
|
||||
|
||||
// Admin
|
||||
syncMatches: () =>
|
||||
request<{ success: boolean; total: number; created: number; updated: number }>(
|
||||
@@ -67,6 +70,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 & 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,8 @@
|
||||
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" })] })] }));
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
.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);
|
||||
}
|
||||
|
||||
.emojiIcon {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.bottomNav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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: boolean }) =>
|
||||
isActive ? styles.tabActive : styles.tab;
|
||||
|
||||
return (
|
||||
<nav className={styles.bottomNav}>
|
||||
<NavLink to="/" end className={linkClass}>
|
||||
<Home size={20} />
|
||||
<span>Home</span>
|
||||
</NavLink>
|
||||
<NavLink to="/spiele" className={linkClass}>
|
||||
<span className={styles.emojiIcon}>⚽</span>
|
||||
<span>Spiele</span>
|
||||
</NavLink>
|
||||
<NavLink to="/rangliste" className={linkClass}>
|
||||
<Trophy size={20} />
|
||||
<span>Rangliste</span>
|
||||
</NavLink>
|
||||
<NavLink to="/profil" className={linkClass}>
|
||||
<User size={20} />
|
||||
<span>Profil</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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: 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" })] }) }));
|
||||
}
|
||||
@@ -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={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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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)) }))] }))] }));
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
||||
import { Check, TrendingUp, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import styles from './MatchCard.module.css';
|
||||
function getCardState(match) {
|
||||
if (match.status === 'IN_PLAY' || match.status === 'PAUSED')
|
||||
return 'live';
|
||||
if (match.status === 'FINISHED') {
|
||||
return match.userTip ? 'finished' : 'missed';
|
||||
}
|
||||
// SCHEDULED or TIMED
|
||||
return match.userTip ? 'tipped' : 'open';
|
||||
}
|
||||
function useCountdown(minutesUntilKickoff) {
|
||||
const [remaining, setRemaining] = useState(minutesUntilKickoff);
|
||||
useEffect(() => {
|
||||
if (minutesUntilKickoff > 60)
|
||||
return; // only active for <1h
|
||||
setRemaining(minutesUntilKickoff);
|
||||
const interval = setInterval(() => {
|
||||
setRemaining(r => Math.max(0, r - 1 / 60));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [minutesUntilKickoff]);
|
||||
return remaining;
|
||||
}
|
||||
const STATUS_LABELS = {
|
||||
SCHEDULED: 'Geplant',
|
||||
TIMED: 'Terminiert',
|
||||
IN_PLAY: 'Live',
|
||||
PAUSED: 'Pause',
|
||||
FINISHED: 'Beendet',
|
||||
POSTPONED: 'Verschoben',
|
||||
CANCELLED: 'Abgesagt',
|
||||
};
|
||||
function formatKickoff(utcDate) {
|
||||
return new Date(utcDate).toLocaleString('de-DE', {
|
||||
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin',
|
||||
}) + ' Uhr';
|
||||
}
|
||||
function CountdownBadge({ minutes }) {
|
||||
if (minutes <= 0)
|
||||
return null;
|
||||
if (minutes < 60)
|
||||
return _jsxs("span", { className: styles.badgeUrgent, children: ["\u26A1 in ", minutes, " Min."] });
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
if (h < 24)
|
||||
return _jsxs("span", { className: styles.badge, children: ["in ", h, "h", m > 0 ? ` ${m}m` : ''] });
|
||||
const d = Math.floor(h / 24);
|
||||
return _jsxs("span", { className: styles.badge, children: ["in ", d, " Tag", d > 1 ? 'en' : ''] });
|
||||
}
|
||||
function FlagBox({ crest, name }) {
|
||||
return (_jsx("div", { className: styles.flagBox, children: crest
|
||||
? _jsx("img", { className: styles.crest, src: crest, alt: name })
|
||||
: _jsx("span", { style: { fontSize: 18 }, children: "\uD83C\uDFF3\uFE0F" }) }));
|
||||
}
|
||||
export default function MatchCard({ match, onTip }) {
|
||||
const state = getCardState(match);
|
||||
const remaining = useCountdown(match.minutesUntilKickoff);
|
||||
const remainingMins = Math.ceil(remaining);
|
||||
const isFinished = state === 'finished' || state === 'missed';
|
||||
const isLive = state === 'live';
|
||||
const hasTip = !!match.userTip;
|
||||
const points = match.userTip?.points ?? null;
|
||||
const resultClass = points === 3 ? styles.exact :
|
||||
points === 1 ? styles.tendency :
|
||||
(points === 0 && isFinished) ? styles.wrong : '';
|
||||
const glowClass = isFinished && points === 3 ? styles.glowExact :
|
||||
isFinished && points === 1 ? styles.glowTendency :
|
||||
isFinished && points === 0 ? styles.glowWrong : '';
|
||||
return (_jsxs("div", { className: `card ${styles.card} ${styles[`card_${state}`]} ${isLive ? styles.live : ''} ${glowClass}`, children: [_jsxs("div", { className: styles.topRow, children: [_jsxs("span", { className: `${styles.status} ${isLive ? styles.statusLive : ''}`, children: [isLive && _jsx("span", { className: styles.liveDot }), STATUS_LABELS[match.status] ?? match.status] }), match.group && (_jsx("span", { className: styles.group, children: match.group.replace('GROUP_', 'Gruppe ') })), (state === 'open' || state === 'tipped') && match.tippable && (match.minutesUntilKickoff < 60 ? (_jsxs("span", { className: `${styles.countdown} ${remainingMins < 5 ? styles.countdownUrgent : ''}`, children: ["Noch ", remainingMins, " Min!"] })) : (_jsx(CountdownBadge, { minutes: match.minutesUntilKickoff })))] }), _jsxs("div", { className: styles.matchRow, children: [_jsxs("div", { className: styles.teamHome, children: [_jsx("span", { className: styles.teamName, children: match.homeTeam.name }), _jsx(FlagBox, { crest: match.homeTeam.crest, name: match.homeTeam.name })] }), _jsx("div", { className: styles.scoreBox, children: isFinished || isLive ? (_jsxs("div", { className: styles.scoreStack, children: [_jsxs("span", { className: styles.score, children: [match.score.home ?? '–', "\u00A0:\u00A0", match.score.away ?? '–'] }), isLive && hasTip && (_jsxs("span", { className: styles.liveTipCompare, children: ["Tipp: ", match.userTip.home, ":", match.userTip.away] }))] })) : (_jsx("div", { className: styles.kickoffCenter, children: _jsx("span", { className: styles.kickoffCenterTime, children: formatKickoff(match.utcDate) }) })) }), _jsxs("div", { className: styles.teamAway, children: [_jsx(FlagBox, { crest: match.awayTeam.crest, name: match.awayTeam.name }), _jsx("span", { className: styles.teamName, children: match.awayTeam.name })] })] }), _jsx("div", { className: `${styles.tipRow} ${hasTip && points !== null ? `${styles.resultBanner} ${resultClass}` : ''}`, children: state === 'missed' ? (
|
||||
/* ── Missed: no tip, match finished ── */
|
||||
_jsx("span", { className: styles.missedLabel, children: "Nicht getippt" })) : state === 'live' ? (
|
||||
/* ── Live: no tip input, locked ── */
|
||||
hasTip ? (_jsxs("div", { className: styles.tipDisplay, children: [_jsx("div", { className: styles.tipLeft }), _jsxs("div", { className: styles.tipCenter, children: [_jsx("span", { className: styles.tipLabel, children: "DEIN TIPP" }), _jsxs("span", { className: styles.tipScore, children: [match.userTip.home, " : ", match.userTip.away] })] }), _jsx("div", { className: styles.tipRight })] })) : (_jsx("span", { className: styles.noTip, children: "Kein Tipp abgegeben" }))) : hasTip ? (points !== null ? (
|
||||
/* ── Auswertungs-Banner ── */
|
||||
_jsxs("div", { className: styles.tipDisplay, children: [_jsxs("div", { className: `${styles.tipLeft} ${styles.bannerLeft}`, children: [_jsx("span", { className: styles.resultIcon, children: points === 3 ? _jsx(Check, { size: 14, strokeWidth: 3 }) :
|
||||
points === 1 ? _jsx(TrendingUp, { size: 14, strokeWidth: 2.5 }) :
|
||||
_jsx(X, { size: 14, strokeWidth: 3 }) }), _jsx("span", { className: styles.resultLabel, children: points === 3 ? 'Exakt' : points === 1 ? 'Tendenz' : 'Falsch' })] }), _jsx("div", { className: styles.tipCenter, children: _jsxs("span", { className: styles.tipScoreBanner, children: [match.userTip.home, " : ", match.userTip.away] }) }), _jsx("div", { className: styles.tipRight, children: _jsx("span", { className: `${styles.pointsBadge} ${points === 3 ? styles.pointsBadge_exact :
|
||||
points === 1 ? styles.pointsBadge_tendency :
|
||||
styles.pointsBadge_wrong}`, children: points === 0 ? '0 Pkt.' : `+${points} Pkt.` }) })] })) : (
|
||||
/* ── Tipp vorhanden, noch nicht ausgewertet (tipped state) ── */
|
||||
_jsxs("div", { className: styles.tipDisplay, children: [_jsx("div", { className: styles.tipLeft, children: match.tippable && (_jsx("button", { className: styles.changeBtn, onClick: onTip, children: "\u00C4ndern" })) }), _jsxs("div", { className: styles.tipCenter, children: [!match.tippable && _jsx("span", { className: styles.tipLabel, children: "DEIN TIPP" }), _jsxs("span", { className: styles.tipDisplay_score, children: [_jsx(Check, { size: 13, strokeWidth: 3, style: { color: 'var(--success)', flexShrink: 0 } }), _jsxs("span", { className: styles.tipScore, children: [match.userTip.home, " : ", match.userTip.away] })] })] }), _jsx("div", { className: styles.tipRight })] }))) : match.tippable ? (_jsx("button", { className: `btn-primary ${styles.tipBtn}`, onClick: onTip, children: "Tipp abgeben" })) : (_jsx("span", { className: styles.noTip, children: "Kein Tipp abgegeben" })) })] }));
|
||||
}
|
||||
@@ -341,3 +341,121 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
|
||||
/* Score + live tip stacked */
|
||||
.scoreStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.liveTipCompare {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* 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, var(--gold), #FFD700);
|
||||
color: #1a1a1a;
|
||||
animation: shimmer 2s ease-in-out;
|
||||
}
|
||||
|
||||
.pointsBadge_tendency {
|
||||
background: var(--success);
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.pointsBadge_wrong {
|
||||
background: var(--text-muted);
|
||||
color: var(--bg-deep);
|
||||
}
|
||||
|
||||
/* 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); }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Check, TrendingUp, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Match } from '../api/client';
|
||||
import styles from './MatchCard.module.css';
|
||||
|
||||
@@ -7,6 +8,32 @@ interface Props {
|
||||
onTip: () => void;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
SCHEDULED: 'Geplant',
|
||||
TIMED: 'Terminiert',
|
||||
@@ -45,10 +72,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,19 +92,29 @@ 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}
|
||||
{isLive && <span className={styles.liveDot} />}
|
||||
{STATUS_LABELS[match.status] ?? match.status}
|
||||
</span>
|
||||
{match.group && (
|
||||
<span className={styles.group}>
|
||||
{match.group.replace('GROUP_', 'Gruppe ')}
|
||||
</span>
|
||||
)}
|
||||
{match.tippable && <CountdownBadge minutes={match.minutesUntilKickoff} />}
|
||||
{/* Countdown only shown for open/tipped states */}
|
||||
{(state === 'open' || state === 'tipped') && match.tippable && (
|
||||
match.minutesUntilKickoff < 60 ? (
|
||||
<span className={`${styles.countdown} ${remainingMins < 5 ? styles.countdownUrgent : ''}`}>
|
||||
Noch {remainingMins} Min!
|
||||
</span>
|
||||
) : (
|
||||
<CountdownBadge minutes={match.minutesUntilKickoff} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Teams + Score */}
|
||||
@@ -86,9 +128,17 @@ export default function MatchCard({ match, onTip }: Props) {
|
||||
{/* Score / Kickoff time */}
|
||||
<div className={styles.scoreBox}>
|
||||
{isFinished || isLive ? (
|
||||
<span className={styles.score}>
|
||||
{match.score.home ?? '–'} : {match.score.away ?? '–'}
|
||||
</span>
|
||||
<div className={styles.scoreStack}>
|
||||
<span className={styles.score}>
|
||||
{match.score.home ?? '–'} : {match.score.away ?? '–'}
|
||||
</span>
|
||||
{/* For live: show user's tip next to score for comparison */}
|
||||
{isLive && hasTip && (
|
||||
<span className={styles.liveTipCompare}>
|
||||
Tipp: {match.userTip!.home}:{match.userTip!.away}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.kickoffCenter}>
|
||||
<span className={styles.kickoffCenterTime}>{formatKickoff(match.utcDate)}</span>
|
||||
@@ -105,7 +155,26 @@ export default function MatchCard({ match, onTip }: Props) {
|
||||
|
||||
{/* 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}>
|
||||
@@ -130,24 +199,30 @@ 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 ── */
|
||||
/* ── Tipp vorhanden, noch nicht ausgewertet (tipped state) ── */
|
||||
<div className={styles.tipDisplay}>
|
||||
<div className={styles.tipLeft}>
|
||||
{match.tippable && (
|
||||
<button className={styles.editBtn} onClick={onTip}>Ändern</button>
|
||||
<button className={styles.changeBtn} 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 className={styles.tipDisplay_score}>
|
||||
<Check size={13} strokeWidth={3} style={{ color: 'var(--success)', flexShrink: 0 }} />
|
||||
<span className={styles.tipScore}>
|
||||
{match.userTip!.home} : {match.userTip!.away}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.tipRight} />
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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 || 1;
|
||||
const segments = [
|
||||
{ value: exact / all, color: 'var(--gold)', label: 'Exakt' },
|
||||
{ value: tendency / all, color: 'var(--success)', label: 'Tendenz' },
|
||||
{ value: wrong / all, color: 'var(--error)', label: 'Falsch' },
|
||||
];
|
||||
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: "Punkte" })] }), _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, ": ", Math.round(seg.value * all)] }, i))) })] }));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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 || 1;
|
||||
|
||||
const segments = [
|
||||
{ value: exact / all, color: 'var(--gold)', label: 'Exakt' },
|
||||
{ value: tendency / all, color: 'var(--success)', label: 'Tendenz' },
|
||||
{ value: wrong / all, color: 'var(--error)', label: 'Falsch' },
|
||||
];
|
||||
|
||||
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">
|
||||
Punkte
|
||||
</text>
|
||||
</svg>
|
||||
<div className={styles.legend}>
|
||||
{segments.map((seg, i) => (
|
||||
<span key={i} className={styles.legendItem}>
|
||||
<span className={styles.dot} style={{ background: seg.color }} />
|
||||
{seg.label}: {Math.round(seg.value * all)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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" })] }));
|
||||
}
|
||||
@@ -57,27 +57,6 @@
|
||||
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;
|
||||
@@ -141,13 +120,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;
|
||||
@@ -155,31 +127,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;
|
||||
@@ -241,7 +188,7 @@
|
||||
inset 0 -1px 0 rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
/* Glossy sheen – gleich wie flagLarge */
|
||||
/* Glossy sheen */
|
||||
.pickerBtn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -297,7 +244,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;
|
||||
@@ -319,253 +266,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 var(--border-subtle);
|
||||
}
|
||||
|
||||
.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);
|
||||
@@ -600,3 +300,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; }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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 styles from './TipModal.module.css';
|
||||
|
||||
@@ -17,132 +16,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 +39,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,15 +59,6 @@ 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}>
|
||||
@@ -193,21 +71,7 @@ export default function TipModal({ match, onClose, onSaved }: Props) {
|
||||
<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}>
|
||||
@@ -240,93 +104,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}`}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { jsx as _jsx } from "react/jsx-runtime";
|
||||
import { useEffect } from 'react';
|
||||
import styles from './Toast.module.css';
|
||||
export default function Toast({ message, onDismiss, duration = 5000 }) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onDismiss, duration);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onDismiss, duration]);
|
||||
return (_jsx("div", { className: styles.toast, onClick: onDismiss, children: message }));
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect } 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) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onDismiss, duration);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onDismiss, duration]);
|
||||
|
||||
return (
|
||||
<div className={styles.toast} onClick={onDismiss}>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+6
@@ -4,8 +4,14 @@ interface Window {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../api/client';
|
||||
const RANK_KEY = 'tippspiel_last_rank';
|
||||
export function useRankChange() {
|
||||
const [message, setMessage] = useState(null);
|
||||
useEffect(() => {
|
||||
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!`);
|
||||
}
|
||||
}
|
||||
localStorage.setItem(RANK_KEY, String(stats.rank));
|
||||
}).catch(() => { });
|
||||
}, []);
|
||||
function dismiss() { setMessage(null); }
|
||||
return { message, dismiss };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../api/client';
|
||||
|
||||
const RANK_KEY = 'tippspiel_last_rank';
|
||||
|
||||
export function useRankChange() {
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
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!`);
|
||||
}
|
||||
}
|
||||
localStorage.setItem(RANK_KEY, String(stats.rank));
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
function dismiss() { setMessage(null); }
|
||||
return { message, dismiss };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -27,6 +27,9 @@
|
||||
--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 --- */
|
||||
@@ -50,6 +53,7 @@
|
||||
--shadow-card: 0 4px 16px rgba(0,0,0,0.10);
|
||||
--card-shine: rgba(255,255,255,0.7);
|
||||
--scrollbar-bg: var(--surface-high);
|
||||
--primary-rgb: 26, 143, 227;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
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, {}) }) }));
|
||||
@@ -0,0 +1,62 @@
|
||||
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 })] }));
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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: 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: 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: styles.statTile, children: [_jsx("span", { className: styles.statValue, children: stats.totalPoints }), _jsx("span", { className: styles.statLabel, children: "Punkte" })] }), _jsxs("div", { className: 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: styles.nudge, onClick: () => {
|
||||
if (nudge.type === 'untipped')
|
||||
navigate('/spiele');
|
||||
else if (nudge.type === 'leader')
|
||||
navigate('/rangliste');
|
||||
}, children: nudge.text }, i))) }))] }));
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
.dashboard {
|
||||
padding: 16px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero {
|
||||
background: var(--surface-mid);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(75, 183, 248, 0.1);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.hero:hover {
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
.heroLabel {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.heroCountdown {
|
||||
color: var(--gold);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.heroTeams {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.heroTeam {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.heroCrest {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.heroVs {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.heroTip {
|
||||
background: var(--surface-high);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 16px;
|
||||
text-align: center;
|
||||
color: var(--gold);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.heroTipBtn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.statsRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.statTile {
|
||||
background: var(--surface-mid);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 8px;
|
||||
text-align: center;
|
||||
border: 1px solid rgba(75, 183, 248, 0.08);
|
||||
}
|
||||
|
||||
.statValue {
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.nudges {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.nudge {
|
||||
background: var(--surface-low);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 16px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.nudge:hover {
|
||||
background: var(--surface-mid);
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api, DashboardData } from '../api/client';
|
||||
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 formatCountdown(minutes: number): string {
|
||||
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: Props) {
|
||||
const [data, setData] = useState<DashboardData | null>(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 <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;
|
||||
|
||||
return (
|
||||
<div className={styles.dashboard}>
|
||||
{/* Hero Card */}
|
||||
<div className={styles.hero} onClick={() => navigate('/spiele')}>
|
||||
<div className={styles.heroLabel}>
|
||||
<span>Nächstes Spiel</span>
|
||||
{hero && (
|
||||
<span className={styles.heroCountdown}>
|
||||
{formatCountdown(hero.match.minutesUntilKickoff)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hero ? (
|
||||
<>
|
||||
<div className={styles.heroTeams}>
|
||||
<div className={styles.heroTeam}>
|
||||
{hero.match.homeTeam.crest ? (
|
||||
<img
|
||||
src={hero.match.homeTeam.crest}
|
||||
alt={hero.match.homeTeam.name}
|
||||
className={styles.heroCrest}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.heroCrest} />
|
||||
)}
|
||||
<span>{hero.match.homeTeam.shortName}</span>
|
||||
</div>
|
||||
<span className={styles.heroVs}>vs</span>
|
||||
<div className={styles.heroTeam}>
|
||||
{hero.match.awayTeam.crest ? (
|
||||
<img
|
||||
src={hero.match.awayTeam.crest}
|
||||
alt={hero.match.awayTeam.name}
|
||||
className={styles.heroCrest}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.heroCrest} />
|
||||
)}
|
||||
<span>{hero.match.awayTeam.shortName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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(); navigate('/spiele'); }}
|
||||
>
|
||||
Jetzt tippen
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p style={{ textAlign: 'center', color: 'var(--text-muted)', margin: '16px 0' }}>
|
||||
Keine anstehenden Spiele
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className={styles.statsRow}>
|
||||
<div className={styles.statTile}>
|
||||
<span className={styles.statValue}>
|
||||
{stats.rank !== null ? stats.rank : '—'}
|
||||
</span>
|
||||
<span className={styles.statLabel}>Dein Rang</span>
|
||||
</div>
|
||||
<div className={styles.statTile}>
|
||||
<span className={styles.statValue}>{stats.totalPoints}</span>
|
||||
<span className={styles.statLabel}>Punkte</span>
|
||||
</div>
|
||||
<div className={styles.statTile}>
|
||||
<span className={styles.statValue}>{formatStreak(stats.streak)}</span>
|
||||
<span className={styles.statLabel}>Streak</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nudges */}
|
||||
{nudges.length > 0 && (
|
||||
<div className={styles.nudges}>
|
||||
{nudges.map((nudge, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={styles.nudge}
|
||||
onClick={() => {
|
||||
if (nudge.type === 'untipped') navigate('/spiele');
|
||||
else if (nudge.type === 'leader') navigate('/rangliste');
|
||||
}}
|
||||
>
|
||||
{nudge.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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" })] }))] }))] }));
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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 }))] }));
|
||||
}
|
||||
@@ -28,83 +28,66 @@
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
.stageFilter {
|
||||
background: var(--surface-mid);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid rgba(75, 183, 248, 0.15);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
font-size: 0.85rem;
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.filter, .filterActive {
|
||||
padding: 7px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.filter:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.filterActive {
|
||||
background: var(--primary-dim);
|
||||
border-color: rgba(75,183,248,0.3);
|
||||
color: var(--primary);
|
||||
.section {
|
||||
margin-bottom: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Gruppen-Filter (zweite Ebene) */
|
||||
.groupFilters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
margin-top: -8px;
|
||||
.sectionHighlight {
|
||||
border-left: 3px solid var(--gold);
|
||||
}
|
||||
|
||||
.groupFilter, .groupFilterActive {
|
||||
padding: 5px 13px;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.groupFilter:hover { border-color: rgba(75,183,248,0.3); color: var(--text-secondary); }
|
||||
.groupFilterActive {
|
||||
background: rgba(75,183,248,0.12);
|
||||
border-color: rgba(75,183,248,0.25);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.dayGroup { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.dayHeader {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0 4px;
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--surface-mid);
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.todayDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
box-shadow: 0 0 6px rgba(75,183,248,0.6);
|
||||
flex-shrink: 0;
|
||||
.sectionHeader:hover {
|
||||
background: var(--surface-high);
|
||||
}
|
||||
|
||||
.matchList { display: flex; flex-direction: column; gap: 10px; }
|
||||
.sectionLabel {
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sectionCount {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sectionChevron {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sectionContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* States */
|
||||
.loadingState, .errorState, .emptyState {
|
||||
|
||||
+106
-138
@@ -1,33 +1,66 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { api, Match } 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';
|
||||
|
||||
const STAGES: { key: string; label: string }[] = [
|
||||
{ key: '', label: 'Alle' },
|
||||
{ key: 'GROUP_STAGE', label: 'Gruppenphase' },
|
||||
{ key: 'LAST_32', label: 'Runde der 32' },
|
||||
{ key: 'LAST_16', label: 'Achtelfinale' },
|
||||
{ key: 'QUARTER_FINALS', label: 'Viertelfinale' },
|
||||
{ key: 'SEMI_FINALS', label: 'Halbfinale' },
|
||||
{ key: 'THIRD_PLACE', label: 'Platz 3' },
|
||||
{ key: 'FINAL', label: 'Finale' },
|
||||
];
|
||||
type Section = {
|
||||
key: string;
|
||||
label: string;
|
||||
matches: Match[];
|
||||
defaultOpen: boolean;
|
||||
highlight: boolean;
|
||||
};
|
||||
|
||||
const GROUPS = ['A','B','C','D','E','F','G','H','I','J','K','L'];
|
||||
function groupIntoSections(matches: Match[]): Section[] {
|
||||
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: Section[] = [
|
||||
{ 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<Match[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [stage, setStage] = useState('');
|
||||
const [group, setGroup] = useState('');
|
||||
const [stageFilter, setStageFilter] = useState('');
|
||||
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
||||
const todayRef = useRef<HTMLDivElement | null>(null);
|
||||
const hasScrolled = useRef(false);
|
||||
const [openSections, setOpenSections] = useState<Set<string>>(new Set());
|
||||
|
||||
// Alle Spiele einmalig laden — Filterung passiert im Frontend
|
||||
const loadMatches = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -43,15 +76,14 @@ export default function MatchesPage() {
|
||||
|
||||
useEffect(() => { loadMatches(); }, [loadMatches]);
|
||||
|
||||
// Auto-Scroll zum heutigen Datum nach dem ersten Laden
|
||||
// Initialize open sections after initial load
|
||||
useEffect(() => {
|
||||
if (!loading && !hasScrolled.current && todayRef.current && !stage) {
|
||||
hasScrolled.current = true;
|
||||
setTimeout(() => {
|
||||
todayRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, 100);
|
||||
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)));
|
||||
}
|
||||
}, [loading, stage]);
|
||||
}, [allMatches]); // only on initial load
|
||||
|
||||
const handleTipSaved = (matchId: number, tipHome: number, tipAway: number) => {
|
||||
setAllMatches(prev => prev.map(m =>
|
||||
@@ -62,46 +94,34 @@ export default function MatchesPage() {
|
||||
setSelectedMatch(null);
|
||||
};
|
||||
|
||||
// Gefilterte Matches für aktuelle Ansicht
|
||||
const matches = allMatches
|
||||
.filter(m => !stage || m.stage === stage)
|
||||
.filter(m => !group || m.group === `GROUP_${group}`);
|
||||
function toggleSection(key: string) {
|
||||
setOpenSections(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// Stats immer über alle Spiele (nicht gefiltert)
|
||||
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;
|
||||
|
||||
// Verfügbare Gruppen für den aktiven Stage-Filter
|
||||
const availableGroups = stage === 'GROUP_STAGE'
|
||||
? [...new Set(allMatches
|
||||
.filter(m => m.stage === 'GROUP_STAGE' && m.group)
|
||||
.map(m => m.group!.replace('GROUP_', ''))
|
||||
)].sort()
|
||||
: [];
|
||||
|
||||
// Spiele nach Datum gruppieren (Gruppenphase) oder nach Gruppe (wenn Gruppe gewählt)
|
||||
const grouped = matches.reduce<Record<string, Match[]>>((acc, m) => {
|
||||
let key: string;
|
||||
if (stage === 'GROUP_STAGE' && !group) {
|
||||
// Gruppenphase ohne Gruppen-Filter: nach Gruppe gruppieren
|
||||
key = `Gruppe ${m.group?.replace('GROUP_', '') ?? '?'}`;
|
||||
} else {
|
||||
// Sonst nach Datum
|
||||
key = new Date(m.utcDate).toLocaleDateString('de-DE', {
|
||||
weekday: 'long', day: 'numeric', month: 'long'
|
||||
});
|
||||
}
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(m);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
{revealMatch && (
|
||||
<ConfettiReveal match={revealMatch} onDismiss={dismissCurrent} />
|
||||
)}
|
||||
|
||||
{/* Header Stats */}
|
||||
<div className={styles.statsRow}>
|
||||
<div className={`card ${styles.statCard}`}>
|
||||
<span className={styles.statValue}>{matches.length}</span>
|
||||
<span className={styles.statValue}>{allMatches.length}</span>
|
||||
<span className={styles.statLabel}>Spiele gesamt</span>
|
||||
</div>
|
||||
<div className={`card ${styles.statCard}`}>
|
||||
@@ -114,39 +134,21 @@ export default function MatchesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stage Filter */}
|
||||
<div className={styles.filters}>
|
||||
{STAGES.map(s => (
|
||||
<button
|
||||
key={s.key}
|
||||
className={stage === s.key ? styles.filterActive : styles.filter}
|
||||
onClick={() => { setStage(s.key); setGroup(''); }}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Gruppen-Filter (nur bei Gruppenphase) */}
|
||||
{availableGroups.length > 0 && (
|
||||
<div className={styles.groupFilters}>
|
||||
<button
|
||||
className={group === '' ? styles.groupFilterActive : styles.groupFilter}
|
||||
onClick={() => setGroup('')}
|
||||
>
|
||||
Alle Gruppen
|
||||
</button>
|
||||
{availableGroups.map(g => (
|
||||
<button
|
||||
key={g}
|
||||
className={group === g ? styles.groupFilterActive : styles.groupFilter}
|
||||
onClick={() => setGroup(g)}
|
||||
>
|
||||
Gruppe {g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Stage Filter Dropdown */}
|
||||
<select
|
||||
className={styles.stageFilter}
|
||||
value={stageFilter}
|
||||
onChange={e => setStageFilter(e.target.value)}
|
||||
>
|
||||
<option value="">Alle Phasen</option>
|
||||
<option value="GROUP_STAGE">Gruppenphase</option>
|
||||
<option value="ROUND_OF_32">Runde der 32</option>
|
||||
<option value="LAST_16">Achtelfinale</option>
|
||||
<option value="QUARTER_FINALS">Viertelfinale</option>
|
||||
<option value="SEMI_FINALS">Halbfinale</option>
|
||||
<option value="THIRD_PLACE">Platz 3</option>
|
||||
<option value="FINAL">Finale</option>
|
||||
</select>
|
||||
|
||||
{/* Content */}
|
||||
{loading && (
|
||||
@@ -163,7 +165,7 @@ export default function MatchesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && matches.length === 0 && (
|
||||
{!loading && !error && filteredMatches.length === 0 && (
|
||||
<div className={styles.emptyState}>
|
||||
<span className={styles.emptyIcon}>⚽</span>
|
||||
<p>Noch keine Spiele vorhanden.</p>
|
||||
@@ -173,56 +175,22 @@ export default function MatchesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (() => {
|
||||
const entries = Object.entries(grouped);
|
||||
if (stage === 'GROUP_STAGE' && !group) {
|
||||
entries.sort(([a], [b]) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
const todayLabel = new Date().toLocaleDateString('de-DE', {
|
||||
weekday: 'long', day: 'numeric', month: 'long'
|
||||
});
|
||||
|
||||
// "Nächster bevorstehender Tag" als Fallback wenn kein heutiger Spieltag
|
||||
let scrollTarget: string | null = null;
|
||||
if (!stage) {
|
||||
if (entries.find(([l]) => l === todayLabel)) {
|
||||
scrollTarget = todayLabel;
|
||||
} else {
|
||||
const now = Date.now();
|
||||
const future = entries.find(([, ms]) => new Date(ms[0].utcDate).getTime() > now);
|
||||
scrollTarget = future?.[0] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return entries.map(([label, labelMatches]) => {
|
||||
const isScrollTarget = label === scrollTarget;
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
className={styles.dayGroup}
|
||||
ref={isScrollTarget ? todayRef : null}
|
||||
>
|
||||
<h2 className={styles.dayHeader}>
|
||||
{label === todayLabel ? (
|
||||
<><span className={styles.todayDot} />Heute — {label}</>
|
||||
) : label}
|
||||
</h2>
|
||||
<div className={styles.matchList}>
|
||||
{labelMatches
|
||||
.sort((a, b) => new Date(a.utcDate).getTime() - new Date(b.utcDate).getTime())
|
||||
.map(match => (
|
||||
<MatchCard
|
||||
key={match.id}
|
||||
match={match}
|
||||
onTip={() => setSelectedMatch(match)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!loading && !error && sections.map(section => (
|
||||
<div key={section.key} className={`${styles.section} ${section.highlight ? styles.sectionHighlight : ''}`}>
|
||||
<button className={styles.sectionHeader} onClick={() => toggleSection(section.key)}>
|
||||
<span className={styles.sectionLabel}>{section.label}</span>
|
||||
<span className={styles.sectionCount}>{section.matches.length} Spiele</span>
|
||||
<span className={styles.sectionChevron}>{openSections.has(section.key) ? '▾' : '▸'}</span>
|
||||
</button>
|
||||
{openSections.has(section.key) && (
|
||||
<div className={styles.sectionContent}>
|
||||
{section.matches.map(match => (
|
||||
<MatchCard key={match.id} match={match} onTip={() => setSelectedMatch(match)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{selectedMatch && (
|
||||
<TipModal
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
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 })] })] })] }))] }));
|
||||
}
|
||||
@@ -1,18 +1,42 @@
|
||||
/* ── Layout ── */
|
||||
.page { display: flex; flex-direction: column; gap: 20px; max-width: 640px; }
|
||||
|
||||
.loading { display: flex; justify-content: center; padding: 60px; }
|
||||
.spinner { width: 32px; height: 32px; border: 3px solid var(--surface-high); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
.spinnerSm { width: 14px; height: 14px; border: 2px solid var(--surface-high); border-top-color: var(--text-primary); border-radius: 50%; animation: spin 0.7s linear infinite; display: inline-block; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.empty { color: var(--text-secondary); padding: 40px; text-align: center; }
|
||||
|
||||
.heroCard { padding: 28px; display: flex; align-items: flex-start; gap: 20px; }
|
||||
.avatar { width: 60px; height: 60px; border-radius: 50%; background: var(--primary-dim); border: 2px solid rgba(75,183,248,0.3); display: flex; align-items: center; justify-content: center; font-family: 'Plus Jakarta Sans', sans-serif; font-size: 22px; font-weight: 800; color: var(--primary); flex-shrink: 0; margin-top: 2px; }
|
||||
.heroInfo { flex: 1; min-width: 0; }
|
||||
.name { font-size: 22px; font-weight: 800; }
|
||||
.rankBadge { font-size: 13px; color: var(--gold); margin-top: 4px; font-weight: 600; }
|
||||
/* ── Header card ── */
|
||||
.heroCard {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* Team-Feld */
|
||||
.teamRow { margin-top: 8px; }
|
||||
.avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-dim);
|
||||
border: 2px solid rgba(75,183,248,0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.heroInfo { flex: 1; min-width: 0; }
|
||||
.name { font-size: 22px; font-weight: 800; margin: 0 0 4px; }
|
||||
.rankBadge { font-size: 13px; color: var(--gold); font-weight: 600; margin-bottom: 8px; }
|
||||
|
||||
/* ── Team field ── */
|
||||
.teamRow { margin-top: 4px; }
|
||||
|
||||
.teamBtn {
|
||||
background: none;
|
||||
@@ -85,22 +109,122 @@
|
||||
.teamMsg { font-size: 12px; margin-top: 4px; }
|
||||
.teamMsgOk { color: var(--success); }
|
||||
.teamMsgErr { color: var(--error); }
|
||||
.heroPoints { text-align: right; }
|
||||
.pointsVal { font-size: 40px; font-weight: 800; color: var(--primary); line-height: 1; display: block; }
|
||||
.pointsLbl { font-size: 12px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
.statsGrid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.statCard { padding: 20px; text-align: center; }
|
||||
.statVal { font-size: 36px; font-weight: 800; display: block; }
|
||||
.statLbl { font-size: 13px; color: var(--text-secondary); display: block; margin-top: 4px; }
|
||||
/* ── Section title ── */
|
||||
.sectionTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.accuracyCard { padding: 24px; }
|
||||
.accuracyHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
|
||||
.accuracyLabel { font-size: 14px; color: var(--text-secondary); }
|
||||
.accuracyVal { font-size: 28px; font-weight: 800; color: var(--text-primary); }
|
||||
.bar { height: 10px; background: var(--surface-high); border-radius: 5px; overflow: hidden; display: flex; margin-bottom: 12px; }
|
||||
.barFill { height: 100%; transition: width 0.5s ease; }
|
||||
.exact { background: var(--gold); }
|
||||
.tendency { background: var(--primary); }
|
||||
.barLegend { display: flex; gap: 16px; font-size: 12px; color: var(--text-secondary); }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; }
|
||||
/* ── Stats ring card ── */
|
||||
.ringCard { padding: 20px 24px 16px; }
|
||||
|
||||
.accuracyRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--surface-high);
|
||||
}
|
||||
.accuracyLabel { font-size: 13px; color: var(--text-secondary); }
|
||||
.accuracyVal { font-size: 24px; font-weight: 800; color: var(--text-primary); }
|
||||
|
||||
/* ── Tip history ── */
|
||||
.historyCard { padding: 20px 0 8px; overflow: hidden; }
|
||||
.historyCard .sectionTitle { padding: 0 20px; }
|
||||
|
||||
.tipList {
|
||||
list-style: none;
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
max-height: 340px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tipRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tipRowAlt {
|
||||
background: rgba(255,255,255,0.03);
|
||||
}
|
||||
|
||||
.tipMatch {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tipScore {
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pointBadge {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
background: var(--surface-high);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.badgeExact { background: rgba(255,196,0,0.15); color: var(--gold); }
|
||||
.badgeTendency { background: rgba(34,197,94,0.15); color: var(--success); }
|
||||
.badgeWrong { background: rgba(239,68,68,0.12); color: var(--error); }
|
||||
|
||||
/* ── Fun stats ── */
|
||||
.funStats { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.funGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.funGrid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.funCard {
|
||||
padding: 16px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.funIcon { font-size: 22px; line-height: 1; }
|
||||
|
||||
.funLabel {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.funValue {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,49 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api, UserStats } from '../api/client';
|
||||
import { api, UserStats, MyTip } from '../api/client';
|
||||
import StatsRing from '../components/StatsRing';
|
||||
import styles from './ProfilePage.module.css';
|
||||
|
||||
function initials(name: string) {
|
||||
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
function mostCommonTip(tips: MyTip[]): string {
|
||||
const counts: Record<string, number> = {};
|
||||
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: MyTip[]): number {
|
||||
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<UserStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [teamEdit, setTeamEdit] = useState(false);
|
||||
const [teamValue, setTeamValue] = useState('');
|
||||
const [stats, setStats] = useState<UserStats | null>(null);
|
||||
const [tips, setTips] = useState<MyTip[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [teamEdit, setTeamEdit] = useState(false);
|
||||
const [teamValue, setTeamValue] = useState('');
|
||||
const [teamSaving, setTeamSaving] = useState(false);
|
||||
const [teamMsg, setTeamMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [teamMsg, setTeamMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.getMyStats().then(s => {
|
||||
Promise.all([
|
||||
api.getMyStats(),
|
||||
api.getMyTips(),
|
||||
]).then(([s, t]) => {
|
||||
setStats(s);
|
||||
setTeamValue(s.team ?? '');
|
||||
setTips(t.tips);
|
||||
}).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
@@ -41,19 +67,37 @@ export default function ProfilePage() {
|
||||
if (loading) return <div className={styles.loading}><div className={styles.spinner} /></div>;
|
||||
if (!stats) return <div className={styles.empty}>Profil nicht verfügbar.</div>;
|
||||
|
||||
const evaluated = stats.exactCount + stats.tendencyCount + stats.wrongCount;
|
||||
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: number | null) {
|
||||
if (points === null) return '';
|
||||
if (points >= 3) return styles.badgeExact;
|
||||
if (points >= 1) return styles.badgeTendency;
|
||||
return styles.badgeWrong;
|
||||
}
|
||||
|
||||
function pointLabel(points: number | null) {
|
||||
if (points === null) return '';
|
||||
if (points >= 3) return `${points} Pkt ✓✓`;
|
||||
if (points >= 1) return `${points} Pkt ✓`;
|
||||
return `${points} Pkt ✗`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
|
||||
{/* Hero card */}
|
||||
{/* Header card */}
|
||||
<div className={`card ${styles.heroCard}`}>
|
||||
<div className={styles.avatar}>{initials(stats.fullName)}</div>
|
||||
<div className={styles.heroInfo}>
|
||||
<h1 className={`font-display ${styles.name}`}>{stats.fullName}</h1>
|
||||
{stats.rank && <div className={styles.rankBadge}>🏆 Platz {stats.rank}</div>}
|
||||
|
||||
{/* Team-Feld */}
|
||||
{/* Team field */}
|
||||
<div className={styles.teamRow}>
|
||||
{teamEdit ? (
|
||||
<div className={styles.teamEditRow}>
|
||||
@@ -64,12 +108,20 @@ export default function ProfilePage() {
|
||||
placeholder="z. B. Vertrieb Süd"
|
||||
maxLength={80}
|
||||
autoFocus
|
||||
onKeyDown={e => { if (e.key === 'Enter') saveTeam(); if (e.key === 'Escape') setTeamEdit(false); }}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') saveTeam();
|
||||
if (e.key === 'Escape') setTeamEdit(false);
|
||||
}}
|
||||
/>
|
||||
<button className={styles.teamSaveBtn} onClick={saveTeam} disabled={teamSaving}>
|
||||
{teamSaving ? <span className={styles.spinnerSm} /> : '✓'}
|
||||
</button>
|
||||
<button className={styles.teamCancelBtn} onClick={() => { setTeamEdit(false); setTeamValue(stats.team ?? ''); }}>✕</button>
|
||||
<button
|
||||
className={styles.teamCancelBtn}
|
||||
onClick={() => { setTeamEdit(false); setTeamValue(stats.team ?? ''); }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className={styles.teamBtn} onClick={() => setTeamEdit(true)}>
|
||||
@@ -86,49 +138,67 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.heroPoints}>
|
||||
<span className={`font-display ${styles.pointsVal}`}>{stats.totalPoints}</span>
|
||||
<span className={styles.pointsLbl}>Punkte</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className={styles.statsGrid}>
|
||||
<div className={`card ${styles.statCard}`}>
|
||||
<span className={`font-display ${styles.statVal} text-gold`}>{stats.exactCount}</span>
|
||||
<span className={styles.statLbl}>🎯 Exakt</span>
|
||||
</div>
|
||||
<div className={`card ${styles.statCard}`}>
|
||||
<span className={`font-display ${styles.statVal} text-primary`}>{stats.tendencyCount}</span>
|
||||
<span className={styles.statLbl}>✓ Tendenz</span>
|
||||
</div>
|
||||
<div className={`card ${styles.statCard}`}>
|
||||
<span className={`font-display ${styles.statVal}`} style={{ color: 'var(--error)' }}>{stats.wrongCount}</span>
|
||||
<span className={styles.statLbl}>✗ Falsch</span>
|
||||
</div>
|
||||
<div className={`card ${styles.statCard}`}>
|
||||
<span className={`font-display ${styles.statVal}`}>{stats.tipsCount}</span>
|
||||
<span className={styles.statLbl}>Tipps gesamt</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accuracy bar */}
|
||||
{evaluated > 0 && (
|
||||
<div className={`card ${styles.accuracyCard}`}>
|
||||
<div className={styles.accuracyHeader}>
|
||||
{/* Stats donut ring */}
|
||||
<div className={`card ${styles.ringCard}`}>
|
||||
<h2 className={styles.sectionTitle}>Tipp-Statistik</h2>
|
||||
<StatsRing
|
||||
exact={stats.exactCount}
|
||||
tendency={stats.tendencyCount}
|
||||
wrong={stats.wrongCount}
|
||||
total={stats.totalPoints}
|
||||
/>
|
||||
{stats.accuracy > 0 && (
|
||||
<div className={styles.accuracyRow}>
|
||||
<span className={styles.accuracyLabel}>Trefferquote</span>
|
||||
<span className={`font-display ${styles.accuracyVal}`}>{stats.accuracy}%</span>
|
||||
</div>
|
||||
<div className={styles.bar}>
|
||||
<div className={`${styles.barFill} ${styles.exact}`}
|
||||
style={{ width: `${(stats.exactCount / evaluated) * 100}%` }} />
|
||||
<div className={`${styles.barFill} ${styles.tendency}`}
|
||||
style={{ width: `${(stats.tendencyCount / evaluated) * 100}%` }} />
|
||||
</div>
|
||||
<div className={styles.barLegend}>
|
||||
<span><span className={styles.dot} style={{ background: 'var(--gold)' }} /> Exakt</span>
|
||||
<span><span className={styles.dot} style={{ background: 'var(--primary)' }} /> Tendenz</span>
|
||||
<span><span className={styles.dot} style={{ background: 'var(--surface-high)' }} /> Falsch</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tip history */}
|
||||
{recentTips.length > 0 && (
|
||||
<div className={`card ${styles.historyCard}`}>
|
||||
<h2 className={styles.sectionTitle}>Letzte Tipps</h2>
|
||||
<ul className={styles.tipList}>
|
||||
{recentTips.map((tip, i) => (
|
||||
<li key={tip.match_id} className={`${styles.tipRow} ${i % 2 === 1 ? styles.tipRowAlt : ''}`}>
|
||||
<span className={styles.tipMatch}>
|
||||
{tip.home_team_short} vs {tip.away_team_short}
|
||||
</span>
|
||||
<span className={styles.tipScore}>
|
||||
Tipp: {tip.tip_home}:{tip.tip_away}
|
||||
</span>
|
||||
<span className={`${styles.pointBadge} ${pointBadgeClass(tip.points)}`}>
|
||||
{pointLabel(tip.points)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fun stats */}
|
||||
{tips.length > 0 && (
|
||||
<div className={styles.funStats}>
|
||||
<h2 className={styles.sectionTitle}>Fun Facts</h2>
|
||||
<div className={styles.funGrid}>
|
||||
<div className={`card ${styles.funCard}`}>
|
||||
<span className={styles.funIcon}>🎯</span>
|
||||
<span className={styles.funLabel}>Lieblings-Tipp</span>
|
||||
<span className={styles.funValue}>{favTip}</span>
|
||||
</div>
|
||||
<div className={`card ${styles.funCard}`}>
|
||||
<span className={styles.funIcon}>🏠</span>
|
||||
<span className={styles.funLabel}>Heimsiege getippt</span>
|
||||
<span className={styles.funValue}>{homePct}%</span>
|
||||
</div>
|
||||
<div className={`card ${styles.funCard}`}>
|
||||
<span className={styles.funIcon}>📊</span>
|
||||
<span className={styles.funLabel}>Tipps abgegeben</span>
|
||||
<span className={styles.funValue}>{stats.tipsCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user