import axios from 'axios' import { addToPendingSync, syncWithServer, getPendingSync } from '@/utils/offlineStorage' // Create axios instance with default config const instance = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '', // Use relative URLs to leverage Vite proxy timeout: 15000, headers: { 'Content-Type': 'application/json', }, }) // Function to check if we're offline (more robust than just checking error message) function isNetworkError(error) { // No response means network failure if (!error.response) { // Check various network error indicators if (error.message === 'Network Error') return true if (error.code === 'ERR_NETWORK') return true if (error.code === 'ECONNABORTED') return true if (!navigator.onLine) return true // Axios timeout or other connection issues if (error.message && error.message.includes('timeout')) return true } return false } // Function to check if request can be handled offline function canHandleOffline(config) { // Handle POST and PUT requests to /api/workouts offline const isWorkoutEndpoint = config.url.includes('/api/workouts') const isWriteMethod = config.method === 'post' || config.method === 'put' return isWorkoutEndpoint && isWriteMethod } // Function to save request for later sync async function saveForOfflineSync(config, error) { try { // Parse the data if it's a string let parsedData = config.data if (typeof config.data === 'string') { try { parsedData = JSON.parse(config.data) } catch { // Keep original if not JSON } } const requestData = { method: config.method, url: config.url, data: parsedData, headers: { 'Content-Type': config.headers['Content-Type'], 'Authorization': config.headers['Authorization'] }, timestamp: Date.now() } await addToPendingSync({ operation: 'API_REQUEST', data: requestData, timestamp: Date.now() }) console.log('Request saved for offline sync:', config.url) // Dispatch custom event to notify UI window.dispatchEvent(new CustomEvent('offline-save', { detail: { type: config.method === 'post' ? 'created' : 'updated', message: 'Workout saved offline. Will sync when back online.' } })) // Return a response-like object that the calling code can handle return { data: { id: `offline-${Date.now()}`, // Temporary ID for offline items success: true, offline: true, message: 'Saved offline. Will sync when connection is restored.' }, status: 202, // Accepted statusText: 'Accepted (Offline)', config } } catch (saveError) { console.error('Failed to save request offline:', saveError) throw error // Throw original error if we can't save offline } } // Request interceptor instance.interceptors.request.use( (config) => { // Add token to request if it exists const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }, (error) => { return Promise.reject(error) } ) // Track if we're currently refreshing the token let isRefreshing = false let failedQueue = [] const processQueue = (error, token = null) => { failedQueue.forEach(prom => { if (error) { prom.reject(error) } else { prom.resolve(token) } }) failedQueue = [] } // Response interceptor instance.interceptors.response.use( (response) => { return response }, async (error) => { const originalRequest = error.config // Handle network errors (offline) if (isNetworkError(error)) { console.log('Network error detected, checking if can handle offline...', { message: error.message, code: error.code, online: navigator.onLine }) // Check if this request can be handled offline if (canHandleOffline(originalRequest)) { console.log('Request can be handled offline, saving for sync...') return saveForOfflineSync(originalRequest, error) } // For GET requests, the service worker cache should handle it // If we get here, the cache missed - let the error propagate console.log('Request cannot be handled offline (GET requests rely on SW cache)') } // Handle 401 Unauthorized if (error.response?.status === 401 && !originalRequest._retry) { // Check if this is the refresh endpoint failing if (originalRequest.url === '/api/auth/refresh') { // Refresh token is invalid/expired, clear everything and redirect localStorage.removeItem('token') localStorage.removeItem('user') localStorage.removeItem('refreshToken') window.location.href = '/login' return Promise.reject(error) } // If we're already refreshing, queue this request if (isRefreshing) { return new Promise((resolve, reject) => { failedQueue.push({ resolve, reject }) }).then(token => { originalRequest.headers.Authorization = `Bearer ${token}` return instance(originalRequest) }).catch(err => { return Promise.reject(err) }) } originalRequest._retry = true isRefreshing = true const refreshToken = localStorage.getItem('refreshToken') if (!refreshToken) { // No refresh token available, redirect to login localStorage.removeItem('token') localStorage.removeItem('user') window.location.href = '/login' return Promise.reject(error) } try { // Attempt to refresh the access token const response = await instance.post('/api/auth/refresh', { refresh_token: refreshToken }) const newToken = response.data.token const newUser = response.data.user // Update localStorage localStorage.setItem('token', newToken) localStorage.setItem('user', JSON.stringify(newUser)) // Update the authorization header instance.defaults.headers.common['Authorization'] = `Bearer ${newToken}` originalRequest.headers.Authorization = `Bearer ${newToken}` // Process queued requests processQueue(null, newToken) // Retry the original request return instance(originalRequest) } catch (refreshError) { // Refresh failed, clear everything and redirect processQueue(refreshError, null) localStorage.removeItem('token') localStorage.removeItem('user') localStorage.removeItem('refreshToken') window.location.href = '/login' return Promise.reject(refreshError) } finally { isRefreshing = false } } // Handle 403 protected_user — protected account write attempt if (error.response?.status === 403 && error.response?.data?.error === 'protected_user') { const data = error.response.data window.dispatchEvent(new CustomEvent('protected-user-write', { detail: { type: 'warning', message: data.message || 'Protected account — no changes saved.', ...(data.documentation_url ? { documentationUrl: data.documentation_url } : {}), } })) } // Handle 503 protected_invariant_degraded — admin operations degraded if (error.response?.status === 503 && error.response?.data?.error === 'protected_invariant_degraded') { window.dispatchEvent(new CustomEvent('admin-degraded', { detail: { type: 'error', message: 'Admin user actions are temporarily unavailable. Operator: see logs.', } })) } // Handle 402 Payment Required (subscription expired) if (error.response?.status === 402) { console.log('Subscription expired (HTTP 402), updating subscription store...') // Import subscription store dynamically to avoid circular dependencies import('@/stores/subscription').then(({ useSubscriptionStore }) => { const subscriptionStore = useSubscriptionStore() subscriptionStore.setExpired() }) // Dispatch custom event to notify UI window.dispatchEvent(new CustomEvent('subscription-expired', { detail: { message: error.response?.data?.message || 'Subscription required', url: originalRequest.url } })) } return Promise.reject(error) } ) // Export sync function for external use export async function triggerSync() { try { console.log('Triggering background sync...') await syncWithServer(instance) console.log('Background sync completed') return true } catch (error) { console.error('Background sync failed:', error) return false } } // Export pending sync count checker export async function getPendingSyncCount() { try { const pending = await getPendingSync() return pending.length } catch (error) { console.error('Failed to get pending sync count:', error) return 0 } } export default instance