Working state before icon/utils fixes - Oct 22

This commit is contained in:
2025-10-21 21:56:54 +02:00
parent a7d9d44a13
commit 5be47b91a2
39 changed files with 6017 additions and 508 deletions

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" x-data="dashboardData()" lang="en">
<html :class="{ 'theme-dark': dark }" x-data="adminDashboard()" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -12,28 +12,53 @@
</head>
<body x-cloak>
<div class="flex h-screen bg-gray-50 dark:bg-gray-900" :class="{ 'overflow-hidden': isSideMenuOpen }">
<!-- Sidebar Container -->
<!-- Sidebar Container (loaded via partial-loader) -->
<div id="sidebar-container"></div>
<div class="flex flex-col flex-1 w-full">
<!-- Header Container -->
<!-- Header Container (loaded via partial-loader) -->
<div id="header-container"></div>
<!-- Main Content -->
<main class="h-full overflow-y-auto">
<div class="container px-6 mx-auto grid">
<h2 class="my-6 text-2xl font-semibold text-gray-700 dark:text-gray-200">
Dashboard
</h2>
<!-- Page Header with Refresh Button -->
<div class="flex items-center justify-between my-6">
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Dashboard
</h2>
<button
@click="refresh()"
:disabled="loading"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
</button>
</div>
<!-- Loading State -->
<div x-show="loading" class="text-center py-12">
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading dashboard...</p>
</div>
<!-- Error State -->
<div x-show="error && !loading" class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
<div>
<p class="font-semibold">Error loading dashboard</p>
<p class="text-sm" x-text="error"></p>
</div>
</div>
<!-- Stats Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Card: Total Vendors -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z"></path>
</svg>
<span x-html="$icon('user-group', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
@@ -48,9 +73,7 @@
<!-- Card: Active Users -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 4a2 2 0 00-2 2v4a2 2 0 002 2V6h10a2 2 0 00-2-2H4zm2 6a2 2 0 012-2h8a2 2 0 012 2v4a2 2 0 01-2 2H8a2 2 0 01-2-2v-4zm6 4a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"></path>
</svg>
<span x-html="$icon('users', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
@@ -65,9 +88,7 @@
<!-- Card: Verified Vendors -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M3 1a1 1 0 000 2h1.22l.305 1.222a.997.997 0 00.01.042l1.358 5.43-.893.892C3.74 11.846 4.632 14 6.414 14H15a1 1 0 000-2H6.414l1-1H14a1 1 0 00.894-.553l3-6A1 1 0 0017 3H6.28l-.31-1.243A1 1 0 005 1H3zM16 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM6.5 18a1.5 1.5 0 100-3 1.5 1.5 0 000 3z"></path>
</svg>
<span x-html="$icon('badge-check', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
@@ -82,9 +103,7 @@
<!-- Card: Import Jobs -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-teal-500 bg-teal-100 rounded-full dark:text-teal-100 dark:bg-teal-500">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 5v8a2 2 0 01-2 2h-5l-5 4v-4H4a2 2 0 01-2-2V5a2 2 0 012-2h12a2 2 0 012 2zM7 8H5v2h2V8zm2 0h2v2H9V8zm6 0h-2v2h2V8z" clip-rule="evenodd"></path>
</svg>
<span x-html="$icon('download', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
@@ -98,7 +117,7 @@
</div>
<!-- Recent Vendors Table -->
<div class="w-full overflow-hidden rounded-lg shadow-xs">
<div x-show="!loading" class="w-full overflow-hidden rounded-lg shadow-xs">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap">
<thead>
@@ -110,21 +129,24 @@
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="vendors.length === 0">
<template x-if="recentVendors.length === 0">
<tr>
<td colspan="4" class="px-4 py-3 text-sm text-center text-gray-600 dark:text-gray-400">
No vendors yet. <a href="/static/admin/vendors.html" class="text-purple-600 hover:underline">Create your first vendor</a>
<td colspan="4" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('user-group', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p>No vendors yet.</p>
</div>
</td>
</tr>
</template>
<template x-for="vendor in vendors" :key="vendor.id">
<tr class="text-gray-700 dark:text-gray-400">
<template x-for="vendor in recentVendors" :key="vendor.vendor_code">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block">
<div class="absolute inset-0 rounded-full bg-purple-100 dark:bg-purple-600 flex items-center justify-center">
<span class="text-xs font-semibold text-purple-600 dark:text-purple-100" x-text="vendor.name.charAt(0)"></span>
<span class="text-xs font-semibold text-purple-600 dark:text-purple-100" x-text="vendor.name?.charAt(0).toUpperCase() || '?'"></span>
</div>
</div>
<div>
@@ -134,20 +156,22 @@
</div>
</td>
<td class="px-4 py-3 text-xs">
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="vendor.is_verified ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-orange-700 bg-orange-100 dark:text-white dark:bg-orange-600'"
x-text="vendor.is_verified ? 'Verified' : 'Pending'">
<span class="inline-flex items-center px-2 py-1 font-semibold leading-tight rounded-full"
:class="vendor.is_verified ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-orange-700 bg-orange-100 dark:text-white dark:bg-orange-600'">
<span x-show="vendor.is_verified" x-html="$icon('badge-check', 'w-3 h-3 mr-1')"></span>
<span x-text="vendor.is_verified ? 'Verified' : 'Pending'"></span>
</span>
</td>
<td class="px-4 py-3 text-sm" x-text="formatDate(vendor.created_at)">
</td>
<td class="px-4 py-3">
<div class="flex items-center space-x-4 text-sm">
<button class="flex items-center justify-between px-2 py-2 text-sm font-medium leading-5 text-purple-600 rounded-lg dark:text-gray-400 focus:outline-none focus:shadow-outline-gray"
aria-label="Edit">
<svg class="w-5 h-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"></path>
</svg>
<div class="flex items-center space-x-2 text-sm">
<button
@click="viewVendor(vendor.vendor_code)"
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-gray-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="View vendor"
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</button>
</div>
</td>
@@ -162,11 +186,15 @@
</div>
</div>
<!-- Load partials BEFORE Alpine -->
<!-- Scripts in CORRECT ORDER -->
<!-- 1. Partial Loader (auto-detects admin area) -->
<script src="/static/shared/js/partial-loader.js"></script>
<!-- 2. Icons Helper -->
<script src="/static/shared/js/icons.js"></script>
<!-- 3. Load Partials (before Alpine initializes) -->
<script>
// Load partials synchronously before Alpine starts
(async () => {
await window.partialLoader.loadAll({
'header-container': 'header.html',
@@ -175,84 +203,17 @@
})();
</script>
<!-- Alpine.js v3 -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
<!-- Initialize Alpine data -->
<!-- 4. Base Alpine Data -->
<script src="/static/admin/js/init-alpine.js"></script>
<!-- Dashboard-specific logic -->
<script>
function dashboardData() {
return {
...data(), // Spread base data from init-alpine.js
currentPage: 'dashboard',
stats: {
totalVendors: 0,
activeUsers: 0,
verifiedVendors: 0,
importJobs: 0
},
vendors: [],
loading: false,
<!-- 5. API Client & Utils -->
<script src="/static/shared/js/api-client.js"></script>
<script src="/static/shared/js/utils.js"></script>
async init() {
await this.loadStats();
await this.loadVendors();
},
<!-- 6. Alpine.js v3 (deferred) -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
async loadStats() {
try {
// Replace with your actual API endpoint
const response = await fetch('/api/v1/admin/dashboard/stats', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
const data = await response.json();
this.stats = {
totalVendors: data.vendors?.total_vendors || 0,
activeUsers: data.users?.active_users || 0,
verifiedVendors: data.vendors?.verified_vendors || 0,
importJobs: data.imports?.total_imports || 0
};
}
} catch (error) {
console.error('Error loading stats:', error);
}
},
async loadVendors() {
try {
// Replace with your actual API endpoint
const response = await fetch('/api/v1/admin/vendors?limit=5', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
const data = await response.json();
this.vendors = data.vendors || [];
}
} catch (error) {
console.error('Error loading vendors:', error);
}
},
formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
}
}
</script>
<!-- 7. Dashboard-specific script (AFTER Alpine loads) -->
<script src="/static/admin/js/dashboard.js"></script>
</body>
</html>

View File

@@ -1,72 +1,126 @@
/**
* Admin Dashboard Component
* Extends adminLayout with dashboard-specific functionality
*/
// static/admin/js/dashboard.js
// Log levels: 0 = None, 1 = Error, 2 = Warning, 3 = Info, 4 = Debug
const DASHBOARD_LOG_LEVEL = 3; // Set to 3 for production, 4 for full debugging
const dashLog = {
error: (...args) => DASHBOARD_LOG_LEVEL >= 1 && console.error('❌ [DASHBOARD ERROR]', ...args),
warn: (...args) => DASHBOARD_LOG_LEVEL >= 2 && console.warn('⚠️ [DASHBOARD WARN]', ...args),
info: (...args) => DASHBOARD_LOG_LEVEL >= 3 && console.info(' [DASHBOARD INFO]', ...args),
debug: (...args) => DASHBOARD_LOG_LEVEL >= 4 && console.log('🔍 [DASHBOARD DEBUG]', ...args)
};
function adminDashboard() {
return {
// Inherit all adminLayout functionality
...window.adminLayout(),
// Inherit base layout functionality from init-alpine.js
...data(),
// Dashboard-specific state
currentSection: 'dashboard',
currentPage: 'dashboard',
stats: {
vendors: {},
users: {},
imports: {}
totalVendors: 0,
activeUsers: 0,
verifiedVendors: 0,
importJobs: 0
},
vendors: [],
users: [],
imports: [],
recentVendors: [],
recentImports: [],
loading: false,
loading: true,
error: null,
/**
* Initialize dashboard
*/
async init() {
// Call parent init from adminLayout
this.currentPage = this.getCurrentPage();
await this.loadUserData();
dashLog.info('=== DASHBOARD INITIALIZING ===');
dashLog.debug('Current URL:', window.location.href);
dashLog.debug('Current pathname:', window.location.pathname);
// Load dashboard data
await this.loadDashboardData();
const token = localStorage.getItem('admin_token');
dashLog.debug('Has admin_token?', !!token);
if (token) {
dashLog.debug('Token preview:', token.substring(0, 20) + '...');
}
// Prevent multiple initializations
if (window._dashboardInitialized) {
dashLog.warn('Dashboard already initialized, skipping...');
return;
}
window._dashboardInitialized = true;
dashLog.debug('Dashboard initialization flag set');
await this.loadDashboard();
dashLog.info('=== DASHBOARD INITIALIZATION COMPLETE ===');
},
/**
* Load all dashboard data
*/
async loadDashboardData() {
async loadDashboard() {
dashLog.info('Loading dashboard data...');
this.loading = true;
this.error = null;
dashLog.debug('Dashboard state: loading=true, error=null');
try {
dashLog.info('Starting parallel data fetch...');
const startTime = Date.now();
// Load stats and vendors in parallel
await Promise.all([
this.loadStats(),
this.loadRecentVendors(),
this.loadRecentImports()
this.loadRecentVendors()
]);
const duration = Date.now() - startTime;
dashLog.info(`Dashboard data loaded successfully in ${duration}ms`);
} catch (error) {
console.error('Error loading dashboard data:', error);
this.showErrorModal({
message: 'Failed to load dashboard data',
details: error.message
dashLog.error('Dashboard load error:', error);
dashLog.error('Error details:', {
message: error.message,
name: error.name,
stack: error.stack
});
this.error = error.message;
Utils.showToast('Failed to load dashboard data', 'error');
} finally {
this.loading = false;
dashLog.debug('Dashboard state: loading=false');
dashLog.info('Dashboard load attempt finished');
}
},
/**
* Load statistics
* Load platform statistics
*/
async loadStats() {
dashLog.info('Loading platform statistics...');
dashLog.debug('API endpoint: /admin/dashboard/stats/platform');
try {
const response = await apiClient.get('/admin/stats');
this.stats = response;
const startTime = Date.now();
const data = await apiClient.get('/admin/dashboard/stats/platform');
const duration = Date.now() - startTime;
dashLog.info(`Stats loaded in ${duration}ms`);
dashLog.debug('Raw stats data:', data);
// Map API response to stats cards
this.stats = {
totalVendors: data.vendors?.total_vendors || 0,
activeUsers: data.users?.active_users || 0,
verifiedVendors: data.vendors?.verified_vendors || 0,
importJobs: data.imports?.total_imports || 0
};
dashLog.info('Stats mapped:', this.stats);
} catch (error) {
console.error('Failed to load stats:', error);
// Don't show error modal for stats, just log it
dashLog.error('Failed to load stats:', error);
throw error;
}
},
@@ -74,111 +128,32 @@ function adminDashboard() {
* Load recent vendors
*/
async loadRecentVendors() {
dashLog.info('Loading recent vendors...');
dashLog.debug('API endpoint: /admin/dashboard');
try {
const response = await apiClient.get('/admin/vendors', {
skip: 0,
limit: 5
const startTime = Date.now();
const data = await apiClient.get('/admin/dashboard');
const duration = Date.now() - startTime;
dashLog.info(`Recent vendors loaded in ${duration}ms`);
dashLog.debug('Vendors data:', {
count: data.recent_vendors?.length || 0,
hasData: !!data.recent_vendors
});
this.recentVendors = response.vendors || response;
this.recentVendors = data.recent_vendors || [];
if (this.recentVendors.length > 0) {
dashLog.info(`Loaded ${this.recentVendors.length} recent vendors`);
dashLog.debug('First vendor:', this.recentVendors[0]);
} else {
dashLog.warn('No recent vendors found');
}
} catch (error) {
console.error('Failed to load recent vendors:', error);
}
},
/**
* Load recent import jobs
*/
async loadRecentImports() {
try {
const response = await apiClient.get('/admin/imports', {
skip: 0,
limit: 5
});
this.recentImports = response.imports || response;
} catch (error) {
console.error('Failed to load recent imports:', error);
}
},
/**
* Show different sections
*/
async showSection(section) {
this.currentSection = section;
// Load data based on section
if (section === 'vendors' && this.vendors.length === 0) {
await this.loadAllVendors();
} else if (section === 'users' && this.users.length === 0) {
await this.loadAllUsers();
} else if (section === 'imports' && this.imports.length === 0) {
await this.loadAllImports();
}
},
/**
* Load all vendors
*/
async loadAllVendors() {
this.loading = true;
try {
const response = await apiClient.get('/admin/vendors', {
skip: 0,
limit: 100
});
this.vendors = response.vendors || response;
} catch (error) {
console.error('Failed to load vendors:', error);
this.showErrorModal({
message: 'Failed to load vendors',
details: error.message
});
} finally {
this.loading = false;
}
},
/**
* Load all users
*/
async loadAllUsers() {
this.loading = true;
try {
const response = await apiClient.get('/admin/users', {
skip: 0,
limit: 100
});
this.users = response.users || response;
} catch (error) {
console.error('Failed to load users:', error);
this.showErrorModal({
message: 'Failed to load users',
details: error.message
});
} finally {
this.loading = false;
}
},
/**
* Load all import jobs
*/
async loadAllImports() {
this.loading = true;
try {
const response = await apiClient.get('/admin/imports', {
skip: 0,
limit: 100
});
this.imports = response.imports || response;
} catch (error) {
console.error('Failed to load import jobs:', error);
this.showErrorModal({
message: 'Failed to load import jobs',
details: error.message
});
} finally {
this.loading = false;
dashLog.error('Failed to load recent vendors:', error);
throw error;
}
},
@@ -186,18 +161,35 @@ function adminDashboard() {
* Format date for display
*/
formatDate(dateString) {
if (!dateString) return '-';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch (error) {
return dateString;
if (!dateString) {
dashLog.debug('formatDate called with empty dateString');
return '-';
}
const formatted = Utils.formatDate(dateString);
dashLog.debug(`Date formatted: ${dateString} -> ${formatted}`);
return formatted;
},
/**
* Navigate to vendor detail page
*/
viewVendor(vendorCode) {
dashLog.info('Navigating to vendor:', vendorCode);
const url = `/admin/vendors?code=${vendorCode}`;
dashLog.debug('Navigation URL:', url);
window.location.href = url;
},
/**
* Refresh dashboard data
*/
async refresh() {
dashLog.info('=== DASHBOARD REFRESH TRIGGERED ===');
await this.loadDashboard();
Utils.showToast('Dashboard refreshed', 'success');
dashLog.info('=== DASHBOARD REFRESH COMPLETE ===');
}
};
}
}
dashLog.info('Dashboard module loaded');

View File

@@ -1,7 +1,18 @@
// Admin Login Component
// static/admin/js/login.js
// Log levels: 0 = None, 1 = Error, 2 = Warning, 3 = Info, 4 = Debug
const LOG_LEVEL = 4; // Set to 4 for full debugging, 1 for errors only
const log = {
error: (...args) => LOG_LEVEL >= 1 && console.error('❌ [ERROR]', ...args),
warn: (...args) => LOG_LEVEL >= 2 && console.warn('⚠️ [WARN]', ...args),
info: (...args) => LOG_LEVEL >= 3 && console.info(' [INFO]', ...args),
debug: (...args) => LOG_LEVEL >= 4 && console.log('🔍 [DEBUG]', ...args)
};
function adminLogin() {
return {
dark: false, // For dark mode toggle
dark: false,
credentials: {
username: '',
password: ''
@@ -12,119 +23,192 @@ function adminLogin() {
errors: {},
init() {
// Check if already logged in
this.checkExistingAuth();
log.info('Login page initializing...');
log.debug('Current pathname:', window.location.pathname);
log.debug('Current URL:', window.location.href);
// Check for dark mode preference
// Just set theme - NO auth checking, NO token clearing!
this.dark = localStorage.getItem('theme') === 'dark';
},
log.debug('Dark mode:', this.dark);
checkExistingAuth() {
const token = localStorage.getItem('admin_token') || localStorage.getItem('token');
// DON'T clear tokens on init!
// If user lands here with a valid token, they might be navigating manually
// or got redirected. Let them try to login or navigate away.
const token = localStorage.getItem('admin_token');
if (token) {
// Verify token is still valid
const userData = localStorage.getItem('admin_user');
if (userData) {
try {
const user = JSON.parse(userData);
if (user.role === 'admin') {
window.location.href = '/static/admin/dashboard.html';
}
} catch (e) {
// Invalid user data, clear storage
this.clearAuthData();
}
}
log.warn('Found existing token on login page');
log.debug('Token preview:', token.substring(0, 20) + '...');
log.info('Not clearing token - user may have navigated here manually');
} else {
log.debug('No existing token found');
}
log.info('Login page initialization complete');
},
clearAuthData() {
clearTokens() {
log.debug('Clearing all auth tokens...');
const tokensBefore = {
admin_token: !!localStorage.getItem('admin_token'),
admin_user: !!localStorage.getItem('admin_user'),
token: !!localStorage.getItem('token')
};
log.debug('Tokens before clear:', tokensBefore);
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
localStorage.removeItem('token');
const tokensAfter = {
admin_token: !!localStorage.getItem('admin_token'),
admin_user: !!localStorage.getItem('admin_user'),
token: !!localStorage.getItem('token')
};
log.debug('Tokens after clear:', tokensAfter);
},
clearErrors() {
log.debug('Clearing form errors');
this.error = null;
this.success = null;
this.errors = {};
},
validateForm() {
log.debug('Validating login form...');
this.clearErrors();
let isValid = true;
if (!this.credentials.username.trim()) {
this.errors.username = 'Username is required';
log.warn('Validation failed: Username is required');
isValid = false;
}
if (!this.credentials.password) {
this.errors.password = 'Password is required';
log.warn('Validation failed: Password is required');
isValid = false;
} else if (this.credentials.password.length < 6) {
this.errors.password = 'Password must be at least 6 characters';
log.warn('Validation failed: Password too short');
isValid = false;
}
log.info('Form validation result:', isValid ? 'VALID' : 'INVALID');
return isValid;
},
async handleLogin() {
log.info('=== LOGIN ATTEMPT STARTED ===');
if (!this.validateForm()) {
log.warn('Form validation failed, aborting login');
return;
}
this.loading = true;
this.clearErrors();
log.debug('Login state set to loading');
try {
// Use apiClient from api-client.js
log.info('Calling login API endpoint...');
log.debug('Username:', this.credentials.username);
log.debug('API endpoint: /api/v1/admin/auth/login');
const startTime = Date.now();
const response = await apiClient.post('/admin/auth/login', {
username: this.credentials.username.trim(),
password: this.credentials.password
});
const duration = Date.now() - startTime;
log.info(`Login API response received in ${duration}ms`);
log.debug('Response structure:', {
hasToken: !!response.access_token,
hasUser: !!response.user,
userRole: response.user?.role,
userName: response.user?.username
});
// Validate response
if (!response.access_token) {
throw new Error('Invalid response from server');
log.error('Invalid response: No access token');
throw new Error('Invalid response from server - no token');
}
// Check if user is admin (if user data is provided)
if (response.user && response.user.role !== 'admin') {
log.error('Authorization failed: User is not admin', {
actualRole: response.user.role
});
throw new Error('Access denied. Admin privileges required.');
}
log.info('Login successful, storing authentication data...');
// Store authentication data
localStorage.setItem('admin_token', response.access_token);
localStorage.setItem('token', response.access_token); // Backup
localStorage.setItem('token', response.access_token);
log.debug('Token stored, length:', response.access_token.length);
if (response.user) {
localStorage.setItem('admin_user', JSON.stringify(response.user));
log.debug('User data stored:', {
username: response.user.username,
role: response.user.role,
id: response.user.id
});
}
// Verify storage
const storedToken = localStorage.getItem('admin_token');
const storedUser = localStorage.getItem('admin_user');
log.info('Storage verification:', {
tokenStored: !!storedToken,
userStored: !!storedUser,
tokenLength: storedToken?.length
});
// Show success message
this.success = 'Login successful! Redirecting...';
log.info('Success message displayed to user');
// Redirect after short delay
setTimeout(() => {
window.location.href = '/static/admin/dashboard.html';
}, 1000);
log.info('Redirecting to dashboard immediately...');
log.info('=== EXECUTING REDIRECT ===');
log.debug('Target URL: /admin/dashboard');
log.debug('Redirect method: window.location.href');
// Use href instead of replace to allow back button
// But redirect IMMEDIATELY - don't wait!
window.location.href = '/admin/dashboard';
} catch (error) {
console.error('Login error:', error);
this.error = error.message || 'Invalid username or password. Please try again.';
log.error('Login failed:', error);
log.error('Error details:', {
message: error.message,
name: error.name,
stack: error.stack
});
this.error = error.message || 'Invalid username or password. Please try again.';
log.info('Error message displayed to user:', this.error);
// Only clear tokens on login FAILURE
this.clearTokens();
log.info('Tokens cleared after error');
// Clear any partial auth data
this.clearAuthData();
} finally {
this.loading = false;
log.debug('Login state set to not loading');
log.info('=== LOGIN ATTEMPT FINISHED ===');
}
},
toggleDarkMode() {
log.debug('Toggling dark mode...');
this.dark = !this.dark;
localStorage.setItem('theme', this.dark ? 'dark' : 'light');
log.info('Dark mode:', this.dark ? 'ON' : 'OFF');
}
}
}

View File

@@ -46,7 +46,7 @@ function vendorEdit() {
// Check authentication
if (!Auth.isAuthenticated() || !Auth.isAdmin()) {
console.log('Not authenticated as admin, redirecting to login');
window.location.href = '/static/admin/login.html';
window.location.href = '/admin/login';
return;
}
@@ -60,7 +60,7 @@ function vendorEdit() {
if (!this.vendorId) {
console.error('No vendor ID in URL');
alert('No vendor ID provided');
window.location.href = '/static/admin/dashboard.html#vendors';
window.location.href = '/admin/dashboard.html#vendors';
return;
}
@@ -95,7 +95,7 @@ function vendorEdit() {
} catch (error) {
console.error('❌ Failed to load vendor:', error);
Utils.showToast('Failed to load vendor details: ' + (error.message || 'Unknown error'), 'error');
window.location.href = '/static/admin/dashboard.html#vendors';
window.location.href = '/admin/dashboard';
} finally {
this.loadingVendor = false;
}
@@ -331,7 +331,7 @@ function vendorEdit() {
// Redirect to login after brief delay
setTimeout(() => {
window.location.href = '/static/admin/login.html';
window.location.href = '/admin/login';
}, 500);
},
};

View File

@@ -1,3 +1,4 @@
// static/admin/js/vendors.js
// Admin Vendor Creation Component
function vendorCreation() {
return {
@@ -26,7 +27,8 @@ function vendorCreation() {
checkAuth() {
if (!Auth.isAuthenticated()) {
window.location.href = '/static/admin/login.html';
// ← CHANGED: Use new Jinja2 route
window.location.href = '/admin/login';
return false;
}
@@ -34,7 +36,8 @@ function vendorCreation() {
if (!user || user.role !== 'admin') {
Utils.showToast('Access denied. Admin privileges required.', 'error');
Auth.logout();
window.location.href = '/static/admin/login.html';
// ← CHANGED: Use new Jinja2 route
window.location.href = '/admin/login';
return false;
}
@@ -52,11 +55,14 @@ function vendorCreation() {
Auth.logout();
Utils.showToast('Logged out successfully', 'success', 2000);
setTimeout(() => {
window.location.href = '/static/admin/login.html';
// ← CHANGED: Use new Jinja2 route
window.location.href = '/admin/login';
}, 500);
}
},
// ... rest of the methods stay the same ...
// Auto-format vendor code (uppercase)
formatVendorCode() {
this.formData.vendor_code = this.formData.vendor_code

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" x-data="loginData()" lang="en">
<html :class="{ 'theme-dark': dark }" x-data="adminLogin()" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -45,6 +45,7 @@
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
:class="{ 'border-red-600': errors.username }"
placeholder="Enter your username"
autocomplete="username"
required />
<span x-show="errors.username" x-text="errors.username"
class="text-xs text-red-600 dark:text-red-400"></span>
@@ -59,6 +60,7 @@
:class="{ 'border-red-600': errors.password }"
placeholder="***************"
type="password"
autocomplete="current-password"
required />
<span x-show="errors.password" x-text="errors.password"
class="text-xs text-red-600 dark:text-red-400"></span>
@@ -85,80 +87,29 @@
Forgot your password?
</a>
</p>
<p class="mt-2">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="/">
← Back to Platform
</a>
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Alpine.js v3 -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
<!-- Scripts in CORRECT ORDER -->
<!-- 1. Icons FIRST (defines $icon magic) -->
<script src="/static/shared/js/icons.js"></script>
<!-- API Client -->
<!-- 2. API Client -->
<script src="/static/shared/js/api-client.js"></script>
<!-- Login Logic -->
<script>
function loginData() {
return {
dark: false,
loading: false,
error: '',
success: '',
credentials: {
username: '',
password: ''
},
errors: {
username: '',
password: ''
},
<!-- 3. Login Logic -->
<script src="/static/admin/js/login.js"></script>
clearErrors() {
this.error = '';
this.errors = { username: '', password: '' };
},
async handleLogin() {
this.clearErrors();
this.loading = true;
try {
// Your existing API call
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(this.credentials)
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Login failed');
}
// Store token
localStorage.setItem('token', data.access_token);
// Show success
this.success = 'Login successful! Redirecting...';
// Redirect to dashboard
setTimeout(() => {
window.location.href = '/static/admin/dashboard.html';
}, 1000);
} catch (error) {
this.error = error.message || 'Invalid username or password';
console.error('Login error:', error);
} finally {
this.loading = false;
}
}
}
}
</script>
<!-- 4. Alpine.js LAST with defer -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login - Multi-Tenant Ecommerce Platform</title>
<link rel="stylesheet" href="/static/css/shared/base.css">
<link rel="stylesheet" href="/static/css/shared/auth.css">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
<div class="auth-page" x-data="adminLogin()" x-cloak>
<div class="login-container">
<div class="login-header">
<div class="auth-logo">🔐</div>
<h1>Admin Portal</h1>
<p>Multi-Tenant Ecommerce Platform</p>
</div>
<!-- Alert Messages -->
<div x-show="error"
x-text="error"
class="alert alert-error"
x-transition></div>
<div x-show="success"
x-text="success"
class="alert alert-success"
x-transition></div>
<!-- Login Form -->
<form @submit.prevent="handleLogin">
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
id="username"
x-model="credentials.username"
:class="{ 'error': errors.username }"
required
autocomplete="username"
placeholder="Enter your username"
:disabled="loading"
@input="clearErrors"
>
<div x-show="errors.username"
x-text="errors.username"
class="error-message show"
x-transition></div>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
x-model="credentials.password"
:class="{ 'error': errors.password }"
required
autocomplete="current-password"
placeholder="Enter your password"
:disabled="loading"
@input="clearErrors"
>
<div x-show="errors.password"
x-text="errors.password"
class="error-message show"
x-transition></div>
</div>
<button type="submit"
class="btn-login"
:disabled="loading">
<template x-if="!loading">
<span>Sign In</span>
</template>
<template x-if="loading">
<span>
<span class="loading-spinner"></span>
Signing in...
</span>
</template>
</button>
</form>
<div class="login-footer">
<a href="/">← Back to Platform</a>
</div>
</div>
</div>
<script src="/static/js/shared/api-client.js"></script>
<script src="/static/admin/js/login.js"></script>
</body>
</html>

View File

@@ -0,0 +1,72 @@
<!-- static/admin/partials/base-layout.html -->
<!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-page-title>Admin Panel - Multi-Tenant Platform</title>
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<!-- Tailwind CSS -->
<link rel="stylesheet" href="/static/admin/css/tailwind.output.css" />
<!-- Alpine Cloak -->
<style>
[x-cloak] { display: none !important; }
</style>
<!-- Page-specific styles slot -->
<slot name="head"></slot>
</head>
<body x-cloak>
<div class="flex h-screen bg-gray-50 dark:bg-gray-900" :class="{ 'overflow-hidden': isSideMenuOpen }">
<!-- Sidebar Container -->
<div id="sidebar-container"></div>
<div class="flex flex-col flex-1 w-full">
<!-- Header Container -->
<div id="header-container"></div>
<!-- Main Content Area (Child pages inject content here) -->
<main class="h-full overflow-y-auto">
<div class="container px-6 mx-auto grid">
<!-- Page content slot -->
<slot name="content"></slot>
</div>
</main>
</div>
</div>
<!-- Core Scripts (loaded for all pages) -->
<!-- 1. Partial Loader -->
<script src="/static/shared/js/partial-loader.js"></script>
<!-- 2. Icons Helper -->
<script src="/static/shared/js/icons.js"></script>
<!-- 3. Load Header & Sidebar -->
<script>
(async () => {
await window.partialLoader.loadAll({
'header-container': 'header.html',
'sidebar-container': 'sidebar.html'
});
})();
</script>
<!-- 4. Base Alpine Data -->
<script src="/static/admin/js/init-alpine.js"></script>
<!-- 5. API Client & Utils -->
<script src="/static/shared/js/api-client.js"></script>
<script src="/static/shared/js/utils.js"></script>
<!-- 6. Alpine.js v3 -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
<!-- Page-specific scripts slot -->
<slot name="scripts"></slot>
</body>
</html>

View File

@@ -1,133 +0,0 @@
<!-- Top header bar with search, theme toggle, notifications, profile -->
<header class="z-10 py-4 bg-white shadow-md dark:bg-gray-800">
<div class="container flex items-center justify-between h-full px-6 mx-auto text-purple-600 dark:text-purple-300">
<!-- Mobile hamburger -->
<button class="p-1 mr-5 -ml-1 rounded-md md:hidden focus:outline-none focus:shadow-outline-purple"
@click="toggleSideMenu" aria-label="Menu">
<span x-html="$icon('menu', 'w-6 h-6')"></span>
</button>
<!-- Search input -->
<div class="flex justify-center flex-1 lg:mr-32">
<div class="relative w-full max-w-xl mr-6 focus-within:text-purple-500">
<div class="absolute inset-y-0 flex items-center pl-2">
<span x-html="$icon('search', 'w-4 h-4')"></span>
</div>
<input class="w-full pl-8 pr-2 text-sm text-gray-700 placeholder-gray-600 bg-gray-100 border-0 rounded-md dark:placeholder-gray-500 dark:focus:shadow-outline-gray dark:focus:placeholder-gray-600 dark:bg-gray-700 dark:text-gray-200 focus:placeholder-gray-500 focus:bg-white focus:border-purple-300 focus:outline-none focus:shadow-outline-purple form-input"
type="text" placeholder="Search for projects" aria-label="Search"/>
</div>
</div>
<ul class="flex items-center flex-shrink-0 space-x-6">
<!-- Theme toggler -->
<li class="flex">
<button class="rounded-md focus:outline-none focus:shadow-outline-purple"
@click="toggleTheme" aria-label="Toggle color mode">
<template x-if="!dark">
<span x-html="$icon('moon', 'w-5 h-5')"></span>
</template>
<template x-if="dark">
<span x-html="$icon('sun', 'w-5 h-5')"></span>
</template>
</button>
</li>
<!-- Notifications menu -->
<li class="relative">
<button class="relative align-middle rounded-md focus:outline-none focus:shadow-outline-purple"
@click="toggleNotificationsMenu" @keydown.escape="closeNotificationsMenu"
aria-label="Notifications" aria-haspopup="true">
<span x-html="$icon('bell', 'w-5 h-5')"></span>
<span aria-hidden="true" class="absolute top-0 right-0 inline-block w-3 h-3 transform translate-x-1 -translate-y-1 bg-red-600 border-2 border-white rounded-full dark:border-gray-800"></span>
</button>
<template x-if="isNotificationsMenuOpen">
<ul x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click.away="closeNotificationsMenu"
@keydown.escape="closeNotificationsMenu"
class="absolute right-0 w-56 p-2 mt-2 space-y-2 text-gray-600 bg-white border border-gray-100 rounded-md shadow-md dark:text-gray-300 dark:border-gray-700 dark:bg-gray-700">
<li class="flex">
<a class="inline-flex items-center justify-between w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200" href="#">
<span>Messages</span>
<span class="inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-red-600 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-600">13</span>
</a>
</li>
<li class="flex">
<a class="inline-flex items-center justify-between w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200" href="#">
<span>Sales</span>
<span class="inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-red-600 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-600">2</span>
</a>
</li>
<li class="flex">
<a class="inline-flex items-center justify-between w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200" href="#">
<span>Alerts</span>
</a>
</li>
</ul>
</template>
</li>
<!-- Profile menu -->
<li class="relative">
<button class="align-middle rounded-full focus:shadow-outline-purple focus:outline-none"
@click="toggleProfileMenu" @keydown.escape="closeProfileMenu"
aria-label="Account" aria-haspopup="true">
<img class="object-cover w-8 h-8 rounded-full"
src="https://images.unsplash.com/photo-1502378735452-bc7d86632805?ixlib=rb-0.3.5&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=200&fit=max&s=aa3a807e1bbdfd4364d1f449eaa96d82"
alt="" aria-hidden="true"/>
</button>
<template x-if="isProfileMenuOpen">
<ul x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click.away="closeProfileMenu"
@keydown.escape="closeProfileMenu"
class="absolute right-0 w-56 p-2 mt-2 space-y-2 text-gray-600 bg-white border border-gray-100 rounded-md shadow-md dark:border-gray-700 dark:text-gray-300 dark:bg-gray-700"
aria-label="submenu">
<li class="flex">
<a class="inline-flex items-center w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200" href="#">
<span x-html="$icon('user', 'w-4 h-4 mr-3')"></span>
<span>Profile</span>
</a>
</li>
<li class="flex">
<a class="inline-flex items-center w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200" href="#">
<span x-html="$icon('cog', 'w-4 h-4 mr-3')"></span>
<span>Settings</span>
</a>
</li>
<li class="flex">
<a class="inline-flex items-center w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200" href="/static/admin/login.html">
<span x-html="$icon('logout', 'w-4 h-4 mr-3')"></span>
<span>Log out</span>
</a>
</li>
</ul>
</template>
</li>
</ul>
</div>
</header>.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<span>Settings</span>
</a>
</li>
<li class="flex">
<a class="inline-flex items-center w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200" href="/static/admin/login.html">
<!-- Heroicon: logout -->
<svg class="w-4 h-4 mr-3" aria-hidden="true" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" stroke="currentColor">
<path d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path>
</svg>
<span>Log out</span>
</a>
</li>
</ul>
</template>
</li>
</ul>
</div>
</header>

View File

@@ -1,116 +0,0 @@
<!-- Desktop sidebar -->
<aside class="z-20 hidden w-64 overflow-y-auto bg-white dark:bg-gray-800 md:block flex-shrink-0">
<div class="py-4 text-gray-500 dark:text-gray-400">
<a class="ml-6 text-lg font-bold text-gray-800 dark:text-gray-200" href="/static/admin/dashboard.html">
Admin Portal
</a>
<ul class="mt-6">
<li class="relative px-6 py-3">
<span x-show="currentPage === 'dashboard'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'dashboard' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/static/admin/dashboard.html">
<span x-html="$icon('home')"></span>
<span class="ml-4">Dashboard</span>
</a>
</li>
</ul>
<ul>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'vendors'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'vendors' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/static/admin/vendors.html">
<span x-html="$icon('shopping-bag')"></span>
<span class="ml-4">Vendors</span>
</a>
</li>
<li class="relative px-6 py-3">
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200" href="#">
<span x-html="$icon('users')"></span>
<span class="ml-4">Users</span>
</a>
</li>
<li class="relative px-6 py-3">
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200" href="#">
<span x-html="$icon('cube')"></span>
<span class="ml-4">Import Jobs</span>
</a>
</li>
</ul>
<div class="px-6 my-6">
<button class="flex items-center justify-between w-full px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
Create vendor
<span class="ml-2" aria-hidden="true">+</span>
</button>
</div>
</div>
</aside>
<!-- Mobile sidebar -->
<!-- Backdrop -->
<div x-show="isSideMenuOpen"
x-transition:enter="transition ease-in-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in-out duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-10 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"></div>
<aside class="fixed inset-y-0 z-20 flex-shrink-0 w-64 mt-16 overflow-y-auto bg-white dark:bg-gray-800 md:hidden"
x-show="isSideMenuOpen"
x-transition:enter="transition ease-in-out duration-150"
x-transition:enter-start="opacity-0 transform -translate-x-20"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in-out duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0 transform -translate-x-20"
@click.away="closeSideMenu"
@keydown.escape="closeSideMenu">
<div class="py-4 text-gray-500 dark:text-gray-400">
<a class="ml-6 text-lg font-bold text-gray-800 dark:text-gray-200" href="/static/admin/dashboard.html">
Admin Portal
</a>
<ul class="mt-6">
<li class="relative px-6 py-3">
<span x-show="currentPage === 'dashboard'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'dashboard' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/static/admin/dashboard.html">
<span x-html="$icon('home')"></span>
<span class="ml-4">Dashboard</span>
</a>
</li>
</ul>
<ul>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'vendors'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'vendors' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/static/admin/vendors.html">
<span x-html="$icon('shopping-bag')"></span>
<span class="ml-4">Vendors</span>
</a>
</li>
<li class="relative px-6 py-3">
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200" href="#">
<span x-html="$icon('users')"></span>
<span class="ml-4">Users</span>
</a>
</li>
<li class="relative px-6 py-3">
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200" href="#">
<span x-html="$icon('cube')"></span>
<span class="ml-4">Import Jobs</span>
</a>
</li>
</ul>
<div class="px-6 my-6">
<button class="flex items-center justify-between px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
Create vendor
<span class="ml-2" aria-hidden="true">+</span>
</button>
</div>
</div>
</aside>

View File

@@ -0,0 +1,644 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Auth Flow Testing - Admin Panel</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
padding: 20px;
background: #f5f5f5;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 14px;
}
.test-section {
margin-bottom: 30px;
padding: 20px;
background: #f9f9f9;
border-radius: 6px;
border-left: 4px solid #3b82f6;
}
.test-section h2 {
color: #333;
margin-bottom: 15px;
font-size: 20px;
}
.test-description {
color: #666;
margin-bottom: 15px;
font-size: 14px;
}
.test-steps {
background: white;
padding: 15px;
border-radius: 4px;
margin-bottom: 15px;
}
.test-steps ol {
margin-left: 20px;
}
.test-steps li {
margin-bottom: 8px;
color: #444;
}
.expected-result {
background: #e8f5e9;
padding: 12px;
border-radius: 4px;
border-left: 3px solid #4caf50;
margin-bottom: 15px;
}
.expected-result strong {
color: #2e7d32;
display: block;
margin-bottom: 5px;
}
.expected-result ul {
margin-left: 20px;
color: #555;
}
.button-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
button:active {
transform: translateY(0);
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-warning {
background: #f59e0b;
color: white;
}
.btn-warning:hover {
background: #d97706;
}
.btn-success {
background: #10b981;
color: white;
}
.btn-success:hover {
background: #059669;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-secondary:hover {
background: #4b5563;
}
.status-panel {
background: #1e293b;
color: #e2e8f0;
padding: 20px;
border-radius: 6px;
margin-top: 30px;
font-family: 'Courier New', monospace;
font-size: 13px;
}
.status-panel h3 {
color: #38bdf8;
margin-bottom: 15px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.status-item {
margin-bottom: 8px;
display: flex;
justify-content: space-between;
}
.status-label {
color: #94a3b8;
}
.status-value {
color: #34d399;
font-weight: 500;
}
.status-value.false {
color: #f87171;
}
.log-level-control {
background: #fef3c7;
padding: 15px;
border-radius: 6px;
margin-bottom: 30px;
border-left: 4px solid #f59e0b;
}
.log-level-control h3 {
color: #92400e;
margin-bottom: 10px;
font-size: 16px;
}
.log-level-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.log-level-buttons button {
padding: 8px 16px;
font-size: 12px;
}
.warning-box {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 6px;
padding: 15px;
margin-top: 30px;
}
.warning-box h3 {
color: #991b1b;
margin-bottom: 10px;
font-size: 16px;
}
.warning-box ul {
margin-left: 20px;
color: #7f1d1d;
}
.warning-box li {
margin-bottom: 5px;
}
</style>
</head>
<body>
<div class="container">
<h1>🧪 Auth Flow Testing</h1>
<p class="subtitle">Comprehensive testing for the Jinja2 migration auth loop fix</p>
<!-- Log Level Control -->
<div class="log-level-control">
<h3>📊 Log Level Control</h3>
<p style="color: #78350f; font-size: 13px; margin-bottom: 10px;">
Change logging verbosity for login.js and api-client.js
</p>
<div class="log-level-buttons">
<button onclick="setLogLevel(0)" class="btn-secondary">0 - None</button>
<button onclick="setLogLevel(1)" class="btn-danger">1 - Errors Only</button>
<button onclick="setLogLevel(2)" class="btn-warning">2 - Warnings</button>
<button onclick="setLogLevel(3)" class="btn-success">3 - Info (Production)</button>
<button onclick="setLogLevel(4)" class="btn-primary">4 - Debug (Full)</button>
</div>
<p style="color: #78350f; font-size: 12px; margin-top: 10px; font-style: italic;">
Current levels: LOGIN = <span id="currentLoginLevel">4</span>, API = <span id="currentApiLevel">3</span>
</p>
</div>
<!-- Test 1: Clean Slate -->
<div class="test-section">
<h2>Test 1: Clean Slate - Fresh Login Flow</h2>
<p class="test-description">
Tests the complete login flow from scratch with no existing tokens.
</p>
<div class="test-steps">
<strong>Steps:</strong>
<ol>
<li>Click "Clear All Data" below</li>
<li>Click "Navigate to /admin"</li>
<li>Observe browser behavior and console logs</li>
<li>You should land on login page</li>
</ol>
</div>
<div class="expected-result">
<strong>✅ Expected Result:</strong>
<ul>
<li>Single redirect: /admin → /admin/login</li>
<li>Login page loads with NO API calls to /admin/auth/me</li>
<li>No loops, no errors in console</li>
<li>Form is ready for input</li>
</ul>
</div>
<div class="button-group">
<button onclick="clearAllData()" class="btn-danger">Clear All Data</button>
<button onclick="navigateToAdmin()" class="btn-primary">Navigate to /admin</button>
<button onclick="navigateToLogin()" class="btn-secondary">Go to Login</button>
</div>
</div>
<!-- Test 2: Login Success -->
<div class="test-section">
<h2>Test 2: Successful Login</h2>
<p class="test-description">
Tests that login works correctly and redirects to dashboard.
</p>
<div class="test-steps">
<strong>Steps:</strong>
<ol>
<li>Ensure you're on /admin/login</li>
<li>Enter valid admin credentials</li>
<li>Click "Login"</li>
<li>Observe redirect and dashboard load</li>
</ol>
</div>
<div class="expected-result">
<strong>✅ Expected Result:</strong>
<ul>
<li>Login API call succeeds (check Network tab)</li>
<li>Token stored in localStorage</li>
<li>Success message shows briefly</li>
<li>Redirect to /admin/dashboard after 500ms</li>
<li>Dashboard loads with stats and recent vendors</li>
</ul>
</div>
<div class="button-group">
<button onclick="navigateToLogin()" class="btn-primary">Go to Login Page</button>
<button onclick="checkAuthStatus()" class="btn-secondary">Check Auth Status</button>
</div>
</div>
<!-- Test 3: Dashboard Refresh -->
<div class="test-section">
<h2>Test 3: Dashboard Refresh (Authenticated)</h2>
<p class="test-description">
Tests that refreshing the dashboard works without redirect loops.
</p>
<div class="test-steps">
<strong>Steps:</strong>
<ol>
<li>Complete Test 2 (login successfully)</li>
<li>On dashboard, press F5 or click "Refresh Page"</li>
<li>Observe page reload behavior</li>
</ol>
</div>
<div class="expected-result">
<strong>✅ Expected Result:</strong>
<ul>
<li>Dashboard reloads normally</li>
<li>No redirects to login</li>
<li>Stats and vendors load correctly</li>
<li>No console errors</li>
</ul>
</div>
<div class="button-group">
<button onclick="navigateToDashboard()" class="btn-primary">Go to Dashboard</button>
<button onclick="window.location.reload()" class="btn-secondary">Refresh Page</button>
</div>
</div>
<!-- Test 4: Expired Token -->
<div class="test-section">
<h2>Test 4: Expired Token Handling</h2>
<p class="test-description">
Tests that expired tokens are handled gracefully with redirect to login.
</p>
<div class="test-steps">
<strong>Steps:</strong>
<ol>
<li>Click "Set Expired Token"</li>
<li>Click "Navigate to Dashboard"</li>
<li>Observe authentication failure and redirect</li>
</ol>
</div>
<div class="expected-result">
<strong>✅ Expected Result:</strong>
<ul>
<li>Server detects expired token</li>
<li>Returns 401 Unauthorized</li>
<li>Browser redirects to /admin/login</li>
<li>Token is cleared from localStorage</li>
<li>No infinite loops</li>
</ul>
</div>
<div class="button-group">
<button onclick="setExpiredToken()" class="btn-warning">Set Expired Token</button>
<button onclick="navigateToDashboard()" class="btn-primary">Navigate to Dashboard</button>
</div>
</div>
<!-- Test 5: Direct Dashboard Access (No Token) -->
<div class="test-section">
<h2>Test 5: Direct Dashboard Access (Unauthenticated)</h2>
<p class="test-description">
Tests that accessing dashboard without token redirects to login.
</p>
<div class="test-steps">
<strong>Steps:</strong>
<ol>
<li>Click "Clear All Data"</li>
<li>Click "Navigate to Dashboard"</li>
<li>Observe immediate redirect to login</li>
</ol>
</div>
<div class="expected-result">
<strong>✅ Expected Result:</strong>
<ul>
<li>Redirect from /admin/dashboard to /admin/login</li>
<li>No API calls attempted</li>
<li>Login page loads correctly</li>
</ul>
</div>
<div class="button-group">
<button onclick="clearAllData()" class="btn-danger">Clear All Data</button>
<button onclick="navigateToDashboard()" class="btn-primary">Navigate to Dashboard</button>
</div>
</div>
<!-- Test 6: Login Page with Valid Token -->
<div class="test-section">
<h2>Test 6: Login Page with Valid Token</h2>
<p class="test-description">
Tests what happens when user visits login page while already authenticated.
</p>
<div class="test-steps">
<strong>Steps:</strong>
<ol>
<li>Login successfully (Test 2)</li>
<li>Click "Go to Login Page" below</li>
<li>Observe behavior</li>
</ol>
</div>
<div class="expected-result">
<strong>✅ Expected Result:</strong>
<ul>
<li>Login page loads</li>
<li>Existing token is cleared (init() clears it)</li>
<li>Form is displayed normally</li>
<li>NO redirect loops</li>
<li>NO API calls to validate token</li>
</ul>
</div>
<div class="button-group">
<button onclick="setValidToken()" class="btn-success">Set Valid Token (Mock)</button>
<button onclick="navigateToLogin()" class="btn-primary">Go to Login Page</button>
</div>
</div>
<!-- Status Panel -->
<div class="status-panel">
<h3>🔍 Current Auth Status</h3>
<div id="statusDisplay">
<div class="status-item">
<span class="status-label">Current URL:</span>
<span class="status-value" id="currentUrl">-</span>
</div>
<div class="status-item">
<span class="status-label">Has admin_token:</span>
<span class="status-value" id="hasToken">-</span>
</div>
<div class="status-item">
<span class="status-label">Has admin_user:</span>
<span class="status-value" id="hasUser">-</span>
</div>
<div class="status-item">
<span class="status-label">Token Preview:</span>
<span class="status-value" id="tokenPreview">-</span>
</div>
<div class="status-item">
<span class="status-label">Username:</span>
<span class="status-value" id="username">-</span>
</div>
</div>
<button onclick="updateStatus()" style="margin-top: 15px; background: #38bdf8; color: #0f172a; padding: 8px 16px; border-radius: 4px; font-size: 12px; cursor: pointer; border: none;">
🔄 Refresh Status
</button>
</div>
<!-- Warning Box -->
<div class="warning-box">
<h3>⚠️ Important Notes</h3>
<ul>
<li>Always check browser console for detailed logs</li>
<li>Use Network tab to see actual HTTP requests and redirects</li>
<li>Clear browser cache if you see unexpected behavior</li>
<li>Make sure FastAPI server is running on localhost:8000</li>
<li>Valid admin credentials required for login tests</li>
</ul>
</div>
</div>
<script>
// Update status display
function updateStatus() {
const token = localStorage.getItem('admin_token');
const userStr = localStorage.getItem('admin_user');
let user = null;
try {
user = userStr ? JSON.parse(userStr) : null;
} catch (e) {
console.error('Failed to parse user data:', e);
}
document.getElementById('currentUrl').textContent = window.location.href;
const hasTokenEl = document.getElementById('hasToken');
hasTokenEl.textContent = token ? 'Yes' : 'No';
hasTokenEl.className = token ? 'status-value' : 'status-value false';
const hasUserEl = document.getElementById('hasUser');
hasUserEl.textContent = user ? 'Yes' : 'No';
hasUserEl.className = user ? 'status-value' : 'status-value false';
document.getElementById('tokenPreview').textContent = token
? token.substring(0, 30) + '...'
: 'No token';
document.getElementById('username').textContent = user?.username || 'Not logged in';
console.log('📊 Status Updated:', {
hasToken: !!token,
hasUser: !!user,
user: user
});
}
// Test functions
function clearAllData() {
console.log('🗑️ Clearing all localStorage data...');
localStorage.clear();
console.log('✅ All data cleared');
alert('✅ All localStorage data cleared!\n\nCheck console for details.');
updateStatus();
}
function navigateToAdmin() {
console.log('🚀 Navigating to /admin...');
window.location.href = '/admin';
}
function navigateToLogin() {
console.log('🚀 Navigating to /admin/login...');
window.location.href = '/admin/login';
}
function navigateToDashboard() {
console.log('🚀 Navigating to /admin/dashboard...');
window.location.href = '/admin/dashboard';
}
function checkAuthStatus() {
updateStatus();
alert('Check console and status panel for auth details.');
}
function setExpiredToken() {
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyMzkwMjJ9.invalidexpiredtoken';
console.log('⚠️ Setting expired/invalid token...');
localStorage.setItem('admin_token', expiredToken);
localStorage.setItem('admin_user', JSON.stringify({
id: 1,
username: 'test_expired',
role: 'admin'
}));
console.log('✅ Expired token set');
alert('⚠️ Expired token set!\n\nNow try navigating to dashboard.');
updateStatus();
}
function setValidToken() {
// This is a mock token - won't actually work with backend
const mockToken = 'mock_valid_token_' + Date.now();
console.log('✅ Setting mock valid token...');
localStorage.setItem('admin_token', mockToken);
localStorage.setItem('admin_user', JSON.stringify({
id: 1,
username: 'test_user',
role: 'admin'
}));
console.log('✅ Mock token set (will not work with real backend)');
alert('✅ Mock token set!\n\nNote: This is a fake token and won\'t work with the real backend.');
updateStatus();
}
// Log level control
function setLogLevel(level) {
console.log(`📊 Setting log level to ${level}...`);
// Note: This only works if login.js and api-client.js are loaded
// In production, you'd need to reload the page or use a more sophisticated approach
if (typeof LOG_LEVEL !== 'undefined') {
window.LOG_LEVEL = level;
document.getElementById('currentLoginLevel').textContent = level;
console.log('✅ LOGIN log level set to', level);
} else {
console.warn('⚠️ LOG_LEVEL not found (login.js not loaded)');
}
if (typeof API_LOG_LEVEL !== 'undefined') {
window.API_LOG_LEVEL = level;
document.getElementById('currentApiLevel').textContent = level;
console.log('✅ API log level set to', level);
} else {
console.warn('⚠️ API_LOG_LEVEL not found (api-client.js not loaded)');
}
alert(`Log level set to ${level}\n\n0 = None\n1 = Errors\n2 = Warnings\n3 = Info\n4 = Debug\n\nNote: Changes apply to current page. Reload to apply to all scripts.`);
}
// Initialize status on load
updateStatus();
// Auto-refresh status every 2 seconds
setInterval(updateStatus, 2000);
console.log('🧪 Auth Flow Testing Script Loaded');
console.log('📊 Use the buttons above to run tests');
console.log('🔍 Watch browser console and Network tab for details');
</script>
</body>
</html>

View File

@@ -1,4 +1,4 @@
// static/js/shared/api-client.js
// static/shared/js/api-client.js
/**
* API Client for Multi-Tenant Ecommerce Platform
*
@@ -9,6 +9,16 @@
* - 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';
/**
@@ -17,13 +27,24 @@ const API_BASE_URL = '/api/v1';
class APIClient {
constructor(baseURL = API_BASE_URL) {
this.baseURL = baseURL;
apiLog.info('API Client initialized with base URL:', baseURL);
}
/**
* Get stored authentication token
*/
getToken() {
return localStorage.getItem('admin_token') || localStorage.getItem('vendor_token');
const adminToken = localStorage.getItem('admin_token');
const vendorToken = localStorage.getItem('vendor_token');
const token = adminToken || vendorToken;
apiLog.debug('Getting token:', {
hasAdminToken: !!adminToken,
hasVendorToken: !!vendorToken,
usingToken: token ? 'admin or vendor' : 'none'
});
return token;
}
/**
@@ -38,6 +59,9 @@ class APIClient {
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;
@@ -48,6 +72,14 @@ class APIClient {
*/
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,
@@ -55,26 +87,61 @@ class APIClient {
};
try {
const startTime = Date.now();
const response = await fetch(url, config);
const duration = Date.now() - startTime;
// Handle 401 Unauthorized
if (response.status === 401) {
this.handleUnauthorized();
throw new Error('Unauthorized - please login again');
}
apiLog.info(`Response: ${response.status} ${response.statusText} (${duration}ms)`);
// Parse response
const data = await response.json();
let data;
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 - Just clear tokens, DON'T redirect
if (response.status === 401) {
apiLog.warn('401 Unauthorized - Authentication failed');
apiLog.debug('Error details:', data);
apiLog.info('Clearing authentication tokens');
this.clearTokens();
const errorMessage = data.message || data.detail || 'Unauthorized - please login again';
apiLog.error('Throwing authentication error:', errorMessage);
throw new Error(errorMessage);
}
// Handle non-OK responses
if (!response.ok) {
throw new Error(data.detail || data.message || 'Request failed');
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
});
throw new Error(errorMessage);
}
apiLog.info('Request completed successfully');
return data;
} catch (error) {
console.error('API request failed:', 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;
}
}
@@ -86,6 +153,8 @@ class APIClient {
const queryString = new URLSearchParams(params).toString();
const url = queryString ? `${endpoint}?${queryString}` : endpoint;
apiLog.debug('GET request params:', params);
return this.request(url, {
method: 'GET'
});
@@ -95,6 +164,11 @@ class APIClient {
* POST request
*/
async post(endpoint, data = {}) {
apiLog.debug('POST request data:', {
hasData: !!data,
dataKeys: Object.keys(data)
});
return this.request(endpoint, {
method: 'POST',
body: JSON.stringify(data)
@@ -105,6 +179,11 @@ class APIClient {
* 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)
@@ -115,31 +194,59 @@ class APIClient {
* DELETE request
*/
async delete(endpoint) {
apiLog.debug('DELETE request');
return this.request(endpoint, {
method: 'DELETE'
});
}
/**
* Handle unauthorized access
* Clear authentication tokens
*/
handleUnauthorized() {
clearTokens() {
apiLog.info('Clearing all authentication tokens...');
const tokensBefore = {
admin_token: !!localStorage.getItem('admin_token'),
admin_user: !!localStorage.getItem('admin_user'),
vendor_token: !!localStorage.getItem('vendor_token'),
vendor_user: !!localStorage.getItem('vendor_user'),
token: !!localStorage.getItem('token')
};
apiLog.debug('Tokens before clear:', tokensBefore);
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
localStorage.removeItem('vendor_token');
localStorage.removeItem('vendor_user');
localStorage.removeItem('token');
// Redirect to appropriate login page
if (window.location.pathname.includes('/admin/')) {
window.location.href = '/static/admin/login.html';
} else if (window.location.pathname.includes('/vendor/')) {
window.location.href = '/static/vendor/login.html';
}
const tokensAfter = {
admin_token: !!localStorage.getItem('admin_token'),
admin_user: !!localStorage.getItem('admin_user'),
vendor_token: !!localStorage.getItem('vendor_token'),
vendor_user: !!localStorage.getItem('vendor_user'),
token: !!localStorage.getItem('token')
};
apiLog.debug('Tokens after clear:', tokensAfter);
apiLog.info('All tokens cleared');
}
/**
* 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
@@ -150,7 +257,9 @@ const Auth = {
*/
isAuthenticated() {
const token = localStorage.getItem('admin_token') || localStorage.getItem('vendor_token');
return !!token;
const isAuth = !!token;
apiLog.debug('Auth check:', isAuth ? 'authenticated' : 'not authenticated');
return isAuth;
},
/**
@@ -158,11 +267,21 @@ const Auth = {
*/
getCurrentUser() {
const userStr = localStorage.getItem('admin_user') || localStorage.getItem('vendor_user');
if (!userStr) return null;
if (!userStr) {
apiLog.debug('No user found in storage');
return null;
}
try {
return JSON.parse(userStr);
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;
}
},
@@ -172,13 +291,16 @@ const Auth = {
*/
isAdmin() {
const user = this.getCurrentUser();
return user && user.role === 'admin';
const isAdmin = user && user.role === 'admin';
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
@@ -186,9 +308,11 @@ const Auth = {
// Store token and user
if (response.user.role === 'admin') {
apiLog.info('Storing admin credentials');
localStorage.setItem('admin_token', response.access_token);
localStorage.setItem('admin_user', JSON.stringify(response.user));
} else {
apiLog.info('Storing vendor credentials');
localStorage.setItem('vendor_token', response.access_token);
localStorage.setItem('vendor_user', JSON.stringify(response.user));
}
@@ -200,10 +324,9 @@ const Auth = {
* Logout
*/
logout() {
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
localStorage.removeItem('vendor_token');
localStorage.removeItem('vendor_user');
apiLog.info('Auth.logout called');
apiClient.clearTokens();
apiLog.info('User logged out');
}
};
@@ -269,6 +392,8 @@ const Utils = {
* Show toast notification
*/
showToast(message, type = 'info', duration = 3000) {
apiLog.debug('Showing toast:', { message, type, duration });
// Create toast element
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
@@ -374,4 +499,6 @@ if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTableScrollDetection);
} else {
initTableScrollDetection();
}
}
apiLog.info('API Client module loaded');

406
static/shared/js/icons.js Normal file
View File

@@ -0,0 +1,406 @@
/**
* Heroicons Helper - Inline SVG Icons
* Usage: icon('home') or icon('home', 'w-6 h-6')
*/
const Icons = {
// Navigation
home: `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>`,
menu: `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/>
</svg>`,
search: `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"/>
</svg>`,
// User & Profile
user: `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>`,
users: `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>`,
// Actions
edit: `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/>
</svg>`,
delete: `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>`,
plus: `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>`,
check: `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>`,
close: `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>`,
// Theme & Settings
sun: `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"/>
</svg>`,
moon: `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
</svg>`,
cog: `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>`,
// Notifications & Communication
bell: `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6zM10 18a3 3 0 01-3-3h6a3 3 0 01-3 3z"/>
</svg>`,
mail: `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/>
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/>
</svg>`,
// Logout
logout: `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>`,
// Business/Commerce
'shopping-bag': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"/>
</svg>`,
cube: `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>`,
chart: `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"/>
</svg>`,
// Arrows & Directions
'chevron-down': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>`,
'chevron-right': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
</svg>`,
'arrow-left': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>`,
// Status & Indicators
'exclamation': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>`,
'information-circle': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>`,
// Loading
spinner: `<svg class="{{classes}} animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>`,
// E-commerce Specific
'shopping-cart': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M3 1a1 1 0 000 2h1.22l.305 1.222a.997.997 0 00.01.042l1.358 5.43-.893.892C3.74 11.846 4.632 14 6.414 14H15a1 1 0 000-2H6.414l1-1H14a1 1 0 00.894-.553l3-6A1 1 0 0017 3H6.28l-.31-1.243A1 1 0 005 1H3zM16 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM6.5 18a1.5 1.5 0 100-3 1.5 1.5 0 000 3z"/>
</svg>`,
'credit-card': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/>
</svg>`,
'currency-dollar': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>`,
'gift': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7"/>
</svg>`,
'tag': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
</svg>`,
'truck': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0"/>
</svg>`,
'receipt': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
</svg>`,
'clipboard-list': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>`,
// Inventory & Products
'collection': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>`,
'photograph': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>`,
'color-swatch': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"/>
</svg>`,
'template': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"/>
</svg>`,
// Analytics & Reports
'trending-up': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
</svg>`,
'trending-down': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"/>
</svg>`,
'presentation-chart-line': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"/>
</svg>`,
'calculator': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>`,
// Customer Management
'user-circle': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-6-3a2 2 0 11-4 0 2 2 0 014 0zm-2 4a5 5 0 00-4.546 2.916A5.986 5.986 0 0010 16a5.986 5.986 0 004.546-2.084A5 5 0 0010 11z" clip-rule="evenodd"/>
</svg>`,
'user-group': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z"/>
</svg>`,
'identification': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2"/>
</svg>`,
'badge-check': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>`,
// Documents & Files
'document': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>`,
'folder': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/>
</svg>`,
'folder-open': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z" clip-rule="evenodd"/>
<path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z"/>
</svg>`,
'download': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>`,
'upload': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</svg>`,
// Time & Calendar
'calendar': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>`,
'clock': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>`,
// System & Settings
'database': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"/>
</svg>`,
'server': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/>
</svg>`,
'shield-check': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>`,
'key': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z" clip-rule="evenodd"/>
</svg>`,
'lock-closed': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/>
</svg>`,
'lock-open': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a5 5 0 00-5 5v2a2 2 0 00-2 2v5a2 2 0 002 2h10a2 2 0 002-2v-5a2 2 0 00-2-2H7V7a3 3 0 015.905-.75 1 1 0 001.937-.5A5.002 5.002 0 0010 2z"/>
</svg>`,
// Actions & Interactions
'refresh': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>`,
'duplicate': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>`,
'eye': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>`,
'eye-off': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>`,
'filter': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
</svg>`,
'dots-vertical': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z"/>
</svg>`,
'dots-horizontal': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z"/>
</svg>`,
// Communication
'chat': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg>`,
'annotation': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/>
</svg>`,
'phone': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3z"/>
</svg>`,
// Location
'location-marker': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"/>
</svg>`,
'globe': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>`,
// Links & External
'external-link': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>`,
'link': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
</svg>`,
// Status Badges
'star': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>`,
'heart': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clip-rule="evenodd"/>
</svg>`,
'flag': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 6a3 3 0 013-3h10a1 1 0 01.8 1.6L14.25 8l2.55 3.4A1 1 0 0116 13H6a1 1 0 00-1 1v3a1 1 0 11-2 0V6z" clip-rule="evenodd"/>
</svg>`
};
/**
* Get icon SVG with custom classes
* @param {string} name - Icon name from Icons object
* @param {string} classes - Tailwind classes (default: 'w-5 h-5')
* @returns {string} SVG markup
*/
function icon(name, classes = 'w-5 h-5') {
const iconTemplate = Icons[name];
if (!iconTemplate) {
console.warn(`Icon "${name}" not found`);
return '';
}
return iconTemplate.replace('{{classes}}', classes);
}
/**
* Alpine.js magic helper
* Usage in Alpine: x-html="$icon('home')" or x-html="$icon('home', 'w-6 h-6')"
*/
if (typeof Alpine !== 'undefined') {
document.addEventListener('alpine:init', () => {
Alpine.magic('icon', () => {
return (name, classes) => icon(name, classes);
});
});
}
// Export for use in modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = { icon, Icons };
}
// Make available globally
window.icon = icon;
window.Icons = Icons;
/**
* Get icon SVG with custom classes
* @param {string} name - Icon name from Icons object
* @param {string} classes - Tailwind classes (default: 'w-5 h-5')
* @returns {string} SVG markup
*/
function icon(name, classes = 'w-5 h-5') {
const iconTemplate = Icons[name];
if (!iconTemplate) {
console.warn(`Icon "${name}" not found`);
return '';
}
return iconTemplate.replace('{{classes}}', classes);
}
/**
* Alpine.js magic helper
* Usage in Alpine: x-html="$icon('home')" or x-html="$icon('home', 'w-6 h-6')"
*/
if (typeof Alpine !== 'undefined') {
document.addEventListener('alpine:init', () => {
Alpine.magic('icon', () => {
return (name, classes) => icon(name, classes);
});
});
}
// Export for use in modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = { icon, Icons };
}
// Make available globally
window.icon = icon;
window.Icons = Icons;

193
static/shared/js/utils.js Normal file
View File

@@ -0,0 +1,193 @@
// static/shared/js/utils.js
/**
* Utility functions for the application
*/
const Utils = {
/**
* Format date for display
* @param {string} dateString - ISO date string
* @returns {string} Formatted date
*/
formatDate(dateString) {
if (!dateString) return '-';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch (error) {
console.error('Error formatting date:', error);
return dateString;
}
},
/**
* Format date with time
* @param {string} dateString - ISO date string
* @returns {string} Formatted date with time
*/
formatDateTime(dateString) {
if (!dateString) return '-';
try {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch (error) {
console.error('Error formatting datetime:', error);
return dateString;
}
},
/**
* Format currency
* @param {number} amount - Amount to format
* @param {string} currency - Currency code (default: USD)
* @returns {string} Formatted currency
*/
formatCurrency(amount, currency = 'USD') {
if (amount === null || amount === undefined) return '-';
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency
}).format(amount);
} catch (error) {
console.error('Error formatting currency:', error);
return amount.toString();
}
},
/**
* Format number with commas
* @param {number} num - Number to format
* @returns {string} Formatted number
*/
formatNumber(num) {
if (num === null || num === undefined) return '0';
return num.toLocaleString('en-US');
},
/**
* Show toast notification
* @param {string} message - Toast message
* @param {string} type - Toast type: 'success', 'error', 'warning', 'info'
* @param {number} duration - Duration in ms (default: 3000)
*/
showToast(message, type = 'info', duration = 3000) {
// Create toast element
const toast = document.createElement('div');
toast.className = `fixed bottom-4 right-4 px-6 py-3 rounded-lg shadow-lg text-white z-50 transition-opacity duration-300 ${
type === 'success' ? 'bg-green-500' :
type === 'error' ? 'bg-red-500' :
type === 'warning' ? 'bg-yellow-500' :
'bg-blue-500'
}`;
toast.textContent = message;
document.body.appendChild(toast);
// Fade out and remove
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, duration);
},
/**
* Debounce function
* @param {Function} func - Function to debounce
* @param {number} wait - Wait time in ms
* @returns {Function} Debounced function
*/
debounce(func, wait = 300) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
/**
* Get query parameter from URL
* @param {string} param - Parameter name
* @returns {string|null} Parameter value
*/
getQueryParam(param) {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get(param);
},
/**
* Copy text to clipboard
* @param {string} text - Text to copy
*/
async copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
this.showToast('Copied to clipboard', 'success');
} catch (error) {
console.error('Failed to copy:', error);
this.showToast('Failed to copy', 'error');
}
},
/**
* Truncate string
* @param {string} str - String to truncate
* @param {number} maxLength - Maximum length
* @returns {string} Truncated string
*/
truncate(str, maxLength = 50) {
if (!str || str.length <= maxLength) return str;
return str.substring(0, maxLength - 3) + '...';
},
/**
* Validate email format
* @param {string} email - Email to validate
* @returns {boolean} Is valid email
*/
isValidEmail(email) {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
},
/**
* Get status badge class
* @param {string} status - Status value
* @returns {string} Tailwind classes for badge
*/
getStatusBadgeClass(status) {
const statusClasses = {
'active': 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100',
'inactive': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-100',
'pending': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100',
'verified': 'bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100',
'rejected': 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100'
};
return statusClasses[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
}
};
// Make available globally
window.Utils = Utils;
// Export for modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = Utils;
}