edf33fa932
Backend: - New /api/achievements endpoint calculating 6 badges: Scharfschütze, Serien-Tipper, Tabellenführer, Frühtipper, Globetrotter, Diamant - Each with progress tracking (current/target) Frontend: - AchievementBadge component with Stitch-inspired design - Material Symbols Outlined font (filled icons) - Unlocked: colored icon with glow + drop-shadow, rank label - Locked: grayscale, lock overlay, progress bar - ProfilePage: real badges replacing emoji placeholders - Progress bar showing X/6 collected - Mobile: 2-col grid, Desktop: 6-col grid Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
176 lines
4.3 KiB
TypeScript
176 lines
4.3 KiB
TypeScript
const BASE = '/api';
|
|
|
|
function withDevUser(path: string): string {
|
|
const devUser = new URLSearchParams(window.location.search).get('devUser');
|
|
if (!devUser) return path;
|
|
const sep = path.includes('?') ? '&' : '?';
|
|
return `${path}${sep}devUser=${devUser}`;
|
|
}
|
|
|
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
|
const res = await fetch(`${BASE}${withDevUser(path)}`, {
|
|
...options,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...options?.headers,
|
|
},
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
throw new Error(err.message || err.error || 'Request failed');
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export const api = {
|
|
// Matches
|
|
getMatches: (params?: { stage?: string; group?: string }) => {
|
|
const q = new URLSearchParams(params as Record<string, string>).toString();
|
|
return request<{ matches: Match[]; count: number }>(`/matches${q ? '?' + q : ''}`);
|
|
},
|
|
|
|
// Tips
|
|
submitTip: (matchId: number, tipHome: number, tipAway: number) =>
|
|
request<{ success: boolean; tip: TipInfo; message: string }>('/tips', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ matchId, tipHome, tipAway }),
|
|
}),
|
|
|
|
getMyTips: () =>
|
|
request<{ tips: MyTip[]; count: number }>('/tips'),
|
|
|
|
// Leaderboard
|
|
getLeaderboard: () =>
|
|
request<LeaderboardResponse>('/leaderboard'),
|
|
|
|
getMyStats: () =>
|
|
request<UserStats>('/leaderboard/me'),
|
|
|
|
// Profile
|
|
updateTeam: (team: string) =>
|
|
request<{ success: boolean; team: string }>('/profile/team', {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ team }),
|
|
}),
|
|
|
|
// Dashboard
|
|
getDashboard: () => request<DashboardData>('/dashboard'),
|
|
|
|
// Achievements
|
|
getAchievements: () => request<AchievementsData>('/achievements'),
|
|
|
|
// Admin
|
|
syncMatches: () =>
|
|
request<{ success: boolean; total: number; created: number; updated: number }>(
|
|
'/admin/sync',
|
|
{ method: 'POST' }
|
|
),
|
|
evaluateTips: () =>
|
|
request<{ success: boolean; matchesEvaluated: number; tipsUpdated: number }>(
|
|
'/admin/evaluate',
|
|
{ method: 'POST' }
|
|
),
|
|
};
|
|
|
|
// Types (gespiegelt vom Backend)
|
|
export interface DashboardData {
|
|
hero: {
|
|
match: {
|
|
id: number;
|
|
homeTeam: { name: string; shortName: string; crest: string | null };
|
|
awayTeam: { name: string; shortName: string; crest: string | null };
|
|
utcDate: string;
|
|
status: string;
|
|
minutesUntilKickoff: number;
|
|
};
|
|
userTip: { home: number; away: number } | null;
|
|
tippable: boolean;
|
|
} | null;
|
|
stats: { rank: number | null; totalPoints: number; streak: number };
|
|
nudges: Array<{ type: string; text: string; matchId?: number }>;
|
|
}
|
|
|
|
|
|
export interface Match {
|
|
id: number;
|
|
externalId: number;
|
|
utcDate: string;
|
|
status: string;
|
|
stage: string;
|
|
group: string | null;
|
|
homeTeam: { name: string; shortName: string; crest: string | null };
|
|
awayTeam: { name: string; shortName: string; crest: string | null };
|
|
score: { home: number | null; away: number | null };
|
|
userTip: TipInfo | null;
|
|
minutesUntilKickoff: number;
|
|
tippable: boolean;
|
|
}
|
|
|
|
export interface TipInfo {
|
|
home: number;
|
|
away: number;
|
|
points: number | null;
|
|
}
|
|
|
|
export interface MyTip {
|
|
match_id: number;
|
|
tip_home: number;
|
|
tip_away: number;
|
|
points: number | null;
|
|
utc_date: string;
|
|
home_team_short: string;
|
|
away_team_short: string;
|
|
status: string;
|
|
}
|
|
|
|
export interface LeaderboardEntry {
|
|
user_id: string;
|
|
full_name: string;
|
|
team: string | null;
|
|
total_points: number;
|
|
tips_count: number;
|
|
exact_count: number;
|
|
tendency_count: number;
|
|
rank: number;
|
|
}
|
|
|
|
export interface LeaderboardResponse {
|
|
entries: LeaderboardEntry[];
|
|
currentUserRank: number | null;
|
|
currentUserId: string | null;
|
|
totalParticipants: number;
|
|
lastUpdated: string;
|
|
}
|
|
|
|
export interface UserStats {
|
|
userId: string;
|
|
fullName: string;
|
|
team: string | null;
|
|
totalPoints: number;
|
|
rank: number | null;
|
|
tipsCount: number;
|
|
exactCount: number;
|
|
tendencyCount: number;
|
|
wrongCount: number;
|
|
accuracy: number;
|
|
}
|
|
|
|
export interface Achievement {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
icon: string;
|
|
color: string;
|
|
rankLabel: string;
|
|
unlocked: boolean;
|
|
progress: number;
|
|
current: number;
|
|
target: number;
|
|
}
|
|
|
|
export interface AchievementsData {
|
|
achievements: Achievement[];
|
|
unlockedCount: number;
|
|
total: number;
|
|
}
|