// Use relative URL - works for both dev (localhost:8080) and prod (localhost) // Nginx proxies /api to backend const API_URL = '/api'; class ApiError extends Error { constructor(message, status, data) { super(message); this.name = 'ApiError'; this.status = status; this.data = data; } } // CSRF Token Management (Phase 3 - Security Hardening) let csrfToken = null; async function getCsrfToken() { if (csrfToken) return csrfToken; try { const response = await fetch(`${API_URL}/csrf-token`, { credentials: 'include', // Important for cookies }); const data = await response.json(); csrfToken = data.csrfToken; return csrfToken; } catch (error) { console.warn('Failed to fetch CSRF token:', error); return null; } } // Reset CSRF token (call this on 403 CSRF errors) function resetCsrfToken() { csrfToken = null; } async function fetchAPI(endpoint, options = {}) { const url = `${API_URL}${endpoint}`; const config = { ...options, credentials: 'include', // Include cookies for CSRF headers: { 'Content-Type': 'application/json', ...options.headers, }, }; // Add auth token if available const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = `Bearer ${token}`; } // Add CSRF token for state-changing requests (Phase 3) if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(options.method?.toUpperCase())) { const csrf = await getCsrfToken(); if (csrf) { config.headers['X-CSRF-Token'] = csrf; } } try { const response = await fetch(url, config); // Check if response is JSON before parsing const contentType = response.headers.get('content-type'); if (!contentType || !contentType.includes('application/json')) { throw new ApiError( 'Server returned non-JSON response', response.status, { message: 'The server is not responding correctly. Please try again later.' } ); } const data = await response.json(); if (!response.ok) { // Handle CSRF token errors (Phase 3) if (response.status === 403 && data.error === 'Invalid CSRF token') { resetCsrfToken(); // Retry the request once with a fresh CSRF token if (!options._csrfRetry) { return fetchAPI(endpoint, { ...options, _csrfRetry: true }); } } throw new ApiError( data.error || 'API request failed', response.status, data ); } return data; } catch (error) { if (error instanceof ApiError) { throw error; } // Handle JSON parsing errors if (error instanceof SyntaxError) { throw new ApiError( 'Invalid server response', 0, { message: 'The server returned an invalid response. Please check if the server is running.' } ); } throw new ApiError('Network error', 0, { message: error.message }); } } // Auth API export const authAPI = { async register(username, email, password, firstName = null, lastName = null, wsdcId = null) { const data = await fetchAPI('/auth/register', { method: 'POST', body: JSON.stringify({ username, email, password, firstName, lastName, wsdcId }), }); // Save token if (data.data.token) { localStorage.setItem('token', data.data.token); } return data.data; }, async login(email, password) { const data = await fetchAPI('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }), }); // Save token if (data.data.token) { localStorage.setItem('token', data.data.token); } return data.data; }, async getCurrentUser() { const data = await fetchAPI('/users/me'); return data.data; }, async verifyEmailByToken(token) { const data = await fetchAPI(`/auth/verify-email?token=${token}`); return data; }, async verifyEmailByCode(email, code) { const data = await fetchAPI('/auth/verify-code', { method: 'POST', body: JSON.stringify({ email, code }), }); return data; }, async resendVerification(email) { const data = await fetchAPI('/auth/resend-verification', { method: 'POST', body: JSON.stringify({ email }), }); return data; }, async requestPasswordReset(email) { const data = await fetchAPI('/auth/request-password-reset', { method: 'POST', body: JSON.stringify({ email }), }); return data; }, async resetPassword(token, newPassword) { const data = await fetchAPI('/auth/reset-password', { method: 'POST', body: JSON.stringify({ token, newPassword }), }); return data; }, async updateProfile(profileData) { const data = await fetchAPI('/users/me', { method: 'PATCH', body: JSON.stringify(profileData), }); // Update token if it was returned (email changed) if (data.data?.token) { localStorage.setItem('token', data.data.token); } return data; }, async changePassword(currentPassword, newPassword) { const data = await fetchAPI('/users/me/password', { method: 'PATCH', body: JSON.stringify({ currentPassword, newPassword }), }); return data; }, async getUserByUsername(username) { const data = await fetchAPI(`/users/${username}`); return data.data; }, logout() { localStorage.removeItem('token'); localStorage.removeItem('user'); }, }; // WSDC API (Phase 1.5) export const wsdcAPI = { async lookupDancer(wsdcId) { const data = await fetchAPI(`/wsdc/lookup?id=${wsdcId}`); return data; }, }; // Events API export const eventsAPI = { async getAll() { const data = await fetchAPI('/events'); return data.data; }, async getBySlug(slug) { const data = await fetchAPI(`/events/${slug}`); return data.data; }, async getMessages(slug, before = null, limit = 20) { const params = new URLSearchParams({ limit: limit.toString() }); if (before) { params.append('before', before.toString()); } const data = await fetchAPI(`/events/${slug}/messages?${params}`); return data; }, async getDetails(slug) { const data = await fetchAPI(`/events/${slug}/details`); return data; }, async checkin(token) { const data = await fetchAPI(`/events/checkin/${token}`, { method: 'POST', }); return data; }, async leave(slug) { const data = await fetchAPI(`/events/${slug}/leave`, { method: 'DELETE', }); return data; }, }; // Divisions API (Phase 1.6) export const divisionsAPI = { async getAll() { const data = await fetchAPI('/divisions'); return data.data; }, }; // Competition Types API (Phase 1.6) export const competitionTypesAPI = { async getAll() { const data = await fetchAPI('/competition-types'); return data.data; }, }; // Heats API (Phase 1.6) export const heatsAPI = { async saveHeats(slug, heats) { const data = await fetchAPI(`/events/${slug}/heats`, { method: 'POST', body: JSON.stringify({ heats }), }); return data; }, async getMyHeats(slug) { const data = await fetchAPI(`/events/${slug}/heats/me`); return data.data; }, async getAllHeats(slug) { const data = await fetchAPI(`/events/${slug}/heats/all`); return data.data; }, async deleteHeat(slug, heatId) { const data = await fetchAPI(`/events/${slug}/heats/${heatId}`, { method: 'DELETE', }); return data; }, }; // Matches API (Phase 2) export const matchesAPI = { async createMatch(targetUserId, eventSlug) { const data = await fetchAPI('/matches', { method: 'POST', body: JSON.stringify({ targetUserId, eventSlug }), }); return data; }, async getMatches(eventSlug = null, status = null) { const params = new URLSearchParams(); if (eventSlug) params.append('eventSlug', eventSlug); if (status) params.append('status', status); const queryString = params.toString(); const endpoint = queryString ? `/matches?${queryString}` : '/matches'; const data = await fetchAPI(endpoint); return data; }, async getMatch(matchSlug) { const data = await fetchAPI(`/matches/${matchSlug}`); return data; }, async acceptMatch(matchSlug) { const data = await fetchAPI(`/matches/${matchSlug}/accept`, { method: 'PUT', }); return data; }, async deleteMatch(matchSlug) { const data = await fetchAPI(`/matches/${matchSlug}`, { method: 'DELETE', }); return data; }, async getMatchMessages(matchSlug) { const data = await fetchAPI(`/matches/${matchSlug}/messages`); return data; }, async createRating(matchSlug, { score, comment, wouldCollaborateAgain }) { const data = await fetchAPI(`/matches/${matchSlug}/ratings`, { method: 'POST', body: JSON.stringify({ score, comment, wouldCollaborateAgain }), }); return data; }, }; // Ratings API export const ratingsAPI = { async getUserRatings(username) { const data = await fetchAPI(`/users/${username}/ratings`); return data; }, }; export { ApiError };