All checks were successful
Yesterday's redirectIfCustomerAreaUnauthorized was scoped to /account/*
only. Admin, store, and merchant pages still hit the same UX gap when
an AJAX call returned 401 on token expiry: apiClient cleared tokens
and threw, leaving the page in a broken state with whatever generic
error UI the caller had wired up — no redirect, no `?next=` round-trip,
identical bug to the customer flicker we fixed in `b04b36a2` /
`6564f138`.
Rename and dispatch by path:
- /account/* (not /account/login) → /account/login?next=…
- /admin/* (not /admin/login) → /admin/login?next=…
- /merchants/* (not /merchants/login) → /merchants/login?next=…
- /store/{code}/* (not /store/{code}/login) → /store/{code}/login?next=…
- anything else → return false (caller throws)
Store paths include the per-store code, so the helper does a small regex
to extract `{code}` from the current pathname and builds the persona's
login URL with the right prefix.
All three 401 handlers in apiClient (request, requestFormData, getBlob)
already wrap this with the `return new Promise(() => {})` pattern from
6564f138, so the caller's `.finally(() => loading = false)` doesn't fire
before navigation completes — kills the wrong-state UI flash on every
persona, not just customer.
Login pages updated to honour `?next=` precedence over the existing
`*_last_visited_page` localStorage fallback, with persona-specific
safety checks (must start with /admin/, /merchants/, /store/{code}/
respectively; must not be a login or onboarding URL). The store login
also normalises the basePath because the store-code path prefix can
flip between subdomain (/store/{code}/...) and dev/path-based
(/platforms/{platform}/store/{code}/...) modes.
Customer login already honoured `?next=` from bbb481aa; left unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
666 lines
22 KiB
JavaScript
666 lines
22 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.redirectIfUnauthorized()) {
|
||
// 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.redirectIfUnauthorized()) {
|
||
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.redirectIfUnauthorized()) {
|
||
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 protected page (customer / admin / store /
|
||
* merchant area) and gets a 401, send them to the persona's login
|
||
* page with a `?next=` return URL so they land back here after
|
||
* re-authenticating.
|
||
*
|
||
* Dispatches by path:
|
||
* /account/* (not /account/login) → /account/login?next=...
|
||
* /admin/* (not /admin/login) → /admin/login?next=...
|
||
* /store/{code}/* (not /store/{code}/login) → /store/{code}/login?next=...
|
||
* /merchants/* (not /merchants/login) → /merchants/login?next=...
|
||
*
|
||
* Returns true if a redirect was scheduled (caller should return a
|
||
* never-resolving promise so its `.finally(() => loading = false)`
|
||
* doesn't fire mid-redirect and flash a wrong UI state).
|
||
* Returns false for unknown paths or login pages — caller throws as
|
||
* usual.
|
||
*/
|
||
redirectIfUnauthorized() {
|
||
const path = window.location.pathname;
|
||
let loginUrl = null;
|
||
|
||
if (path.startsWith('/account/') && path !== '/account/login') {
|
||
loginUrl = '/account/login';
|
||
} else if (path.startsWith('/admin/') && path !== '/admin/login') {
|
||
loginUrl = '/admin/login';
|
||
} else if (path.startsWith('/merchants/') && path !== '/merchants/login') {
|
||
loginUrl = '/merchants/login';
|
||
} else if (path.startsWith('/store/')) {
|
||
// Store paths include the store code: /store/{code}/<rest>.
|
||
// Login URL is /store/{code}/login. Skip if already on it.
|
||
const m = path.match(/^\/store\/([^/]+)\//);
|
||
if (m) {
|
||
const candidate = `/store/${m[1]}/login`;
|
||
if (path !== candidate) loginUrl = candidate;
|
||
}
|
||
}
|
||
|
||
if (!loginUrl) return false;
|
||
|
||
const next = encodeURIComponent(path + window.location.search);
|
||
apiLog.info(`Redirecting to ${loginUrl} (session expired), next=${next}`);
|
||
window.location.href = `${loginUrl}?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');
|