web/src/utils/axios.js
Rama main · réplica de lectura del repositorio original.
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