Compare commits
82 Commits
3e02cdab3b
...
a521f5656c
| Author | SHA1 | Date | |
|---|---|---|---|
| a521f5656c | |||
| aa0c065bd6 | |||
| 786b3586be | |||
| edf33fa932 | |||
| d370558174 | |||
| 132ea4f7d0 | |||
| 1be1cdba2f | |||
| 2414fc04d9 | |||
| ab09b92050 | |||
| 1addb3940d | |||
| c2195aa02f | |||
| d38f650261 | |||
| fe777a2bab | |||
| 1999ef8a21 | |||
| 9c19188a7e | |||
| 9b78c4a46c | |||
| f88f2ac6bc | |||
| f8a6d12db1 | |||
| 048bf15a5e | |||
| 07f6a8ae13 | |||
| a7485da0e9 | |||
| d1189d5d6e | |||
| d44927ec23 | |||
| 799239dcc1 | |||
| 0e1675fe90 | |||
| 44199b0a90 | |||
| 91ea1f4dc3 | |||
| 27f93f76f9 | |||
| 75d69191fa | |||
| b2ca2c733a | |||
| c6c167abb3 | |||
| 8503592c7b | |||
| 23116a847a | |||
| 8b7b31826a | |||
| 1eaec75901 | |||
| 0f70a1913c | |||
| e9143d6ebe | |||
| 92f847c075 | |||
| 9cd55f8e28 | |||
| 3a1d99a92f | |||
| b7068ea2b0 | |||
| 6be9bcdc1b | |||
| 137e14b3d1 | |||
| e1b9f03d60 | |||
| d39ec7a579 | |||
| a7ce8141a3 | |||
| 4fe4d45270 | |||
| 6a40d71634 | |||
| 8bc00f12aa | |||
| 5af41a8a2c | |||
| e10aeadb6b | |||
| 676ed9c1b3 | |||
| 57bae63b68 | |||
| 7bb35ecf65 | |||
| addff8f0cc | |||
| dd65f7c4fe | |||
| 77ee3f9a45 | |||
| 7b19f3db98 | |||
| f1b4b63324 | |||
| 2dc55f29db | |||
| a304ceeff5 | |||
| f6ab2c719d | |||
| 89046a2e29 | |||
| 1ed64078b4 | |||
| 9a9b85a269 | |||
| d27881c1c2 | |||
| b10f0f6ad4 | |||
| cb095126ef | |||
| 4f148811f0 | |||
| 62aeda1395 | |||
| 69585cfac1 | |||
| ea5b7b19fa | |||
| e0462c5ba4 | |||
| 7bbbe01a03 | |||
| 7c9b7344aa | |||
| e0c4beadb1 | |||
| 7dac1befe7 | |||
| 01d7c10719 | |||
| f56ecb724b | |||
| 2127ebceeb | |||
| d7cd558caf | |||
| 94be5620a6 |
@@ -8,15 +8,139 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
steps:
|
steps:
|
||||||
|
- name: Setup tools
|
||||||
|
run: apk add --no-cache curl python3
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
rm -rf workspace && mkdir workspace
|
rm -rf workspace && mkdir workspace
|
||||||
git clone --depth 1 --branch main http://gitea:3000/mwf975_git/tippspiel.git workspace
|
GIT_TERMINAL_PROMPT=0 git clone \
|
||||||
|
--depth 1 \
|
||||||
|
--branch main \
|
||||||
|
http://x-token:${{ secrets.DEPLOY_TOKEN }}@gitea:3000/mwf975_git/tippspiel.git \
|
||||||
|
workspace
|
||||||
|
|
||||||
- name: Build Docker Image
|
- name: Create build context
|
||||||
run: |
|
run: |
|
||||||
cd workspace
|
cd workspace
|
||||||
docker build -t wm2026-tippspiel:latest -t wm2026-tippspiel:${GITHUB_SHA:-latest} .
|
tar cf /tmp/tippspiel-ci.tar \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='node_modules' \
|
||||||
|
--exclude='*.docx' \
|
||||||
|
--exclude='prototyp_*.html' \
|
||||||
|
.
|
||||||
|
echo "Build context size: $(du -sh /tmp/tippspiel-ci.tar | cut -f1)"
|
||||||
|
|
||||||
|
- name: Build Docker Image via Portainer
|
||||||
|
run: |
|
||||||
|
REGISTRY="git.home.rm-warpstation.de"
|
||||||
|
IMAGE_TAG="${REGISTRY}/mwf975_git/tippspiel:latest"
|
||||||
|
echo "Building image: $IMAGE_TAG"
|
||||||
|
curl -s -k -X POST \
|
||||||
|
"https://192.168.1.60:9444/api/endpoints/2/docker/build?t=${IMAGE_TAG}&dockerfile=./Dockerfile&nocache=1" \
|
||||||
|
-H "X-API-Key: ${{ secrets.PORTAINER_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/x-tar" \
|
||||||
|
--data-binary @/tmp/tippspiel-ci.tar \
|
||||||
|
--max-time 600 \
|
||||||
|
| tail -5
|
||||||
|
echo "Build completed."
|
||||||
|
|
||||||
|
- name: Push to Gitea Registry
|
||||||
|
run: |
|
||||||
|
REGISTRY="git.home.rm-warpstation.de"
|
||||||
|
IMAGE_TAG="${REGISTRY}/mwf975_git/tippspiel:latest"
|
||||||
|
DEPLOY_TOKEN="${{ secrets.DEPLOY_TOKEN }}"
|
||||||
|
|
||||||
|
AUTH_HEADER=$(python3 -c "
|
||||||
|
import base64, json
|
||||||
|
auth = json.dumps({'username': 'mwf975_git', 'password': '${DEPLOY_TOKEN}', 'serveraddress': 'https://${REGISTRY}'})
|
||||||
|
print(base64.urlsafe_b64encode(auth.encode()).decode())
|
||||||
|
")
|
||||||
|
|
||||||
|
echo "Pushing $IMAGE_TAG..."
|
||||||
|
curl -s -k -X POST \
|
||||||
|
"https://192.168.1.60:9444/api/endpoints/2/docker/images/${IMAGE_TAG}/push" \
|
||||||
|
-H "X-API-Key: ${{ secrets.PORTAINER_TOKEN }}" \
|
||||||
|
-H "X-Registry-Auth: $AUTH_HEADER" \
|
||||||
|
--max-time 300
|
||||||
|
echo ""
|
||||||
|
echo "Push completed."
|
||||||
|
|
||||||
|
- name: Redeploy Stack via Portainer
|
||||||
|
run: |
|
||||||
|
REGISTRY="git.home.rm-warpstation.de"
|
||||||
|
|
||||||
|
# Compose-File als separate Datei schreiben
|
||||||
|
cat > /tmp/compose-deploy.yml << 'COMPOSE_EOF'
|
||||||
|
services:
|
||||||
|
tippspiel:
|
||||||
|
image: git.home.rm-warpstation.de/mwf975_git/tippspiel:latest
|
||||||
|
container_name: wm2026-tippspiel
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3301:3001"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=${NODE_ENV}
|
||||||
|
- PORT=${PORT}
|
||||||
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
|
- SUPABASE_URL=${SUPABASE_URL}
|
||||||
|
- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY}
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||||
|
- FOOTBALL_API_KEY=${FOOTBALL_API_KEY}
|
||||||
|
- FOOTBALL_API_BASE_URL=${FOOTBALL_API_BASE_URL}
|
||||||
|
- ELEVENLABS_API_KEY=${ELEVENLABS_API_KEY}
|
||||||
|
- CORS_ORIGIN=${CORS_ORIGIN}
|
||||||
|
- STAFFBASE_PUBLIC_KEY=${STAFFBASE_PUBLIC_KEY:-}
|
||||||
|
- STAFFBASE_PLUGIN_ID=${STAFFBASE_PLUGIN_ID:-}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:3001/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 10s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- main-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
main-network:
|
||||||
|
external: true
|
||||||
|
COMPOSE_EOF
|
||||||
|
|
||||||
|
# Env-Vars aus Portainer lesen
|
||||||
|
ENV_VARS=$(curl -s -k \
|
||||||
|
"https://192.168.1.60:9444/api/stacks/115" \
|
||||||
|
-H "X-API-Key: ${{ secrets.PORTAINER_TOKEN }}" \
|
||||||
|
| python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin).get('Env', [])))")
|
||||||
|
|
||||||
|
# Stack-File lesen und Payload bauen
|
||||||
|
STACK_CONTENT=$(cat /tmp/compose-deploy.yml)
|
||||||
|
PAYLOAD=$(python3 -c "
|
||||||
|
import json, sys
|
||||||
|
compose = open('/tmp/compose-deploy.yml').read()
|
||||||
|
env_vars = json.loads(sys.argv[1])
|
||||||
|
print(json.dumps({
|
||||||
|
'stackFileContent': compose,
|
||||||
|
'env': env_vars,
|
||||||
|
'prune': True,
|
||||||
|
'pullImage': True
|
||||||
|
}))
|
||||||
|
" "$ENV_VARS")
|
||||||
|
|
||||||
|
echo "Redeploying stack..."
|
||||||
|
curl -s -k -X PUT \
|
||||||
|
"https://192.168.1.60:9444/api/stacks/115?endpointId=2" \
|
||||||
|
-H "X-API-Key: ${{ secrets.PORTAINER_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$PAYLOAD" \
|
||||||
|
| python3 -c "import sys,json; d=json.load(sys.stdin); print('Stack:', d.get('Name'), '| Status:', d.get('Status'))" \
|
||||||
|
|| echo "Stack redeploy triggered."
|
||||||
|
|
||||||
|
- name: Verify deployment
|
||||||
|
run: |
|
||||||
|
sleep 20
|
||||||
|
STATUS=$(curl -s http://192.168.1.60:3301/health | python3 -c "import sys,json; print(d:=json.load(sys.stdin), d.get('status'))" 2>/dev/null || echo "unreachable")
|
||||||
|
echo "Health check: $STATUS"
|
||||||
|
|
||||||
- name: Cleanup
|
- name: Cleanup
|
||||||
run: rm -rf workspace
|
if: always()
|
||||||
|
run: rm -rf workspace /tmp/tippspiel-ci.tar /tmp/compose-deploy.yml
|
||||||
|
|||||||
@@ -18,3 +18,17 @@ backend/public/
|
|||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
|
.mcp.json
|
||||||
|
|
||||||
|
# Docs / local tools
|
||||||
|
DEPLOY_DOCKER_TEST.md
|
||||||
|
tools.yaml
|
||||||
|
|
||||||
|
# Compiled JS in TypeScript source
|
||||||
|
frontend/src/**/*.js
|
||||||
|
|
||||||
|
# Superpowers brainstorm artifacts
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
docs/superpowers/**
|
||||||
@@ -7,6 +7,7 @@ WORKDIR /app/frontend
|
|||||||
COPY frontend/package.json frontend/package-lock.json ./
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
|
ENV VITE_TEST_MODE=true
|
||||||
RUN npx vite build
|
RUN npx vite build
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -41,7 +42,7 @@ COPY --from=build-frontend /app/backend/public ./public
|
|||||||
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=development
|
||||||
ENV PORT=3001
|
ENV PORT=3001
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
|
|||||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 282 KiB |
@@ -6,7 +6,6 @@ import rateLimit from 'express-rate-limit';
|
|||||||
|
|
||||||
import { staffbaseAuth as createStaffbaseAuth } from './middleware/staffbaseAuth';
|
import { staffbaseAuth as createStaffbaseAuth } from './middleware/staffbaseAuth';
|
||||||
import { devAuth } from './middleware/devAuth';
|
import { devAuth } from './middleware/devAuth';
|
||||||
import { checkDbConnection } from './db/client';
|
|
||||||
import { logger } from './services/logger';
|
import { logger } from './services/logger';
|
||||||
|
|
||||||
import matchesRouter from './routes/matches';
|
import matchesRouter from './routes/matches';
|
||||||
@@ -15,7 +14,8 @@ import leaderboardRouter from './routes/leaderboard';
|
|||||||
import adminRouter from './routes/admin';
|
import adminRouter from './routes/admin';
|
||||||
import profileRouter from './routes/profile';
|
import profileRouter from './routes/profile';
|
||||||
import devRouter from './routes/dev';
|
import devRouter from './routes/dev';
|
||||||
import agentRouter from './routes/agent';
|
import dashboardRouter from './routes/dashboard';
|
||||||
|
import achievementsRouter from './routes/achievements';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = parseInt(process.env.PORT ?? '3001');
|
const PORT = parseInt(process.env.PORT ?? '3001');
|
||||||
@@ -28,13 +28,19 @@ app.use(
|
|||||||
// Staffbase lädt das Plugin in einem iFrame
|
// Staffbase lädt das Plugin in einem iFrame
|
||||||
// X-Frame-Options muss daher SAMEORIGIN oder ALLOW-FROM erlauben
|
// X-Frame-Options muss daher SAMEORIGIN oder ALLOW-FROM erlauben
|
||||||
frameguard: false,
|
frameguard: false,
|
||||||
|
// HSTS nur aktivieren wenn wir über HTTPS laufen (z.B. hinter einem Reverse Proxy)
|
||||||
|
hsts: process.env.NODE_ENV === 'production' && process.env.PLUGIN_BASE_URL?.startsWith('https')
|
||||||
|
? { maxAge: 15552000, includeSubDomains: true }
|
||||||
|
: false,
|
||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
directives: {
|
directives: {
|
||||||
defaultSrc: ["'self'"],
|
defaultSrc: ["'self'"],
|
||||||
scriptSrc: ["'self'"],
|
scriptSrc: ["'self'"],
|
||||||
styleSrc: ["'self'", "'unsafe-inline'"], // für inline styles im Frontend
|
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
||||||
|
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'https://cdn.jsdelivr.net'],
|
||||||
imgSrc: ["'self'", 'data:', 'https://crests.football-data.org'],
|
imgSrc: ["'self'", 'data:', 'https://crests.football-data.org'],
|
||||||
frameAncestors: ['https://app.staffbase.com', 'https://*.staffbase.com'],
|
frameAncestors: ['https://app.staffbase.com', 'https://*.staffbase.com'],
|
||||||
|
upgradeInsecureRequests: process.env.PLUGIN_BASE_URL?.startsWith('https') ? [] : null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -48,6 +54,10 @@ const allowedOrigins = (process.env.CORS_ORIGIN ?? 'https://app.staffbase.com')
|
|||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: (origin, callback) => {
|
origin: (origin, callback) => {
|
||||||
|
// Wildcard: alle Origins erlauben
|
||||||
|
if (allowedOrigins.includes('*')) {
|
||||||
|
return callback(null, true);
|
||||||
|
}
|
||||||
// Requests ohne Origin (z.B. direkte API-Calls) in Development erlauben
|
// Requests ohne Origin (z.B. direkte API-Calls) in Development erlauben
|
||||||
if (!origin && process.env.NODE_ENV !== 'production') {
|
if (!origin && process.env.NODE_ENV !== 'production') {
|
||||||
return callback(null, true);
|
return callback(null, true);
|
||||||
@@ -87,24 +97,12 @@ app.use(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Rate limit für Agent-Chat (Claude API Calls sind kostenpflichtig)
|
|
||||||
app.use(
|
|
||||||
'/api/agent',
|
|
||||||
rateLimit({
|
|
||||||
windowMs: 60 * 1000, // 1 Minute
|
|
||||||
max: 10, // 10 Chat-Nachrichten pro User/Minute
|
|
||||||
message: { error: 'Too many agent requests, bitte kurz warten.' },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Health Check (ohne Auth – für Railway/Render/Uptime-Monitoring)
|
// Health Check (ohne Auth – für Railway/Render/Uptime-Monitoring)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
app.get('/health', async (_req, res) => {
|
app.get('/health', (_req, res) => {
|
||||||
const dbOk = await checkDbConnection();
|
res.status(200).json({
|
||||||
res.status(dbOk ? 200 : 503).json({
|
status: 'ok',
|
||||||
status: dbOk ? 'ok' : 'degraded',
|
|
||||||
db: dbOk ? 'connected' : 'unreachable',
|
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
version: process.env.npm_package_version ?? '1.0.0',
|
version: process.env.npm_package_version ?? '1.0.0',
|
||||||
});
|
});
|
||||||
@@ -130,22 +128,30 @@ app.use('/api/tips', tipsRouter);
|
|||||||
app.use('/api/leaderboard', leaderboardRouter);
|
app.use('/api/leaderboard', leaderboardRouter);
|
||||||
app.use('/api/admin', adminRouter);
|
app.use('/api/admin', adminRouter);
|
||||||
app.use('/api/profile', profileRouter);
|
app.use('/api/profile', profileRouter);
|
||||||
app.use('/api/agent', agentRouter);
|
app.use('/api/dashboard', dashboardRouter);
|
||||||
|
app.use('/api/achievements', achievementsRouter);
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
app.use('/api/dev', devRouter);
|
app.use('/api/dev', devRouter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Frontend (React Build) – statisches Serving
|
// Frontend (React Build) – statisches Serving
|
||||||
// In Production liefert das Backend auch das Frontend aus
|
// Wird in allen Modi aktiviert, wenn ein public-Ordner existiert
|
||||||
|
// (Docker-Container liefern Frontend immer über das Backend aus)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
if (process.env.NODE_ENV === 'production') {
|
{
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const path = require('path') as typeof import('path');
|
const path = require('path') as typeof import('path');
|
||||||
app.use(express.static(path.join(process.cwd(), 'public')));
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const fs = require('fs') as typeof import('fs');
|
||||||
|
const publicDir = path.join(process.cwd(), 'public');
|
||||||
|
if (fs.existsSync(publicDir)) {
|
||||||
|
logger.info('Serving frontend static files from', { publicDir });
|
||||||
|
app.use(express.static(publicDir));
|
||||||
app.get('*', (_req, res) => {
|
app.get('*', (_req, res) => {
|
||||||
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
|
res.sendFile(path.join(publicDir, 'index.html'));
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { query } from '../db/client';
|
||||||
|
import { logger } from '../services/logger';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
export interface Achievement {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string; // Material Symbol name
|
||||||
|
color: string; // CSS color for the glow
|
||||||
|
rankLabel: string; // e.g. "Gold-Rang", "On Fire"
|
||||||
|
unlocked: boolean;
|
||||||
|
progress: number; // 0-100 percentage
|
||||||
|
current: number; // current value
|
||||||
|
target: number; // target value
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const userId = req.staffbaseUser?.sub;
|
||||||
|
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get user stats
|
||||||
|
const statsResult = await query<any>(
|
||||||
|
`SELECT total_points, tips_count, exact_count, tendency_count, rank
|
||||||
|
FROM leaderboard WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
const stats = statsResult[0];
|
||||||
|
const exactCount = parseInt(String(stats?.exact_count ?? '0'));
|
||||||
|
const tipsCount = parseInt(String(stats?.tips_count ?? '0'));
|
||||||
|
const rank = stats?.rank ? parseInt(String(stats.rank)) : null;
|
||||||
|
|
||||||
|
// Calculate streak (same logic as dashboard)
|
||||||
|
const pastMatches = await query<{ has_tip: boolean }>(
|
||||||
|
`SELECT CASE WHEN t.id IS NOT NULL THEN true ELSE false END AS has_tip
|
||||||
|
FROM matches m
|
||||||
|
LEFT JOIN tips t ON t.match_id = m.id AND t.user_id = $1
|
||||||
|
WHERE m.status IN ('FINISHED', 'IN_PLAY')
|
||||||
|
ORDER BY m.utc_date DESC`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
let maxStreak = 0;
|
||||||
|
let currentStreak = 0;
|
||||||
|
for (const m of pastMatches) {
|
||||||
|
if (m.has_tip === true || (m.has_tip as unknown) === 't') {
|
||||||
|
currentStreak++;
|
||||||
|
if (currentStreak > maxStreak) maxStreak = currentStreak;
|
||||||
|
} else {
|
||||||
|
currentStreak = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check early tipper: tips submitted >24h before kickoff
|
||||||
|
const earlyTips = await query<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) AS count FROM tips t
|
||||||
|
JOIN matches m ON m.id = t.match_id
|
||||||
|
WHERE t.user_id = $1
|
||||||
|
AND m.status = 'FINISHED'
|
||||||
|
AND t.created_at < m.utc_date - interval '24 hours'`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
const earlyCount = parseInt(earlyTips[0]?.count || '0');
|
||||||
|
const finishedWithTips = await query<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) AS count FROM tips t
|
||||||
|
JOIN matches m ON m.id = t.match_id
|
||||||
|
WHERE t.user_id = $1 AND m.status = 'FINISHED'`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
const finishedTipCount = parseInt(finishedWithTips[0]?.count || '0');
|
||||||
|
|
||||||
|
// Check globetrotter: tips in all groups
|
||||||
|
const groupsTipped = await query<{ count: string }>(
|
||||||
|
`SELECT COUNT(DISTINCT m.group_name) AS count
|
||||||
|
FROM tips t JOIN matches m ON m.id = t.match_id
|
||||||
|
WHERE t.user_id = $1 AND m.stage = 'GROUP_STAGE' AND m.group_name IS NOT NULL`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
const groupCount = parseInt(groupsTipped[0]?.count || '0');
|
||||||
|
const totalGroups = 12; // WM 2026 has 12 groups (A-L)
|
||||||
|
|
||||||
|
// Build achievements
|
||||||
|
const achievements: Achievement[] = [
|
||||||
|
{
|
||||||
|
id: 'sharpshooter',
|
||||||
|
name: 'Scharfschütze',
|
||||||
|
description: '5 exakte Tipps',
|
||||||
|
icon: 'target',
|
||||||
|
color: '#f5ce53',
|
||||||
|
rankLabel: 'Gold-Rang',
|
||||||
|
unlocked: exactCount >= 5,
|
||||||
|
progress: Math.min(100, (exactCount / 5) * 100),
|
||||||
|
current: exactCount,
|
||||||
|
target: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'streak_master',
|
||||||
|
name: 'Serien-Tipper',
|
||||||
|
description: '10er Tipp-Serie',
|
||||||
|
icon: 'local_fire_department',
|
||||||
|
color: '#ff716c',
|
||||||
|
rankLabel: 'On Fire',
|
||||||
|
unlocked: maxStreak >= 10,
|
||||||
|
progress: Math.min(100, (maxStreak / 10) * 100),
|
||||||
|
current: maxStreak,
|
||||||
|
target: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'league_leader',
|
||||||
|
name: 'Tabellenführer',
|
||||||
|
description: 'Platz 1 erreicht',
|
||||||
|
icon: 'crown',
|
||||||
|
color: '#e6c047',
|
||||||
|
rankLabel: 'Prestige',
|
||||||
|
unlocked: rank === 1,
|
||||||
|
progress: rank === 1 ? 100 : 0,
|
||||||
|
current: rank === 1 ? 1 : 0,
|
||||||
|
target: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'early_bird',
|
||||||
|
name: 'Frühtipper',
|
||||||
|
description: 'Tipps 24h vor Anpfiff',
|
||||||
|
icon: 'alarm',
|
||||||
|
color: '#4BB7F8',
|
||||||
|
rankLabel: 'Speedster',
|
||||||
|
unlocked: finishedTipCount > 0 && earlyCount === finishedTipCount,
|
||||||
|
progress: finishedTipCount > 0 ? Math.min(100, (earlyCount / finishedTipCount) * 100) : 0,
|
||||||
|
current: earlyCount,
|
||||||
|
target: finishedTipCount || 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'globetrotter',
|
||||||
|
name: 'Globetrotter',
|
||||||
|
description: 'Alle Gruppenspiele',
|
||||||
|
icon: 'public',
|
||||||
|
color: '#4BB7F8',
|
||||||
|
rankLabel: 'Reisender',
|
||||||
|
unlocked: groupCount >= totalGroups,
|
||||||
|
progress: Math.min(100, (groupCount / totalGroups) * 100),
|
||||||
|
current: groupCount,
|
||||||
|
target: totalGroups,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'diamond',
|
||||||
|
name: 'Diamant',
|
||||||
|
description: '20 exakte Tipps',
|
||||||
|
icon: 'diamond',
|
||||||
|
color: '#a066ff',
|
||||||
|
rankLabel: 'Legendär',
|
||||||
|
unlocked: exactCount >= 20,
|
||||||
|
progress: Math.min(100, (exactCount / 20) * 100),
|
||||||
|
current: exactCount,
|
||||||
|
target: 20,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const unlockedCount = achievements.filter(a => a.unlocked).length;
|
||||||
|
|
||||||
|
res.json({ achievements, unlockedCount, total: achievements.length });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Achievements failed', { error });
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -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.status IN ('FINISHED', 'IN_PLAY')
|
||||||
|
ORDER BY m.utc_date DESC`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
let streak = 0;
|
||||||
|
for (const m of pastMatches) {
|
||||||
|
if (m.has_tip === true || (m.has_tip as unknown) === 't' || (m.has_tip as unknown) === '1') streak++;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Nudges
|
||||||
|
const nudges: Array<{ type: string; text: string; matchId?: number }> = [];
|
||||||
|
|
||||||
|
const untipped = await query<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) AS count FROM matches m
|
||||||
|
LEFT JOIN tips t ON t.match_id = m.id AND t.user_id = $1
|
||||||
|
WHERE m.utc_date::date = CURRENT_DATE
|
||||||
|
AND m.status IN ('SCHEDULED', 'TIMED')
|
||||||
|
AND t.id IS NULL`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
const untippedCount = parseInt(untipped[0]?.count || '0');
|
||||||
|
if (untippedCount > 0) {
|
||||||
|
nudges.push({
|
||||||
|
type: 'untipped',
|
||||||
|
text: `📅 Heute noch ${untippedCount} ${untippedCount === 1 ? 'Spiel' : 'Spiele'} ohne Tipp`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const leader = await query<{ full_name: string; total_points: string }>(
|
||||||
|
`SELECT full_name, total_points FROM leaderboard ORDER BY rank ASC LIMIT 1`
|
||||||
|
);
|
||||||
|
if (leader[0]) {
|
||||||
|
nudges.push({ type: 'leader', text: `🏆 ${leader[0].full_name} führt mit ${leader[0].total_points} Punkten` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = await query<any>(
|
||||||
|
`SELECT m.home_team_short, m.away_team_short, m.score_home, m.score_away, t.points, m.id AS match_id
|
||||||
|
FROM tips t JOIN matches m ON m.id = t.match_id
|
||||||
|
WHERE t.user_id = $1 AND t.points IS NOT NULL
|
||||||
|
ORDER BY m.utc_date DESC LIMIT 1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
if (latest[0]) {
|
||||||
|
const r = latest[0];
|
||||||
|
nudges.push({
|
||||||
|
type: 'result',
|
||||||
|
text: `🎯 Letzte Auswertung: ${r.points} Punkte für ${r.home_team_short} ${r.score_home}:${r.score_away} ${r.away_team_short}`,
|
||||||
|
matchId: r.match_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
hero,
|
||||||
|
stats: {
|
||||||
|
rank: s ? parseInt(s.rank) : null,
|
||||||
|
totalPoints: s ? parseInt(s.total_points) : 0,
|
||||||
|
streak,
|
||||||
|
},
|
||||||
|
nudges,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Dashboard failed', { error });
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -101,24 +101,22 @@ router.get('/me', async (req: Request, res: Response): Promise<void> => {
|
|||||||
|
|
||||||
const evaluatedCount = parseInt(tipsStats?.count ?? '0');
|
const evaluatedCount = parseInt(tipsStats?.count ?? '0');
|
||||||
const wrongCount = parseInt(tipsStats?.wrong_count ?? '0');
|
const wrongCount = parseInt(tipsStats?.wrong_count ?? '0');
|
||||||
|
const exactCount = parseInt(String(lb?.exact_count ?? '0'));
|
||||||
|
const tendencyCount = parseInt(String(lb?.tendency_count ?? '0'));
|
||||||
const accuracy =
|
const accuracy =
|
||||||
evaluatedCount > 0
|
evaluatedCount > 0
|
||||||
? Math.round(
|
? Math.round(((exactCount + tendencyCount) / evaluatedCount) * 100)
|
||||||
(((lb?.exact_count ?? 0) + (lb?.tendency_count ?? 0)) /
|
|
||||||
evaluatedCount) *
|
|
||||||
100
|
|
||||||
)
|
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const response: UserStatsResponse = {
|
const response: UserStatsResponse = {
|
||||||
userId,
|
userId,
|
||||||
fullName: lb?.full_name ?? req.staffbaseUser!.name ?? 'Unbekannt',
|
fullName: lb?.full_name ?? req.staffbaseUser!.name ?? 'Unbekannt',
|
||||||
team: lb?.team ?? null,
|
team: lb?.team ?? null,
|
||||||
totalPoints: lb?.total_points ?? 0,
|
totalPoints: parseInt(String(lb?.total_points ?? '0')),
|
||||||
rank: lb?.rank ?? null,
|
rank: lb?.rank ? parseInt(String(lb.rank)) : null,
|
||||||
tipsCount: lb?.tips_count ?? 0,
|
tipsCount: parseInt(String(lb?.tips_count ?? '0')),
|
||||||
exactCount: lb?.exact_count ?? 0,
|
exactCount,
|
||||||
tendencyCount: lb?.tendency_count ?? 0,
|
tendencyCount,
|
||||||
wrongCount,
|
wrongCount,
|
||||||
accuracy,
|
accuracy,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
services:
|
services:
|
||||||
tippspiel:
|
tippspiel:
|
||||||
build:
|
image: git.home.rm-warpstation.de/mwf975_git/tippspiel:latest
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: wm2026-tippspiel
|
container_name: wm2026-tippspiel
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3301:3001"
|
- "3301:3001"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=development
|
||||||
- PORT=3001
|
- PORT=3001
|
||||||
- DATABASE_URL=${DATABASE_URL}
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
- SUPABASE_URL=${SUPABASE_URL}
|
- SUPABASE_URL=${SUPABASE_URL}
|
||||||
@@ -17,7 +15,7 @@ services:
|
|||||||
- FOOTBALL_API_KEY=${FOOTBALL_API_KEY}
|
- FOOTBALL_API_KEY=${FOOTBALL_API_KEY}
|
||||||
- FOOTBALL_API_BASE_URL=https://api.football-data.org/v4
|
- FOOTBALL_API_BASE_URL=https://api.football-data.org/v4
|
||||||
- ELEVENLABS_API_KEY=${ELEVENLABS_API_KEY}
|
- ELEVENLABS_API_KEY=${ELEVENLABS_API_KEY}
|
||||||
- CORS_ORIGIN=${CORS_ORIGIN:-https://app.staffbase.com,http://localhost:5173}
|
- CORS_ORIGIN=*
|
||||||
- STAFFBASE_PUBLIC_KEY=${STAFFBASE_PUBLIC_KEY:-}
|
- STAFFBASE_PUBLIC_KEY=${STAFFBASE_PUBLIC_KEY:-}
|
||||||
- STAFFBASE_PLUGIN_ID=${STAFFBASE_PLUGIN_ID:-}
|
- STAFFBASE_PLUGIN_ID=${STAFFBASE_PLUGIN_ID:-}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
<!-- markdownlint-disable -->
|
||||||
|
# Phase 1: Engagement & UX-Polish — Design Spec
|
||||||
|
|
||||||
|
**Datum:** 2026-04-11
|
||||||
|
**Projekt:** WM 2026 Tippspiel
|
||||||
|
**Ziel:** Die App von "funktional" zu "macht Spaß" transformieren. Mitarbeiter sollen beim Öffnen lächeln und täglich zurückkommen.
|
||||||
|
**Zielplattform:** Mobile-First (Staffbase-App auf Smartphone), Desktop als Bonus
|
||||||
|
**Launch:** Anfang Juni 2026 (Intranet-Post), WM-Start 11. Juni 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Neue Startseite: Dashboard
|
||||||
|
|
||||||
|
Die aktuelle Startseite zeigt 104 Spiele als endlose Liste. Die neue Startseite wird ein persönliches WM-Cockpit.
|
||||||
|
|
||||||
|
### Layout: "Hero + Stats + Nudges"
|
||||||
|
|
||||||
|
**Hero-Bereich (oberes Drittel):**
|
||||||
|
|
||||||
|
- Nächstes tippbares Spiel prominent angezeigt mit Team-Flaggen und Countdown-Timer
|
||||||
|
|
||||||
|
- Wenn bereits getippt: Tipp anzeigen mit Häkchen ("Dein Tipp: 2:1 ✓")
|
||||||
|
|
||||||
|
- Wenn noch nicht getippt: "Jetzt tippen"-Button
|
||||||
|
|
||||||
|
- Wenn kein Spiel ansteht: Nächstes Spiel mit Datum ("Nächstes Spiel: Morgen 21:00")
|
||||||
|
|
||||||
|
**Stats-Kacheln (mittleres Drittel):**
|
||||||
|
|
||||||
|
- Drei kompakte Kacheln nebeneinander:
|
||||||
|
- **Dein Rang** (Platzierung mit Pfeil hoch/runter seit letzter Änderung)
|
||||||
|
- **Punkte** (Gesamtpunktzahl)
|
||||||
|
- **Streak** (🔥-Counter, aktuelle Serie an abgegebenen Tipps)
|
||||||
|
|
||||||
|
**Nudge-Bereich (unteres Drittel):**
|
||||||
|
|
||||||
|
- Kontextabhängige Handlungsaufforderungen:
|
||||||
|
- "📅 Heute noch 2 Spiele ohne Tipp" → Link zum Spielplan
|
||||||
|
- "🏆 Max führt mit 15 Punkten" → Link zur Rangliste
|
||||||
|
- "🎯 Letzte Auswertung: 3 Punkte für Brazil vs Serbia!" → Link zum Ergebnis
|
||||||
|
|
||||||
|
- Maximal 2-3 Nudges, priorisiert nach Relevanz
|
||||||
|
|
||||||
|
**Navigationsänderung:** Der volle Spielplan wird zur eigenen Seite "Spiele" in der Navigation. Dashboard ist die neue Startseite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Bottom Navigation Bar
|
||||||
|
|
||||||
|
Die Header-Navigation wird durch eine feste Bottom Navigation ersetzt (Mobile-First-Standard).
|
||||||
|
|
||||||
|
**4 Haupttabs:**
|
||||||
|
|
||||||
|
- 🏠 **Home** — Dashboard (neue Startseite)
|
||||||
|
|
||||||
|
- ⚽ **Spiele** — Spielplan mit Smart Sections
|
||||||
|
|
||||||
|
- 🏆 **Rangliste** — Leaderboard
|
||||||
|
|
||||||
|
- 👤 **Profil** — Persönliche Statistiken
|
||||||
|
|
||||||
|
**Header:** Wird schlank — nur Logo ("WM 2026 Tippspiel"), optional Notification-Badge, Theme-Toggle.
|
||||||
|
|
||||||
|
**Admin:** Nicht mehr in der Hauptnavigation. Nur für Editoren sichtbar als Zahnrad-Icon im Header. Viewer sehen es nicht.
|
||||||
|
|
||||||
|
**Desktop:** Bottom Nav wird zur Standard-Sidebar oder bleibt als Header-Nav — sekundäre Priorität, Mobile-Erlebnis bestimmt das Design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Smart Sections im Spielplan
|
||||||
|
|
||||||
|
Die 104-Spiele-Liste wird intelligent in Zeitabschnitte gruppiert.
|
||||||
|
|
||||||
|
**Abschnitte (automatisch basierend auf aktuellem Datum):**
|
||||||
|
|
||||||
|
| Abschnitt | Default-Zustand | Inhalt |
|
||||||
|
|-----------|----------------|--------|
|
||||||
|
| **Heute** | Expandiert, farbig hervorgehoben | Alle Spiele des Tages |
|
||||||
|
| **Morgen** | Expandiert | Alle Spiele des Folgetags |
|
||||||
|
| **Diese Woche** | Kollapsiert, Anzahl angezeigt | Restliche Spiele der Woche |
|
||||||
|
| **Nächste Woche+** | Kollapsiert | Spätere Spiele |
|
||||||
|
| **Vergangene Spiele** | Kollapsiert | Letzte Ergebnisse + eigene Punkte |
|
||||||
|
|
||||||
|
**Verhalten:**
|
||||||
|
|
||||||
|
- Abschnitte sind auf-/zuklappbar (Accordion)
|
||||||
|
|
||||||
|
- "Heute" scrollt automatisch in den Viewport beim Öffnen
|
||||||
|
|
||||||
|
- Leerer Abschnitt wird ausgeblendet (z.B. "Heute" wenn kein Spiel)
|
||||||
|
|
||||||
|
- Stage-Filter (Gruppenphase, Achtelfinale...) wird zum optionalen Dropdown statt Button-Leiste
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Zustandsbasierte Match-Cards
|
||||||
|
|
||||||
|
Jede Match-Card sieht visuell anders aus je nach Spielstatus. Die aktuellen Cards sind identisch unabhängig vom Zustand.
|
||||||
|
|
||||||
|
**5 visuelle Zustände:**
|
||||||
|
|
||||||
|
### ⏳ Offen (tippbar)
|
||||||
|
|
||||||
|
- Standard-Design mit Team-Flaggen und Kick-off-Zeit
|
||||||
|
|
||||||
|
- "Tipp abgeben"-Button prominent
|
||||||
|
|
||||||
|
- Countdown wenn <24h bis Anpfiff
|
||||||
|
|
||||||
|
- Pulsierender roter Countdown wenn <1h
|
||||||
|
|
||||||
|
### ✅ Getippt
|
||||||
|
|
||||||
|
- Grüner linker Rand / Akzent
|
||||||
|
|
||||||
|
- Tipp prominent angezeigt ("Dein Tipp: 2:1")
|
||||||
|
|
||||||
|
- "Ändern"-Link statt "Tipp abgeben"
|
||||||
|
|
||||||
|
- Häkchen-Icon
|
||||||
|
|
||||||
|
### 🔴 Live (in Spielgang)
|
||||||
|
|
||||||
|
- Pulsierender roter Punkt neben "LIVE"
|
||||||
|
|
||||||
|
- Aktueller Spielstand (wenn über Sync verfügbar)
|
||||||
|
|
||||||
|
- Tipp daneben zum Vergleich
|
||||||
|
|
||||||
|
- Tippfenster geschlossen — Schloss-Icon
|
||||||
|
|
||||||
|
### 🏁 Beendet (ausgewertet)
|
||||||
|
|
||||||
|
- Endergebnis prominent
|
||||||
|
|
||||||
|
- Dein Tipp + Punkte-Badge:
|
||||||
|
- 🥇 **Gold-Badge (3 Pkt):** Exakter Treffer, goldener Schimmer-Effekt
|
||||||
|
- ✅ **Grüner Badge (1 Pkt):** Richtige Tendenz
|
||||||
|
- ❌ **Grauer Badge (0 Pkt):** Falsch getippt
|
||||||
|
|
||||||
|
### 🔒 Verpasst (nicht getippt, Spiel vorbei)
|
||||||
|
|
||||||
|
- Ausgegraut / reduzierte Opacity
|
||||||
|
|
||||||
|
- "Nicht getippt" Label
|
||||||
|
|
||||||
|
- Leichter visueller "Shame-Effekt" als Motivation fürs nächste Mal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Emotionale Momente & Animationen
|
||||||
|
|
||||||
|
5 Schlüsselmomente, die die App lebendig machen.
|
||||||
|
|
||||||
|
### 5.1 Tipp-Bestätigung
|
||||||
|
|
||||||
|
- **Trigger:** User klickt "Tipp bestätigen"
|
||||||
|
|
||||||
|
- **Animation:** Card pulsiert kurz grün, animiertes Häkchen fliegt rein
|
||||||
|
|
||||||
|
- **Text:** "Dein Tipp ist drin! 🎯" als kurze Success-Message
|
||||||
|
|
||||||
|
- **Mobile:** Subtiles Haptic-Feedback (Vibration via `navigator.vibrate`)
|
||||||
|
|
||||||
|
- **Dauer:** ~1 Sekunde, dann Modal schließt
|
||||||
|
|
||||||
|
### 5.2 Live-Countdown vor Anpfiff
|
||||||
|
|
||||||
|
- **Trigger:** Spiel startet in <1 Stunde
|
||||||
|
|
||||||
|
- **Anzeige:** Pulsierender roter Countdown-Timer auf der Match-Card ("Noch 12 Min!")
|
||||||
|
|
||||||
|
- **Urgency-Stufen:**
|
||||||
|
- <24h: Countdown in Stunden ("in 5h")
|
||||||
|
- <1h: Pulsierender roter Countdown in Minuten
|
||||||
|
- <5min: Schnelleres Pulsieren
|
||||||
|
|
||||||
|
- **Nach Anpfiff:** "Tippfenster geschlossen" mit Schloss-Icon, sanfte Transition
|
||||||
|
|
||||||
|
### 5.3 Punkte-Reveal nach Spielende
|
||||||
|
|
||||||
|
- **Trigger:** User öffnet App / navigiert zu einem ausgewerteten Spiel, das noch nicht "gesehen" wurde
|
||||||
|
|
||||||
|
- **Sequenz (gestaffelt, ~2-3 Sekunden):**
|
||||||
|
1. Endergebnis einblenden (z.B. "Brazil 3:1 Serbia")
|
||||||
|
2. "Dein Tipp war: 3:1" einblenden
|
||||||
|
3. Punkte-Counter animiert hochzählen
|
||||||
|
|
||||||
|
- **Varianten nach Ergebnis:**
|
||||||
|
- **Exakt (3 Pkt):** 🎉 Konfetti-Explosion, goldener "EXAKT!"-Badge, Celebratory-Puls
|
||||||
|
- **Tendenz (1 Pkt):** 👏 Grüner Puls-Effekt, "Richtige Tendenz!"
|
||||||
|
- **Falsch (0 Pkt):** 😅 Kurzes Kopfschütteln-Emoji, "Knapp daneben..."
|
||||||
|
|
||||||
|
- **"Gesehen"-Flag:** Jeder Reveal wird nur einmal gezeigt (localStorage-basiert)
|
||||||
|
|
||||||
|
### 5.4 Ranglistenveränderung
|
||||||
|
|
||||||
|
- **Trigger:** Rang hat sich seit letztem Besuch geändert
|
||||||
|
|
||||||
|
- **Anzeige:** Toast-Notification beim App-Öffnen:
|
||||||
|
- Aufstieg: "⬆️ Du bist auf Platz 3 aufgestiegen!"
|
||||||
|
- Abstieg: "⬇️ Du bist auf Platz 7 gerutscht — hol dir die Punkte zurück!"
|
||||||
|
- Knapper Verfolger: "⚠️ Anna ist nur noch 1 Punkt hinter dir!"
|
||||||
|
|
||||||
|
- **Speicherung:** Letzter bekannter Rang in localStorage, Vergleich bei jedem Laden
|
||||||
|
|
||||||
|
### 5.5 Streak-Tracker
|
||||||
|
|
||||||
|
- **Definition:** Anzahl aufeinanderfolgender Spiele, für die ein Tipp abgegeben wurde (nicht Korrektheit, nur Teilnahme)
|
||||||
|
|
||||||
|
- **Anzeige:** 🔥-Counter auf dem Dashboard und im Profil
|
||||||
|
|
||||||
|
- **Meilensteine:**
|
||||||
|
- 3er-Streak: 🔥 Feuer-Icon erscheint
|
||||||
|
- 10er-Streak: 🔥🔥 Doppel-Feuer
|
||||||
|
- 20er-Streak: ⚡ Blitz-Icon
|
||||||
|
|
||||||
|
- **Streak-Bruch:** "Deine 7er-Serie ist gerissen! Starte eine neue." als Nudge auf dem Dashboard
|
||||||
|
|
||||||
|
- **Backend:** Streak wird bei Tipp-Abgabe berechnet (aufeinanderfolgende Spiele nach Kick-off-Datum)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Profil-Seite: Reiches Profil
|
||||||
|
|
||||||
|
Die aktuelle Profil-Seite zeigt Name, Rang und 4 Stat-Boxen. Das Redesign macht sie persönlich und interessant.
|
||||||
|
|
||||||
|
### Layout (von oben nach unten):
|
||||||
|
|
||||||
|
**Header-Card:**
|
||||||
|
|
||||||
|
- Initialen-Avatar (bestehendes Design)
|
||||||
|
|
||||||
|
- Name + Rang-Badge ("🏆 Platz 5")
|
||||||
|
|
||||||
|
- Lieblingsteam mit Flagge (bestehende "Team hinzufügen"-Funktion, prominenter platziert)
|
||||||
|
|
||||||
|
**Stats-Ring:**
|
||||||
|
|
||||||
|
- Kreisdiagramm (Donut-Chart) mit Verteilung: Exakt / Tendenz / Falsch
|
||||||
|
|
||||||
|
- Gesamtpunktzahl in der Mitte des Rings
|
||||||
|
|
||||||
|
- Legende darunter
|
||||||
|
|
||||||
|
**Tipp-Historie:**
|
||||||
|
|
||||||
|
- Scrollbare Liste: "Deine letzten 10 Tipps"
|
||||||
|
|
||||||
|
- Jeder Eintrag: Teams, dein Tipp, Ergebnis, Punkte-Badge
|
||||||
|
|
||||||
|
- Expandierbar auf alle Tipps
|
||||||
|
|
||||||
|
**Fun-Stats:**
|
||||||
|
|
||||||
|
- "Dein Lieblings-Tipp: 1:0 (5x getippt)"
|
||||||
|
|
||||||
|
- "Du tippst 70% Heimsiege"
|
||||||
|
|
||||||
|
- "Längste Streak: 🔥12"
|
||||||
|
|
||||||
|
- Rotiert oder zeigt 2-3 gleichzeitig
|
||||||
|
|
||||||
|
**Achievement-Platzhalter (Vorgriff Phase 2):**
|
||||||
|
|
||||||
|
- Badge-Leiste am unteren Rand
|
||||||
|
|
||||||
|
- Grau/locked wenn noch nicht erreicht, farbig wenn freigeschaltet
|
||||||
|
|
||||||
|
- Phase 1: Platzhalter-UI, Logik kommt in Phase 2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Entfernte / Vereinfachte Features
|
||||||
|
|
||||||
|
### KI-Agent / Expertenblick — ENTFERNT
|
||||||
|
|
||||||
|
- Chat-Widget (Fußball-Icon unten rechts) wird entfernt
|
||||||
|
|
||||||
|
- "Expertenblick"-Accordion im Tipp-Modal wird entfernt
|
||||||
|
|
||||||
|
- Dateien: `AgentChat.tsx`, Agent-Route im Backend (`/api/agent`) können entfernt oder deaktiviert werden
|
||||||
|
|
||||||
|
- Begründung: Nice-to-have, lenkt vom Kernflow ab, spart Platz auf Mobile
|
||||||
|
|
||||||
|
### Tipp-Modal — VERSCHLANKT
|
||||||
|
|
||||||
|
- **Entfernt:** Gruppeninfo-Header, Kick-off-Datum (steht schon auf der Card), Expertenblick-Accordion
|
||||||
|
|
||||||
|
- **Bleibt:** Team-Flaggen + Namen, Score-Picker (+/-), Tendenz-Anzeige, "Tipp bestätigen"-Button
|
||||||
|
|
||||||
|
- **Neu:** Erfolgsanimation nach Bestätigung (siehe 5.1)
|
||||||
|
|
||||||
|
### Stage-Filter — VEREINFACHT
|
||||||
|
|
||||||
|
- Aktuell: 8 horizontale Filter-Buttons
|
||||||
|
|
||||||
|
- Neu: Dropdown/Select im Spielplan-Header
|
||||||
|
|
||||||
|
- Smart Sections übernehmen die Hauptnavigation im Spielplan
|
||||||
|
|
||||||
|
### Admin-Navigation — VERSTECKT
|
||||||
|
|
||||||
|
- Nicht mehr als Haupttab sichtbar
|
||||||
|
|
||||||
|
- Nur für User mit Editor-Rolle: Zahnrad-Icon im Header
|
||||||
|
|
||||||
|
- Viewer sehen keine Admin-UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Technische Hinweise
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- **Framework:** React + Vite + TypeScript (bestehend)
|
||||||
|
|
||||||
|
- **Animationen:** CSS-Animationen + `framer-motion` für komplexere Sequenzen (Konfetti, Punkte-Reveal)
|
||||||
|
|
||||||
|
- **Konfetti:** Lightweight-Library wie `canvas-confetti` (~3KB)
|
||||||
|
|
||||||
|
- **Charts:** Donut-Chart für Profil — entweder SVG-basiert selbst gebaut oder leichtgewichtige Library
|
||||||
|
|
||||||
|
- **Haptic:** `navigator.vibrate(50)` für Mobile (Progressive Enhancement, kein Fallback nötig)
|
||||||
|
|
||||||
|
- **State:** localStorage für Streak, gesehene Reveals, letzter Rang
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- **Streak-Berechnung:** Neue Query oder Service-Funktion, die aufeinanderfolgende Tipps zählt
|
||||||
|
|
||||||
|
- **Dashboard-Daten:** Neuer Endpoint `/api/dashboard` der Hero-Spiel, Stats und Nudges gebündelt liefert
|
||||||
|
|
||||||
|
- **Cleanup:** Agent-Route kann deaktiviert werden (Feature-Flag oder entfernen)
|
||||||
|
|
||||||
|
### Kein Breaking Change
|
||||||
|
|
||||||
|
- Bestehende API-Endpoints bleiben kompatibel
|
||||||
|
|
||||||
|
- Datenbank-Schema braucht keine Migration (Streak wird berechnet, nicht gespeichert)
|
||||||
|
|
||||||
|
- Bestehende Tipps und Punkte bleiben erhalten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Phasen-Übersicht
|
||||||
|
|
||||||
|
| Phase | Inhalt | Zeitrahmen |
|
||||||
|
|-------|--------|------------|
|
||||||
|
| **Phase 1** (dieses Dokument) | Dashboard, Bottom Nav, Smart Sections, Emotionale Momente, Match-Cards, Profil, Cleanup | Vor Launch (bis Ende Mai) |
|
||||||
|
| **Phase 2** | Badges & Achievements, Wochenwertung, Streak-Meilenstein-Rewards | Zum Launch / erste WM-Woche |
|
||||||
|
| **Phase 3** | Tipps anderer sehen, Reaktionen/Emojis, Abteilungs-Challenge | Während der WM, iterativ |
|
||||||
@@ -8,12 +8,14 @@
|
|||||||
"name": "wm2026-tippspiel-frontend",
|
"name": "wm2026-tippspiel-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"canvas-confetti": "^1.9.4",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.24.0"
|
"react-router-dom": "^6.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
@@ -1155,6 +1157,13 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -1279,6 +1288,16 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
|
|||||||
@@ -8,12 +8,14 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"canvas-confetti": "^1.9.4",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.24.0"
|
"react-router-dom": "^6.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 118 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ns0:svg xmlns:ns0="http://www.w3.org/2000/svg" viewBox="140 -10 340 470"><ns0:path fill="#e30613" d="M216.85 142.13a102.3 102.3 0 0 1-3.35-83.69c4.14-8.29 19.84-37.17 54.49-51.13a103.1 103.1 0 0 1 49.2-6.72 38.93 38.93 0 0 1 25.18 69.48c-13.68 14.17-26.69 20.66-35.74 24-14.71 5.35-34.28 6.84-53.75 8.58-15.55 1.4-21.43 1.21-27.12 6.1-9.72 8.32-9.81 23.73-8.91 33.38" data-source="https://football-logos.cc" /><ns0:path fill="#fff" d="m292.8 80.84 12.21-5.29-6.14-18.71h19.47a18.85 18.85 0 0 1 1.3-12.22 19.2 19.2 0 0 1 11.4-9.77l-4.07-4.08q.6-5.49 1.22-11l-12.63 6.92-3.66-5.7-16.29 11q-2.65-10.57-5.3-21.17l-6.51 9.31-11-4.48v11l-15.88-2.44a35.5 35.5 0 0 1 10.18 10.18 35 35 0 0 1 4.07 8.55l-15.47-.41c-.14 2.58-.27 5.16-.41 7.74l-14 5.28 5.81 4.9-1.62 5.3a28 28 0 0 1 13.84.81 27.4 27.4 0 0 1 7.33 3.67l2.45 7.73a40.3 40.3 0 0 1 10.58-11.4 39.8 39.8 0 0 1 10.12-5.29z" /><ns0:path fill="#077947" d="M357.17 16.06a107.5 107.5 0 0 1 29.25 31.26A52.9 52.9 0 0 1 395.8 69c2.12 6.82 9.22 32.46-3.14 60.67a87 87 0 0 1-33.1 38.3c-1.28.44-11.62 3.86-21.59-2.45-9.7-6.13-11.22-16.51-11.4-17.92a83.75 83.75 0 0 1 .41-34.21c8.27-36.26 38.79-49.92 36.65-78.19a43 43 0 0 0-6.46-19.14" /><ns0:ellipse cx="362.26" cy="216.51" fill="#fff" rx="34.92" ry="21.31" transform="rotate(-69.12 295.198 173.757)" /><ns0:ellipse cx="360.61" cy="217.01" fill="#e30613" rx="29.44" ry="15.86" transform="rotate(-73.7 299.202 174.037)" /><ns0:ellipse cx="360.72" cy="221.24" fill="#fff" rx="18.94" ry="9.27" transform="rotate(-73.79 299.064 178.285)" /><ns0:path fill="#0a3047" d="M364.43 172.84a125.45 125.45 0 0 1-61.42 20.38c-10.45-.21-33.27-2-53.36-15.73-5.26-3.59-12.73-8.9-18.59-18.69a61.2 61.2 0 0 1-7.32-20.42 63.3 63.3 0 0 1-.73-15.93 24.2 24.2 0 0 1 6.11-9.47c13.89-12.94 42.77-5.46 60 5.7a88.8 88.8 0 0 1 21.18 20c7.4 10 7.52 15 16.29 24 5.09 5.24 10.72 10.88 19.67 12.56a31.14 31.14 0 0 0 18.17-2.4" /><ns0:path fill="#fff" d="M258.95 128.06c1.5-2.12 3.12-4.3 4.89-6.52q2.06-2.58 4.07-4.89l4.89 19.55 20.95 11-15.65 6.92a36.18 36.18 0 0 0 6.1 24 32.8 32.8 0 0 1-15.47-7.74 32.4 32.4 0 0 1-7.74-10.54l-17.51 1.22 5.7-13.64-10.18-23z" /><ns0:path fill="#132e49" d="M256.1 196.84a250.71 250.71 0 0 1 6.11 211.36 145.5 145.5 0 0 0 107.11 0 511 511 0 0 0-48.46-120.54 510.3 510.3 0 0 0-64.76-90.82" /><ns0:path fill="#0d7247" d="m256.1 420.46-10.58 26.07a173.78 173.78 0 0 0 135.2-2l-7.33-24a188.18 188.18 0 0 1-117.29 0z" data-source="https://football-logos.cc" /><ns0:path fill="#e30613" d="m314.04 243.71 32.68 62.31a215 215 0 0 1 12.34-112.79 101.23 101.23 0 0 0-45 50.48z" /></ns0:svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 118 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 292 B |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 154 B |
|
After Width: | Height: | Size: 252 B |
|
After Width: | Height: | Size: 231 B |
|
After Width: | Height: | Size: 854 B |
|
After Width: | Height: | Size: 604 B |
|
After Width: | Height: | Size: 940 B |
|
After Width: | Height: | Size: 151 B |
|
After Width: | Height: | Size: 989 B |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 254 B |
|
After Width: | Height: | Size: 245 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 625 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 789 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 658 B |
|
After Width: | Height: | Size: 932 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 928 B |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 153 B |
|
After Width: | Height: | Size: 323 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 307 B |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 138 B |
|
After Width: | Height: | Size: 681 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 947 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 566 B |
|
After Width: | Height: | Size: 982 B |
@@ -5,9 +5,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
background: rgba(10,14,26,0.92);
|
background: color-mix(in srgb, var(--bg-deep) 92%, transparent);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
border-bottom: 1px solid rgba(75,183,248,0.1);
|
border-bottom: 1px solid rgba(75,183,248,0.12);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
@@ -27,9 +27,25 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoFlag { font-size: 22px; }
|
.logoExtra {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoImg {
|
||||||
|
height: 32px;
|
||||||
|
width: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoTextBlock {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.logoText {
|
.logoText {
|
||||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||||
@@ -39,6 +55,15 @@
|
|||||||
letter-spacing: -0.3px;
|
letter-spacing: -0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logoSub {
|
||||||
|
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gold);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.devBadge {
|
.devBadge {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -66,17 +91,66 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navLink:hover { color: var(--text-primary); background: var(--surface-mid); }
|
.navLink:hover { color: var(--text-primary); background: var(--surface-high); }
|
||||||
|
|
||||||
.navLinkActive {
|
.navLinkActive {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
background: var(--primary-dim);
|
background: var(--primary-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.themeToggle {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.themeToggle:hover { color: var(--text-primary); }
|
||||||
|
.themeToggle:active { transform: scale(0.92); }
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 1100px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 32px 24px;
|
padding: 32px 24px;
|
||||||
|
padding-bottom: 70px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.main {
|
||||||
|
padding-bottom: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header actions — always visible (theme toggle + admin) */
|
||||||
|
.headerActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide header nav on mobile, keep actions */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin link: icon only */
|
||||||
|
.adminLink {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.adminLink:hover {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,26 +1,67 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Routes, Route, NavLink } from 'react-router-dom';
|
import { Routes, Route, NavLink } from 'react-router-dom';
|
||||||
|
import { Sun, Moon, Monitor } from 'lucide-react';
|
||||||
|
import DashboardPage from './pages/DashboardPage';
|
||||||
import MatchesPage from './pages/MatchesPage';
|
import MatchesPage from './pages/MatchesPage';
|
||||||
import LeaderboardPage from './pages/LeaderboardPage';
|
import LeaderboardPage from './pages/LeaderboardPage';
|
||||||
import ProfilePage from './pages/ProfilePage';
|
import ProfilePage from './pages/ProfilePage';
|
||||||
import AdminPage from './pages/AdminPage';
|
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';
|
import styles from './App.module.css';
|
||||||
|
|
||||||
const IS_DEV = import.meta.env.DEV;
|
const IS_DEV = import.meta.env.DEV || import.meta.env.VITE_TEST_MODE === 'true';
|
||||||
|
|
||||||
// Lazy-load DevPanel nur in Development
|
// Lazy-load DevPanel in Development/Test-Mode
|
||||||
let DevPanel: React.ComponentType<any> | null = null;
|
let DevPanel: React.ComponentType<any> | null = null;
|
||||||
if (IS_DEV) {
|
// VITE_TEST_MODE wird erst zur Laufzeit geprüft, daher Import immer einbinden
|
||||||
// Dynamic import — kein Bundle-Impact in Production
|
import('./components/DevPanel').then(m => { DevPanel = m.default; }).catch(() => {});
|
||||||
import('./components/DevPanel').then(m => { DevPanel = m.default; });
|
|
||||||
|
type ThemeSetting = 'dark' | 'light' | 'system';
|
||||||
|
|
||||||
|
function getInitialSetting(): ThemeSetting {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('theme') as ThemeSetting | null;
|
||||||
|
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored;
|
||||||
|
} catch {}
|
||||||
|
return 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTheme(setting: ThemeSetting): 'dark' | 'light' {
|
||||||
|
if (setting === 'system') {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||||
|
}
|
||||||
|
return setting;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const [themeSetting, setThemeSetting] = useState<ThemeSetting>(getInitialSetting);
|
||||||
|
const theme = resolveTheme(themeSetting);
|
||||||
|
const { message: rankMsg, dismiss: dismissRank } = useRankChange();
|
||||||
const [devUser, setDevUser] = useState(1);
|
const [devUser, setDevUser] = useState(1);
|
||||||
const [devMatches, setDevMatches] = useState<any[]>([]);
|
const [devMatches, setDevMatches] = useState<any[]>([]);
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
// Theme auf <html> setzen und in localStorage speichern
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
try { localStorage.setItem('theme', themeSetting); } catch {}
|
||||||
|
}, [theme, themeSetting]);
|
||||||
|
|
||||||
|
// Listen for OS theme changes when in system mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (themeSetting !== 'system') return;
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: light)');
|
||||||
|
const handler = () => setRefreshKey(k => k + 1); // force re-render to re-resolve
|
||||||
|
mq.addEventListener('change', handler);
|
||||||
|
return () => mq.removeEventListener('change', handler);
|
||||||
|
}, [themeSetting]);
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
setThemeSetting(t => t === 'dark' ? 'light' : t === 'light' ? 'system' : 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
// DevUser als Query-Parameter im API-Fetch setzen
|
// DevUser als Query-Parameter im API-Fetch setzen
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_DEV) return;
|
if (!IS_DEV) return;
|
||||||
@@ -52,15 +93,24 @@ export default function App() {
|
|||||||
<div className={styles.app}>
|
<div className={styles.app}>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<div className={styles.headerInner}>
|
<div className={styles.headerInner}>
|
||||||
<div className={styles.logo}>
|
<NavLink to="/" className={styles.logo}>
|
||||||
<span className={styles.logoFlag}>🏆</span>
|
<img
|
||||||
<span className={styles.logoText}>WM 2026 Tippspiel</span>
|
src="/assets/wm2026-trophy.svg"
|
||||||
|
alt="FIFA WM 2026"
|
||||||
|
className={styles.logoImg}
|
||||||
|
/>
|
||||||
|
<div className={styles.logoTextBlock}>
|
||||||
|
<span className={styles.logoText}>Tippspiel</span>
|
||||||
|
<span className={styles.logoSub}>FIFA World Cup 2026</span>
|
||||||
|
</div>
|
||||||
|
</NavLink>
|
||||||
|
<div className={styles.logoExtra}>
|
||||||
{IS_DEV && (
|
{IS_DEV && (
|
||||||
<span className={styles.devBadge}>DEV · User {devUser}</span>
|
<span className={styles.devBadge}>DEV · User {devUser}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<nav className={styles.nav}>
|
<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
|
Spielplan
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink to="/rangliste" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
|
<NavLink to="/rangliste" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
|
||||||
@@ -73,18 +123,32 @@ export default function App() {
|
|||||||
Admin
|
Admin
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
<button
|
||||||
|
className={styles.themeToggle}
|
||||||
|
onClick={toggleTheme}
|
||||||
|
title={themeSetting === 'dark' ? 'Light Mode' : themeSetting === 'light' ? 'System' : 'Dark Mode'}
|
||||||
|
aria-label="Theme wechseln"
|
||||||
|
>
|
||||||
|
{themeSetting === 'dark' ? <Sun size={16} /> : themeSetting === 'light' ? <Monitor size={16} /> : <Moon size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className={styles.main}>
|
<main className={styles.main}>
|
||||||
<Routes>
|
<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="/rangliste" element={<LeaderboardPage key={refreshKey} />} />
|
||||||
<Route path="/profil" element={<ProfilePage key={refreshKey} />} />
|
<Route path="/profil" element={<ProfilePage key={refreshKey} />} />
|
||||||
<Route path="/admin" element={<AdminPage />} />
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{rankMsg && <Toast message={rankMsg} onDismiss={dismissRank} />}
|
||||||
|
<BottomNav />
|
||||||
|
|
||||||
{IS_DEV && DevPanel && (
|
{IS_DEV && DevPanel && (
|
||||||
<DevPanel
|
<DevPanel
|
||||||
currentUser={devUser}
|
currentUser={devUser}
|
||||||
@@ -93,9 +157,6 @@ export default function App() {
|
|||||||
onRefresh={handleDevRefresh}
|
onRefresh={handleDevRefresh}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Fußball-Experte Chat-Widget – immer sichtbar */}
|
|
||||||
<AgentChat />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
const BASE = '/api';
|
const BASE = '/api';
|
||||||
|
|
||||||
|
function withDevUser(path: string): string {
|
||||||
|
const devUser = new URLSearchParams(window.location.search).get('devUser');
|
||||||
|
if (!devUser) return path;
|
||||||
|
const sep = path.includes('?') ? '&' : '?';
|
||||||
|
return `${path}${sep}devUser=${devUser}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${BASE}${path}`, {
|
const res = await fetch(`${BASE}${withDevUser(path)}`, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -46,6 +53,12 @@ export const api = {
|
|||||||
body: JSON.stringify({ team }),
|
body: JSON.stringify({ team }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
getDashboard: () => request<DashboardData>('/dashboard'),
|
||||||
|
|
||||||
|
// Achievements
|
||||||
|
getAchievements: () => request<AchievementsData>('/achievements'),
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
syncMatches: () =>
|
syncMatches: () =>
|
||||||
request<{ success: boolean; total: number; created: number; updated: number }>(
|
request<{ success: boolean; total: number; created: number; updated: number }>(
|
||||||
@@ -60,6 +73,24 @@ export const api = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Types (gespiegelt vom Backend)
|
// 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 {
|
export interface Match {
|
||||||
id: number;
|
id: number;
|
||||||
externalId: number;
|
externalId: number;
|
||||||
@@ -123,3 +154,22 @@ export interface UserStats {
|
|||||||
wrongCount: number;
|
wrongCount: number;
|
||||||
accuracy: number;
|
accuracy: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Achievement {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
rankLabel: string;
|
||||||
|
unlocked: boolean;
|
||||||
|
progress: number;
|
||||||
|
current: number;
|
||||||
|
target: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AchievementsData {
|
||||||
|
achievements: Achievement[];
|
||||||
|
unlockedCount: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/* ═══ Achievement Badge — Premium Gaming Style ═══ */
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
position: relative;
|
||||||
|
background: var(--surface-mid);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 20px 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Unlocked ── */
|
||||||
|
.unlocked {
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow {
|
||||||
|
position: absolute;
|
||||||
|
inset: -4px;
|
||||||
|
border-radius: inherit;
|
||||||
|
filter: blur(20px);
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Locked ── */
|
||||||
|
.locked {
|
||||||
|
opacity: 0.45;
|
||||||
|
filter: grayscale(0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lockOverlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lockCircle {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Icon ── */
|
||||||
|
.iconWrap {
|
||||||
|
position: relative;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconGlow {
|
||||||
|
position: absolute;
|
||||||
|
inset: -8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(16px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 42px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text ── */
|
||||||
|
.name {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Rank Badge (unlocked) ── */
|
||||||
|
.rankBadge {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Progress (locked) ── */
|
||||||
|
.progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressBar {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--surface-high);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressFill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressText {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Light Mode ── */
|
||||||
|
:global([data-theme="light"]) .badge {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="light"]) .locked {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="light"]) .lockCircle {
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
color: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Achievement } from '../api/client';
|
||||||
|
import styles from './AchievementBadge.module.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
achievement: Achievement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AchievementBadge({ achievement }: Props) {
|
||||||
|
const { name, description, icon, color, rankLabel, unlocked, current, target } = achievement;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.badge} ${unlocked ? styles.unlocked : styles.locked}`}>
|
||||||
|
{/* Glow background for unlocked */}
|
||||||
|
{unlocked && (
|
||||||
|
<div className={styles.glow} style={{ background: `${color}20` }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lock overlay for locked */}
|
||||||
|
{!unlocked && (
|
||||||
|
<div className={styles.lockOverlay}>
|
||||||
|
<div className={styles.lockCircle}>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>lock</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div className={styles.iconWrap}>
|
||||||
|
{unlocked && (
|
||||||
|
<div className={styles.iconGlow} style={{ background: `${color}30` }} />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`material-symbols-outlined ${styles.icon}`}
|
||||||
|
style={{
|
||||||
|
color: unlocked ? color : 'var(--text-muted)',
|
||||||
|
fontVariationSettings: "'FILL' 1",
|
||||||
|
filter: unlocked ? `drop-shadow(0 0 10px ${color}cc)` : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name + Description */}
|
||||||
|
<h3 className={styles.name}>{name}</h3>
|
||||||
|
<p className={styles.desc}>{description}</p>
|
||||||
|
|
||||||
|
{/* Progress or Rank label */}
|
||||||
|
{unlocked ? (
|
||||||
|
<div className={styles.rankBadge} style={{
|
||||||
|
background: `${color}15`,
|
||||||
|
borderColor: `${color}40`,
|
||||||
|
color: color,
|
||||||
|
}}>
|
||||||
|
{rankLabel}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.progress}>
|
||||||
|
<div className={styles.progressBar}>
|
||||||
|
<div
|
||||||
|
className={styles.progressFill}
|
||||||
|
style={{ width: `${achievement.progress}%`, background: color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={styles.progressText}>{current}/{target}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,466 +0,0 @@
|
|||||||
/* ============================================================
|
|
||||||
AgentChat – Fußball-Experte Chat-Widget
|
|
||||||
Stadium Elite Design
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
/* ---- Floating Button ---- */
|
|
||||||
.trigger {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 28px;
|
|
||||||
right: 28px;
|
|
||||||
z-index: 500;
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
cursor: pointer;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 32px;
|
|
||||||
line-height: 1;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
color: #fff;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger.open {
|
|
||||||
background: rgba(28, 38, 64, 0.9);
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger:hover {
|
|
||||||
transform: scale(1.08);
|
|
||||||
box-shadow: 0 6px 28px rgba(75, 183, 248, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger.open {
|
|
||||||
background: linear-gradient(135deg, #1C2640 0%, #111827 100%);
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Puls-Ring wenn geschlossen */
|
|
||||||
.trigger:not(.open)::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: -4px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 2px solid rgba(75, 183, 248, 0.4);
|
|
||||||
animation: pulse 2.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { transform: scale(1); opacity: 0.8; }
|
|
||||||
50% { transform: scale(1.15); opacity: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Panel ---- */
|
|
||||||
.panel {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 100px;
|
|
||||||
right: 28px;
|
|
||||||
z-index: 499;
|
|
||||||
width: 380px;
|
|
||||||
max-height: 560px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--surface-mid);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid rgba(75, 183, 248, 0.15);
|
|
||||||
box-shadow:
|
|
||||||
0 20px 60px rgba(0, 0, 0, 0.5),
|
|
||||||
0 0 0 1px rgba(255, 255, 255, 0.04),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.07);
|
|
||||||
overflow: hidden;
|
|
||||||
animation: slideUp 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideUp {
|
|
||||||
from { opacity: 0; transform: translateY(16px) scale(0.97); }
|
|
||||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Header ---- */
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
background: linear-gradient(135deg, rgba(75, 183, 248, 0.1) 0%, rgba(33, 150, 243, 0.05) 100%);
|
|
||||||
border-bottom: 1px solid rgba(75, 183, 248, 0.12);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.headerIcon {
|
|
||||||
font-size: 22px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.headerTitle {
|
|
||||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 15px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.headerSub {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-top: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.headerOnline {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--success);
|
|
||||||
box-shadow: 0 0 6px var(--success);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Messages Area ---- */
|
|
||||||
.messages {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 16px 14px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Quick Action Chips ---- */
|
|
||||||
.quickActions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 0 14px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quickActionsLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.07em;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quickChips {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip {
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 20px;
|
|
||||||
border: 1px solid rgba(75, 183, 248, 0.25);
|
|
||||||
background: rgba(75, 183, 248, 0.07);
|
|
||||||
color: var(--primary);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip:hover {
|
|
||||||
background: rgba(75, 183, 248, 0.15);
|
|
||||||
border-color: rgba(75, 183, 248, 0.4);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Message Bubbles ---- */
|
|
||||||
.message {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-width: 88%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message.user {
|
|
||||||
align-self: flex-end;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message.assistant {
|
|
||||||
align-self: flex-start;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble {
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.55;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message.user .bubble {
|
|
||||||
background: linear-gradient(135deg, var(--primary) 0%, #2196f3 100%);
|
|
||||||
color: #fff;
|
|
||||||
border-bottom-right-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message.assistant .bubble {
|
|
||||||
background: var(--surface-high);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
border-bottom-left-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Markdown-Inhalt in Assistenten-Bubbles */
|
|
||||||
.markdownBody {
|
|
||||||
font-size: 13.5px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdownBody strong {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdownBody em {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdownBody ul {
|
|
||||||
padding-left: 1.2em;
|
|
||||||
margin: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdownBody li {
|
|
||||||
margin-bottom: 3px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageTime {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 4px;
|
|
||||||
padding: 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Typing Indicator ---- */
|
|
||||||
.typing {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
background: var(--surface-high);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border-bottom-left-radius: 4px;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--text-secondary);
|
|
||||||
animation: bounce 1.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.dot:nth-child(2) { animation-delay: 0.2s; }
|
|
||||||
.dot:nth-child(3) { animation-delay: 0.4s; }
|
|
||||||
|
|
||||||
@keyframes bounce {
|
|
||||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
|
|
||||||
30% { transform: translateY(-5px); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Input Area ---- */
|
|
||||||
.inputArea {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
background: var(--surface-low);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
flex: 1;
|
|
||||||
background: var(--surface-mid);
|
|
||||||
border: 1px solid rgba(75, 183, 248, 0.2);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 10px 13px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 14px;
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
resize: none;
|
|
||||||
min-height: 42px;
|
|
||||||
max-height: 120px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input::placeholder { color: var(--text-muted); }
|
|
||||||
|
|
||||||
.input:focus {
|
|
||||||
border-color: rgba(75, 183, 248, 0.5);
|
|
||||||
box-shadow: 0 0 0 2px rgba(75, 183, 248, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sendBtn {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: none;
|
|
||||||
background: linear-gradient(135deg, var(--primary) 0%, #2196f3 100%);
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: opacity 0.15s, transform 0.1s;
|
|
||||||
box-shadow: 0 2px 10px rgba(75, 183, 248, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sendBtn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); }
|
|
||||||
.sendBtn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
||||||
|
|
||||||
/* ---- Choice Selector ---- */
|
|
||||||
.choiceSelector {
|
|
||||||
margin-top: 10px;
|
|
||||||
border-top: 1px solid rgba(75, 183, 248, 0.12);
|
|
||||||
padding-top: 10px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.choiceList {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.choiceItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 9px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid rgba(75, 183, 248, 0.18);
|
|
||||||
background: rgba(75, 183, 248, 0.05);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.choiceItem:hover:not(:disabled) {
|
|
||||||
background: rgba(75, 183, 248, 0.12);
|
|
||||||
border-color: rgba(75, 183, 248, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.choiceItem:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.choiceItemSelected {
|
|
||||||
background: rgba(75, 183, 248, 0.15);
|
|
||||||
border-color: var(--primary);
|
|
||||||
color: var(--primary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.choiceCheckbox {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1.5px solid rgba(75, 183, 248, 0.4);
|
|
||||||
background: transparent;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 11px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--primary);
|
|
||||||
font-weight: 700;
|
|
||||||
transition: all 0.12s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.choiceItemSelected .choiceCheckbox {
|
|
||||||
background: var(--primary);
|
|
||||||
border-color: var(--primary);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.choiceActions {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.choiceConfirm {
|
|
||||||
flex: 1;
|
|
||||||
padding: 8px 14px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: none;
|
|
||||||
background: linear-gradient(135deg, var(--primary) 0%, #2196f3 100%);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
box-shadow: 0 2px 10px rgba(75, 183, 248, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.choiceConfirm:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.choiceConfirm:hover:not(:disabled) {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.choiceAll {
|
|
||||||
padding: 8px 14px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid rgba(75, 183, 248, 0.25);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.choiceAll:hover:not(:disabled) {
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-color: rgba(75, 183, 248, 0.4);
|
|
||||||
background: rgba(75, 183, 248, 0.07);
|
|
||||||
}
|
|
||||||
|
|
||||||
.choiceAll:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Responsive ---- */
|
|
||||||
@media (max-width: 440px) {
|
|
||||||
.panel {
|
|
||||||
right: 12px;
|
|
||||||
left: 12px;
|
|
||||||
width: auto;
|
|
||||||
bottom: 88px;
|
|
||||||
}
|
|
||||||
.trigger {
|
|
||||||
right: 16px;
|
|
||||||
bottom: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,56 @@
|
|||||||
|
.bottomNav {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
height: 60px;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
background: color-mix(in srgb, var(--bg-deep) 92%, transparent);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-top: 1px solid rgba(75, 183, 248, 0.15);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab, .tabActive {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabActive {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode */
|
||||||
|
:global([data-theme="light"]) .bottomNav {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="light"]) .tab {
|
||||||
|
color: rgba(13, 21, 38, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="light"]) .tabActive {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.bottomNav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import styles from './BottomNav.module.css';
|
||||||
|
|
||||||
|
export default function BottomNav() {
|
||||||
|
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||||
|
isActive ? styles.tabActive : styles.tab;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className={styles.bottomNav}>
|
||||||
|
<NavLink to="/" end className={linkClass}>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M12 3l9 8-1.4 1.3L18 10.8V20h-5v-6H11v6H6v-9.2l-1.6 1.5L3 11l9-8z"/></svg>
|
||||||
|
<span>Home</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/spiele" className={linkClass}>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 3.3l1.35-.95c1.82.56 3.37 1.76 4.38 3.34l-.39 1.34-1.35.46L13 6.7V5.3zm-3.35-.95L11 5.3v1.4L7.01 9.49l-1.35-.46-.39-1.34a9.972 9.972 0 014.38-3.34zM7.08 17.11l-1.14.1C4.73 15.81 4 13.99 4 12c0-.12.01-.23.02-.35l1-.73 1.38.48 1.46 4.34-.78 1.37zm7.42 2.48c-.79.26-1.63.41-2.5.41s-1.71-.15-2.5-.41l-.69-1.49.64-1.1h5.11l.64 1.11-.7 1.48zM14.27 15H9.73l-1.35-4.02L12 8.44l3.63 2.54L14.27 15zm3.79 2.21l-1.14-.1-.78-1.37 1.46-4.34 1.38-.48 1 .73c.01.12.02.23.02.35 0 1.99-.73 3.81-1.94 5.21z"/></svg>
|
||||||
|
<span>Spiele</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/rangliste" className={linkClass}>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M19 5h-2V3H7v2H5c-1.1 0-2 .9-2 2v1c0 2.55 1.92 4.63 4.39 4.94A5.01 5.01 0 0011 15.9V19H7v2h10v-2h-4v-3.1a5.01 5.01 0 003.61-2.96C19.08 12.63 21 10.55 21 8V7c0-1.1-.9-2-2-2zM5 8V7h2v3.82C5.84 10.4 5 9.3 5 8zm14 0c0 1.3-.84 2.4-2 2.82V7h2v1z"/></svg>
|
||||||
|
<span>Rangliste</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/profil" className={linkClass}>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
|
||||||
|
<span>Profil</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/admin" className={linkClass}>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||||
|
<span>Admin</span>
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface-mid);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 32px 24px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 320px;
|
||||||
|
width: 90%;
|
||||||
|
animation: scaleIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipLine {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeExact {
|
||||||
|
background: linear-gradient(135deg, var(--gold), #FFD700);
|
||||||
|
color: #1a1a1a;
|
||||||
|
animation: shimmer 2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeTendency {
|
||||||
|
background: var(--success);
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeWrong {
|
||||||
|
background: var(--text-muted);
|
||||||
|
color: var(--bg-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismissBtn {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 32px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from { transform: scale(0.8); opacity: 0; }
|
||||||
|
to { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { box-shadow: 0 0 0 rgba(254, 174, 50, 0); }
|
||||||
|
50% { box-shadow: 0 0 20px rgba(254, 174, 50, 0.6); }
|
||||||
|
100% { box-shadow: 0 0 0 rgba(254, 174, 50, 0); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import confetti from 'canvas-confetti';
|
||||||
|
import { Match } from '../api/client';
|
||||||
|
import styles from './ConfettiReveal.module.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
match: Match;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfettiReveal({ match, onDismiss }: Props) {
|
||||||
|
const didFire = useRef(false);
|
||||||
|
const tip = match.userTip!;
|
||||||
|
const points = tip.points!;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (points === 3 && !didFire.current) {
|
||||||
|
didFire.current = true;
|
||||||
|
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } });
|
||||||
|
}
|
||||||
|
}, [points]);
|
||||||
|
|
||||||
|
const resultLabel = points === 3 ? 'EXAKT! 🎉' : points === 1 ? 'Richtige Tendenz! 👏' : 'Knapp daneben... 😅';
|
||||||
|
const badgeClass = points === 3 ? styles.badgeExact : points === 1 ? styles.badgeTendency : styles.badgeWrong;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.overlay} onClick={onDismiss}>
|
||||||
|
<div className={`card ${styles.card}`} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className={styles.result}>
|
||||||
|
{match.homeTeam.shortName} {match.score.home}:{match.score.away} {match.awayTeam.shortName}
|
||||||
|
</div>
|
||||||
|
<div className={styles.tipLine}>
|
||||||
|
Dein Tipp: {tip.home}:{tip.away}
|
||||||
|
</div>
|
||||||
|
<div className={`${styles.badge} ${badgeClass}`}>
|
||||||
|
{points} {points === 1 ? 'Punkt' : 'Punkte'}
|
||||||
|
</div>
|
||||||
|
<div className={styles.label}>{resultLabel}</div>
|
||||||
|
<button className={styles.dismissBtn} onClick={onDismiss}>Weiter</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -46,6 +46,23 @@
|
|||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: rgba(254,174,50,0.08);
|
background: rgba(254,174,50,0.08);
|
||||||
border-bottom: 1px solid rgba(254,174,50,0.15);
|
border-bottom: 1px solid rgba(254,174,50,0.15);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.closeBtn:hover {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panelTitle {
|
.panelTitle {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { api, Match } from '../api/client';
|
import { Match } from '../api/client';
|
||||||
import styles from './DevPanel.module.css';
|
import styles from './DevPanel.module.css';
|
||||||
|
|
||||||
const DEV_USERS = [
|
const DEV_USERS = [
|
||||||
@@ -131,6 +131,7 @@ export default function DevPanel({ currentUser, onUserChange, matches, onRefresh
|
|||||||
<div className={styles.panel}>
|
<div className={styles.panel}>
|
||||||
<div className={styles.panelHeader}>
|
<div className={styles.panelHeader}>
|
||||||
<span className={styles.panelTitle}>🧪 Simulations-Modus</span>
|
<span className={styles.panelTitle}>🧪 Simulations-Modus</span>
|
||||||
|
<button className={styles.closeBtn} onClick={() => setOpen(false)}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Switcher */}
|
{/* User Switcher */}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* MatchCard — Stadium Elite Style */
|
/* MatchCard — Stadium Elite Style */
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
padding: 20px 24px;
|
padding: 16px 20px;
|
||||||
transition: box-shadow 0.2s, transform 0.15s;
|
transition: box-shadow 0.2s, transform 0.15s;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
@@ -21,12 +21,24 @@
|
|||||||
inset 0 1px 0 rgba(255,255,255,0.07) !important;
|
inset 0 1px 0 rgba(255,255,255,0.07) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Top row */
|
/* Badge row — mirrors matchRow layout for centered alignment */
|
||||||
.topRow {
|
.badgeRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeSlot {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeSpacer {
|
||||||
|
min-width: 60px; /* matches scoreBox min-width */
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
@@ -39,6 +51,34 @@
|
|||||||
|
|
||||||
.statusLive {
|
.statusLive {
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* BEENDET badge — right-aligned in header */
|
||||||
|
.finishedBadge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
background: var(--surface-high);
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* LIVE badge — right-aligned in header */
|
||||||
|
.liveBadge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--error);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
background: rgba(248, 113, 113, 0.12);
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.25);
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,22 +88,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.badge, .badgeUrgent {
|
.countdownBadge {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 3px 9px;
|
padding: 4px 10px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
background: var(--surface-high);
|
background: var(--surface-high);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badgeUrgent {
|
.countdownUrgent {
|
||||||
background: rgba(254,174,50,0.12);
|
background: rgba(254,174,50,0.12);
|
||||||
color: var(--gold);
|
color: var(--gold);
|
||||||
border: 1px solid rgba(254,174,50,0.2);
|
border: 1px solid rgba(254,174,50,0.2);
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group {
|
.group {
|
||||||
@@ -71,44 +112,65 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
background: var(--primary-dim);
|
background: var(--primary-dim);
|
||||||
padding: 3px 9px;
|
padding: 4px 10px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
border: 1px solid rgba(75,183,248,0.15);
|
border: 1px solid rgba(75,183,248,0.15);
|
||||||
margin-left: auto;
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Match row — Teams + Score */
|
/* LED time between flags */
|
||||||
|
.kickoffLED {
|
||||||
|
font-family: 'DSEG7', 'Courier New', monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #FECC4C;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-shadow:
|
||||||
|
0 0 3px rgba(254, 174, 50, 0.9),
|
||||||
|
0 0 8px rgba(254, 174, 50, 0.5),
|
||||||
|
0 0 16px rgba(254, 174, 50, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Match row — Teams + time/score */
|
||||||
.matchRow {
|
.matchRow {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr 100px 1fr;
|
align-items: flex-start;
|
||||||
align-items: center;
|
justify-content: center;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Teams */
|
/* Teams — flag on top, name below */
|
||||||
.teamHome {
|
.teamHome, .teamAway {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Center: Score or LED time, vertically centered to flag */
|
||||||
|
.scoreBox {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
justify-content: center;
|
||||||
justify-content: flex-end;
|
align-self: flex-start;
|
||||||
|
height: 56px; /* match flag height for vertical centering */
|
||||||
|
min-width: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.teamAway {
|
/* Flag box — fullbleed icon style */
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Flag box — glossy square */
|
|
||||||
.flagBox {
|
.flagBox {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
border-radius: 13px;
|
border-radius: 14px;
|
||||||
background: var(--surface-high);
|
background: var(--surface-high);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 4px 12px rgba(0,0,0,0.3),
|
0 4px 12px rgba(0,0,0,0.25),
|
||||||
inset 0 1px 0 rgba(255,255,255,0.12);
|
inset 0 1px 0 rgba(255,255,255,0.1);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -121,43 +183,46 @@
|
|||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0; left: 0; right: 0;
|
top: 0; left: 0; right: 0;
|
||||||
height: 50%;
|
height: 40%;
|
||||||
background: linear-gradient(180deg, rgba(255,255,255,0.08) 0%, transparent 100%);
|
background: linear-gradient(180deg, rgba(255,255,255,0.12) 0%, transparent 100%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crest {
|
.crest {
|
||||||
width: 38px;
|
width: 100%;
|
||||||
height: 38px;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: cover;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.teamName {
|
.teamName {
|
||||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
font-size: 15px;
|
font-size: 13px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Score / VS center */
|
|
||||||
.scoreBox {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score {
|
.score {
|
||||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||||
font-size: 22px;
|
font-size: 26px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
letter-spacing: 4px;
|
letter-spacing: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scoreLive {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
.kickoffCenter {
|
.kickoffCenter {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -175,8 +240,7 @@
|
|||||||
|
|
||||||
/* Tipp area */
|
/* Tipp area */
|
||||||
.tipRow {
|
.tipRow {
|
||||||
border-top: 1px solid rgba(255,255,255,0.05);
|
padding-top: 8px;
|
||||||
padding-top: 14px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -282,48 +346,51 @@
|
|||||||
|
|
||||||
/* Farb-Varianten Banner */
|
/* Farb-Varianten Banner */
|
||||||
.exact {
|
.exact {
|
||||||
background: linear-gradient(90deg, rgba(52,211,153,0.18) 0%, rgba(52,211,153,0.08) 100%);
|
background: linear-gradient(90deg, rgba(254,174,50,0.22) 0%, rgba(254,174,50,0.09) 100%);
|
||||||
color: #4ade80;
|
color: #FECC4C;
|
||||||
border-top: 1px solid rgba(52,211,153,0.20);
|
border-top: 1px solid rgba(254,174,50,0.30);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tendency {
|
.tendency {
|
||||||
background: linear-gradient(90deg, rgba(75,183,248,0.18) 0%, rgba(75,183,248,0.08) 100%);
|
background: linear-gradient(90deg, rgba(52,211,153,0.18) 0%, rgba(52,211,153,0.08) 100%);
|
||||||
color: var(--primary);
|
color: #4ade80;
|
||||||
border-top: 1px solid rgba(75,183,248,0.20);
|
border-top: 1px solid rgba(52,211,153,0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrong {
|
.wrong {
|
||||||
background: linear-gradient(90deg, rgba(248,113,113,0.15) 0%, rgba(248,113,113,0.06) 100%);
|
background: linear-gradient(90deg, rgba(148,163,184,0.12) 0%, rgba(148,163,184,0.05) 100%);
|
||||||
color: var(--error);
|
color: var(--text-muted);
|
||||||
border-top: 1px solid rgba(248,113,113,0.18);
|
border-top: 1px solid rgba(148,163,184,0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card-Glow je Ergebnis */
|
/* Card-Glow je Ergebnis */
|
||||||
.glowExact {
|
.glowExact {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 1px rgba(52,211,153,0.18),
|
0 0 0 1.5px rgba(254,174,50,0.40),
|
||||||
0 10px 30px rgba(52,211,153,0.07),
|
0 0 20px rgba(254,174,50,0.18),
|
||||||
inset 0 1px 0 rgba(255,255,255,0.07) !important;
|
0 10px 30px rgba(254,174,50,0.10),
|
||||||
|
inset 0 1px 0 rgba(255,255,255,0.09) !important;
|
||||||
|
border-color: rgba(254,174,50,0.35) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glowTendency {
|
.glowTendency {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 1px rgba(75,183,248,0.18),
|
0 0 0 1px rgba(52,211,153,0.28),
|
||||||
0 10px 30px rgba(75,183,248,0.07),
|
0 10px 30px rgba(52,211,153,0.10),
|
||||||
inset 0 1px 0 rgba(255,255,255,0.07) !important;
|
inset 0 1px 0 rgba(255,255,255,0.07) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glowWrong {
|
.glowWrong {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 1px rgba(248,113,113,0.15),
|
0 0 0 1px rgba(148,163,184,0.15),
|
||||||
0 10px 30px rgba(248,113,113,0.05),
|
0 10px 20px rgba(0,0,0,0.08),
|
||||||
inset 0 1px 0 rgba(255,255,255,0.07) !important;
|
inset 0 1px 0 rgba(255,255,255,0.05) !important;
|
||||||
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editBtn {
|
.editBtn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
border: 1px solid var(--border-subtle);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
@@ -339,3 +406,181 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tipped row — clean compact display */
|
||||||
|
.tippedRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(52, 211, 153, 0.08);
|
||||||
|
border: 1px solid rgba(52, 211, 153, 0.2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.tippedRow:hover {
|
||||||
|
background: rgba(52, 211, 153, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tippedIcon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--success);
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tippedLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tippedScore {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tippedEdit {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── State-based card variants ──────────────────────────────────── */
|
||||||
|
|
||||||
|
.card_open { /* default — no extra styling needed */ }
|
||||||
|
|
||||||
|
.card_tipped {
|
||||||
|
border-left: 3px solid var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card_live {
|
||||||
|
border-left: 3px solid var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card_finished { /* glow classes already applied via JS */ }
|
||||||
|
|
||||||
|
.card_missed {
|
||||||
|
opacity: 0.45;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Live pulsing dot */
|
||||||
|
.liveDot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--error);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Points badge */
|
||||||
|
.pointsBadge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pointsBadge_exact {
|
||||||
|
background: linear-gradient(135deg, #FEAE32, #FFD700, #FECC4C);
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 5px 12px;
|
||||||
|
box-shadow: 0 0 12px rgba(254,174,50,0.45);
|
||||||
|
animation: shimmer 2.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pointsBadge_tendency {
|
||||||
|
background: linear-gradient(135deg, var(--success), #22c55e);
|
||||||
|
color: #0a2a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pointsBadge_wrong {
|
||||||
|
background: var(--surface-high);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Missed label */
|
||||||
|
.missedLabel {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Countdown (replaces badge when < 60 min) */
|
||||||
|
.countdown {
|
||||||
|
color: var(--error);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdownUrgent {
|
||||||
|
animation: pulse 0.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tipped state: checkmark + score inline */
|
||||||
|
.tipDisplay_score {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Change button for tipped state */
|
||||||
|
.changeBtn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-decoration: underline;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changeBtn:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { box-shadow: 0 0 0 rgba(254, 174, 50, 0); }
|
||||||
|
50% { box-shadow: 0 0 16px rgba(254, 174, 50, 0.5); }
|
||||||
|
100% { box-shadow: 0 0 0 rgba(254, 174, 50, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Light Mode Overrides ═══ */
|
||||||
|
:global([data-theme="light"]) .kickoffLED {
|
||||||
|
color: #9A6500;
|
||||||
|
text-shadow:
|
||||||
|
0 0 2px rgba(154, 101, 0, 0.3),
|
||||||
|
0 0 6px rgba(154, 101, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="light"]) .flagBox {
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
border-color: rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="light"]) .flagBox::before {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.25) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Check, TrendingUp, X } from 'lucide-react';
|
import { Check, TrendingUp, X } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Match } from '../api/client';
|
import { Match } from '../api/client';
|
||||||
|
import { getFlagUrl } from '../utils/flagUrl';
|
||||||
import styles from './MatchCard.module.css';
|
import styles from './MatchCard.module.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -7,37 +9,44 @@ interface Props {
|
|||||||
onTip: () => void;
|
onTip: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
type CardState = 'open' | 'tipped' | 'live' | 'finished' | 'missed';
|
||||||
SCHEDULED: 'Geplant',
|
|
||||||
TIMED: 'Terminiert',
|
function getCardState(match: Match): CardState {
|
||||||
IN_PLAY: 'Live',
|
if (match.status === 'IN_PLAY' || match.status === 'PAUSED') return 'live';
|
||||||
PAUSED: 'Pause',
|
if (match.status === 'FINISHED') {
|
||||||
FINISHED: 'Beendet',
|
return match.userTip ? 'finished' : 'missed';
|
||||||
POSTPONED: 'Verschoben',
|
}
|
||||||
CANCELLED: 'Abgesagt',
|
// SCHEDULED or TIMED
|
||||||
};
|
return match.userTip ? 'tipped' : 'open';
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCountdown(minutesUntilKickoff: number) {
|
||||||
|
const [remaining, setRemaining] = useState(minutesUntilKickoff);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (minutesUntilKickoff > 60) return; // only active for <1h
|
||||||
|
setRemaining(minutesUntilKickoff);
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setRemaining(r => Math.max(0, r - 1 / 60));
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [minutesUntilKickoff]);
|
||||||
|
|
||||||
|
return remaining;
|
||||||
|
}
|
||||||
|
|
||||||
function formatKickoff(utcDate: string): string {
|
function formatKickoff(utcDate: string): string {
|
||||||
return new Date(utcDate).toLocaleString('de-DE', {
|
return new Date(utcDate).toLocaleString('de-DE', {
|
||||||
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin',
|
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin',
|
||||||
}) + ' Uhr';
|
});
|
||||||
}
|
|
||||||
|
|
||||||
function CountdownBadge({ minutes }: { minutes: number }) {
|
|
||||||
if (minutes <= 0) return null;
|
|
||||||
if (minutes < 60) return <span className={styles.badgeUrgent}>⚡ in {minutes} Min.</span>;
|
|
||||||
const h = Math.floor(minutes / 60);
|
|
||||||
const m = minutes % 60;
|
|
||||||
if (h < 24) return <span className={styles.badge}>in {h}h{m > 0 ? ` ${m}m` : ''}</span>;
|
|
||||||
const d = Math.floor(h / 24);
|
|
||||||
return <span className={styles.badge}>in {d} Tag{d > 1 ? 'en' : ''}</span>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function FlagBox({ crest, name }: { crest: string | null; name: string }) {
|
function FlagBox({ crest, name }: { crest: string | null; name: string }) {
|
||||||
|
const src = getFlagUrl(name, crest);
|
||||||
return (
|
return (
|
||||||
<div className={styles.flagBox}>
|
<div className={styles.flagBox}>
|
||||||
{crest
|
{src
|
||||||
? <img className={styles.crest} src={crest} alt={name} />
|
? <img className={styles.crest} src={src} alt={name} />
|
||||||
: <span style={{ fontSize: 18 }}>🏳️</span>
|
: <span style={{ fontSize: 18 }}>🏳️</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -45,10 +54,15 @@ function FlagBox({ crest, name }: { crest: string | null; name: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MatchCard({ match, onTip }: Props) {
|
export default function MatchCard({ match, onTip }: Props) {
|
||||||
const isFinished = match.status === 'FINISHED';
|
const state = getCardState(match);
|
||||||
const isLive = match.status === 'IN_PLAY' || match.status === 'PAUSED';
|
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 hasTip = !!match.userTip;
|
||||||
const points = match.userTip?.points ?? null;
|
const points = match.userTip?.points ?? null;
|
||||||
|
|
||||||
const resultClass =
|
const resultClass =
|
||||||
points === 3 ? styles.exact :
|
points === 3 ? styles.exact :
|
||||||
points === 1 ? styles.tendency :
|
points === 1 ? styles.tendency :
|
||||||
@@ -60,52 +74,89 @@ export default function MatchCard({ match, onTip }: Props) {
|
|||||||
isFinished && points === 0 ? styles.glowWrong : '';
|
isFinished && points === 0 ? styles.glowWrong : '';
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Badge row — centered above each flag */}
|
||||||
<div className={styles.topRow}>
|
<div className={styles.badgeRow}>
|
||||||
<span className={`${styles.status} ${isLive ? styles.statusLive : ''}`}>
|
<span className={styles.badgeSlot}>
|
||||||
{isLive && '● '}{STATUS_LABELS[match.status] ?? match.status}
|
|
||||||
</span>
|
|
||||||
{match.group && (
|
{match.group && (
|
||||||
<span className={styles.group}>
|
<span className={styles.group}>
|
||||||
{match.group.replace('GROUP_', 'Gruppe ')}
|
{match.group.replace('GROUP_', 'Gruppe ')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{match.tippable && <CountdownBadge minutes={match.minutesUntilKickoff} />}
|
</span>
|
||||||
|
<span className={styles.badgeSpacer} />
|
||||||
|
<span className={styles.badgeSlot}>
|
||||||
|
{isLive && (
|
||||||
|
<span className={styles.liveBadge}>
|
||||||
|
<span className={styles.liveDot} />
|
||||||
|
LIVE
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isFinished && (
|
||||||
|
<span className={styles.finishedBadge}>Beendet</span>
|
||||||
|
)}
|
||||||
|
{(state === 'open' || state === 'tipped') && match.tippable && (
|
||||||
|
<span className={`${styles.countdownBadge} ${remainingMins < 60 ? styles.countdownUrgent : ''}`}>
|
||||||
|
{match.minutesUntilKickoff < 60
|
||||||
|
? `Noch ${remainingMins} Min!`
|
||||||
|
: (() => {
|
||||||
|
const h = Math.floor(match.minutesUntilKickoff / 60);
|
||||||
|
if (h < 24) return `in ${h}h`;
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
return `in ${d} Tag${d > 1 ? 'en' : ''}`;
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Teams + Score */}
|
{/* Teams + Center (time or score) */}
|
||||||
<div className={styles.matchRow}>
|
<div className={styles.matchRow}>
|
||||||
{/* Home */}
|
|
||||||
<div className={styles.teamHome}>
|
<div className={styles.teamHome}>
|
||||||
<span className={styles.teamName}>{match.homeTeam.name}</span>
|
|
||||||
<FlagBox crest={match.homeTeam.crest} name={match.homeTeam.name} />
|
<FlagBox crest={match.homeTeam.crest} name={match.homeTeam.name} />
|
||||||
|
<span className={styles.teamName}>{match.homeTeam.shortName}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Score / Kickoff time */}
|
|
||||||
<div className={styles.scoreBox}>
|
<div className={styles.scoreBox}>
|
||||||
{isFinished || isLive ? (
|
{isFinished || isLive ? (
|
||||||
<span className={styles.score}>
|
<span className={`${styles.score} ${isLive ? styles.scoreLive : ''}`}>
|
||||||
{match.score.home ?? '–'} : {match.score.away ?? '–'}
|
{match.score.home ?? '–'} : {match.score.away ?? '–'}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.kickoffCenter}>
|
<span className={styles.kickoffLED}>{formatKickoff(match.utcDate)}</span>
|
||||||
<span className={styles.kickoffCenterTime}>{formatKickoff(match.utcDate)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Away */}
|
|
||||||
<div className={styles.teamAway}>
|
<div className={styles.teamAway}>
|
||||||
<FlagBox crest={match.awayTeam.crest} name={match.awayTeam.name} />
|
<FlagBox crest={match.awayTeam.crest} name={match.awayTeam.name} />
|
||||||
<span className={styles.teamName}>{match.awayTeam.name}</span>
|
<span className={styles.teamName}>{match.awayTeam.shortName}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tipp area — wird zum farbigen Banner wenn Punkte ausgewertet */}
|
{/* Tipp area — wird zum farbigen Banner wenn Punkte ausgewertet */}
|
||||||
<div className={`${styles.tipRow} ${hasTip && points !== null ? `${styles.resultBanner} ${resultClass}` : ''}`}>
|
<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 ? (
|
points !== null ? (
|
||||||
/* ── Auswertungs-Banner ── */
|
/* ── Auswertungs-Banner ── */
|
||||||
<div className={styles.tipDisplay}>
|
<div className={styles.tipDisplay}>
|
||||||
@@ -117,7 +168,7 @@ export default function MatchCard({ match, onTip }: Props) {
|
|||||||
<X size={14} strokeWidth={3} />}
|
<X size={14} strokeWidth={3} />}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.resultLabel}>
|
<span className={styles.resultLabel}>
|
||||||
{points === 3 ? 'Exakt' : points === 1 ? 'Tendenz' : 'Falsch'}
|
{points === 3 ? '🏆 Exakt' : points === 1 ? 'Tendenz' : 'Falsch'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -130,27 +181,22 @@ export default function MatchCard({ match, onTip }: Props) {
|
|||||||
|
|
||||||
{/* Rechts: Punkte */}
|
{/* Rechts: Punkte */}
|
||||||
<div className={styles.tipRight}>
|
<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.`}
|
{points === 0 ? '0 Pkt.' : `+${points} Pkt.`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* ── Tipp vorhanden, noch nicht ausgewertet ── */
|
/* ── Tipp vorhanden, noch nicht ausgewertet (tipped state) ── */
|
||||||
<div className={styles.tipDisplay}>
|
<div className={styles.tippedRow} onClick={match.tippable ? onTip : undefined}>
|
||||||
<div className={styles.tipLeft}>
|
<span className={styles.tippedIcon}>✓</span>
|
||||||
{match.tippable && (
|
<span className={styles.tippedLabel}>Dein Tipp</span>
|
||||||
<button className={styles.editBtn} onClick={onTip}>Ändern</button>
|
<span className={styles.tippedScore}>{match.userTip!.home} : {match.userTip!.away}</span>
|
||||||
)}
|
{match.tippable && <span className={styles.tippedEdit}>Ändern</span>}
|
||||||
</div>
|
|
||||||
<div className={styles.tipCenter}>
|
|
||||||
{/* Label nur zeigen wenn kein Ändern-Button da ist, sonst fluchtet der Button nicht */}
|
|
||||||
{!match.tippable && <span className={styles.tipLabel}>DEIN TIPP</span>}
|
|
||||||
<span className={styles.tipScore}>
|
|
||||||
{match.userTip!.home} : {match.userTip!.away}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.tipRight} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : match.tippable ? (
|
) : match.tippable ? (
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
.ring {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg {
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import styles from './StatsRing.module.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
exact: number;
|
||||||
|
tendency: number;
|
||||||
|
wrong: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatsRing({ exact, tendency, wrong, total }: Props) {
|
||||||
|
const radius = 55;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const all = exact + tendency + wrong;
|
||||||
|
const hasData = all > 0;
|
||||||
|
|
||||||
|
const segments = hasData ? [
|
||||||
|
{ value: exact / all, color: 'var(--gold)', label: 'Exakt', count: exact },
|
||||||
|
{ value: tendency / all, color: 'var(--success)', label: 'Tendenz', count: tendency },
|
||||||
|
{ value: wrong / all, color: 'var(--error)', label: 'Falsch', count: wrong },
|
||||||
|
] : [];
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.ring}>
|
||||||
|
<svg viewBox="0 0 140 140" className={styles.svg}>
|
||||||
|
{/* Background circle */}
|
||||||
|
<circle cx="70" cy="70" r={radius} fill="none" stroke="var(--surface-high)" strokeWidth="12" />
|
||||||
|
{segments.map((seg, i) => {
|
||||||
|
if (seg.value === 0) return null;
|
||||||
|
const dashArray = `${seg.value * circumference} ${circumference}`;
|
||||||
|
const rotation = offset * 360 - 90;
|
||||||
|
offset += seg.value;
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx="70" cy="70" r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke={seg.color}
|
||||||
|
strokeWidth="12"
|
||||||
|
strokeDasharray={dashArray}
|
||||||
|
transform={`rotate(${rotation} 70 70)`}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<text x="70" y="65" textAnchor="middle" dominantBaseline="central"
|
||||||
|
fill="var(--text-primary)" fontSize="28" fontWeight="700">
|
||||||
|
{total}
|
||||||
|
</text>
|
||||||
|
<text x="70" y="85" textAnchor="middle"
|
||||||
|
fill="var(--text-secondary)" fontSize="11">
|
||||||
|
{hasData ? 'Punkte' : 'Keine Tipps'}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
{hasData && (
|
||||||
|
<div className={styles.legend}>
|
||||||
|
{segments.map((seg, i) => (
|
||||||
|
<span key={i} className={styles.legendItem}>
|
||||||
|
<span className={styles.dot} style={{ background: seg.color }} />
|
||||||
|
{seg.label}: {seg.count}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,32 +52,11 @@
|
|||||||
.handle {
|
.handle {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
background: rgba(255,255,255,0.15);
|
background: var(--surface-high);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
margin: 0 auto 20px;
|
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 */
|
/* Teams row */
|
||||||
.teamsRow {
|
.teamsRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -98,12 +77,10 @@
|
|||||||
.flagLarge {
|
.flagLarge {
|
||||||
width: 72px;
|
width: 72px;
|
||||||
height: 72px;
|
height: 72px;
|
||||||
border-radius: 18px;
|
border-radius: 16px;
|
||||||
background: var(--surface-high);
|
background: var(--surface-high);
|
||||||
box-shadow:
|
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
||||||
0 8px 24px rgba(0,0,0,0.35),
|
border: 1px solid var(--border-subtle);
|
||||||
inset 0 1px 0 rgba(255,255,255,0.12),
|
|
||||||
inset 1px 0 0 rgba(255,255,255,0.06);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -115,16 +92,16 @@
|
|||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0; left: 0; right: 0;
|
top: 0; left: 0; right: 0;
|
||||||
height: 50%;
|
height: 40%;
|
||||||
background: linear-gradient(180deg, rgba(255,255,255,0.08) 0%, transparent 100%);
|
background: linear-gradient(180deg, rgba(255,255,255,0.2) 0%, transparent 100%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flagImg {
|
.flagImg {
|
||||||
width: 48px;
|
width: 100%;
|
||||||
height: 48px;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: cover;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
@@ -140,13 +117,6 @@
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.teamShort {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vsBlock {
|
.vsBlock {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -154,31 +124,6 @@
|
|||||||
height: 72px;
|
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 */
|
/* Picker section */
|
||||||
.pickerSection {
|
.pickerSection {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
@@ -219,13 +164,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pickerBtn {
|
.pickerBtn {
|
||||||
width: 56px;
|
width: 48px;
|
||||||
height: 56px;
|
height: 48px;
|
||||||
background: var(--surface-high);
|
background: var(--surface-high);
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: 16px;
|
border-radius: 14px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 24px;
|
font-size: 22px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -234,31 +179,23 @@
|
|||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow:
|
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
|
||||||
0 6px 16px rgba(0,0,0,0.35),
|
|
||||||
inset 0 1px 0 rgba(255,255,255,0.12),
|
|
||||||
inset 1px 0 0 rgba(255,255,255,0.06);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Glossy sheen – gleich wie flagLarge */
|
|
||||||
.pickerBtn::before {
|
.pickerBtn::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0; left: 0; right: 0;
|
top: 0; left: 0; right: 0;
|
||||||
height: 50%;
|
height: 40%;
|
||||||
background: linear-gradient(180deg, rgba(255,255,255,0.1) 0%, transparent 100%);
|
background: linear-gradient(180deg, rgba(255,255,255,0.12) 0%, transparent 100%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
border-radius: 16px 16px 0 0;
|
border-radius: 14px 14px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pickerBtn:hover {
|
.pickerBtn:hover {
|
||||||
background: rgba(75,183,248,0.12);
|
background: rgba(75,183,248,0.08);
|
||||||
border-color: rgba(75,183,248,0.35);
|
border-color: rgba(75,183,248,0.2);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
box-shadow:
|
|
||||||
0 6px 20px rgba(75,183,248,0.2),
|
|
||||||
inset 0 1px 0 rgba(75,183,248,0.15),
|
|
||||||
inset 1px 0 0 rgba(75,183,248,0.08);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pickerBtn:active {
|
.pickerBtn:active {
|
||||||
@@ -287,7 +224,7 @@
|
|||||||
background: var(--surface-high);
|
background: var(--surface-high);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
border: 1px solid rgba(255,255,255,0.08);
|
border: 1px solid var(--border-subtle);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
@@ -296,7 +233,7 @@
|
|||||||
inset 1px 0 0 rgba(255,255,255,0.05);
|
inset 1px 0 0 rgba(255,255,255,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Glossy sheen – gleich wie flagLarge und pickerBtn */
|
/* Glossy sheen */
|
||||||
.tendencyBar::before {
|
.tendencyBar::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -318,253 +255,6 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Expertenblick ---- */
|
|
||||||
.insightWrapper {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toggle-Zeile mit Play-Button */
|
|
||||||
.insightToggleRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.insightToggleRow .insightToggle {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Audio-Play-Button */
|
|
||||||
.audioBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 9px 12px;
|
|
||||||
background: linear-gradient(135deg, rgba(75,183,248,0.12) 0%, rgba(75,183,248,0.05) 100%);
|
|
||||||
border: 1px solid rgba(75,183,248,0.3);
|
|
||||||
border-radius: 12px;
|
|
||||||
color: var(--cyan);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.18s;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audioBtn:hover:not(:disabled) {
|
|
||||||
background: linear-gradient(135deg, rgba(75,183,248,0.2) 0%, rgba(75,183,248,0.08) 100%);
|
|
||||||
border-color: rgba(75,183,248,0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.audioBtn:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dialog-Format: Delling / Netzer */
|
|
||||||
.dialogLine {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
animation: insightFadeIn 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialogLine:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.speakerDelling {
|
|
||||||
background: rgba(148, 163, 184, 0.06);
|
|
||||||
border-left: 2px solid rgba(148,163,184,0.4);
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.speakerNetzer {
|
|
||||||
background: rgba(254, 174, 50, 0.06);
|
|
||||||
border-left: 2px solid rgba(254,174,50,0.5);
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialogSpeaker {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.speakerDelling .dialogSpeaker {
|
|
||||||
color: rgba(148,163,184,0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.speakerNetzer .dialogSpeaker {
|
|
||||||
color: var(--gold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialogText {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.55;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.insightToggle {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 11px 16px;
|
|
||||||
background: linear-gradient(135deg, rgba(254,174,50,0.12) 0%, rgba(254,174,50,0.05) 100%);
|
|
||||||
border: 1px solid rgba(254,174,50,0.3);
|
|
||||||
border-radius: 12px;
|
|
||||||
color: var(--gold);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.18s;
|
|
||||||
text-align: left;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow:
|
|
||||||
0 2px 12px rgba(254,174,50,0.1),
|
|
||||||
inset 0 1px 0 rgba(254,174,50,0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Glossy sheen */
|
|
||||||
.insightToggle::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0; left: 0; right: 0;
|
|
||||||
height: 50%;
|
|
||||||
background: linear-gradient(180deg, rgba(255,255,255,0.06) 0%, transparent 100%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.insightToggle:hover {
|
|
||||||
background: linear-gradient(135deg, rgba(254,174,50,0.2) 0%, rgba(254,174,50,0.08) 100%);
|
|
||||||
border-color: rgba(254,174,50,0.5);
|
|
||||||
box-shadow:
|
|
||||||
0 4px 20px rgba(254,174,50,0.2),
|
|
||||||
inset 0 1px 0 rgba(254,174,50,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.insightIcon {
|
|
||||||
color: var(--gold);
|
|
||||||
flex-shrink: 0;
|
|
||||||
filter: drop-shadow(0 0 4px rgba(254,174,50,0.5));
|
|
||||||
}
|
|
||||||
|
|
||||||
.insightChevron {
|
|
||||||
margin-left: auto;
|
|
||||||
color: rgba(254,174,50,0.6);
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.insightPanel {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
background: var(--surface-high);
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid rgba(254,174,50,0.1);
|
|
||||||
box-shadow: inset 0 1px 0 rgba(254,174,50,0.05);
|
|
||||||
animation: insightFadeIn 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes insightFadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(-4px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.insightLoading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.insightSpinner {
|
|
||||||
color: var(--primary);
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.insightText {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.insightLine {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
padding: 10px 0;
|
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.insightLine:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.insightLine:first-child {
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.insightLabel {
|
|
||||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--primary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.insightValue {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.insightValue strong {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.insightCursor {
|
|
||||||
display: inline-block;
|
|
||||||
width: 2px;
|
|
||||||
height: 14px;
|
|
||||||
background: var(--primary);
|
|
||||||
border-radius: 1px;
|
|
||||||
margin-left: 2px;
|
|
||||||
vertical-align: middle;
|
|
||||||
animation: blink 0.8s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes blink {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.insightErrorMsg {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Error */
|
/* Error */
|
||||||
.error {
|
.error {
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
@@ -599,3 +289,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cancelBtn:hover { color: var(--text-secondary); }
|
.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,6 +1,6 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState } from 'react';
|
||||||
import { Sparkles, ChevronDown, ChevronUp, Loader2, Volume2, Square } from 'lucide-react';
|
|
||||||
import { Match, api } from '../api/client';
|
import { Match, api } from '../api/client';
|
||||||
|
import { getFlagUrl } from '../utils/flagUrl';
|
||||||
import styles from './TipModal.module.css';
|
import styles from './TipModal.module.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -17,132 +17,14 @@ function getTendency(home: number, away: number): Tendency {
|
|||||||
return 'draw';
|
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) {
|
export default function TipModal({ match, onClose, onSaved }: Props) {
|
||||||
const existing = match.userTip;
|
const existing = match.userTip;
|
||||||
const [home, setHome] = useState(existing?.home ?? 0);
|
const [home, setHome] = useState(existing?.home ?? 0);
|
||||||
const [away, setAway] = useState(existing?.away ?? 0);
|
const [away, setAway] = useState(existing?.away ?? 0);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 tendency = getTendency(home, away);
|
||||||
const tendencyLabel =
|
const tendencyLabel =
|
||||||
tendency === 'home' ? match.homeTeam.shortName || match.homeTeam.name :
|
tendency === 'home' ? match.homeTeam.shortName || match.homeTeam.name :
|
||||||
@@ -158,7 +40,13 @@ export default function TipModal({ match, onClose, onSaved }: Props) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await api.submitTip(match.id, home, away);
|
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);
|
onSaved(match.id, home, away);
|
||||||
|
onClose();
|
||||||
|
}, 1200);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -172,47 +60,24 @@ export default function TipModal({ match, onClose, onSaved }: Props) {
|
|||||||
{/* Drag handle */}
|
{/* Drag handle */}
|
||||||
<div className={styles.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 */}
|
{/* Teams mit Flaggen */}
|
||||||
<div className={styles.teamsRow}>
|
<div className={styles.teamsRow}>
|
||||||
<div className={styles.teamBlock}>
|
<div className={styles.teamBlock}>
|
||||||
<div className={styles.flagLarge}>
|
<div className={styles.flagLarge}>
|
||||||
{match.homeTeam.crest
|
{match.homeTeam.name
|
||||||
? <img src={match.homeTeam.crest} alt={match.homeTeam.name} className={styles.flagImg} />
|
? <img src={getFlagUrl(match.homeTeam.name, match.homeTeam.crest)} alt={match.homeTeam.name} className={styles.flagImg} />
|
||||||
: <span className={styles.flagEmoji}>🏳️</span>
|
: <span className={styles.flagEmoji}>🏳️</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<span className={styles.teamName}>{match.homeTeam.name}</span>
|
<span className={styles.teamName}>{match.homeTeam.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.vsBlock}>
|
<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.teamBlock}>
|
<div className={styles.teamBlock}>
|
||||||
<div className={styles.flagLarge}>
|
<div className={styles.flagLarge}>
|
||||||
{match.awayTeam.crest
|
{match.awayTeam.name
|
||||||
? <img src={match.awayTeam.crest} alt={match.awayTeam.name} className={styles.flagImg} />
|
? <img src={getFlagUrl(match.awayTeam.name, match.awayTeam.crest)} alt={match.awayTeam.name} className={styles.flagImg} />
|
||||||
: <span className={styles.flagEmoji}>🏳️</span>
|
: <span className={styles.flagEmoji}>🏳️</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -240,93 +105,15 @@ export default function TipModal({ match, onClose, onSaved }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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>}
|
{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 */}
|
{/* CTA */}
|
||||||
<button
|
<button
|
||||||
className={`btn-primary ${styles.saveBtn}`}
|
className={`btn-primary ${styles.saveBtn}`}
|
||||||
|
|||||||
@@ -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,24 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import styles from './Toast.module.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
message: string;
|
||||||
|
onDismiss: () => void;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Toast({ message, onDismiss, duration = 5000 }: Props) {
|
||||||
|
const onDismissRef = useRef(onDismiss);
|
||||||
|
onDismissRef.current = onDismiss;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => onDismissRef.current(), duration);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [duration]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`card ${styles.toast}`} onClick={onDismiss}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,17 @@
|
|||||||
interface Window {
|
interface Window {
|
||||||
_devUser?: number;
|
_devUser?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_TEST_MODE?: string;
|
||||||
|
readonly DEV: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.module.css' {
|
||||||
|
const classes: { readonly [key: string]: string };
|
||||||
|
export default classes;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
|
||||||
|
const RANK_KEY = 'tippspiel_last_rank';
|
||||||
|
const SHOWN_KEY = 'tippspiel_rank_toast_shown';
|
||||||
|
|
||||||
|
export function useRankChange() {
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only show once per session
|
||||||
|
if (sessionStorage.getItem(SHOWN_KEY)) return;
|
||||||
|
|
||||||
|
api.getMyStats().then(stats => {
|
||||||
|
if (!stats.rank) return;
|
||||||
|
const lastRank = parseInt(localStorage.getItem(RANK_KEY) || '0');
|
||||||
|
if (lastRank > 0 && lastRank !== stats.rank) {
|
||||||
|
if (stats.rank < lastRank) {
|
||||||
|
setMessage(`⬆️ Du bist auf Platz ${stats.rank} aufgestiegen!`);
|
||||||
|
} else {
|
||||||
|
setMessage(`⬇️ Du bist auf Platz ${stats.rank} gerutscht — hol dir die Punkte zurück!`);
|
||||||
|
}
|
||||||
|
sessionStorage.setItem(SHOWN_KEY, '1');
|
||||||
|
}
|
||||||
|
localStorage.setItem(RANK_KEY, String(stats.rank));
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function dismiss() { setMessage(null); }
|
||||||
|
return { message, dismiss };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -2,12 +2,25 @@
|
|||||||
WM 2026 Tippspiel — Stadium Elite Design System
|
WM 2026 Tippspiel — Stadium Elite Design System
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
|
/* Material Symbols — filled icons for badges */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@400,1&display=swap');
|
||||||
|
|
||||||
|
/* Stadium LED Segment Display Font */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'DSEG7';
|
||||||
|
src: url('https://cdn.jsdelivr.net/npm/dseg@0.46.0/fonts/DSEG7-Classic/DSEG7Classic-Regular.woff2') format('woff2');
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Dark Mode (Standard) --- */
|
||||||
:root {
|
:root {
|
||||||
--bg-deep: #0A0E1A;
|
--bg-deep: #0A0E1A;
|
||||||
--bg-mid: #0F1628;
|
--bg-mid: #0F1628;
|
||||||
--surface-low: #111827;
|
--surface-low: #111827;
|
||||||
--surface-mid: #151D30;
|
--surface-mid: #151D30;
|
||||||
--surface-high: #1C2640;
|
--surface-high: #1C2640;
|
||||||
|
--border-subtle: rgba(255,255,255,0.07);
|
||||||
--primary: #4BB7F8;
|
--primary: #4BB7F8;
|
||||||
--primary-dim: rgba(75,183,248,0.12);
|
--primary-dim: rgba(75,183,248,0.12);
|
||||||
--gold: #FEAE32;
|
--gold: #FEAE32;
|
||||||
@@ -22,6 +35,36 @@
|
|||||||
--radius-md: 14px;
|
--radius-md: 14px;
|
||||||
--radius-lg: 20px;
|
--radius-lg: 20px;
|
||||||
--radius-xl: 28px;
|
--radius-xl: 28px;
|
||||||
|
--shadow-card: 0 10px 25px rgba(0,0,0,0.25);
|
||||||
|
--card-shine: rgba(255,255,255,0.04);
|
||||||
|
--scrollbar-bg: var(--surface-high);
|
||||||
|
--primary-rgb: 75, 183, 248;
|
||||||
|
--transition-fast: 0.15s ease;
|
||||||
|
--transition-normal: 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Light Mode --- */
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-deep: #F0F4FA;
|
||||||
|
--bg-mid: #E8EEF7;
|
||||||
|
--surface-low: #FFFFFF;
|
||||||
|
--surface-mid: #FFFFFF;
|
||||||
|
--surface-high: #E4EAF2;
|
||||||
|
--border-subtle: rgba(0,0,0,0.1);
|
||||||
|
--primary: #1A8FE3;
|
||||||
|
--primary-dim: rgba(26,143,227,0.10);
|
||||||
|
--gold: #B8740A;
|
||||||
|
--gold-glow: rgba(184,116,10,0.3);
|
||||||
|
--cyan: #0080C6;
|
||||||
|
--text-primary: #0D1526;
|
||||||
|
--text-secondary: rgba(13,21,38,0.65);
|
||||||
|
--text-muted: rgba(13,21,38,0.45);
|
||||||
|
--success: #168A5C;
|
||||||
|
--error: #C92A1F;
|
||||||
|
--shadow-card: 0 2px 8px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04);
|
||||||
|
--card-shine: rgba(255,255,255,0.9);
|
||||||
|
--scrollbar-bg: var(--surface-high);
|
||||||
|
--primary-rgb: 26, 143, 227;
|
||||||
}
|
}
|
||||||
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
@@ -38,7 +81,7 @@ html, body, #root {
|
|||||||
/* Scrollbar */
|
/* Scrollbar */
|
||||||
::-webkit-scrollbar { width: 6px; }
|
::-webkit-scrollbar { width: 6px; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
::-webkit-scrollbar-thumb { background: var(--surface-high); border-radius: 3px; }
|
::-webkit-scrollbar-thumb { background: var(--scrollbar-bg); border-radius: 3px; }
|
||||||
|
|
||||||
/* Utility */
|
/* Utility */
|
||||||
.font-display { font-family: 'Plus Jakarta Sans', sans-serif; }
|
.font-display { font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||||
@@ -50,10 +93,10 @@ html, body, #root {
|
|||||||
.card {
|
.card {
|
||||||
background: var(--surface-mid);
|
background: var(--surface-mid);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 10px 25px rgba(0,0,0,0.25),
|
var(--shadow-card),
|
||||||
inset 0 1px 0 rgba(255,255,255,0.07),
|
inset 0 1px 0 var(--border-subtle);
|
||||||
inset 1px 0 0 rgba(255,255,255,0.04);
|
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -61,7 +104,7 @@ html, body, #root {
|
|||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0; left: 0; right: 0; height: 50%;
|
top: 0; left: 0; right: 0; height: 50%;
|
||||||
background: linear-gradient(180deg, rgba(255,255,255,0.04) 0%, transparent 100%);
|
background: linear-gradient(180deg, var(--card-shine) 0%, transparent 100%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -171,8 +171,8 @@
|
|||||||
.spinner {
|
.spinner {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
border: 2px solid rgba(255,255,255,0.3);
|
border: 2px solid var(--surface-high);
|
||||||
border-top-color: #fff;
|
border-top-color: var(--text-primary);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.7s linear infinite;
|
animation: spin 0.7s linear infinite;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
@@ -0,0 +1,477 @@
|
|||||||
|
.dashboard {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: single column */
|
||||||
|
.dashboardGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop: 2-column layout */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.dashboardGrid {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
flex: 3;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
flex: 2;
|
||||||
|
position: sticky;
|
||||||
|
top: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════
|
||||||
|
HERO CARD — Stadium atmosphere
|
||||||
|
═══════════════════════════════════════ */
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 2rem;
|
||||||
|
padding: 28px 22px 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
/* Glassmorphism with curvature shine */
|
||||||
|
background: rgba(49, 52, 66, 0.45);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow:
|
||||||
|
0 40px 80px -15px rgba(0, 0, 0, 0.6),
|
||||||
|
0 8px 24px rgba(0, 0, 0, 0.3),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glossy curvature shine — top highlight */
|
||||||
|
.hero::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 45%;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, transparent 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
border-radius: 2rem 2rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero:hover {
|
||||||
|
transform: scale(1.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Radial glow behind the teams — strong stadium light */
|
||||||
|
.heroGlow {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 400px;
|
||||||
|
height: 350px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: radial-gradient(circle at center, rgba(75, 183, 248, 0.2) 0%, rgba(75, 183, 248, 0.08) 35%, transparent 65%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroLabel {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroCountdown {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--gold);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
background: rgba(254, 174, 50, 0.1);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(254, 174, 50, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdownDot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gold);
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Center column: LED time between flags, vertically centered to flag height */
|
||||||
|
.heroCenter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 64px; /* matches flag box height */
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroLED {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledDigit, .ledColon {
|
||||||
|
font-family: 'DSEG7', 'Courier New', monospace;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #FECC4C;
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
text-shadow:
|
||||||
|
0 0 3px rgba(254, 174, 50, 1),
|
||||||
|
0 0 8px rgba(254, 174, 50, 0.7),
|
||||||
|
0 0 20px rgba(254, 174, 50, 0.4),
|
||||||
|
0 0 40px rgba(254, 174, 50, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledDigit {
|
||||||
|
width: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledColon {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroGroup {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Teams */
|
||||||
|
.heroTeams {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 16px 0 20px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroTeam {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroCrestBox {
|
||||||
|
position: relative;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--surface-high);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 24px rgba(0, 0, 0, 0.4),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glossy shine on flag box */
|
||||||
|
.heroCrestBox::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 40%;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.15) 0%, transparent 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow aura behind each flag */
|
||||||
|
.heroCrestBox::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -12px;
|
||||||
|
background: rgba(75, 183, 248, 0.18);
|
||||||
|
filter: blur(20px);
|
||||||
|
border-radius: 50%;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flag fills the entire box */
|
||||||
|
.heroCrest {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroCrestFallback {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroTeamName {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* VS is now replaced by heroCenter with LED time */
|
||||||
|
.heroVs { display: none; }
|
||||||
|
|
||||||
|
/* CTA / Tip */
|
||||||
|
.heroTipBtn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: linear-gradient(135deg, var(--primary) 0%, #2196f3 100%);
|
||||||
|
color: white;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
box-shadow: 0 4px 20px rgba(75, 183, 248, 0.3);
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroTipBtn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 24px rgba(75, 183, 248, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroTipBtn:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroTip {
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--gold);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: rgba(254, 174, 50, 0.08);
|
||||||
|
border: 1px solid rgba(254, 174, 50, 0.15);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroEmpty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 24px 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════
|
||||||
|
STATS ROW
|
||||||
|
═══════════════════════════════════════ */
|
||||||
|
.statsRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statTile {
|
||||||
|
padding: 18px 8px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 1.5rem !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValue {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statGold {
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rankUp {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--success);
|
||||||
|
margin-left: 4px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rankDown {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--error);
|
||||||
|
margin-left: 4px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statLabel {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════
|
||||||
|
NUDGES
|
||||||
|
═══════════════════════════════════════ */
|
||||||
|
.nudges {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nudge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border-radius: 1.25rem !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nudge:hover {
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nudgeIcon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(75, 183, 248, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nudgeText {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nudgeStreak {
|
||||||
|
border-left: 3px solid var(--error) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════
|
||||||
|
STATES
|
||||||
|
═══════════════════════════════════════ */
|
||||||
|
/* ═══════════════════════════════════════
|
||||||
|
LIGHT MODE OVERRIDES
|
||||||
|
═══════════════════════════════════════ */
|
||||||
|
:global([data-theme="light"]) .hero {
|
||||||
|
background: rgba(255, 255, 255, 0.75);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="light"]) .hero::before {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.5) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="light"]) .heroGlow {
|
||||||
|
background: radial-gradient(circle at center, rgba(26, 143, 227, 0.08) 0%, transparent 65%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="light"]) .heroLED,
|
||||||
|
:global([data-theme="light"]) .ledDigit,
|
||||||
|
:global([data-theme="light"]) .ledColon {
|
||||||
|
color: #9A6500;
|
||||||
|
text-shadow:
|
||||||
|
0 0 3px rgba(154, 101, 0, 0.4),
|
||||||
|
0 0 8px rgba(154, 101, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="light"]) .heroCrestBox {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="light"]) .heroCrestBox::before {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.3) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="light"]) .heroCrestBox::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="light"]) .heroTip {
|
||||||
|
background: rgba(184, 116, 10, 0.08);
|
||||||
|
border: 1px solid rgba(184, 116, 10, 0.2);
|
||||||
|
color: #8B5E00;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="light"]) .heroCountdown {
|
||||||
|
background: rgba(184, 116, 10, 0.08);
|
||||||
|
border-color: rgba(184, 116, 10, 0.2);
|
||||||
|
color: #8B5E00;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="light"]) .statTile {
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.error {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { TrendingUp, TrendingDown } from 'lucide-react';
|
||||||
|
import { api, DashboardData, Match } from '../api/client';
|
||||||
|
import { getFlagUrl } from '../utils/flagUrl';
|
||||||
|
import TipModal from '../components/TipModal';
|
||||||
|
import styles from './DashboardPage.module.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
devUser?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStreak(streak: number): string {
|
||||||
|
if (streak >= 20) return `⚡${streak}`;
|
||||||
|
if (streak >= 10) return `🔥🔥${streak}`;
|
||||||
|
if (streak >= 3) return `🔥${streak}`;
|
||||||
|
if (streak > 0) return String(streak);
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatKickoff(utcDate: string): string {
|
||||||
|
return new Date(utcDate).toLocaleString('de-DE', {
|
||||||
|
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCountdown(minutes: number): string {
|
||||||
|
if (minutes < 60) return `in ${Math.round(minutes)} Min`;
|
||||||
|
if (minutes < 60 * 24) return `in ${Math.floor(minutes / 60)}h`;
|
||||||
|
const days = Math.floor(minutes / (60 * 24));
|
||||||
|
return `in ${days} ${days === 1 ? 'Tag' : 'Tagen'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage(_props: Props) {
|
||||||
|
const [data, setData] = useState<DashboardData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [tipMatch, setTipMatch] = useState<Match | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
|
api.getDashboard()
|
||||||
|
.then(d => { setData(d); setLoading(false); })
|
||||||
|
.catch(() => { setError(true); setLoading(false); });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keep stored streak up to date (hook must be before early returns)
|
||||||
|
const STREAK_KEY = 'tippspiel_last_streak';
|
||||||
|
useEffect(() => {
|
||||||
|
if (data && data.stats.streak > 0) {
|
||||||
|
localStorage.setItem(STREAK_KEY, String(data.stats.streak));
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (loading) return <div className={styles.loading}>Laden...</div>;
|
||||||
|
if (error || !data) return <div className={styles.error}>Dashboard konnte nicht geladen werden.</div>;
|
||||||
|
|
||||||
|
const { hero, stats, nudges } = data;
|
||||||
|
|
||||||
|
const lastRank = parseInt(localStorage.getItem('tippspiel_last_rank') || '0');
|
||||||
|
const rankDiff = lastRank > 0 && stats.rank !== null ? lastRank - stats.rank : 0;
|
||||||
|
|
||||||
|
// Streak break detection via localStorage
|
||||||
|
const lastStreak = parseInt(localStorage.getItem(STREAK_KEY) || '0');
|
||||||
|
const streakBroken = lastStreak >= 3 && stats.streak === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.dashboard}>
|
||||||
|
<div className={styles.dashboardGrid}>
|
||||||
|
{/* Hero Card */}
|
||||||
|
<div className={styles.hero} onClick={() => navigate('/spiele')}>
|
||||||
|
{/* Radial glow background effect */}
|
||||||
|
<div className={styles.heroGlow} />
|
||||||
|
|
||||||
|
<div className={styles.heroHeader}>
|
||||||
|
<span className={styles.heroLabel}>Nächstes Spiel</span>
|
||||||
|
{hero && (
|
||||||
|
<span className={styles.heroCountdown}>
|
||||||
|
<span className={styles.countdownDot} />
|
||||||
|
{formatCountdown(hero.match.minutesUntilKickoff).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hero ? (
|
||||||
|
<>
|
||||||
|
{/* Teams with LED time in center */}
|
||||||
|
<div className={styles.heroTeams}>
|
||||||
|
<div className={styles.heroTeam}>
|
||||||
|
<div className={styles.heroCrestBox}>
|
||||||
|
{hero.match.homeTeam.name ? (
|
||||||
|
<img src={getFlagUrl(hero.match.homeTeam.name, hero.match.homeTeam.crest)} alt={hero.match.homeTeam.name} className={styles.heroCrest} />
|
||||||
|
) : (
|
||||||
|
<span className={styles.heroCrestFallback}>🏳️</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={styles.heroTeamName}>{hero.match.homeTeam.shortName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center: LED time — each digit fixed-width for even spacing */}
|
||||||
|
<div className={styles.heroCenter}>
|
||||||
|
<span className={styles.heroLED}>
|
||||||
|
{formatKickoff(hero.match.utcDate).split('').map((ch, i) => (
|
||||||
|
<span key={i} className={ch === ':' ? styles.ledColon : styles.ledDigit}>{ch}</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.heroTeam}>
|
||||||
|
<div className={styles.heroCrestBox}>
|
||||||
|
{hero.match.awayTeam.name ? (
|
||||||
|
<img src={getFlagUrl(hero.match.awayTeam.name, hero.match.awayTeam.crest)} alt={hero.match.awayTeam.name} className={styles.heroCrest} />
|
||||||
|
) : (
|
||||||
|
<span className={styles.heroCrestFallback}>🏳️</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={styles.heroTeamName}>{hero.match.awayTeam.shortName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA or Tip Display */}
|
||||||
|
{hero.userTip ? (
|
||||||
|
<div className={styles.heroTip}>
|
||||||
|
Dein Tipp: {hero.userTip.home}:{hero.userTip.away} ✓
|
||||||
|
</div>
|
||||||
|
) : hero.tippable ? (
|
||||||
|
<button
|
||||||
|
className={styles.heroTipBtn}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setTipMatch({
|
||||||
|
id: hero.match.id,
|
||||||
|
externalId: 0,
|
||||||
|
utcDate: hero.match.utcDate,
|
||||||
|
status: hero.match.status,
|
||||||
|
stage: '',
|
||||||
|
group: null,
|
||||||
|
homeTeam: hero.match.homeTeam,
|
||||||
|
awayTeam: hero.match.awayTeam,
|
||||||
|
score: { home: null, away: null },
|
||||||
|
userTip: null,
|
||||||
|
minutesUntilKickoff: hero.match.minutesUntilKickoff,
|
||||||
|
tippable: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Jetzt tippen ⚽
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className={styles.heroEmpty}>Keine anstehenden Spiele</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar: Stats + Nudges */}
|
||||||
|
<div className={styles.sidebar}>
|
||||||
|
<div className={styles.statsRow}>
|
||||||
|
<div className={`card ${styles.statTile}`}>
|
||||||
|
<span className={styles.statValue}>
|
||||||
|
{stats.rank !== null ? stats.rank : '—'}
|
||||||
|
{rankDiff > 0 && <span className={styles.rankUp}><TrendingUp size={14} strokeWidth={2.5} /></span>}
|
||||||
|
{rankDiff < 0 && <span className={styles.rankDown}><TrendingDown size={14} strokeWidth={2.5} /></span>}
|
||||||
|
</span>
|
||||||
|
<span className={styles.statLabel}>Dein Rang</span>
|
||||||
|
</div>
|
||||||
|
<div className={`card ${styles.statTile}`}>
|
||||||
|
<span className={`${styles.statValue} ${styles.statGold}`}>{stats.totalPoints}</span>
|
||||||
|
<span className={styles.statLabel}>Punkte</span>
|
||||||
|
</div>
|
||||||
|
<div className={`card ${styles.statTile}`}>
|
||||||
|
<span className={styles.statValue}>{formatStreak(stats.streak)}</span>
|
||||||
|
<span className={styles.statLabel}>Streak</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nudges */}
|
||||||
|
{(streakBroken || nudges.length > 0) && (
|
||||||
|
<div className={styles.nudges}>
|
||||||
|
{streakBroken && (
|
||||||
|
<div
|
||||||
|
className={`card ${styles.nudge} ${styles.nudgeStreak}`}
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.removeItem(STREAK_KEY);
|
||||||
|
navigate('/spiele');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={styles.nudgeIcon}>💔</span>
|
||||||
|
<span className={styles.nudgeText}>
|
||||||
|
Deine {lastStreak}er-Serie ist gerissen! Starte eine neue.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{nudges.map((nudge, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`card ${styles.nudge}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (nudge.type === 'untipped') navigate('/spiele');
|
||||||
|
else if (nudge.type === 'leader') navigate('/rangliste');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={styles.nudgeIcon}>
|
||||||
|
{nudge.type === 'untipped' ? '📅' : nudge.type === 'leader' ? '🏆' : '🎯'}
|
||||||
|
</span>
|
||||||
|
<span className={styles.nudgeText}>{nudge.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>{/* /sidebar */}
|
||||||
|
</div>{/* /dashboardGrid */}
|
||||||
|
|
||||||
|
{tipMatch && (
|
||||||
|
<TipModal
|
||||||
|
match={tipMatch}
|
||||||
|
onClose={() => setTipMatch(null)}
|
||||||
|
onSaved={(_matchId, tipHome, tipAway) => {
|
||||||
|
setTipMatch(null);
|
||||||
|
if (data && data.hero) {
|
||||||
|
setData({
|
||||||
|
...data,
|
||||||
|
hero: { ...data.hero, userTip: { home: tipHome, away: tipAway } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||