const SESSION_STORAGE_KEY = 'pantry-management-session' const SESSION_CHANGE_EVENT = 'pantry-management-session-change' const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://api.pantrymanager.kitchen/').trim().replace(/\/$/, '') let refreshPromise = null export class ApiError extends Error { constructor(message, status = 0, data = null) { super(message) this.name = 'ApiError' this.status = status this.data = data } } function buildUrl(path) { return API_BASE_URL ? `${API_BASE_URL}${path}` : path } function parseStoredSession(rawValue) { if (!rawValue) return null try { return JSON.parse(rawValue) } catch { return null } } function dispatchSessionChange(session) { if (typeof window === 'undefined') return window.dispatchEvent(new CustomEvent(SESSION_CHANGE_EVENT, { detail: session, })) } export function getStoredSession() { if (typeof window === 'undefined') return null return parseStoredSession(window.localStorage.getItem(SESSION_STORAGE_KEY)) } export function saveSession(session) { if (typeof window === 'undefined') return session window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session)) dispatchSessionChange(session) return session } export function clearSession() { if (typeof window === 'undefined') return window.localStorage.removeItem(SESSION_STORAGE_KEY) dispatchSessionChange(null) } export function subscribeToSessionChanges(listener) { if (typeof window === 'undefined') return () => {} function handleCustomEvent(event) { listener(event.detail ?? null) } function handleStorageEvent(event) { if (event.key !== SESSION_STORAGE_KEY) return listener(parseStoredSession(event.newValue)) } window.addEventListener(SESSION_CHANGE_EVENT, handleCustomEvent) window.addEventListener('storage', handleStorageEvent) return () => { window.removeEventListener(SESSION_CHANGE_EVENT, handleCustomEvent) window.removeEventListener('storage', handleStorageEvent) } } async function readResponse(response) { const text = await response.text() if (!text) return null const contentType = response.headers.get('content-type') ?? '' if (contentType.includes('json')) { try { return JSON.parse(text) } catch { return text } } try { return JSON.parse(text) } catch { return text } } function extractErrorMessage(data, fallbackMessage) { if (!data) return fallbackMessage if (typeof data === 'string' && data.trim()) { return data } if (typeof data.message === 'string' && data.message.trim()) { return data.message } if (typeof data.Message === 'string' && data.Message.trim()) { return data.Message } if (typeof data.error === 'string' && data.error.trim()) { return data.error } if (data.errors && typeof data.errors === 'object') { const messages = Object.values(data.errors) .flatMap(value => Array.isArray(value) ? value : [value]) .filter(Boolean) if (messages.length > 0) { return messages.join(' ') } } if (typeof data.title === 'string' && data.title.trim()) { return data.title } return fallbackMessage } async function performRefresh(session) { if (!session?.refreshToken) { clearSession() throw new ApiError('Your session expired. Sign in again.', 401) } let response try { response = await fetch(buildUrl('/api/auth/refresh-token'), { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ accessToken: session.accessToken, refreshToken: session.refreshToken, }), }) } catch (error) { throw new ApiError( 'Unable to refresh your session. Make sure the API is running and reachable.', 0, error, ) } const data = await readResponse(response) if (!response.ok) { clearSession() throw new ApiError( extractErrorMessage(data, 'Your session expired. Sign in again.'), response.status, data, ) } return saveSession({ accessToken: data.accessToken, refreshToken: data.refreshToken, user: data.user ?? session.user ?? null, }) } async function refreshSession() { if (!refreshPromise) { refreshPromise = performRefresh(getStoredSession()) .finally(() => { refreshPromise = null }) } return refreshPromise } async function requestJson(path, options = {}) { const { method = 'GET', body, headers = {}, skipAuth = false, retryOnAuthFailure = true, } = options const session = getStoredSession() const requestHeaders = { Accept: 'application/json', ...headers, } if (body !== undefined) { requestHeaders['Content-Type'] = 'application/json' } if (!skipAuth && session?.accessToken) { requestHeaders.Authorization = `Bearer ${session.accessToken}` } let response try { response = await fetch(buildUrl(path), { method, headers: requestHeaders, body: body === undefined ? undefined : JSON.stringify(body), }) } catch (error) { throw new ApiError( 'Unable to reach the API. Make sure the backend is running and the certificate is trusted.', 0, error, ) } const data = await readResponse(response) if (response.status === 401 && !skipAuth && retryOnAuthFailure && session?.refreshToken) { await refreshSession() return requestJson(path, { ...options, retryOnAuthFailure: false }) } if (!response.ok) { throw new ApiError( extractErrorMessage(data, `${method} ${path} failed with status ${response.status}.`), response.status, data, ) } return data } export const authApi = { async register(payload) { return requestJson('/api/auth/register', { method: 'POST', body: payload, skipAuth: true, }) }, async login(payload) { const data = await requestJson('/api/auth/login', { method: 'POST', body: payload, skipAuth: true, }) saveSession({ accessToken: data.accessToken, refreshToken: data.refreshToken, user: data.user ?? null, }) return data }, async logout() { try { await requestJson('/api/auth/logout', { method: 'POST', }) } finally { clearSession() } }, } export const profileApi = { getProfile() { return requestJson('/api/profile') }, getProtectedData() { return requestJson('/api/profile/data') }, updateProfile(payload) { return requestJson('/api/profile', { method: 'PUT', body: payload, }) }, } export const locationsApi = { getLocations() { return requestJson('/api/locations') }, getLocationHistory(id) { return requestJson(`/api/locations/${id}/history`) }, createLocation(payload) { return requestJson('/api/locations', { method: 'POST', body: payload, }) }, updateLocation(id, payload) { return requestJson(`/api/locations/${id}`, { method: 'PUT', body: payload, }) }, deleteLocation(id) { return requestJson(`/api/locations/${id}`, { method: 'DELETE', }) }, } export const inventoryApi = { getInventoryItems() { return requestJson('/api/inventoryitems') }, getInventoryItem(id) { return requestJson(`/api/inventoryitems/${id}`) }, createInventoryItem(payload) { return requestJson('/api/inventoryitems', { method: 'POST', body: payload, }) }, updateInventoryItem(id, payload) { return requestJson(`/api/inventoryitems/${id}`, { method: 'PUT', body: payload, }) }, deleteInventoryItem(id) { return requestJson(`/api/inventoryitems/${id}`, { method: 'DELETE', }) }, } export const searchApi = { searchLocations(query) { return requestJson(`/api/search/locations?q=${encodeURIComponent(query)}`) }, searchItems(query) { return requestJson(`/api/search/items?q=${encodeURIComponent(query)}`) }, } export const householdsApi = { getHouseholds() { return requestJson('/api/households') }, getHouseholdHistory(id) { return requestJson(`/api/households/${id}/history`) }, createHousehold(payload) { return requestJson('/api/households', { method: 'POST', body: payload, }) }, updateHousehold(id, payload) { return requestJson(`/api/households/${id}`, { method: 'PUT', body: payload, }) }, inviteHouseholdMember(id, payload) { return requestJson(`/api/households/${id}/invite`, { method: 'POST', body: payload, }) }, leaveHousehold(id) { return requestJson(`/api/households/${id}/leave`, { method: 'DELETE', }) }, } export const usersApi = { getUsers() { return requestJson('/api/users') }, getUser(id) { return requestJson(`/api/users/${id}`) }, updateUser(id, payload) { return requestJson(`/api/users/${id}`, { method: 'PUT', body: payload, }) }, } export const shoppingListsApi = { getShoppingLists() { return requestJson('/api/shoppinglists') }, getShoppingList(id) { return requestJson(`/api/shoppinglists/${id}`) }, createShoppingList(payload) { return requestJson('/api/shoppinglists', { method: 'POST', body: payload, }) }, updateShoppingList(id, payload) { return requestJson(`/api/shoppinglists/${id}`, { method: 'PUT', body: payload, }) }, deleteShoppingList(id) { return requestJson(`/api/shoppinglists/${id}`, { method: 'DELETE', }) }, } export const mealPlannersApi = { getMealPlanners() { return requestJson('/api/mealplanners') }, getMealPlanner(id) { return requestJson(`/api/mealplanners/${id}`) }, createMealPlanner(payload) { return requestJson('/api/mealplanners', { method: 'POST', body: payload, }) }, updateMealPlanner(id, payload) { return requestJson(`/api/mealplanners/${id}`, { method: 'PUT', body: payload, }) }, deleteMealPlanner(id) { return requestJson(`/api/mealplanners/${id}`, { method: 'DELETE', }) }, }