Some checks failed
The b04b36a2 fix (loading=true initially) wasn't enough on its own:
once loadCard() got 401, apiClient cleared tokens, scheduled the
redirect, and threw. The caller's catch logged the error and the
finally block ran `loading = false` before the browser actually
navigated away — so Alpine re-rendered with loading=false + card=null
and the "Rejoignez notre programme" CTA flashed for a beat.
Fix: in apiClient's 3 401 paths, when redirectIfCustomerAreaUnauthorized
returns true (meaning a navigation was scheduled), return a
never-resolving promise instead of throwing. The caller's await never
returns, their .finally() never fires, the loading spinner stays up,
and the browser navigates cleanly with no intermediate render.
Other personas (admin/store/merchant) — where the helper returns false
because the path doesn't match /account/* — still get the existing
throw, preserving their current behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
642 lines
21 KiB
JavaScript
642 lines
21 KiB
JavaScript
// static/shared/js/api-client.js
|
||
// noqa: js-001 - Core infrastructure, uses its own logger (apiLog)
|
||
/**
|
||
* API Client for Multi-Tenant Ecommerce Platform
|
||
*
|
||
* Provides utilities for:
|
||
* - Making authenticated API calls
|
||
* - Token management
|
||
* - Error handling
|
||
* - Request/response interceptors
|
||
*/
|
||
|
||
// Log levels: 0 = None, 1 = Error, 2 = Warning, 3 = Info, 4 = Debug
|
||
const API_LOG_LEVEL = 3; // Set to 3 for production, 4 for full debugging
|
||
|
||
const apiLog = {
|
||
error: (...args) => API_LOG_LEVEL >= 1 && console.error('❌ [API ERROR]', ...args),
|
||
warn: (...args) => API_LOG_LEVEL >= 2 && console.warn('⚠️ [API WARN]', ...args),
|
||
info: (...args) => API_LOG_LEVEL >= 3 && console.info('ℹ️ [API INFO]', ...args),
|
||
debug: (...args) => API_LOG_LEVEL >= 4 && console.log('🔍 [API DEBUG]', ...args)
|
||
};
|
||
|
||
const API_BASE_URL = '/api/v1';
|
||
|
||
/**
|
||
* API Client Class
|
||
*/
|
||
class APIClient {
|
||
constructor(baseURL = API_BASE_URL) {
|
||
this.baseURL = baseURL;
|
||
apiLog.info('API Client initialized with base URL:', baseURL);
|
||
}
|
||
|
||
/**
|
||
* Get stored authentication token
|
||
*
|
||
* Uses path-based detection to return the correct token:
|
||
* - /admin/* routes use admin_token
|
||
* - /store/* routes use store_token
|
||
* - /account/* or /api/v1/storefront/* routes use customer_token
|
||
* - /merchants/* routes use merchant_token
|
||
* - Other routes fall back to admin_token || store_token || customer_token
|
||
*/
|
||
getToken() {
|
||
const adminToken = localStorage.getItem('admin_token');
|
||
const storeToken = localStorage.getItem('store_token');
|
||
const customerToken = localStorage.getItem('customer_token');
|
||
const currentPath = window.location.pathname;
|
||
|
||
let token;
|
||
let source;
|
||
|
||
// Path-based token selection
|
||
if (currentPath.startsWith('/store/') || currentPath.startsWith('/api/v1/store/')) {
|
||
token = storeToken;
|
||
source = 'store (path-based)';
|
||
} else if (currentPath.startsWith('/admin/') || currentPath.startsWith('/api/v1/admin/')) {
|
||
token = adminToken;
|
||
source = 'admin (path-based)';
|
||
} else if (
|
||
currentPath.startsWith('/account/') ||
|
||
currentPath.startsWith('/api/v1/storefront/')
|
||
) {
|
||
token = customerToken;
|
||
source = 'customer (path-based)';
|
||
} else if (currentPath.startsWith('/merchants/') || currentPath.startsWith('/api/v1/merchants/')) {
|
||
token = localStorage.getItem('merchant_token');
|
||
source = 'merchant (path-based)';
|
||
} else {
|
||
// Default fallback for other paths
|
||
token = adminToken || storeToken || customerToken;
|
||
source = token === adminToken ? 'admin (fallback)' :
|
||
token === storeToken ? 'store (fallback)' : 'customer (fallback)';
|
||
}
|
||
|
||
apiLog.debug('Getting token:', {
|
||
hasAdminToken: !!adminToken,
|
||
hasStoreToken: !!storeToken,
|
||
hasCustomerToken: !!customerToken,
|
||
currentPath,
|
||
source,
|
||
usingToken: token ? source : 'none'
|
||
});
|
||
|
||
return token;
|
||
}
|
||
|
||
/**
|
||
* Get default headers with authentication
|
||
*/
|
||
getHeaders(additionalHeaders = {}) {
|
||
const headers = {
|
||
'Content-Type': 'application/json',
|
||
...additionalHeaders
|
||
};
|
||
|
||
const token = this.getToken();
|
||
if (token) {
|
||
headers['Authorization'] = `Bearer ${token}`;
|
||
apiLog.debug('Authorization header added');
|
||
} else {
|
||
apiLog.debug('No token available, request will be unauthenticated');
|
||
}
|
||
|
||
return headers;
|
||
}
|
||
|
||
/**
|
||
* Make API request
|
||
*/
|
||
async request(endpoint, options = {}) {
|
||
const url = `${this.baseURL}${endpoint}`;
|
||
const method = options.method || 'GET';
|
||
|
||
apiLog.info(`${method} ${url}`);
|
||
apiLog.debug('Request options:', {
|
||
method,
|
||
hasBody: !!options.body,
|
||
customHeaders: Object.keys(options.headers || {})
|
||
});
|
||
|
||
const config = {
|
||
...options,
|
||
headers: this.getHeaders(options.headers)
|
||
};
|
||
|
||
try {
|
||
const startTime = Date.now();
|
||
const response = await fetch(url, config);
|
||
const duration = Date.now() - startTime;
|
||
|
||
apiLog.info(`Response: ${response.status} ${response.statusText} (${duration}ms)`);
|
||
|
||
// Parse response (handle 204 No Content gracefully)
|
||
let data;
|
||
if (response.status === 204) {
|
||
data = null;
|
||
} else {
|
||
try {
|
||
data = await response.json();
|
||
apiLog.debug('Response data received:', {
|
||
hasData: !!data,
|
||
dataType: typeof data,
|
||
keys: data ? Object.keys(data) : []
|
||
});
|
||
} catch (parseError) {
|
||
apiLog.error('Failed to parse JSON response:', parseError);
|
||
throw new Error('Invalid JSON response from server');
|
||
}
|
||
}
|
||
|
||
// Handle 401 Unauthorized
|
||
if (response.status === 401) {
|
||
apiLog.warn('401 Unauthorized - Authentication failed');
|
||
apiLog.debug('Error details:', data);
|
||
apiLog.info('Clearing authentication tokens');
|
||
this.clearTokens();
|
||
if (this.redirectIfCustomerAreaUnauthorized()) {
|
||
// Page is navigating away to /account/login. Return a
|
||
// never-resolving promise so the caller's await never
|
||
// returns and any `.finally(() => loading = false)`
|
||
// never fires — prevents a wrong-state UI flash
|
||
// between the redirect being scheduled and the browser
|
||
// actually navigating away.
|
||
return new Promise(() => {});
|
||
}
|
||
|
||
const errorMessage = data.message || data.detail || 'Unauthorized - please login again';
|
||
apiLog.error('Throwing authentication error:', errorMessage);
|
||
const authError = new Error(errorMessage);
|
||
authError.status = response.status;
|
||
authError.errorCode = data.error_code;
|
||
throw authError;
|
||
}
|
||
|
||
// Handle non-OK responses
|
||
if (!response.ok) {
|
||
const errorMessage = data.detail || data.message || `Request failed with status ${response.status}`;
|
||
apiLog.error('Request failed:', {
|
||
status: response.status,
|
||
message: errorMessage,
|
||
errorCode: data.error_code
|
||
});
|
||
const apiError = new Error(errorMessage);
|
||
apiError.status = response.status;
|
||
apiError.errorCode = data.error_code;
|
||
// Propagate the details payload so callers can localise the
|
||
// toast (e.g. "cooldown_ends" / "cooldown_minutes" for
|
||
// POINTS_COOLDOWN / STAMP_COOLDOWN).
|
||
apiError.details = data.details || null;
|
||
throw apiError;
|
||
}
|
||
|
||
apiLog.info('Request completed successfully');
|
||
return data;
|
||
|
||
} catch (error) {
|
||
// Log error details
|
||
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
||
apiLog.error('Network error - Failed to connect to server');
|
||
} else {
|
||
apiLog.error('Request error:', error.message);
|
||
}
|
||
|
||
apiLog.debug('Full error:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* GET request
|
||
*/
|
||
async get(endpoint, params = {}) {
|
||
const queryString = new URLSearchParams(params).toString();
|
||
const url = queryString ? `${endpoint}?${queryString}` : endpoint;
|
||
|
||
apiLog.debug('GET request params:', params);
|
||
|
||
return this.request(url, {
|
||
method: 'GET'
|
||
});
|
||
}
|
||
|
||
/**
|
||
* POST request
|
||
*/
|
||
async post(endpoint, data = null) {
|
||
apiLog.debug('POST request data:', {
|
||
hasData: !!data,
|
||
dataKeys: data ? Object.keys(data) : []
|
||
});
|
||
|
||
var opts = { method: 'POST' };
|
||
if (data != null) opts.body = JSON.stringify(data);
|
||
return this.request(endpoint, opts);
|
||
}
|
||
|
||
/**
|
||
* PUT request
|
||
*/
|
||
async put(endpoint, data = {}) {
|
||
apiLog.debug('PUT request data:', {
|
||
hasData: !!data,
|
||
dataKeys: Object.keys(data)
|
||
});
|
||
|
||
return this.request(endpoint, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(data)
|
||
});
|
||
}
|
||
|
||
/**
|
||
* PATCH request
|
||
*/
|
||
async patch(endpoint, data = {}) {
|
||
apiLog.debug('PATCH request data:', {
|
||
hasData: !!data,
|
||
dataKeys: Object.keys(data)
|
||
});
|
||
|
||
return this.request(endpoint, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify(data)
|
||
});
|
||
}
|
||
|
||
/**
|
||
* DELETE request
|
||
*/
|
||
async delete(endpoint) {
|
||
apiLog.debug('DELETE request');
|
||
|
||
return this.request(endpoint, {
|
||
method: 'DELETE'
|
||
});
|
||
}
|
||
|
||
/**
|
||
* POST with FormData (for file uploads)
|
||
* Does not set Content-Type header - browser sets it with boundary
|
||
*/
|
||
async postFormData(endpoint, formData) {
|
||
const url = `${this.baseURL}${endpoint}`;
|
||
apiLog.info(`POST (FormData) ${url}`);
|
||
|
||
const token = this.getToken();
|
||
const headers = {};
|
||
if (token) {
|
||
headers['Authorization'] = `Bearer ${token}`;
|
||
}
|
||
|
||
try {
|
||
const startTime = Date.now();
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
headers,
|
||
body: formData
|
||
});
|
||
const duration = Date.now() - startTime;
|
||
|
||
apiLog.info(`Response: ${response.status} ${response.statusText} (${duration}ms)`);
|
||
|
||
let data;
|
||
try {
|
||
data = await response.json();
|
||
} catch (parseError) {
|
||
apiLog.error('Failed to parse JSON response:', parseError);
|
||
throw new Error('Invalid JSON response from server');
|
||
}
|
||
|
||
if (response.status === 401) {
|
||
apiLog.warn('401 Unauthorized - Authentication failed');
|
||
this.clearTokens();
|
||
if (this.redirectIfCustomerAreaUnauthorized()) {
|
||
return new Promise(() => {});
|
||
}
|
||
throw new Error(data.message || data.detail || 'Unauthorized');
|
||
}
|
||
|
||
if (!response.ok) {
|
||
throw new Error(data.detail || data.message || `Request failed with status ${response.status}`);
|
||
}
|
||
|
||
return data;
|
||
} catch (error) {
|
||
apiLog.error('FormData request error:', error.message);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* GET request that returns a Blob (for file downloads)
|
||
*/
|
||
async getBlob(endpoint) {
|
||
const url = `${this.baseURL}${endpoint}`;
|
||
apiLog.info(`GET (Blob) ${url}`);
|
||
|
||
const token = this.getToken();
|
||
const headers = {};
|
||
if (token) {
|
||
headers['Authorization'] = `Bearer ${token}`;
|
||
}
|
||
|
||
try {
|
||
const startTime = Date.now();
|
||
const response = await fetch(url, {
|
||
method: 'GET',
|
||
headers
|
||
});
|
||
const duration = Date.now() - startTime;
|
||
|
||
apiLog.info(`Response: ${response.status} ${response.statusText} (${duration}ms)`);
|
||
|
||
if (response.status === 401) {
|
||
apiLog.warn('401 Unauthorized - Authentication failed');
|
||
this.clearTokens();
|
||
if (this.redirectIfCustomerAreaUnauthorized()) {
|
||
return new Promise(() => {});
|
||
}
|
||
throw new Error('Unauthorized');
|
||
}
|
||
|
||
if (!response.ok) {
|
||
let errorMessage = `Request failed with status ${response.status}`;
|
||
try {
|
||
const errorData = await response.json();
|
||
errorMessage = errorData.detail || errorData.message || errorMessage;
|
||
} catch (e) {
|
||
// Response wasn't JSON
|
||
}
|
||
throw new Error(errorMessage);
|
||
}
|
||
|
||
return response.blob();
|
||
} catch (error) {
|
||
apiLog.error('Blob request error:', error.message);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clear authentication tokens for current context only.
|
||
*
|
||
* Uses path-based detection to clear only the relevant token:
|
||
* - /admin/* paths clear admin_token
|
||
* - /store/* paths clear store_token
|
||
* - /account/* or /api/v1/storefront/* paths clear customer_token
|
||
* - /merchants/* paths clear merchant_token
|
||
* - Other paths clear all tokens (fallback)
|
||
*/
|
||
clearTokens() {
|
||
const currentPath = window.location.pathname;
|
||
|
||
apiLog.info('Clearing authentication tokens for path:', currentPath);
|
||
|
||
const tokensBefore = {
|
||
admin_token: !!localStorage.getItem('admin_token'),
|
||
admin_user: !!localStorage.getItem('admin_user'),
|
||
store_token: !!localStorage.getItem('store_token'),
|
||
store_user: !!localStorage.getItem('store_user'),
|
||
customer_token: !!localStorage.getItem('customer_token'),
|
||
token: !!localStorage.getItem('token')
|
||
};
|
||
apiLog.debug('Tokens before clear:', tokensBefore);
|
||
|
||
// Context-aware token clearing to prevent cross-context interference
|
||
if (currentPath.startsWith('/admin/') || currentPath.startsWith('/api/v1/admin/')) {
|
||
apiLog.info('Clearing admin tokens only');
|
||
localStorage.removeItem('admin_token');
|
||
localStorage.removeItem('admin_user');
|
||
} else if (currentPath.startsWith('/store/') || currentPath.startsWith('/api/v1/store/')) {
|
||
apiLog.info('Clearing store tokens only');
|
||
localStorage.removeItem('store_token');
|
||
localStorage.removeItem('store_user');
|
||
localStorage.removeItem('currentUser');
|
||
localStorage.removeItem('storeCode');
|
||
} else if (
|
||
currentPath.startsWith('/account/') ||
|
||
currentPath.startsWith('/api/v1/storefront/')
|
||
) {
|
||
apiLog.info('Clearing customer tokens only');
|
||
localStorage.removeItem('customer_token');
|
||
localStorage.removeItem('customer_user');
|
||
} else if (currentPath.startsWith('/merchants/') || currentPath.startsWith('/api/v1/merchants/')) {
|
||
apiLog.info('Clearing merchant tokens only');
|
||
localStorage.removeItem('merchant_token');
|
||
} else {
|
||
// Fallback: clear all tokens for unknown paths
|
||
apiLog.info('Unknown path context, clearing all tokens');
|
||
localStorage.removeItem('admin_token');
|
||
localStorage.removeItem('admin_user');
|
||
localStorage.removeItem('store_token');
|
||
localStorage.removeItem('store_user');
|
||
localStorage.removeItem('customer_token');
|
||
localStorage.removeItem('currentUser');
|
||
localStorage.removeItem('storeCode');
|
||
localStorage.removeItem('token');
|
||
}
|
||
|
||
const tokensAfter = {
|
||
admin_token: !!localStorage.getItem('admin_token'),
|
||
admin_user: !!localStorage.getItem('admin_user'),
|
||
store_token: !!localStorage.getItem('store_token'),
|
||
store_user: !!localStorage.getItem('store_user'),
|
||
customer_token: !!localStorage.getItem('customer_token'),
|
||
token: !!localStorage.getItem('token')
|
||
};
|
||
apiLog.debug('Tokens after clear:', tokensAfter);
|
||
apiLog.info('Context-specific tokens cleared');
|
||
}
|
||
|
||
/**
|
||
* If the user is on a customer-area page (/account/*) and gets 401,
|
||
* send them to the login page with a return URL so they land back
|
||
* here after re-authenticating.
|
||
*
|
||
* No-op for admin/store/merchant areas — those callers handle 401
|
||
* their own way. Also no-op if already on the login page (avoids
|
||
* a redirect loop).
|
||
*
|
||
* Returns true if a redirect was scheduled (caller should suppress
|
||
* its own error UI since the page is about to navigate away).
|
||
*/
|
||
redirectIfCustomerAreaUnauthorized() {
|
||
const path = window.location.pathname;
|
||
const onCustomerArea = path.startsWith('/account/') && path !== '/account/login';
|
||
if (!onCustomerArea) return false;
|
||
const next = encodeURIComponent(path + window.location.search);
|
||
apiLog.info('Redirecting to /account/login (session expired), next=' + next);
|
||
window.location.href = `/account/login?next=${next}`;
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Handle unauthorized access
|
||
* DEPRECATED - Now just clears tokens, doesn't redirect
|
||
* Server handles redirects via exception handler
|
||
*/
|
||
handleUnauthorized() {
|
||
apiLog.warn('handleUnauthorized called (DEPRECATED) - use clearTokens instead');
|
||
this.clearTokens();
|
||
}
|
||
}
|
||
|
||
// Create global API client instance
|
||
const apiClient = new APIClient();
|
||
apiLog.info('Global API client created');
|
||
|
||
/**
|
||
* Authentication helpers
|
||
*/
|
||
const Auth = {
|
||
/**
|
||
* Check if user is authenticated
|
||
*/
|
||
isAuthenticated() {
|
||
const token = localStorage.getItem('admin_token') || localStorage.getItem('store_token');
|
||
const isAuth = !!token;
|
||
apiLog.debug('Auth check:', isAuth ? 'authenticated' : 'not authenticated');
|
||
return isAuth;
|
||
},
|
||
|
||
/**
|
||
* Get current user
|
||
*/
|
||
getCurrentUser() {
|
||
const userStr = localStorage.getItem('admin_user') || localStorage.getItem('store_user');
|
||
if (!userStr) {
|
||
apiLog.debug('No user found in storage');
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const user = JSON.parse(userStr);
|
||
apiLog.debug('Current user:', {
|
||
username: user.username,
|
||
role: user.role,
|
||
id: user.id
|
||
});
|
||
return user;
|
||
} catch (e) {
|
||
apiLog.error('Failed to parse user data:', e);
|
||
return null;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Check if user is admin
|
||
*/
|
||
isAdmin() {
|
||
const user = this.getCurrentUser();
|
||
const isAdmin = user && ['super_admin', 'platform_admin'].includes(user.role);
|
||
apiLog.debug('Admin check:', isAdmin ? 'is admin' : 'not admin');
|
||
return isAdmin;
|
||
},
|
||
|
||
/**
|
||
* Login
|
||
*/
|
||
async login(username, password) {
|
||
apiLog.info('Auth.login called');
|
||
const response = await apiClient.post('/auth/login', {
|
||
username,
|
||
password
|
||
});
|
||
|
||
// Store token and user
|
||
if (['super_admin', 'platform_admin'].includes(response.user.role)) {
|
||
apiLog.info('Storing admin credentials');
|
||
localStorage.setItem('admin_token', response.access_token);
|
||
localStorage.setItem('admin_user', JSON.stringify(response.user));
|
||
} else {
|
||
apiLog.info('Storing store credentials');
|
||
localStorage.setItem('store_token', response.access_token);
|
||
localStorage.setItem('store_user', JSON.stringify(response.user));
|
||
}
|
||
|
||
return response;
|
||
},
|
||
|
||
/**
|
||
* Logout
|
||
*/
|
||
logout() {
|
||
apiLog.info('Auth.logout called');
|
||
apiClient.clearTokens();
|
||
apiLog.info('User logged out');
|
||
}
|
||
};
|
||
|
||
// Add animation styles
|
||
const style = document.createElement('style');
|
||
style.textContent = `
|
||
@keyframes slideIn {
|
||
from {
|
||
transform: translateX(400px);
|
||
opacity: 0;
|
||
}
|
||
to {
|
||
transform: translateX(0);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
@keyframes slideOut {
|
||
from {
|
||
transform: translateX(0);
|
||
opacity: 1;
|
||
}
|
||
to {
|
||
transform: translateX(400px);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
|
||
// Export for use in other scripts
|
||
if (typeof module !== 'undefined' && module.exports) {
|
||
module.exports = { APIClient, apiClient, Auth, Utils };
|
||
}
|
||
|
||
// Table scroll detection helper
|
||
function initTableScrollDetection() {
|
||
const observer = new MutationObserver(() => {
|
||
const tables = document.querySelectorAll('.table-responsive');
|
||
tables.forEach(table => {
|
||
if (!table.hasAttribute('data-scroll-initialized')) {
|
||
table.setAttribute('data-scroll-initialized', 'true');
|
||
|
||
table.addEventListener('scroll', function() {
|
||
if (this.scrollLeft > 0) {
|
||
this.classList.add('is-scrolled');
|
||
} else {
|
||
this.classList.remove('is-scrolled');
|
||
}
|
||
});
|
||
|
||
// Check initial state
|
||
if (table.scrollLeft > 0) {
|
||
table.classList.add('is-scrolled');
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
observer.observe(document.body, {
|
||
childList: true,
|
||
subtree: true
|
||
});
|
||
}
|
||
|
||
// Initialize when DOM is ready
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', initTableScrollDetection);
|
||
} else {
|
||
initTableScrollDetection();
|
||
}
|
||
|
||
apiLog.info('API Client module loaded');
|