feature-custom-store-locations #4
@ -1,5 +1,10 @@
|
||||
import axios from "axios";
|
||||
import { API_BASE_URL } from "../config";
|
||||
import {
|
||||
cacheApiResponse,
|
||||
getCachedApiResponse,
|
||||
isTransientApiError,
|
||||
} from "./offlineCache";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
@ -31,6 +36,7 @@ api.interceptors.response.use(
|
||||
response.request_id = payload.request_id;
|
||||
response.data = payload.data;
|
||||
}
|
||||
cacheApiResponse(response.config, response.data);
|
||||
return response;
|
||||
},
|
||||
error => {
|
||||
@ -55,6 +61,23 @@ api.interceptors.response.use(
|
||||
window.location.href = "/login";
|
||||
alert("Your session has expired. Please log in again.");
|
||||
}
|
||||
|
||||
if (isTransientApiError(error)) {
|
||||
const cached = getCachedApiResponse(error.config);
|
||||
if (cached) {
|
||||
return Promise.resolve({
|
||||
data: cached.data,
|
||||
status: 200,
|
||||
statusText: "OK (stale cache)",
|
||||
headers: {},
|
||||
config: error.config,
|
||||
request: error.request,
|
||||
stale: true,
|
||||
cachedAt: cached.cachedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
186
frontend/src/api/offlineCache.js
Normal file
186
frontend/src/api/offlineCache.js
Normal file
@ -0,0 +1,186 @@
|
||||
const CACHE_PREFIX = "fiddy:api-cache:v1:";
|
||||
const CACHE_INDEX_KEY = "fiddy:api-cache-index:v1";
|
||||
const MAX_CACHE_ENTRIES = 80;
|
||||
const TRANSIENT_STATUS_CODES = new Set([408, 429, 502, 503, 504]);
|
||||
const SENSITIVE_URL_PATTERN = /(receipt|download|update-image)/i;
|
||||
const BINARY_FIELD_PATTERN = /(image|receipt|bytes|buffer|blob|base64)/i;
|
||||
|
||||
function getStorage() {
|
||||
if (typeof window === "undefined" || !window.localStorage) return null;
|
||||
return window.localStorage;
|
||||
}
|
||||
|
||||
function getCurrentUserScope() {
|
||||
const storage = getStorage();
|
||||
if (!storage) return null;
|
||||
|
||||
try {
|
||||
return storage.getItem("userId") || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stableStringify(value) {
|
||||
if (value === null || typeof value !== "object") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(stableStringify).join(",")}]`;
|
||||
}
|
||||
|
||||
return `{${Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
|
||||
function hashString(value) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash << 5) - hash + value.charCodeAt(index);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
function requestFingerprint(config) {
|
||||
if (!config || String(config.method || "get").toLowerCase() !== "get") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (config.responseType && config.responseType !== "json") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = config.url || "";
|
||||
if (!url || SENSITIVE_URL_PATTERN.test(url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return stableStringify({
|
||||
baseURL: config.baseURL || "",
|
||||
method: "get",
|
||||
params: config.params || null,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
function cacheKeyForConfig(config) {
|
||||
const scope = getCurrentUserScope();
|
||||
const fingerprint = requestFingerprint(config);
|
||||
if (!scope || !fingerprint) return null;
|
||||
|
||||
return `${CACHE_PREFIX}${scope}:${hashString(fingerprint)}`;
|
||||
}
|
||||
|
||||
function readIndex(storage) {
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(CACHE_INDEX_KEY) || "[]");
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeIndex(storage, index) {
|
||||
try {
|
||||
storage.setItem(CACHE_INDEX_KEY, JSON.stringify(index.slice(-MAX_CACHE_ENTRIES)));
|
||||
} catch (_) {
|
||||
// Ignore cache index write failures; the live request already succeeded.
|
||||
}
|
||||
}
|
||||
|
||||
function rememberCacheKey(storage, key, scope) {
|
||||
const now = Date.now();
|
||||
const nextIndex = readIndex(storage)
|
||||
.filter((entry) => entry?.key !== key)
|
||||
.concat({ key, scope, touchedAt: now });
|
||||
|
||||
const trimmedIndex = nextIndex.slice(-MAX_CACHE_ENTRIES);
|
||||
for (const staleEntry of nextIndex.slice(0, -MAX_CACHE_ENTRIES)) {
|
||||
try {
|
||||
storage.removeItem(staleEntry.key);
|
||||
} catch (_) {
|
||||
// Best-effort cache pruning only.
|
||||
}
|
||||
}
|
||||
writeIndex(storage, trimmedIndex);
|
||||
}
|
||||
|
||||
function sanitizeForCache(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(sanitizeForCache);
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entryValue]) => [
|
||||
key,
|
||||
BINARY_FIELD_PATTERN.test(key) ? null : sanitizeForCache(entryValue),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
export function cacheApiResponse(config, data) {
|
||||
const storage = getStorage();
|
||||
const key = cacheKeyForConfig(config);
|
||||
const scope = getCurrentUserScope();
|
||||
if (!storage || !key || !scope) return;
|
||||
|
||||
try {
|
||||
storage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
cachedAt: new Date().toISOString(),
|
||||
data: sanitizeForCache(data),
|
||||
})
|
||||
);
|
||||
rememberCacheKey(storage, key, scope);
|
||||
} catch (_) {
|
||||
// Cache is an opportunistic fallback. Quota/private-mode failures are non-fatal.
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedApiResponse(config) {
|
||||
const storage = getStorage();
|
||||
const key = cacheKeyForConfig(config);
|
||||
if (!storage || !key) return null;
|
||||
|
||||
try {
|
||||
const cached = JSON.parse(storage.getItem(key) || "null");
|
||||
if (!cached || !Object.prototype.hasOwnProperty.call(cached, "data")) {
|
||||
return null;
|
||||
}
|
||||
return cached;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isTransientApiError(error) {
|
||||
if (!error?.response) return true;
|
||||
return TRANSIENT_STATUS_CODES.has(error.response.status);
|
||||
}
|
||||
|
||||
export function clearApiCacheForCurrentUser() {
|
||||
const storage = getStorage();
|
||||
const scope = getCurrentUserScope();
|
||||
if (!storage || !scope) return;
|
||||
|
||||
const nextIndex = readIndex(storage).filter((entry) => {
|
||||
if (entry?.scope !== scope) return true;
|
||||
try {
|
||||
storage.removeItem(entry.key);
|
||||
} catch (_) {
|
||||
// Best effort only.
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
writeIndex(storage, nextIndex);
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { createContext, useState } from 'react';
|
||||
import { clearApiCacheForCurrentUser } from '../api/offlineCache';
|
||||
|
||||
export const AuthContext = createContext({
|
||||
token: null,
|
||||
@ -16,6 +17,7 @@ export const AuthProvider = ({ children }) => {
|
||||
const [username, setUsername] = useState(localStorage.getItem('username') || null);
|
||||
|
||||
const clearAuthStorage = () => {
|
||||
clearApiCacheForCurrentUser();
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("userId");
|
||||
localStorage.removeItem("role");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user