admin and vendor backends features

This commit is contained in:
2025-10-19 16:16:13 +02:00
parent 7b8e31a198
commit cbe1ab09d1
25 changed files with 5787 additions and 1540 deletions

View File

@@ -11,19 +11,19 @@ Establish the multi-tenant foundation with complete vendor isolation and admin c
### User Stories
- ✅ As a Super Admin, I can create vendors through the admin interface
- As a Super Admin, I can manage vendor accounts (verify, activate, deactivate)
- As a Vendor Owner, I can log into my vendor-specific admin interface
- [ ] As a Super Admin, I can manage vendor accounts (verify, activate, deactivate)
- As a Vendor Owner, I can log into my vendor-specific admin interface
- ✅ The system correctly isolates vendor contexts (subdomain + path-based)
### Success Criteria
- [ ] Admin can log into admin interface
- [ ] Admin can create new vendors with auto-generated owner accounts
- [ ] System generates secure temporary passwords
- [ ] Vendor owner can log into vendor-specific interface
- Admin can log into admin interface
- Admin can create new vendors with auto-generated owner accounts
- System generates secure temporary passwords
- Vendor owner can log into vendor-specific interface
- [ ] Vendor context detection works in dev (path) and prod (subdomain) modes
- [ ] Database properly isolates vendor data
- [ ] All API endpoints protected with JWT authentication
- [ ] Frontend integrates seamlessly with backend
- Frontend integrates seamlessly with backend
## 📋 Backend Implementation

View File

@@ -4,601 +4,426 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Dashboard - Multi-Tenant Ecommerce Platform</title>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Styles -->
<link rel="stylesheet" href="/static/css/shared/base.css">
<link rel="stylesheet" href="/static/css/shared/components.css">
<link rel="stylesheet" href="/static/css/shared/modals.css">
<link rel="stylesheet" href="/static/css/admin/admin.css">
<style>
[x-cloak] { display: none !important; }
</style>
</head>
<body>
<!-- Header -->
<header class="admin-header">
<div class="header-left">
<h1>🔐 Admin Dashboard</h1>
</div>
<div class="header-right">
<span class="user-info">Welcome, <strong id="adminUsername">Admin</strong></span>
<button class="btn-logout" onclick="handleLogout()">Logout</button>
</div>
</header>
<body x-data="adminDashboard()" x-init="init()" x-cloak>
<!-- Main Container -->
<div class="admin-container">
<!-- Sidebar -->
<aside class="admin-sidebar">
<nav>
<ul class="nav-menu">
<li class="nav-item">
<a href="#" class="nav-link active" onclick="showSection('dashboard')">
📊 Dashboard
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="showSection('vendors')">
🏪 Vendors
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="showSection('users')">
👥 Users
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="showSection('imports')">
📦 Import Jobs
</a>
</li>
</ul>
</nav>
</aside>
<!-- Admin Header (Injected from template) -->
<div x-html="adminLayoutTemplates.header()"></div>
<!-- Main Content -->
<main class="admin-content">
<!-- Dashboard View -->
<div id="dashboardView">
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-header">
<div>
<div class="stat-title">Total Vendors</div>
</div>
<div class="stat-icon">🏪</div>
</div>
<div class="stat-value" id="totalVendors">-</div>
<div class="stat-subtitle">
<span id="activeVendors">-</span> active
</div>
<!-- Admin Sidebar (Injected from template) -->
<div x-html="adminLayoutTemplates.sidebar()"></div>
<!-- Main Content -->
<main class="admin-content">
<!-- Dashboard View -->
<div x-show="currentSection === 'dashboard'">
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-header">
<div class="stat-title">Total Vendors</div>
<div class="stat-icon">🏪</div>
</div>
<div class="stat-card">
<div class="stat-header">
<div>
<div class="stat-title">Total Users</div>
</div>
<div class="stat-icon">👥</div>
</div>
<div class="stat-value" id="totalUsers">-</div>
<div class="stat-subtitle">
<span id="activeUsers">-</span> active
</div>
</div>
<div class="stat-card">
<div class="stat-header">
<div>
<div class="stat-title">Verified Vendors</div>
</div>
<div class="stat-icon"></div>
</div>
<div class="stat-value" id="verifiedVendors">-</div>
<div class="stat-subtitle">
<span id="verificationRate">-</span>% verification rate
</div>
</div>
<div class="stat-card">
<div class="stat-header">
<div>
<div class="stat-title">Import Jobs</div>
</div>
<div class="stat-icon">📦</div>
</div>
<div class="stat-value" id="totalImports">-</div>
<div class="stat-subtitle">
<span id="completedImports">-</span> completed
</div>
<div class="stat-value" x-text="stats.vendors?.total_vendors || 0"></div>
<div class="stat-subtitle">
<span x-text="stats.vendors?.active_vendors || 0"></span> active
</div>
</div>
<!-- Recent Vendors -->
<div class="content-section">
<div class="section-header">
<h2 class="section-title">Recent Vendors</h2>
<button class="btn-primary" onclick="showSection('vendors')">View All</button>
<div class="stat-card">
<div class="stat-header">
<div class="stat-title">Total Users</div>
<div class="stat-icon">👥</div>
</div>
<div id="recentVendorsList">
<div class="loading">Loading recent vendors...</div>
<div class="stat-value" x-text="stats.users?.total_users || 0"></div>
<div class="stat-subtitle">
<span x-text="stats.users?.active_users || 0"></span> active
</div>
</div>
<!-- Recent Import Jobs -->
<div class="content-section">
<div class="section-header">
<h2 class="section-title">Recent Import Jobs</h2>
<button class="btn-primary" onclick="showSection('imports')">View All</button>
<div class="stat-card">
<div class="stat-header">
<div class="stat-title">Verified Vendors</div>
<div class="stat-icon"></div>
</div>
<div id="recentImportsList">
<div class="loading">Loading recent imports...</div>
<div class="stat-value" x-text="stats.vendors?.verified_vendors || 0"></div>
<div class="stat-subtitle">
<span x-text="Math.round(stats.vendors?.verification_rate || 0)"></span>% verification rate
</div>
</div>
<div class="stat-card">
<div class="stat-header">
<div class="stat-title">Import Jobs</div>
<div class="stat-icon">📦</div>
</div>
<div class="stat-value" x-text="stats.imports?.total_imports || 0"></div>
<div class="stat-subtitle">
<span x-text="stats.imports?.completed_imports || 0"></span> completed
</div>
</div>
</div>
<!-- Vendors View -->
<div id="vendorsView" style="display: none;">
<div class="content-section">
<div class="section-header">
<h2 class="section-title">Vendor Management</h2>
<button class="btn-primary" onclick="window.location.href='/static/admin/vendors.html'">
Create New Vendor
<!-- Recent Vendors -->
<div class="content-section">
<div class="section-header">
<h2 class="section-title">Recent Vendors</h2>
<button class="btn-primary" @click="showSection('vendors')">View All</button>
</div>
<div x-show="loading && !recentVendors.length" class="loading">
<span class="loading-spinner loading-spinner-lg"></span>
<p class="loading-text">Loading recent vendors...</p>
</div>
<template x-if="!loading && recentVendors.length === 0">
<div class="empty-state">
<div class="empty-state-icon">🏪</div>
<h3>No Vendors Yet</h3>
<p>Create your first vendor to get started</p>
<button class="btn-primary mt-3" onclick="window.location.href='/admin/vendors.html'">
Create Vendor
</button>
</div>
<div id="vendorsList">
<div class="loading">Loading vendors...</div>
</template>
<template x-if="recentVendors.length > 0">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>Vendor Code</th>
<th>Name</th>
<th>Subdomain</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
<template x-for="vendor in recentVendors" :key="vendor.id">
<tr>
<td><strong x-text="vendor.vendor_code"></strong></td>
<td x-text="vendor.name"></td>
<td x-text="vendor.subdomain"></td>
<td>
<span class="badge"
:class="vendor.is_verified ? 'badge-success' : 'badge-warning'"
x-text="vendor.is_verified ? 'Verified' : 'Pending'"></span>
<span class="badge"
:class="vendor.is_active ? 'badge-success' : 'badge-danger'"
x-text="vendor.is_active ? 'Active' : 'Inactive'"></span>
</td>
<td x-text="formatDate(vendor.created_at)"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
</div>
<!-- Users View -->
<div id="usersView" style="display: none;">
<div class="content-section">
<div class="section-header">
<h2 class="section-title">User Management</h2>
</div>
<div id="usersList">
<div class="loading">Loading users...</div>
</div>
<!-- Recent Import Jobs -->
<div class="content-section">
<div class="section-header">
<h2 class="section-title">Recent Import Jobs</h2>
<button class="btn-primary" @click="showSection('imports')">View All</button>
</div>
</div>
<!-- Imports View -->
<div id="importsView" style="display: none;">
<div class="content-section">
<div class="section-header">
<h2 class="section-title">Import Jobs</h2>
</div>
<div id="importsList">
<div class="loading">Loading import jobs...</div>
</div>
<div x-show="loading && !recentImports.length" class="loading">
<span class="loading-spinner loading-spinner-lg"></span>
<p class="loading-text">Loading recent imports...</p>
</div>
</div>
</main>
</div>
<script>
const API_BASE_URL = '/api/v1';
let currentSection = 'dashboard';
// Check authentication
function checkAuth() {
const token = localStorage.getItem('admin_token');
const user = localStorage.getItem('admin_user');
if (!token || !user) {
window.location.href = '/static/admin/login.html';
return false;
}
try {
const userData = JSON.parse(user);
if (userData.role !== 'admin') {
alert('Access denied. Admin privileges required.');
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
window.location.href = '/static/admin/login.html';
return false;
}
document.getElementById('adminUsername').textContent = userData.username;
return true;
} catch (e) {
window.location.href = '/static/admin/login.html';
return false;
}
}
// Logout
function handleLogout() {
if (confirm('Are you sure you want to logout?')) {
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
window.location.href = '/static/admin/login.html';
}
}
// API Call with auth
async function apiCall(endpoint, options = {}) {
const token = localStorage.getItem('admin_token');
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
};
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...defaultOptions,
...options,
headers: {
...defaultOptions.headers,
...options.headers
}
});
if (response.status === 401) {
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
window.location.href = '/static/admin/login.html';
throw new Error('Unauthorized');
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'API request failed');
}
return response.json();
}
// Load dashboard data
async function loadDashboard() {
try {
const data = await apiCall('/admin/dashboard');
// Update stats
document.getElementById('totalVendors').textContent = data.vendors.total_vendors || 0;
document.getElementById('activeVendors').textContent = data.vendors.active_vendors || 0;
document.getElementById('verifiedVendors').textContent = data.vendors.verified_vendors || 0;
document.getElementById('verificationRate').textContent = Math.round(data.vendors.verification_rate || 0);
document.getElementById('totalUsers').textContent = data.users.total_users || 0;
document.getElementById('activeUsers').textContent = data.users.active_users || 0;
// Display recent vendors
displayRecentVendors(data.recent_vendors || []);
// Display recent imports
displayRecentImports(data.recent_imports || []);
} catch (error) {
console.error('Failed to load dashboard:', error);
}
}
// Display recent vendors
function displayRecentVendors(vendors) {
const container = document.getElementById('recentVendorsList');
if (vendors.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🏪</div>
<p>No vendors yet</p>
</div>
`;
return;
}
const tableHTML = `
<table class="data-table">
<thead>
<tr>
<th>Vendor Code</th>
<th>Name</th>
<th>Subdomain</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
${vendors.map(v => `
<tr>
<td><strong>${v.vendor_code}</strong></td>
<td>${v.name}</td>
<td>${v.subdomain}</td>
<td>
${v.is_verified ? '<span class="badge badge-success">Verified</span>' : '<span class="badge badge-warning">Pending</span>'}
${v.is_active ? '<span class="badge badge-success">Active</span>' : '<span class="badge badge-danger">Inactive</span>'}
</td>
<td>${new Date(v.created_at).toLocaleDateString()}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
container.innerHTML = tableHTML;
}
// Display recent imports
function displayRecentImports(imports) {
const container = document.getElementById('recentImportsList');
if (imports.length === 0) {
container.innerHTML = `
<template x-if="!loading && recentImports.length === 0">
<div class="empty-state">
<div class="empty-state-icon">📦</div>
<p>No import jobs yet</p>
<h3>No Import Jobs Yet</h3>
<p>Import jobs will appear here once vendors start importing products</p>
</div>
`;
return;
}
</template>
const tableHTML = `
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Marketplace</th>
<th>Vendor</th>
<th>Status</th>
<th>Processed</th>
<th>Created</th>
</tr>
</thead>
<tbody>
${imports.map(j => `
<tr>
<td>#${j.id}</td>
<td>${j.marketplace}</td>
<td>${j.vendor_name || '-'}</td>
<td>
${j.status === 'completed' ? '<span class="badge badge-success">Completed</span>' :
j.status === 'failed' ? '<span class="badge badge-danger">Failed</span>' :
'<span class="badge badge-warning">Processing</span>'}
</td>
<td>${j.total_processed || 0}</td>
<td>${new Date(j.created_at).toLocaleDateString()}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
container.innerHTML = tableHTML;
}
// Show section
function showSection(section) {
// Update nav
document.querySelectorAll('.nav-link').forEach(link => {
link.classList.remove('active');
});
event.target.classList.add('active');
// Hide all views
document.getElementById('dashboardView').style.display = 'none';
document.getElementById('vendorsView').style.display = 'none';
document.getElementById('usersView').style.display = 'none';
document.getElementById('importsView').style.display = 'none';
// Show selected view
currentSection = section;
switch(section) {
case 'dashboard':
document.getElementById('dashboardView').style.display = 'block';
loadDashboard();
break;
case 'vendors':
document.getElementById('vendorsView').style.display = 'block';
loadVendors();
break;
case 'users':
document.getElementById('usersView').style.display = 'block';
loadUsers();
break;
case 'imports':
document.getElementById('importsView').style.display = 'block';
loadImports();
break;
}
}
// Load vendors
async function loadVendors() {
try {
const data = await apiCall('/admin/vendors?limit=100');
displayVendorsList(data.vendors);
} catch (error) {
console.error('Failed to load vendors:', error);
document.getElementById('vendorsList').innerHTML = `
<div class="empty-state">
<p>Failed to load vendors: ${error.message}</p>
<template x-if="recentImports.length > 0">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Marketplace</th>
<th>Vendor</th>
<th>Status</th>
<th>Processed</th>
<th>Created</th>
</tr>
</thead>
<tbody>
<template x-for="job in recentImports" :key="job.id">
<tr>
<td><strong x-text="'#' + job.id"></strong></td>
<td x-text="job.marketplace"></td>
<td x-text="job.vendor_name || '-'"></td>
<td>
<span class="badge"
:class="{
'badge-success': job.status === 'completed',
'badge-danger': job.status === 'failed',
'badge-warning': job.status !== 'completed' && job.status !== 'failed'
}"
x-text="job.status === 'completed' ? 'Completed' :
job.status === 'failed' ? 'Failed' : 'Processing'"></span>
</td>
<td x-text="job.total_processed || 0"></td>
<td x-text="formatDate(job.created_at)"></td>
</tr>
</template>
</tbody>
</table>
</div>
`;
}
}
</template>
</div>
</div>
// Display vendors list
function displayVendorsList(vendors) {
const container = document.getElementById('vendorsList');
<!-- Vendors View -->
<div x-show="currentSection === 'vendors'">
<div class="content-section">
<div class="section-header">
<h2 class="section-title">Vendor Management</h2>
<a href="/admin/vendors.html" class="btn-primary">
Create New Vendor
</a>
</div>
if (vendors.length === 0) {
container.innerHTML = `
<div x-show="loading" class="loading">
<span class="loading-spinner loading-spinner-lg"></span>
<p class="loading-text">Loading vendors...</p>
</div>
<template x-if="!loading && vendors.length === 0">
<div class="empty-state">
<div class="empty-state-icon">🏪</div>
<p>No vendors found</p>
<h3>No Vendors Found</h3>
<p>Get started by creating your first vendor</p>
<a href="/admin/vendors.html" class="btn-primary mt-3">
Create First Vendor
</a>
</div>
`;
return;
}
</template>
const tableHTML = `
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Vendor Code</th>
<th>Name</th>
<th>Subdomain</th>
<th>Email</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
${vendors.map(v => `
<tr>
<td>${v.id}</td>
<td><strong>${v.vendor_code}</strong></td>
<td>${v.name}</td>
<td>${v.subdomain}</td>
<td>${v.contact_email || '-'}</td>
<td>
${v.is_verified ? '<span class="badge badge-success">Verified</span>' : '<span class="badge badge-warning">Pending</span>'}
${v.is_active ? '<span class="badge badge-success">Active</span>' : '<span class="badge badge-danger">Inactive</span>'}
</td>
<td>${new Date(v.created_at).toLocaleDateString()}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
container.innerHTML = tableHTML;
}
// Load users
async function loadUsers() {
try {
const users = await apiCall('/admin/users?limit=100');
displayUsersList(users);
} catch (error) {
console.error('Failed to load users:', error);
document.getElementById('usersList').innerHTML = `
<div class="empty-state">
<p>Failed to load users: ${error.message}</p>
<template x-if="vendors.length > 0">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Vendor Code</th>
<th>Name</th>
<th>Subdomain</th>
<th>Email</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<template x-for="vendor in vendors" :key="vendor.id">
<tr>
<td x-text="vendor.id"></td>
<td><strong x-text="vendor.vendor_code"></strong></td>
<td x-text="vendor.name"></td>
<td x-text="vendor.subdomain"></td>
<td x-text="vendor.business_email || vendor.contact_email || '-'"></td>
<td>
<span class="badge"
:class="vendor.is_verified ? 'badge-success' : 'badge-warning'"
x-text="vendor.is_verified ? 'Verified' : 'Pending'"></span>
<span class="badge"
:class="vendor.is_active ? 'badge-success' : 'badge-danger'"
x-text="vendor.is_active ? 'Active' : 'Inactive'"></span>
</td>
<td x-text="formatDate(vendor.created_at)"></td>
<td>
<a :href="`/admin/vendor-edit.html?id=${vendor.id}`"
class="btn btn-sm btn-primary">
✏️ Edit
</a>
</td>
</tr>
</template>
</tbody>
</table>
</div>
`;
}
}
</template>
</div>
</div>
// Display users list
function displayUsersList(users) {
const container = document.getElementById('usersList');
<!-- Users View -->
<div x-show="currentSection === 'users'">
<div class="content-section">
<div class="section-header">
<h2 class="section-title">User Management</h2>
</div>
if (users.length === 0) {
container.innerHTML = `
<div x-show="loading" class="loading">
<span class="loading-spinner loading-spinner-lg"></span>
<p class="loading-text">Loading users...</p>
</div>
<template x-if="!loading && users.length === 0">
<div class="empty-state">
<div class="empty-state-icon">👥</div>
<p>No users found</p>
<h3>No Users Found</h3>
<p>Users will appear here when vendors are created</p>
</div>
`;
return;
}
</template>
const tableHTML = `
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
${users.map(u => `
<tr>
<td>${u.id}</td>
<td><strong>${u.username}</strong></td>
<td>${u.email}</td>
<td>${u.role}</td>
<td>
${u.is_active ? '<span class="badge badge-success">Active</span>' : '<span class="badge badge-danger">Inactive</span>'}
</td>
<td>${new Date(u.created_at).toLocaleDateString()}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
container.innerHTML = tableHTML;
}
// Load imports
async function loadImports() {
try {
const imports = await apiCall('/admin/marketplace-import-jobs?limit=100');
displayImportsList(imports);
} catch (error) {
console.error('Failed to load imports:', error);
document.getElementById('importsList').innerHTML = `
<div class="empty-state">
<p>Failed to load import jobs: ${error.message}</p>
<template x-if="users.length > 0">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
<template x-for="user in users" :key="user.id">
<tr>
<td x-text="user.id"></td>
<td><strong x-text="user.username"></strong></td>
<td x-text="user.email"></td>
<td>
<span class="badge badge-primary" x-text="user.role"></span>
</td>
<td>
<span class="badge"
:class="user.is_active ? 'badge-success' : 'badge-danger'"
x-text="user.is_active ? 'Active' : 'Inactive'"></span>
</td>
<td x-text="formatDate(user.created_at)"></td>
</tr>
</template>
</tbody>
</table>
</div>
`;
}
}
</template>
</div>
</div>
// Display imports list
function displayImportsList(imports) {
const container = document.getElementById('importsList');
<!-- Imports View -->
<div x-show="currentSection === 'imports'">
<div class="content-section">
<div class="section-header">
<h2 class="section-title">Import Jobs</h2>
</div>
if (imports.length === 0) {
container.innerHTML = `
<div x-show="loading" class="loading">
<span class="loading-spinner loading-spinner-lg"></span>
<p class="loading-text">Loading import jobs...</p>
</div>
<template x-if="!loading && imports.length === 0">
<div class="empty-state">
<div class="empty-state-icon">📦</div>
<p>No import jobs found</p>
<h3>No Import Jobs Found</h3>
<p>Marketplace import jobs will appear here</p>
</div>
`;
return;
}
</template>
const tableHTML = `
<table class="data-table">
<thead>
<tr>
<th>Job ID</th>
<th>Marketplace</th>
<th>Vendor</th>
<th>Status</th>
<th>Processed</th>
<th>Errors</th>
<th>Created</th>
</tr>
</thead>
<tbody>
${imports.map(j => `
<tr>
<td>#${j.job_id}</td>
<td>${j.marketplace}</td>
<td>${j.vendor_name || '-'}</td>
<td>
${j.status === 'completed' ? '<span class="badge badge-success">Completed</span>' :
j.status === 'failed' ? '<span class="badge badge-danger">Failed</span>' :
'<span class="badge badge-warning">Processing</span>'}
</td>
<td>${j.total_processed || 0}</td>
<td>${j.error_count || 0}</td>
<td>${new Date(j.created_at).toLocaleDateString()}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
<template x-if="imports.length > 0">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>Job ID</th>
<th>Marketplace</th>
<th>Vendor</th>
<th>Status</th>
<th>Processed</th>
<th>Errors</th>
<th>Created</th>
</tr>
</thead>
<tbody>
<template x-for="job in imports" :key="job.id">
<tr>
<td><strong x-text="'#' + (job.job_id || job.id)"></strong></td>
<td x-text="job.marketplace"></td>
<td x-text="job.vendor_name || '-'"></td>
<td>
<span class="badge"
:class="{
'badge-success': job.status === 'completed',
'badge-danger': job.status === 'failed',
'badge-warning': job.status !== 'completed' && job.status !== 'failed'
}"
x-text="job.status === 'completed' ? 'Completed' :
job.status === 'failed' ? 'Failed' : 'Processing'"></span>
</td>
<td x-text="job.total_processed || 0"></td>
<td>
<span x-text="job.error_count || 0"
:class="{ 'text-danger': (job.error_count || 0) > 0 }"></span>
</td>
<td x-text="formatDate(job.created_at)"></td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</div>
</div>
</main>
container.innerHTML = tableHTML;
}
<!-- Universal Modals (Injected from shared templates) -->
<div x-html="modalTemplates.confirmModal()"></div>
<div x-html="modalTemplates.successModal()"></div>
<div x-html="modalTemplates.errorModal()"></div>
<div x-html="modalTemplates.loadingOverlay()"></div>
// Initialize
window.addEventListener('DOMContentLoaded', () => {
if (checkAuth()) {
loadDashboard();
}
<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script src="/static/js/shared/api-client.js"></script>
<script src="/static/js/shared/modal-templates.js"></script>
<script src="/static/js/shared/alpine-components.js"></script>
<script src="/static/js/shared/modal-system.js"></script>
<script src="/static/js/admin/admin-layout-templates.js"></script>
<script src="/static/js/admin/dashboard.js"></script>
<script>
// Initialize table scroll detection
document.addEventListener('alpine:init', () => {
// Wait for Alpine to finish rendering
setTimeout(() => {
const tables = document.querySelectorAll('.table-responsive');
tables.forEach(table => {
table.addEventListener('scroll', function() {
if (this.scrollLeft > 0) {
this.classList.add('is-scrolled');
} else {
this.classList.remove('is-scrolled');
}
});
});
}, 100);
});
</script>
</body>
</html>

View File

@@ -6,195 +6,90 @@
<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="login-container">
<div class="login-header">
<h1>🔐 Admin Portal</h1>
<p>Multi-Tenant Ecommerce Platform</p>
</div>
<div id="alertBox" class="alert"></div>
<form id="loginForm">
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
id="username"
name="username"
required
autocomplete="username"
placeholder="Enter your username"
>
<div class="error-message" id="usernameError"></div>
<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>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
required
autocomplete="current-password"
placeholder="Enter your password"
>
<div class="error-message" id="passwordError"></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>
<button type="submit" class="btn-login" id="loginButton">
Sign In
</button>
</form>
<div class="login-footer">
<a href="/">← Back to Platform</a>
</div>
</div>
<script src="/static/js/shared/api-client.js"></script>
<script>
// API Client Configuration
const API_BASE_URL = '/api/v1';
// DOM Elements
const loginForm = document.getElementById('loginForm');
const loginButton = document.getElementById('loginButton');
const alertBox = document.getElementById('alertBox');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const usernameError = document.getElementById('usernameError');
const passwordError = document.getElementById('passwordError');
// Show alert message
function showAlert(message, type = 'error') {
alertBox.textContent = message;
alertBox.className = `alert alert-${type} show`;
if (type === 'success') {
setTimeout(() => {
alertBox.classList.remove('show');
}, 3000);
}
}
// Show field error
function showFieldError(field, message) {
const input = field === 'username' ? usernameInput : passwordInput;
const errorDiv = field === 'username' ? usernameError : passwordError;
input.classList.add('error');
errorDiv.textContent = message;
errorDiv.classList.add('show');
}
// Clear field errors
function clearFieldErrors() {
usernameInput.classList.remove('error');
passwordInput.classList.remove('error');
usernameError.classList.remove('show');
passwordError.classList.remove('show');
alertBox.classList.remove('show');
}
// Set loading state
function setLoadingState(loading) {
loginButton.disabled = loading;
if (loading) {
loginButton.innerHTML = '<span class="loading-spinner"></span>Signing in...';
} else {
loginButton.innerHTML = 'Sign In';
}
}
// Handle login
async function handleLogin(event) {
event.preventDefault();
clearFieldErrors();
const username = usernameInput.value.trim();
const password = passwordInput.value;
// Basic validation
if (!username) {
showFieldError('username', 'Username is required');
return;
}
if (!password) {
showFieldError('password', 'Password is required');
return;
}
setLoadingState(true);
try {
const response = await fetch(`${API_BASE_URL}/admin/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Login failed');
}
// Check if user is admin
if (data.user.role !== 'admin') {
throw new Error('Access denied. Admin privileges required.');
}
// Store token
localStorage.setItem('admin_token', data.access_token);
localStorage.setItem('admin_user', JSON.stringify(data.user));
// Show success message
showAlert('Login successful! Redirecting...', 'success');
// Redirect to admin dashboard
setTimeout(() => {
window.location.href = '/static/admin/dashboard.html';
}, 1000);
} catch (error) {
console.error('Login error:', error);
showAlert(error.message || 'Login failed. Please try again.');
} finally {
setLoadingState(false);
}
}
// Event listeners
loginForm.addEventListener('submit', handleLogin);
// Clear errors on input
usernameInput.addEventListener('input', clearFieldErrors);
passwordInput.addEventListener('input', clearFieldErrors);
// Check if already logged in
window.addEventListener('DOMContentLoaded', () => {
const token = localStorage.getItem('admin_token');
const user = localStorage.getItem('admin_user');
if (token && user) {
try {
const userData = JSON.parse(user);
if (userData.role === 'admin') {
window.location.href = '/static/admin/dashboard.html';
}
} catch (e) {
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
}
}
});
</script>
<script src="/static/js/admin/login.js"></script>
</body>
</html>

View File

@@ -0,0 +1,498 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit Vendor - Admin Portal</title>
<link rel="stylesheet" href="/static/css/shared/base.css">
<link rel="stylesheet" href="/static/css/admin/admin.css">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body x-data="vendorEdit()" x-init="init()" x-cloak>
<!-- Header -->
<header class="admin-header">
<div class="header-left">
<h1>🔐 Admin Dashboard</h1>
</div>
<div class="header-right">
<span class="user-info">Welcome, <strong x-text="currentUser.username"></strong></span>
<button class="btn-logout" @click="handleLogout">Logout</button>
</div>
</header>
<!-- Main Container -->
<div class="admin-container">
<!-- Sidebar -->
<aside class="admin-sidebar">
<nav>
<ul class="nav-menu">
<li class="nav-item">
<a href="/static/admin/dashboard.html" class="nav-link">
📊 Dashboard
</a>
</li>
<li class="nav-item">
<a href="/static/admin/dashboard.html#vendors" class="nav-link active">
🏪 Vendors
</a>
</li>
<li class="nav-item">
<a href="/static/admin/dashboard.html#users" class="nav-link">
👥 Users
</a>
</li>
<li class="nav-item">
<a href="/static/admin/dashboard.html#imports" class="nav-link">
📦 Import Jobs
</a>
</li>
</ul>
</nav>
</aside>
<!-- Main Content -->
<main class="admin-content">
<!-- Loading State -->
<div x-show="loadingVendor" class="loading">
<span class="loading-spinner loading-spinner-lg"></span>
<p class="loading-text">Loading vendor details...</p>
</div>
<!-- Edit Form -->
<div x-show="!loadingVendor && vendor">
<div class="content-section">
<div class="section-header">
<h2 class="section-title">
Edit Vendor: <span x-text="vendor?.name"></span>
</h2>
<div>
<a href="/static/admin/dashboard.html#vendors" class="btn btn-secondary">
← Back to Vendor Management
</a>
</div>
</div>
<!-- Quick Actions -->
<div class="quick-actions mb-3">
<button
@click="showVerificationModal()"
class="btn"
:class="vendor?.is_verified ? 'btn-warning' : 'btn-success'"
:disabled="saving">
<span x-text="vendor?.is_verified ? '❌ Unverify Vendor' : '✅ Verify Vendor'"></span>
</button>
<button
@click="showStatusModal()"
class="btn"
:class="vendor?.is_active ? 'btn-danger' : 'btn-success'"
:disabled="saving">
<span x-text="vendor?.is_active ? '🔒 Deactivate Vendor' : '🔓 Activate Vendor'"></span>
</button>
</div>
<form @submit.prevent="handleSubmit">
<div class="form-grid">
<!-- Left Column -->
<div class="form-column">
<h3 class="form-section-title">Basic Information</h3>
<!-- Vendor Code (read-only) -->
<div class="form-group">
<label for="vendorCode">Vendor Code</label>
<input
type="text"
id="vendorCode"
name="vendor_code"
x-model="vendor.vendor_code"
disabled
class="form-control-disabled"
autocomplete="off"
>
<div class="form-help">Cannot be changed after creation</div>
</div>
<!-- Vendor Name -->
<div class="form-group">
<label for="name">
Vendor Name <span class="required">*</span>
</label>
<input
type="text"
id="name"
name="vendor_name"
x-model="formData.name"
:class="{ 'error': errors.name }"
required
maxlength="255"
:disabled="saving"
autocomplete="organization"
>
<div x-show="errors.name"
x-text="errors.name"
class="error-message show"></div>
</div>
<!-- Subdomain -->
<div class="form-group">
<label for="subdomain">
Subdomain <span class="required">*</span>
</label>
<input
type="text"
id="subdomain"
name="subdomain"
x-model="formData.subdomain"
@input="formatSubdomain"
:class="{ 'error': errors.subdomain }"
required
maxlength="100"
:disabled="saving"
autocomplete="off"
>
<div class="form-help">Lowercase letters, numbers, and hyphens only</div>
<div x-show="errors.subdomain"
x-text="errors.subdomain"
class="error-message show"></div>
</div>
<!-- Description -->
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
name="description"
x-model="formData.description"
rows="3"
:disabled="saving"
autocomplete="off"
></textarea>
</div>
</div>
<!-- Right Column -->
<div class="form-column">
<h3 class="form-section-title">Contact & Business Information</h3>
<!-- Owner Email (read-only with warning) -->
<div class="form-group">
<label for="ownerEmail">Owner Email (Login)</label>
<input
type="email"
id="ownerEmail"
name="owner_email"
x-model="vendor.owner_email"
disabled
class="form-control-disabled"
autocomplete="off"
>
<div class="form-help">
⚠️ Owner email cannot be changed here. Use "Transfer Ownership" below to change the owner.
</div>
</div>
<!-- Contact Email (editable) -->
<div class="form-group">
<label for="contactEmail">Business Contact Email</label>
<input
type="email"
id="contactEmail"
name="contact_email"
x-model="formData.contact_email"
:disabled="saving"
autocomplete="email"
>
<div class="form-help">
Public business contact email (can be different from owner email)
</div>
</div>
<!-- Contact Phone -->
<div class="form-group">
<label for="contactPhone">Contact Phone</label>
<input
type="tel"
id="contactPhone"
name="contact_phone"
x-model="formData.contact_phone"
:disabled="saving"
autocomplete="tel"
>
</div>
<!-- Website -->
<div class="form-group">
<label for="website">Website</label>
<input
type="url"
id="website"
name="website"
x-model="formData.website"
:disabled="saving"
autocomplete="url"
>
</div>
<!-- Business Address -->
<div class="form-group">
<label for="businessAddress">Business Address</label>
<textarea
id="businessAddress"
name="business_address"
x-model="formData.business_address"
rows="3"
:disabled="saving"
autocomplete="street-address"
></textarea>
</div>
<!-- Tax Number -->
<div class="form-group">
<label for="taxNumber">Tax Number</label>
<input
type="text"
id="taxNumber"
name="tax_number"
x-model="formData.tax_number"
:disabled="saving"
autocomplete="off"
>
</div>
</div>
</div>
<!-- Transfer Ownership Section -->
<div class="form-section-divider"></div>
<div class="form-group">
<h3 class="form-section-title">⚠️ Change Vendor Owner</h3>
<p class="text-muted mb-2">
To change the owner to a different user account, use the Transfer Ownership feature.
This will assign ownership to another user and demote the current owner to Manager.
</p>
<button
type="button"
@click="showTransferOwnership = true"
class="btn btn-warning"
:disabled="saving">
🔄 Transfer Ownership to Different User
</button>
</div>
<div class="form-actions">
<button type="button"
class="btn btn-secondary"
@click="window.location.href='/static/admin/dashboard.html#vendors'"
:disabled="saving">
Cancel
</button>
<button type="submit"
class="btn btn-primary"
:disabled="saving">
<span x-show="!saving">💾 Save Changes</span>
<span x-show="saving">
<span class="loading-spinner"></span>
Saving...
</span>
</button>
</div>
</form>
</div>
</div>
</main>
</div>
<!-- Confirmation Modal (for verify/status changes) -->
<div x-show="confirmModal.show"
class="modal-overlay"
@click.self="confirmModal.onCancel ? confirmModal.onCancel() : (confirmModal.show = false)"
x-transition>
<div class="modal-content modal-sm">
<div class="modal-header">
<h3 x-text="confirmModal.title"></h3>
<button @click="confirmModal.onCancel ? confirmModal.onCancel() : (confirmModal.show = false)" class="btn-close">×</button>
</div>
<div class="modal-body">
<p x-text="confirmModal.message"></p>
<div x-show="confirmModal.warning" class="alert alert-warning mt-3" style="white-space: pre-line;">
<strong>⚠️ Warning:</strong><br>
<span x-text="confirmModal.warning"></span>
</div>
</div>
<div class="modal-footer">
<button
@click="confirmModal.onCancel ? confirmModal.onCancel() : (confirmModal.show = false)"
class="btn btn-secondary"
:disabled="saving">
Cancel
</button>
<button
@click="confirmModal.onConfirm(); confirmModal.show = false"
class="btn"
:class="confirmModal.buttonClass"
:disabled="saving">
<span x-show="!saving" x-text="confirmModal.buttonText"></span>
<span x-show="saving">
<span class="loading-spinner"></span>
Processing...
</span>
</button>
</div>
</div>
</div>
<!-- Success Modal (for transfer ownership success) -->
<div x-show="successModal.show"
class="modal-overlay"
@click.self="successModal.show = false"
x-transition>
<div class="modal-content modal-md">
<div class="modal-header modal-header-success">
<h3><span x-text="successModal.title"></span></h3>
<button @click="successModal.show = false" class="btn-close">×</button>
</div>
<div class="modal-body">
<div class="success-icon-wrapper">
<div class="success-icon"></div>
</div>
<p class="text-center mb-4" x-text="successModal.message"></p>
<!-- Transfer Details -->
<div x-show="successModal.details" class="transfer-details">
<div class="detail-row">
<div class="detail-label">Previous Owner:</div>
<div class="detail-value">
<strong x-text="successModal.details?.oldOwner?.username"></strong>
<br>
<span class="text-muted" x-text="successModal.details?.oldOwner?.email"></span>
</div>
</div>
<div class="detail-arrow"></div>
<div class="detail-row">
<div class="detail-label">New Owner:</div>
<div class="detail-value">
<strong x-text="successModal.details?.newOwner?.username"></strong>
<br>
<span class="text-muted" x-text="successModal.details?.newOwner?.email"></span>
</div>
</div>
</div>
<div x-show="successModal.note" class="alert alert-info mt-3">
<strong> Note:</strong>
<span x-text="successModal.note"></span>
</div>
</div>
<div class="modal-footer">
<button
@click="successModal.show = false"
class="btn btn-primary btn-block">
Close
</button>
</div>
</div>
</div>
<!-- Confirmation Modal (for verify/status changes) -->
<!-- Transfer Ownership Modal -->
<div x-show="showTransferOwnership"
class="modal-overlay"
@click.self="showTransferOwnership = false"
x-transition>
<div class="modal-content">
<div class="modal-header">
<h3>🔄 Transfer Vendor Ownership</h3>
<button @click="showTransferOwnership = false" class="btn-close">×</button>
</div>
<div class="modal-body">
<div class="alert alert-warning mb-3">
<strong>⚠️ Warning:</strong> This will transfer complete ownership to another user.
The current owner will be demoted to Manager role.
</div>
<div class="info-box mb-3">
<strong>Current Owner:</strong><br>
<span x-text="vendor?.owner_username"></span> (<span x-text="vendor?.owner_email"></span>)
</div>
<div class="form-group">
<label for="newOwnerId">
New Owner User ID <span class="required">*</span>
</label>
<input
type="number"
id="newOwnerId"
name="new_owner_user_id"
x-model.number="transferData.new_owner_user_id"
required
placeholder="Enter user ID"
min="1"
autocomplete="off"
>
<div class="form-help">
Enter the ID of the user who will become the new owner
</div>
</div>
<div class="form-group">
<label for="transferReason">Reason for Transfer</label>
<textarea
id="transferReason"
name="transfer_reason"
x-model="transferData.transfer_reason"
rows="3"
placeholder="Optional: Why is ownership being transferred?"
autocomplete="off"
></textarea>
<div class="form-help">
This will be logged for audit purposes
</div>
</div>
<div class="form-group">
<label for="confirmTransfer" class="checkbox-label">
<input
type="checkbox"
id="confirmTransfer"
name="confirm_transfer"
x-model="transferData.confirm_transfer"
>
I confirm this ownership transfer
</label>
</div>
</div>
<div class="modal-footer">
<button
@click="showTransferOwnership = false"
class="btn btn-secondary"
:disabled="transferring">
Cancel
</button>
<button
@click="handleTransferOwnership()"
class="btn btn-danger"
:disabled="!transferData.confirm_transfer || !transferData.new_owner_user_id || transferring">
<span x-show="!transferring">🔄 Transfer Ownership</span>
<span x-show="transferring">
<span class="loading-spinner"></span>
Transferring...
</span>
</button>
</div>
</div>
</div>
<script src="/static/js/shared/api-client.js"></script>
<script src="/static/js/admin/vendor-edit.js"></script>
</body>
</html>

View File

@@ -6,342 +6,396 @@
<title>Create Vendor - Admin Portal</title>
<link rel="stylesheet" href="/static/css/shared/base.css">
<link rel="stylesheet" href="/static/css/admin/admin.css">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
<header class="header">
<h1>Create New Vendor</h1>
<a href="/static/admin/dashboard.html" class="btn-back">← Back to Dashboard</a>
<body x-data="vendorCreation()" x-init="init()" x-cloak>
<!-- Header -->
<header class="admin-header">
<div class="header-left">
<h1>🔐 Admin Dashboard</h1>
</div>
<div class="header-right">
<span class="user-info">Welcome, <strong x-text="currentUser.username"></strong></span>
<button class="btn-logout" @click="handleLogout">Logout</button>
</div>
</header>
<div class="container">
<div class="form-card">
<h2 class="form-title">Vendor Information</h2>
<!-- Main Container -->
<div class="admin-container">
<!-- Sidebar -->
<aside class="admin-sidebar">
<nav>
<ul class="nav-menu">
<li class="nav-item">
<a href="/static/admin/dashboard.html" class="nav-link">
📊 Dashboard
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link active">
🏪 Vendors
</a>
</li>
<li class="nav-item">
<a href="/static/admin/dashboard.html#users" class="nav-link">
👥 Users
</a>
</li>
<li class="nav-item">
<a href="/static/admin/dashboard.html#imports" class="nav-link">
📦 Import Jobs
</a>
</li>
</ul>
</nav>
</aside>
<div id="alertBox" class="alert"></div>
<!-- Main Content -->
<main class="admin-content">
<!-- Form View -->
<div x-show="!showCredentials">
<div class="content-section">
<div class="section-header">
<h2 class="section-title">Create New Vendor</h2>
<a href="/static/admin/dashboard.html" class="btn btn-secondary">
← Back to Dashboard
</a>
</div>
<form id="createVendorForm">
<!-- Vendor Code -->
<div class="form-group">
<label for="vendorCode">
Vendor Code <span class="required">*</span>
</label>
<input
type="text"
id="vendorCode"
name="vendor_code"
required
placeholder="e.g., TECHSTORE"
pattern="[A-Z0-9_-]+"
maxlength="50"
>
<div class="form-help">Uppercase letters, numbers, underscores, and hyphens only</div>
<div class="error-message" id="vendorCodeError"></div>
<form @submit.prevent="handleSubmit">
<div class="form-grid">
<!-- Left Column -->
<div class="form-column">
<h3 class="form-section-title">Basic Information</h3>
<!-- Vendor Code -->
<div class="form-group">
<label for="vendorCode">
Vendor Code <span class="required">*</span>
</label>
<input
type="text"
id="vendorCode"
x-model="formData.vendor_code"
@input="formatVendorCode"
:class="{ 'error': errors.vendor_code }"
required
placeholder="e.g., TECHSTORE"
maxlength="50"
:disabled="loading"
>
<div class="form-help">Uppercase letters, numbers, underscores, and hyphens only</div>
<div x-show="errors.vendor_code"
x-text="errors.vendor_code"
class="error-message show"></div>
</div>
<!-- Vendor Name -->
<div class="form-group">
<label for="name">
Vendor Name <span class="required">*</span>
</label>
<input
type="text"
id="name"
x-model="formData.name"
:class="{ 'error': errors.name }"
required
placeholder="e.g., Tech Store Luxembourg"
maxlength="255"
:disabled="loading"
>
<div class="form-help">Display name for the vendor</div>
<div x-show="errors.name"
x-text="errors.name"
class="error-message show"></div>
</div>
<!-- Subdomain -->
<div class="form-group">
<label for="subdomain">
Subdomain <span class="required">*</span>
</label>
<input
type="text"
id="subdomain"
x-model="formData.subdomain"
@input="formatSubdomain"
:class="{ 'error': errors.subdomain }"
required
placeholder="e.g., techstore"
maxlength="100"
:disabled="loading"
>
<div class="form-help">Lowercase letters, numbers, and hyphens only</div>
<div x-show="errors.subdomain"
x-text="errors.subdomain"
class="error-message show"></div>
</div>
<!-- Description -->
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
x-model="formData.description"
placeholder="Brief description of the vendor's business"
rows="3"
:disabled="loading"
></textarea>
<div class="form-help">Optional description of the vendor</div>
</div>
</div>
<!-- Right Column -->
<div class="form-column">
<h3 class="form-section-title">Contact & Business Information</h3>
<!-- Owner Email -->
<div class="form-group">
<label for="ownerEmail">
Owner Email <span class="required">*</span>
</label>
<input
type="email"
id="ownerEmail"
x-model="formData.owner_email"
:class="{ 'error': errors.owner_email }"
required
placeholder="owner@example.com"
:disabled="loading"
>
<div class="form-help">Login credentials will be sent to this email</div>
<div x-show="errors.owner_email"
x-text="errors.owner_email"
class="error-message show"></div>
</div>
<!-- Contact Phone -->
<div class="form-group">
<label for="contactPhone">Contact Phone</label>
<input
type="tel"
id="contactPhone"
x-model="formData.business_phone"
placeholder="+352 123 456 789"
:disabled="loading"
>
<div class="form-help">Optional contact phone number</div>
</div>
<!-- Website -->
<div class="form-group">
<label for="website">Website</label>
<input
type="url"
id="website"
x-model="formData.website"
placeholder="https://example.com"
:disabled="loading"
>
<div class="form-help">Optional website URL</div>
</div>
<!-- Business Address -->
<div class="form-group">
<label for="businessAddress">Business Address</label>
<textarea
id="businessAddress"
x-model="formData.business_address"
placeholder="Street, City, Country"
rows="3"
:disabled="loading"
></textarea>
</div>
<!-- Tax Number -->
<div class="form-group">
<label for="taxNumber">Tax Number</label>
<input
type="text"
id="taxNumber"
x-model="formData.tax_number"
placeholder="LU12345678"
:disabled="loading"
>
</div>
</div>
</div>
<div class="form-actions">
<button type="button"
class="btn btn-secondary"
@click="window.location.href='/static/admin/dashboard.html'"
:disabled="loading">
Cancel
</button>
<button type="submit"
class="btn btn-primary"
:disabled="loading">
<span x-show="!loading">Create Vendor</span>
<span x-show="loading">
<span class="loading-spinner"></span>
Creating vendor...
</span>
</button>
</div>
</form>
</div>
<!-- Vendor Name -->
<div class="form-group">
<label for="name">
Vendor Name <span class="required">*</span>
</label>
<input
type="text"
id="name"
name="name"
required
placeholder="e.g., Tech Store Luxembourg"
maxlength="255"
>
<div class="form-help">Display name for the vendor</div>
<div class="error-message" id="nameError"></div>
</div>
<!-- Subdomain -->
<div class="form-group">
<label for="subdomain">
Subdomain <span class="required">*</span>
</label>
<input
type="text"
id="subdomain"
name="subdomain"
required
placeholder="e.g., techstore"
pattern="[a-z0-9][a-z0-9-]*[a-z0-9]"
maxlength="100"
>
<div class="form-help">Lowercase letters, numbers, and hyphens only (e.g., techstore.platform.com)</div>
<div class="error-message" id="subdomainError"></div>
</div>
<!-- Description -->
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
name="description"
placeholder="Brief description of the vendor's business"
></textarea>
<div class="form-help">Optional description of the vendor</div>
</div>
<!-- Owner Email -->
<div class="form-group">
<label for="ownerEmail">
Owner Email <span class="required">*</span>
</label>
<input
type="email"
id="ownerEmail"
name="owner_email"
required
placeholder="owner@example.com"
>
<div class="form-help">Email for the vendor owner (login credentials will be sent here)</div>
<div class="error-message" id="ownerEmailError"></div>
</div>
<!-- Contact Phone -->
<div class="form-group">
<label for="contactPhone">Contact Phone</label>
<input
type="tel"
id="contactPhone"
name="contact_phone"
placeholder="+352 123 456 789"
>
<div class="form-help">Optional contact phone number</div>
</div>
<!-- Website -->
<div class="form-group">
<label for="website">Website</label>
<input
type="url"
id="website"
name="website"
placeholder="https://example.com"
>
<div class="form-help">Optional website URL</div>
</div>
<!-- Business Address -->
<div class="form-group">
<label for="businessAddress">Business Address</label>
<textarea
id="businessAddress"
name="business_address"
placeholder="Street, City, Country"
></textarea>
</div>
<!-- Tax Number -->
<div class="form-group">
<label for="taxNumber">Tax Number</label>
<input
type="text"
id="taxNumber"
name="tax_number"
placeholder="LU12345678"
>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="window.history.back()">
Cancel
</button>
<button type="submit" class="btn btn-primary" id="submitButton">
Create Vendor
</button>
</div>
</form>
</div>
<!-- Success credentials display -->
<div id="credentialsDisplay" style="display: none;">
<div class="credentials-card">
<h3>✅ Vendor Created Successfully!</h3>
<div class="credential-item">
<label>Vendor Code:</label>
<span class="value" id="displayVendorCode"></span>
<div x-show="showCredentials">
<div class="content-section">
<div class="credentials-success">
<div class="success-icon"></div>
<h2>Vendor Created Successfully!</h2>
<p class="success-message">
The vendor has been created and credentials have been generated.
Please save these credentials securely.
</p>
</div>
<div class="credential-item">
<label>Subdomain:</label>
<span class="value" id="displaySubdomain"></span>
<!-- Debug info (remove in production) -->
<div x-show="credentials" style="display: none;">
<pre x-text="JSON.stringify(credentials, null, 2)"></pre>
</div>
<div class="credential-item">
<label>Owner Username:</label>
<span class="value" id="displayUsername"></span>
<div class="credentials-grid">
<div class="credential-card">
<div class="credential-label">Vendor Code</div>
<div class="credential-value-group">
<span class="credential-value" x-text="credentials?.vendor_code || 'N/A'"></span>
<button type="button"
class="btn-icon"
@click="copyToClipboard(credentials?.vendor_code, 'Vendor code')"
title="Copy to clipboard"
:disabled="!credentials?.vendor_code">
📋
</button>
</div>
</div>
<div class="credential-card">
<div class="credential-label">Vendor Name</div>
<div class="credential-value-group">
<span class="credential-value" x-text="credentials?.name || 'N/A'"></span>
<button type="button"
class="btn-icon"
@click="copyToClipboard(credentials?.name, 'Vendor name')"
title="Copy to clipboard"
:disabled="!credentials?.name">
📋
</button>
</div>
</div>
<div class="credential-card">
<div class="credential-label">Subdomain</div>
<div class="credential-value-group">
<span class="credential-value" x-text="credentials?.subdomain || 'N/A'"></span>
<button type="button"
class="btn-icon"
@click="copyToClipboard(credentials?.subdomain, 'Subdomain')"
title="Copy to clipboard"
:disabled="!credentials?.subdomain">
📋
</button>
</div>
</div>
<div class="credential-card">
<div class="credential-label">Owner Email</div>
<div class="credential-value-group">
<span class="credential-value" x-text="credentials?.owner_email || 'N/A'"></span>
<button type="button"
class="btn-icon"
@click="copyToClipboard(credentials?.owner_email, 'Email')"
title="Copy to clipboard"
:disabled="!credentials?.owner_email">
📋
</button>
</div>
</div>
<div class="credential-card">
<div class="credential-label">Owner Username</div>
<div class="credential-value-group">
<span class="credential-value" x-text="credentials?.owner_username || 'N/A'"></span>
<button type="button"
class="btn-icon"
@click="copyToClipboard(credentials?.owner_username, 'Username')"
title="Copy to clipboard"
:disabled="!credentials?.owner_username">
📋
</button>
</div>
</div>
<div class="credential-card credential-card-highlight">
<div class="credential-label">
<span>⚠️ Temporary Password</span>
</div>
<div class="credential-value-group">
<template x-if="credentials?.temporary_password && credentials.temporary_password !== 'PASSWORD_NOT_RETURNED'">
<span class="credential-value" x-text="credentials.temporary_password"></span>
</template>
<template x-if="!credentials?.temporary_password || credentials.temporary_password === 'PASSWORD_NOT_RETURNED'">
<span class="credential-value text-danger">
⚠️ Password not returned - Check server logs
</span>
</template>
<button type="button"
class="btn-icon"
@click="copyToClipboard(credentials?.temporary_password, 'Password')"
title="Copy to clipboard"
:disabled="!credentials?.temporary_password || credentials.temporary_password === 'PASSWORD_NOT_RETURNED'">
📋
</button>
</div>
<div class="credential-warning">
This password will not be shown again. Make sure to save it securely.
</div>
</div>
<div class="credential-card credential-card-full">
<div class="credential-label">Login URL</div>
<div class="credential-value-group">
<span class="credential-value credential-value-url" x-text="credentials?.login_url || 'N/A'"></span>
<button type="button"
class="btn-icon"
@click="copyToClipboard(credentials?.login_url, 'Login URL')"
title="Copy to clipboard"
:disabled="!credentials?.login_url">
📋
</button>
</div>
</div>
</div>
<div class="credential-item">
<label>Owner Email:</label>
<span class="value" id="displayEmail"></span>
<!-- Warning if password not returned -->
<div x-show="!credentials?.temporary_password || credentials.temporary_password === 'PASSWORD_NOT_RETURNED'"
class="alert alert-warning mt-3">
<strong>⚠️ Warning:</strong> The temporary password was not returned by the server.
This might be a backend configuration issue. Please check the server logs or contact the system administrator.
</div>
<div class="credential-item">
<label>Temporary Password:</label>
<span class="value" id="displayPassword"></span>
</div>
<div class="credential-item">
<label>Login URL:</label>
<span class="value" id="displayLoginUrl"></span>
</div>
<p class="warning-text">
⚠️ Important: Save these credentials! The password will not be shown again.
</p>
<div class="form-actions" style="margin-top: 20px;">
<button class="btn btn-primary" onclick="window.location.href='/static/admin/dashboard.html'">
Go to Dashboard
<div class="form-actions" style="margin-top: 24px;">
<button class="btn btn-secondary"
@click="resetForm(); showCredentials = false">
Create Another Vendor
</button>
<button class="btn btn-secondary" onclick="location.reload()">
Create Another Vendor
<button class="btn btn-primary"
@click="window.location.href='/static/admin/dashboard.html'">
← Back to Dashboard
</button>
</div>
</div>
</div>
</div>
</main>
</div>
<script>
const API_BASE_URL = '/api/v1';
// Check authentication
function checkAuth() {
const token = localStorage.getItem('admin_token');
const user = localStorage.getItem('admin_user');
if (!token || !user) {
window.location.href = '/static/admin/login.html';
return false;
}
try {
const userData = JSON.parse(user);
if (userData.role !== 'admin') {
alert('Access denied. Admin privileges required.');
window.location.href = '/static/admin/login.html';
return false;
}
return true;
} catch (e) {
window.location.href = '/static/admin/login.html';
return false;
}
}
// Show alert
function showAlert(message, type = 'error') {
const alertBox = document.getElementById('alertBox');
alertBox.textContent = message;
alertBox.className = `alert alert-${type} show`;
// Scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' });
}
// Clear all errors
function clearErrors() {
document.querySelectorAll('.error-message').forEach(el => {
el.classList.remove('show');
});
document.querySelectorAll('input, textarea').forEach(el => {
el.classList.remove('error');
});
document.getElementById('alertBox').classList.remove('show');
}
// Show field error
function showFieldError(fieldName, message) {
const input = document.querySelector(`[name="${fieldName}"]`);
const errorDiv = document.getElementById(`${fieldName.replace('_', '')}Error`);
if (input) input.classList.add('error');
if (errorDiv) {
errorDiv.textContent = message;
errorDiv.classList.add('show');
}
}
// Auto-format inputs
document.getElementById('vendorCode').addEventListener('input', function(e) {
this.value = this.value.toUpperCase().replace(/[^A-Z0-9_-]/g, '');
});
document.getElementById('subdomain').addEventListener('input', function(e) {
this.value = this.value.toLowerCase().replace(/[^a-z0-9-]/g, '');
});
// Handle form submission
document.getElementById('createVendorForm').addEventListener('submit', async function(e) {
e.preventDefault();
clearErrors();
const submitButton = document.getElementById('submitButton');
submitButton.disabled = true;
submitButton.innerHTML = '<span class="loading-spinner"></span>Creating vendor...';
// Collect form data
const formData = {
vendor_code: document.getElementById('vendorCode').value.trim(),
name: document.getElementById('name').value.trim(),
subdomain: document.getElementById('subdomain').value.trim(),
description: document.getElementById('description').value.trim() || null,
owner_email: document.getElementById('ownerEmail').value.trim(),
contact_phone: document.getElementById('contactPhone').value.trim() || null,
website: document.getElementById('website').value.trim() || null,
business_address: document.getElementById('businessAddress').value.trim() || null,
tax_number: document.getElementById('taxNumber').value.trim() || null,
};
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_BASE_URL}/admin/vendors`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(formData)
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Failed to create vendor');
}
// Display success and credentials
document.getElementById('createVendorForm').style.display = 'none';
document.getElementById('credentialsDisplay').style.display = 'block';
document.getElementById('displayVendorCode').textContent = data.vendor_code;
document.getElementById('displaySubdomain').textContent = data.subdomain;
document.getElementById('displayUsername').textContent = data.owner_username;
document.getElementById('displayEmail').textContent = data.owner_email;
document.getElementById('displayPassword').textContent = data.temporary_password;
document.getElementById('displayLoginUrl').textContent = data.login_url || `${data.subdomain}.platform.com/vendor/login`;
showAlert('Vendor created successfully!', 'success');
} catch (error) {
console.error('Error creating vendor:', error);
showAlert(error.message || 'Failed to create vendor');
submitButton.disabled = false;
submitButton.innerHTML = 'Create Vendor';
}
});
// Initialize
window.addEventListener('DOMContentLoaded', () => {
checkAuth();
});
</script>
<script src="/static/js/shared/api-client.js"></script>
<script src="/static/js/admin/vendors.js"></script>
</body>
</html>

View File

@@ -347,73 +347,255 @@
}
/* Modal/Dialog */
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--bg-overlay);
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
.modal-content {
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
border-radius: 8px;
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
.modal-header {
padding: var(--spacing-lg);
border-bottom: 1px solid var(--border-color);
padding: 20px;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
font-size: var(--font-xl);
font-weight: 600;
.modal-header h3 {
margin: 0;
font-size: 1.25rem;
}
.modal-close {
.btn-close {
background: none;
border: none;
font-size: var(--font-2xl);
font-size: 28px;
line-height: 1;
cursor: pointer;
color: var(--text-muted);
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
transition: all var(--transition-base);
color: #6b7280;
}
.modal-close:hover {
background: var(--gray-100);
color: var(--text-primary);
.btn-close:hover {
color: #1f2937;
}
.modal-body {
padding: var(--spacing-lg);
padding: 20px;
}
.modal-footer {
padding: var(--spacing-lg);
border-top: 1px solid var(--border-color);
padding: 20px;
border-top: 1px solid #e5e7eb;
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
gap: 12px;
}
.info-box {
background-color: #eff6ff;
border: 1px solid #3b82f6;
border-radius: 6px;
padding: 12px;
color: #1e40af;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: auto;
margin: 0;
}
.form-section-divider {
border-top: 2px solid #e5e7eb;
margin: 32px 0;
}
/* Small modal variant for confirmations */
.modal-sm {
max-width: 500px;
}
/* Info box styling */
.info-box {
background: #e3f2fd;
border-left: 4px solid #2196f3;
padding: 12px 16px;
border-radius: 4px;
font-size: 14px;
}
/* Quick actions spacing */
.quick-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
/* Success Modal Styles */
.modal-header-success {
background: linear-gradient(135deg, #4caf50 0%, #45a049 100%);
color: white;
border-radius: 8px 8px 0 0;
}
.modal-header-success .btn-close {
color: white;
opacity: 0.9;
}
.modal-header-success .btn-close:hover {
opacity: 1;
}
.success-icon-wrapper {
display: flex;
justify-content: center;
margin: 20px 0;
}
.success-icon {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, #4caf50 0%, #45a049 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
font-weight: bold;
animation: successPulse 0.6s ease-out;
box-shadow: 0 4px 20px rgba(76, 175, 80, 0.3);
}
@keyframes successPulse {
0% {
transform: scale(0.5);
opacity: 0;
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.transfer-details {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
.detail-row {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: white;
border-radius: 6px;
margin-bottom: 12px;
}
.detail-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: #666;
letter-spacing: 0.5px;
}
.detail-value {
font-size: 15px;
}
.detail-value strong {
color: #333;
font-size: 16px;
}
.detail-arrow {
text-align: center;
font-size: 24px;
color: #4caf50;
font-weight: bold;
margin: 8px 0;
}
.modal-md {
max-width: 600px;
}
.btn-block {
width: 100%;
padding: 12px;
font-size: 16px;
font-weight: 600;
}
/* Alert variants in modals */
.modal-body .alert {
margin-top: 16px;
padding: 12px 16px;
border-radius: 6px;
border: none;
}
.modal-body .alert-info {
background: #e3f2fd;
color: #1976d2;
}
.modal-body .alert-info strong {
color: #0d47a1;
}
/* Text utilities */
.text-center {
text-align: center;
}
.text-muted {
color: #6c757d;
font-size: 14px;
}
.mb-3 {
margin-bottom: 1rem;
}
.mb-4 {
margin-bottom: 1.5rem;
}
.mt-3 {
margin-top: 1rem;
}
/* Pagination */
@@ -553,4 +735,160 @@
.content-section {
box-shadow: none;
border: 1px solid var(--border-color);
}
}
/* Value with copy button */
.value-with-copy {
display: flex;
gap: var(--spacing-sm);
align-items: center;
flex: 1;
}
.credential-item.highlight {
background: #fff3cd;
border: 2px solid var(--warning-color);
}
.credential-item.highlight .value {
background: white;
font-weight: 600;
color: var(--text-primary);
}
.btn-copy {
background: var(--gray-100);
border: 1px solid var(--border-color);
padding: 6px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-secondary);
font-size: var(--font-xs);
transition: all var(--transition-base);
white-space: nowrap;
}
.btn-copy:hover {
background: var(--primary-color);
border-color: var(--primary-color);
color: white;
}
/* Text color utilities */
.text-danger {
color: var(--danger-color) !important;
}
.text-warning {
color: var(--warning-color) !important;
}
.text-success {
color: var(--success-color) !important;
}
/* Disabled button styles */
.btn-icon:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-icon:disabled:hover {
background: white;
border-color: var(--border-color);
transform: none;
}
/* Alert improvements */
.alert {
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Form Section Styles */
.form-section-title {
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid #e5e7eb;
}
.form-control-disabled {
background-color: #f3f4f6;
cursor: not-allowed;
color: #6b7280;
}
.form-help {
font-size: 0.875rem;
color: #6b7280;
margin-top: 4px;
}
.text-muted {
color: #6b7280;
}
.mb-2 {
margin-bottom: 0.5rem;
}
.mb-3 {
margin-bottom: 1rem;
}
.mt-3 {
margin-top: 1rem;
}
/* Alert Styles */
.alert {
padding: 12px 16px;
border-radius: 6px;
margin-bottom: 16px;
}
.alert-warning {
background-color: #fef3c7;
border: 1px solid #f59e0b;
color: #92400e;
}
.alert-warning strong {
color: #78350f;
}
/* Quick Actions */
.quick-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.quick-actions .btn {
min-width: 180px;
}
/* Button Sizes */
.btn-sm {
padding: 6px 12px;
font-size: 0.875rem;
}
/* Required Field Indicator */
.required {
color: #ef4444;
font-weight: bold;
}

View File

@@ -1,6 +1,10 @@
/* static/css/shared/base.css */
/* Base styles shared across all pages */
/* Import responsive utilities */
@import url('responsive-utilities.css');
/* Rest of your base.css... */
:root {
/* Color Palette */
--primary-color: #667eea;

View File

@@ -0,0 +1,728 @@
/**
* Universal Components Styles
* Shared component styles for Admin, Vendor, and Shop sections
*/
/* =============================================================================
MODAL SYSTEM STYLES
============================================================================= */
/* Modal Backdrop */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
animation: fadeIn 0.2s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Modal Container */
.modal-container {
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Modal Header */
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-title {
font-size: 1.25rem;
font-weight: 600;
color: #111827;
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s;
}
.modal-close:hover {
background-color: #f3f4f6;
color: #111827;
}
/* Modal Body */
.modal-body {
padding: 24px;
}
.modal-message {
font-size: 0.95rem;
color: #374151;
line-height: 1.6;
margin-bottom: 12px;
}
.modal-warning {
background-color: #fef3c7;
border-left: 4px solid #f59e0b;
padding: 12px 16px;
border-radius: 4px;
margin-top: 16px;
}
.modal-warning p {
margin: 0;
color: #92400e;
font-size: 0.875rem;
}
.modal-details {
background-color: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 4px;
padding: 12px;
margin-top: 12px;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
color: #374151;
white-space: pre-wrap;
word-break: break-word;
}
/* Modal Footer */
.modal-footer {
padding: 16px 24px;
border-top: 1px solid #e5e7eb;
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* Modal Icon Styles */
.modal-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
font-size: 24px;
}
.modal-icon.success {
background-color: #d1fae5;
color: #059669;
}
.modal-icon.error {
background-color: #fee2e2;
color: #dc2626;
}
.modal-icon.warning {
background-color: #fef3c7;
color: #f59e0b;
}
/* Loading Overlay */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 4px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* =============================================================================
BUTTON STYLES
============================================================================= */
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 8px;
text-decoration: none;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background-color: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #2563eb;
}
.btn-secondary {
background-color: #6b7280;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background-color: #4b5563;
}
.btn-success {
background-color: #10b981;
color: white;
}
.btn-success:hover:not(:disabled) {
background-color: #059669;
}
.btn-danger {
background-color: #ef4444;
color: white;
}
.btn-danger:hover:not(:disabled) {
background-color: #dc2626;
}
.btn-warning {
background-color: #f59e0b;
color: white;
}
.btn-warning:hover:not(:disabled) {
background-color: #d97706;
}
.btn-outline {
background-color: transparent;
border: 1px solid #d1d5db;
color: #374151;
}
.btn-outline:hover:not(:disabled) {
background-color: #f9fafb;
border-color: #9ca3af;
}
.btn-ghost {
background-color: transparent;
color: #6b7280;
}
.btn-ghost:hover:not(:disabled) {
background-color: #f3f4f6;
}
.btn-sm {
padding: 6px 12px;
font-size: 0.875rem;
}
.btn-lg {
padding: 12px 24px;
font-size: 1.05rem;
}
/* =============================================================================
ADMIN LAYOUT STYLES
============================================================================= */
.admin-header {
background-color: #1f2937;
color: white;
padding: 0 24px;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
}
.menu-toggle {
background: none;
border: none;
color: white;
font-size: 1.5rem;
cursor: pointer;
padding: 8px;
display: none;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.user-name {
font-size: 0.95rem;
color: #e5e7eb;
}
.btn-logout {
background-color: #374151;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
transition: background-color 0.2s;
}
.btn-logout:hover {
background-color: #4b5563;
}
.admin-sidebar {
width: 260px;
background-color: #f9fafb;
border-right: 1px solid #e5e7eb;
height: calc(100vh - 64px);
position: fixed;
left: 0;
top: 64px;
overflow-y: auto;
transition: transform 0.3s ease;
}
.sidebar-nav {
padding: 16px 0;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 24px;
color: #374151;
text-decoration: none;
transition: all 0.2s;
font-size: 0.95rem;
}
.nav-item:hover {
background-color: #e5e7eb;
color: #111827;
}
.nav-item.active {
background-color: #3b82f6;
color: white;
border-left: 4px solid #2563eb;
}
.nav-item i {
width: 20px;
text-align: center;
}
.admin-content {
margin-left: 260px;
padding: 24px;
min-height: calc(100vh - 64px);
}
/* =============================================================================
VENDOR LAYOUT STYLES
============================================================================= */
.vendor-header {
background-color: #059669;
color: white;
padding: 0 24px;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.vendor-header .btn-logout {
background-color: #047857;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
transition: background-color 0.2s;
}
.vendor-header .btn-logout:hover {
background-color: #065f46;
}
.vendor-header .menu-toggle {
background: none;
border: none;
color: white;
font-size: 1.5rem;
cursor: pointer;
padding: 8px;
display: none;
}
.vendor-sidebar {
width: 260px;
background-color: #f9fafb;
border-right: 1px solid #e5e7eb;
height: calc(100vh - 64px);
position: fixed;
left: 0;
top: 64px;
overflow-y: auto;
transition: transform 0.3s ease;
}
.vendor-content {
margin-left: 260px;
padding: 24px;
min-height: calc(100vh - 64px);
}
/* =============================================================================
SHOP LAYOUT STYLES
============================================================================= */
.shop-header {
background-color: white;
border-bottom: 1px solid #e5e7eb;
padding: 16px 24px;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.shop-header-top {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1280px;
margin: 0 auto;
}
.shop-logo {
font-size: 1.5rem;
font-weight: 700;
color: #111827;
text-decoration: none;
}
.shop-search {
flex: 1;
max-width: 500px;
margin: 0 32px;
}
.search-form {
display: flex;
gap: 8px;
}
.search-input {
flex: 1;
padding: 10px 16px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.95rem;
}
.search-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.shop-actions {
display: flex;
align-items: center;
gap: 16px;
}
.cart-button {
position: relative;
background: none;
border: none;
cursor: pointer;
padding: 8px;
font-size: 1.5rem;
color: #374151;
}
.cart-button:hover {
color: #111827;
}
.cart-count {
position: absolute;
top: 0;
right: 0;
background-color: #ef4444;
color: white;
font-size: 0.75rem;
font-weight: 600;
padding: 2px 6px;
border-radius: 10px;
min-width: 20px;
text-align: center;
}
.shop-nav {
display: flex;
gap: 24px;
max-width: 1280px;
margin: 16px auto 0;
padding-top: 16px;
border-top: 1px solid #e5e7eb;
}
.shop-nav-item {
color: #6b7280;
text-decoration: none;
font-size: 0.95rem;
padding: 8px 0;
transition: color 0.2s;
}
.shop-nav-item:hover {
color: #111827;
}
.shop-nav-item.active {
color: #3b82f6;
font-weight: 500;
border-bottom: 2px solid #3b82f6;
}
.shop-content {
max-width: 1280px;
margin: 0 auto;
padding: 32px 24px;
}
/* =============================================================================
SHOP ACCOUNT LAYOUT STYLES
============================================================================= */
.account-layout {
display: grid;
grid-template-columns: 260px 1fr;
gap: 32px;
max-width: 1280px;
margin: 0 auto;
padding: 32px 24px;
}
.account-sidebar {
background-color: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
height: fit-content;
}
.account-nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
color: #374151;
text-decoration: none;
border-radius: 6px;
transition: all 0.2s;
font-size: 0.95rem;
}
.account-nav-item:hover {
background-color: #e5e7eb;
}
.account-nav-item.active {
background-color: #3b82f6;
color: white;
}
.account-content {
background-color: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 32px;
}
/* =============================================================================
RESPONSIVE STYLES
============================================================================= */
@media (max-width: 768px) {
.menu-toggle {
display: block;
}
.vendor-header .menu-toggle {
display: block;
}
.admin-sidebar,
.vendor-sidebar {
transform: translateX(-100%);
}
.admin-sidebar.open,
.vendor-sidebar.open {
transform: translateX(0);
}
.admin-content,
.vendor-content {
margin-left: 0;
}
.shop-search {
display: none;
}
.account-layout {
grid-template-columns: 1fr;
}
.account-sidebar {
display: none;
}
.modal-container {
width: 95%;
margin: 16px;
}
.modal-footer {
flex-direction: column;
}
.modal-footer .btn {
width: 100%;
}
}
/* =============================================================================
UTILITY CLASSES
============================================================================= */
.text-center {
text-align: center;
}
.mb-0 { margin-bottom: 0; }
.mb-1 { margin-bottom: 8px; }
.mb-2 { margin-bottom: 16px; }
.mb-3 { margin-bottom: 24px; }
.mb-4 { margin-bottom: 32px; }
.mt-0 { margin-top: 0; }
.mt-1 { margin-top: 8px; }
.mt-2 { margin-top: 16px; }
.mt-3 { margin-top: 24px; }
.mt-4 { margin-top: 32px; }
.hidden {
display: none !important;
}
.pointer {
cursor: pointer;
}

View File

@@ -0,0 +1,428 @@
/**
* Enhanced Modal Styles
* Additional styling and animations for modal system
*/
/* =============================================================================
MODAL ANIMATIONS
============================================================================= */
/* Fade In Animation */
@keyframes modalFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Slide Down Animation */
@keyframes modalSlideDown {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Slide Up Animation */
@keyframes modalSlideUp {
from {
transform: translateY(50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Scale Animation */
@keyframes modalScale {
from {
transform: scale(0.9);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
/* Shake Animation for errors */
@keyframes modalShake {
0%, 100% {
transform: translateX(0);
}
10%, 30%, 50%, 70%, 90% {
transform: translateX(-10px);
}
20%, 40%, 60%, 80% {
transform: translateX(10px);
}
}
/* =============================================================================
MODAL VARIANTS
============================================================================= */
/* Modal with slide down animation */
.modal-container.slide-down {
animation: modalSlideDown 0.3s ease-out;
}
/* Modal with scale animation */
.modal-container.scale {
animation: modalScale 0.3s ease-out;
}
/* Modal with shake animation (for errors) */
.modal-container.shake {
animation: modalShake 0.5s ease-in-out;
}
/* =============================================================================
MODAL SIZES
============================================================================= */
.modal-container.modal-sm {
max-width: 400px;
}
.modal-container.modal-md {
max-width: 500px;
}
.modal-container.modal-lg {
max-width: 700px;
}
.modal-container.modal-xl {
max-width: 900px;
}
.modal-container.modal-full {
max-width: 95%;
max-height: 95vh;
}
/* =============================================================================
MODAL THEMES
============================================================================= */
/* Success Modal Theme */
.modal-container.modal-success .modal-header {
background-color: #d1fae5;
border-bottom-color: #a7f3d0;
}
.modal-container.modal-success .modal-title {
color: #065f46;
}
/* Error Modal Theme */
.modal-container.modal-error .modal-header {
background-color: #fee2e2;
border-bottom-color: #fecaca;
}
.modal-container.modal-error .modal-title {
color: #991b1b;
}
/* Warning Modal Theme */
.modal-container.modal-warning .modal-header {
background-color: #fef3c7;
border-bottom-color: #fde68a;
}
.modal-container.modal-warning .modal-title {
color: #92400e;
}
/* Info Modal Theme */
.modal-container.modal-info .modal-header {
background-color: #dbeafe;
border-bottom-color: #bfdbfe;
}
.modal-container.modal-info .modal-title {
color: #1e40af;
}
/* =============================================================================
MODAL CONTENT STYLES
============================================================================= */
/* Modal List */
.modal-list {
list-style: none;
padding: 0;
margin: 16px 0;
}
.modal-list-item {
padding: 12px;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: center;
gap: 12px;
}
.modal-list-item:last-child {
border-bottom: none;
}
.modal-list-item i {
width: 20px;
text-align: center;
color: #6b7280;
}
/* Modal Form Elements */
.modal-form-group {
margin-bottom: 16px;
}
.modal-label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #374151;
font-size: 0.95rem;
}
.modal-input,
.modal-textarea,
.modal-select {
width: 100%;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.95rem;
transition: all 0.2s;
}
.modal-input:focus,
.modal-textarea:focus,
.modal-select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.modal-textarea {
min-height: 100px;
resize: vertical;
}
.modal-input.error,
.modal-textarea.error,
.modal-select.error {
border-color: #ef4444;
}
.modal-error-text {
color: #ef4444;
font-size: 0.875rem;
margin-top: 4px;
}
.modal-help-text {
color: #6b7280;
font-size: 0.875rem;
margin-top: 4px;
}
/* =============================================================================
MODAL ALERTS
============================================================================= */
.modal-alert {
padding: 12px 16px;
border-radius: 6px;
margin-bottom: 16px;
display: flex;
align-items: start;
gap: 12px;
}
.modal-alert i {
margin-top: 2px;
}
.modal-alert.alert-success {
background-color: #d1fae5;
color: #065f46;
border: 1px solid #a7f3d0;
}
.modal-alert.alert-error {
background-color: #fee2e2;
color: #991b1b;
border: 1px solid #fecaca;
}
.modal-alert.alert-warning {
background-color: #fef3c7;
color: #92400e;
border: 1px solid #fde68a;
}
.modal-alert.alert-info {
background-color: #dbeafe;
color: #1e40af;
border: 1px solid #bfdbfe;
}
/* =============================================================================
MODAL PROGRESS
============================================================================= */
.modal-progress {
width: 100%;
height: 8px;
background-color: #e5e7eb;
border-radius: 4px;
overflow: hidden;
margin: 16px 0;
}
.modal-progress-bar {
height: 100%;
background-color: #3b82f6;
transition: width 0.3s ease;
}
.modal-progress-bar.success {
background-color: #10b981;
}
.modal-progress-bar.error {
background-color: #ef4444;
}
.modal-progress-bar.warning {
background-color: #f59e0b;
}
/* =============================================================================
LOADING SPINNER VARIANTS
============================================================================= */
.loading-spinner.spinner-sm {
width: 32px;
height: 32px;
border-width: 3px;
}
.loading-spinner.spinner-lg {
width: 64px;
height: 64px;
border-width: 5px;
}
.loading-spinner.spinner-success {
border-top-color: #10b981;
}
.loading-spinner.spinner-error {
border-top-color: #ef4444;
}
.loading-spinner.spinner-warning {
border-top-color: #f59e0b;
}
/* =============================================================================
MODAL BACKDROP VARIANTS
============================================================================= */
.modal-backdrop.backdrop-dark {
background-color: rgba(0, 0, 0, 0.7);
}
.modal-backdrop.backdrop-light {
background-color: rgba(0, 0, 0, 0.3);
}
.modal-backdrop.backdrop-blur {
backdrop-filter: blur(4px);
}
/* =============================================================================
RESPONSIVE ENHANCEMENTS
============================================================================= */
@media (max-width: 640px) {
.modal-container {
width: 100%;
max-width: 100%;
max-height: 100vh;
border-radius: 0;
margin: 0;
}
.modal-header {
padding: 16px 20px;
}
.modal-body {
padding: 20px;
}
.modal-footer {
padding: 12px 20px;
flex-direction: column-reverse;
}
.modal-footer .btn {
width: 100%;
}
.modal-alert {
flex-direction: column;
}
}
/* =============================================================================
ACCESSIBILITY
============================================================================= */
.modal-backdrop:focus {
outline: none;
}
.modal-container:focus {
outline: 2px solid #3b82f6;
outline-offset: -2px;
}
/* Reduce motion for users who prefer it */
@media (prefers-reduced-motion: reduce) {
.modal-backdrop,
.modal-container,
.loading-spinner {
animation: none !important;
transition: none !important;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.modal-container {
border: 2px solid currentColor;
}
.modal-header {
border-bottom-width: 2px;
}
.modal-footer {
border-top-width: 2px;
}
}

View File

@@ -0,0 +1,370 @@
/* static/css/shared/responsive-utilities.css */
/* Responsive utility classes - Framework-like responsiveness without the framework */
/* ================================
BREAKPOINTS (Mobile-first)
================================ */
:root {
--breakpoint-sm: 640px;
--breakpoint-md: 768px;
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
--breakpoint-2xl: 1536px;
}
/* ================================
CONTAINER
================================ */
.container {
width: 100%;
margin-left: auto;
margin-right: auto;
padding-left: var(--spacing-md);
padding-right: var(--spacing-md);
}
@media (min-width: 640px) {
.container { max-width: 640px; }
}
@media (min-width: 768px) {
.container { max-width: 768px; }
}
@media (min-width: 1024px) {
.container { max-width: 1024px; }
}
@media (min-width: 1280px) {
.container { max-width: 1280px; }
}
/* ================================
DISPLAY UTILITIES
================================ */
.hidden { display: none !important; }
.block { display: block !important; }
.inline-block { display: inline-block !important; }
.inline { display: inline !important; }
.flex { display: flex !important; }
.inline-flex { display: inline-flex !important; }
.grid { display: grid !important; }
/* Responsive Display */
@media (max-width: 639px) {
.sm\:hidden { display: none !important; }
.sm\:block { display: block !important; }
.sm\:flex { display: flex !important; }
.sm\:grid { display: grid !important; }
}
@media (min-width: 640px) and (max-width: 767px) {
.md\:hidden { display: none !important; }
.md\:block { display: block !important; }
.md\:flex { display: flex !important; }
.md\:grid { display: grid !important; }
}
@media (min-width: 768px) and (max-width: 1023px) {
.lg\:hidden { display: none !important; }
.lg\:block { display: block !important; }
.lg\:flex { display: flex !important; }
.lg\:grid { display: grid !important; }
}
@media (min-width: 1024px) {
.xl\:hidden { display: none !important; }
.xl\:block { display: block !important; }
.xl\:flex { display: flex !important; }
.xl\:grid { display: grid !important; }
}
/* ================================
FLEXBOX UTILITIES
================================ */
.flex-row { flex-direction: row; }
.flex-col { flex-direction: column; }
.flex-row-reverse { flex-direction: row-reverse; }
.flex-col-reverse { flex-direction: column-reverse; }
.flex-wrap { flex-wrap: wrap; }
.flex-nowrap { flex-wrap: nowrap; }
.items-start { align-items: flex-start; }
.items-center { align-items: center; }
.items-end { align-items: flex-end; }
.items-stretch { align-items: stretch; }
.justify-start { justify-content: flex-start; }
.justify-center { justify-content: center; }
.justify-end { justify-content: flex-end; }
.justify-between { justify-content: space-between; }
.justify-around { justify-content: space-around; }
.justify-evenly { justify-content: space-evenly; }
.flex-1 { flex: 1 1 0%; }
.flex-auto { flex: 1 1 auto; }
.flex-none { flex: none; }
.gap-0 { gap: 0; }
.gap-1 { gap: var(--spacing-xs); }
.gap-2 { gap: var(--spacing-sm); }
.gap-3 { gap: var(--spacing-md); }
.gap-4 { gap: var(--spacing-lg); }
.gap-5 { gap: var(--spacing-xl); }
.gap-6 { gap: var(--spacing-2xl); }
/* ================================
GRID UTILITIES
================================ */
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.grid-cols-12 { grid-template-columns: repeat(12, minmax(0, 1fr)); }
/* Responsive Grid */
@media (min-width: 640px) {
.sm\:grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.sm\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.sm\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (min-width: 768px) {
.md\:grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.md\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (min-width: 1024px) {
.lg\:grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.lg\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.lg\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.lg\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.lg\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
}
/* ================================
SPACING UTILITIES
================================ */
/* Margin */
.m-0 { margin: 0; }
.m-1 { margin: var(--spacing-xs); }
.m-2 { margin: var(--spacing-sm); }
.m-3 { margin: var(--spacing-md); }
.m-4 { margin: var(--spacing-lg); }
.m-5 { margin: var(--spacing-xl); }
.m-auto { margin: auto; }
.mx-auto { margin-left: auto; margin-right: auto; }
.my-auto { margin-top: auto; margin-bottom: auto; }
.mt-0 { margin-top: 0; }
.mt-1 { margin-top: var(--spacing-xs); }
.mt-2 { margin-top: var(--spacing-sm); }
.mt-3 { margin-top: var(--spacing-md); }
.mt-4 { margin-top: var(--spacing-lg); }
.mt-5 { margin-top: var(--spacing-xl); }
.mb-0 { margin-bottom: 0; }
.mb-1 { margin-bottom: var(--spacing-xs); }
.mb-2 { margin-bottom: var(--spacing-sm); }
.mb-3 { margin-bottom: var(--spacing-md); }
.mb-4 { margin-bottom: var(--spacing-lg); }
.mb-5 { margin-bottom: var(--spacing-xl); }
.ml-0 { margin-left: 0; }
.ml-1 { margin-left: var(--spacing-xs); }
.ml-2 { margin-left: var(--spacing-sm); }
.ml-3 { margin-left: var(--spacing-md); }
.ml-4 { margin-left: var(--spacing-lg); }
.ml-auto { margin-left: auto; }
.mr-0 { margin-right: 0; }
.mr-1 { margin-right: var(--spacing-xs); }
.mr-2 { margin-right: var(--spacing-sm); }
.mr-3 { margin-right: var(--spacing-md); }
.mr-4 { margin-right: var(--spacing-lg); }
.mr-auto { margin-right: auto; }
/* Padding */
.p-0 { padding: 0; }
.p-1 { padding: var(--spacing-xs); }
.p-2 { padding: var(--spacing-sm); }
.p-3 { padding: var(--spacing-md); }
.p-4 { padding: var(--spacing-lg); }
.p-5 { padding: var(--spacing-xl); }
.px-0 { padding-left: 0; padding-right: 0; }
.px-1 { padding-left: var(--spacing-xs); padding-right: var(--spacing-xs); }
.px-2 { padding-left: var(--spacing-sm); padding-right: var(--spacing-sm); }
.px-3 { padding-left: var(--spacing-md); padding-right: var(--spacing-md); }
.px-4 { padding-left: var(--spacing-lg); padding-right: var(--spacing-lg); }
.py-0 { padding-top: 0; padding-bottom: 0; }
.py-1 { padding-top: var(--spacing-xs); padding-bottom: var(--spacing-xs); }
.py-2 { padding-top: var(--spacing-sm); padding-bottom: var(--spacing-sm); }
.py-3 { padding-top: var(--spacing-md); padding-bottom: var(--spacing-md); }
.py-4 { padding-top: var(--spacing-lg); padding-bottom: var(--spacing-lg); }
/* ================================
WIDTH & HEIGHT UTILITIES
================================ */
.w-full { width: 100%; }
.w-auto { width: auto; }
.w-1\/2 { width: 50%; }
.w-1\/3 { width: 33.333333%; }
.w-2\/3 { width: 66.666667%; }
.w-1\/4 { width: 25%; }
.w-3\/4 { width: 75%; }
.h-full { height: 100%; }
.h-auto { height: auto; }
.h-screen { height: 100vh; }
.min-h-screen { min-height: 100vh; }
.max-w-full { max-width: 100%; }
/* ================================
TEXT UTILITIES
================================ */
.text-left { text-align: left; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.text-xs { font-size: var(--font-xs); }
.text-sm { font-size: var(--font-sm); }
.text-base { font-size: var(--font-base); }
.text-lg { font-size: var(--font-lg); }
.text-xl { font-size: var(--font-xl); }
.text-2xl { font-size: var(--font-2xl); }
.text-3xl { font-size: var(--font-3xl); }
.font-normal { font-weight: 400; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.uppercase { text-transform: uppercase; }
.lowercase { text-transform: lowercase; }
.capitalize { text-transform: capitalize; }
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ================================
POSITION UTILITIES
================================ */
.relative { position: relative; }
.absolute { position: absolute; }
.fixed { position: fixed; }
.sticky { position: sticky; }
.top-0 { top: 0; }
.right-0 { right: 0; }
.bottom-0 { bottom: 0; }
.left-0 { left: 0; }
.z-0 { z-index: 0; }
.z-10 { z-index: 10; }
.z-20 { z-index: 20; }
.z-30 { z-index: 30; }
.z-40 { z-index: 40; }
.z-50 { z-index: 50; }
/* ================================
OVERFLOW UTILITIES
================================ */
.overflow-auto { overflow: auto; }
.overflow-hidden { overflow: hidden; }
.overflow-visible { overflow: visible; }
.overflow-scroll { overflow: scroll; }
.overflow-x-auto { overflow-x: auto; }
.overflow-y-auto { overflow-y: auto; }
/* ================================
BORDER UTILITIES
================================ */
.rounded-none { border-radius: 0; }
.rounded-sm { border-radius: var(--radius-sm); }
.rounded { border-radius: var(--radius-md); }
.rounded-lg { border-radius: var(--radius-lg); }
.rounded-xl { border-radius: var(--radius-xl); }
.rounded-full { border-radius: var(--radius-full); }
.border { border: 1px solid var(--border-color); }
.border-0 { border: 0; }
.border-2 { border: 2px solid var(--border-color); }
/* ================================
SHADOW UTILITIES
================================ */
.shadow-none { box-shadow: none; }
.shadow-sm { box-shadow: var(--shadow-sm); }
.shadow { box-shadow: var(--shadow-md); }
.shadow-lg { box-shadow: var(--shadow-lg); }
.shadow-xl { box-shadow: var(--shadow-xl); }
/* ================================
RESPONSIVE HELPERS
================================ */
/* Hide on mobile, show on desktop */
.mobile-hidden {
display: none;
}
@media (min-width: 768px) {
.mobile-hidden {
display: block;
}
}
/* Show on mobile, hide on desktop */
.desktop-hidden {
display: block;
}
@media (min-width: 768px) {
.desktop-hidden {
display: none;
}
}
/* Tablet specific */
@media (min-width: 640px) and (max-width: 1023px) {
.tablet-only {
display: block;
}
}
/* ================================
CURSOR & POINTER EVENTS
================================ */
.cursor-pointer { cursor: pointer; }
.cursor-default { cursor: default; }
.cursor-not-allowed { cursor: not-allowed; }
.pointer-events-none { pointer-events: none; }
/* ================================
OPACITY UTILITIES
================================ */
.opacity-0 { opacity: 0; }
.opacity-25 { opacity: 0.25; }
.opacity-50 { opacity: 0.5; }
.opacity-75 { opacity: 0.75; }
.opacity-100 { opacity: 1; }
/* ================================
TRANSITION UTILITIES
================================ */
.transition { transition: all var(--transition-base); }
.transition-fast { transition: all var(--transition-fast); }
.transition-slow { transition: all var(--transition-slow); }
.transition-none { transition: none; }

View File

@@ -0,0 +1,67 @@
/**
* Admin Layout Templates
* Header and Sidebar specific to Admin Portal
*/
window.adminLayoutTemplates = {
/**
* Admin Header
*/
header: () => `
<header class="admin-header">
<div class="header-left">
<button @click="toggleMenu()" class="menu-toggle">
<i class="fas fa-bars"></i>
</button>
<h1 class="header-title">Admin Portal</h1>
</div>
<div class="header-right">
<span class="user-name" x-text="user?.username || 'Admin'"></span>
<button @click="confirmLogout()" class="btn-logout">
<i class="fas fa-sign-out-alt"></i> Logout
</button>
</div>
</header>
`,
/**
* Admin Sidebar
*/
sidebar: () => `
<aside class="admin-sidebar" :class="{ 'open': menuOpen }">
<nav class="sidebar-nav">
<a href="/admin/dashboard.html"
class="nav-item"
:class="{ 'active': isActive('dashboard') }">
<i class="fas fa-tachometer-alt"></i>
<span>Dashboard</span>
</a>
<a href="/admin/vendors.html"
class="nav-item"
:class="{ 'active': isActive('vendors') }">
<i class="fas fa-store"></i>
<span>Vendors</span>
</a>
<a href="/admin/users.html"
class="nav-item"
:class="{ 'active': isActive('users') }">
<i class="fas fa-users"></i>
<span>Users</span>
</a>
<a href="/admin/marketplace.html"
class="nav-item"
:class="{ 'active': isActive('marketplace') }">
<i class="fas fa-shopping-cart"></i>
<span>Marketplace</span>
</a>
<a href="/admin/monitoring.html"
class="nav-item"
:class="{ 'active': isActive('monitoring') }">
<i class="fas fa-chart-line"></i>
<span>Monitoring</span>
</a>
</nav>
</aside>
`
};

View File

@@ -1 +1,203 @@
// Admin dashboard
/**
* Admin Dashboard Component
* Extends adminLayout with dashboard-specific functionality
*/
function adminDashboard() {
return {
// Inherit all adminLayout functionality
...window.adminLayout(),
// Dashboard-specific state
currentSection: 'dashboard',
stats: {
vendors: {},
users: {},
imports: {}
},
vendors: [],
users: [],
imports: [],
recentVendors: [],
recentImports: [],
loading: false,
/**
* Initialize dashboard
*/
async init() {
// Call parent init from adminLayout
this.currentPage = this.getCurrentPage();
await this.loadUserData();
// Load dashboard data
await this.loadDashboardData();
},
/**
* Load all dashboard data
*/
async loadDashboardData() {
this.loading = true;
try {
await Promise.all([
this.loadStats(),
this.loadRecentVendors(),
this.loadRecentImports()
]);
} catch (error) {
console.error('Error loading dashboard data:', error);
this.showErrorModal({
message: 'Failed to load dashboard data',
details: error.message
});
} finally {
this.loading = false;
}
},
/**
* Load statistics
*/
async loadStats() {
try {
const response = await apiClient.get('/admin/stats');
this.stats = response;
} catch (error) {
console.error('Failed to load stats:', error);
// Don't show error modal for stats, just log it
}
},
/**
* Load recent vendors
*/
async loadRecentVendors() {
try {
const response = await apiClient.get('/admin/vendors', {
skip: 0,
limit: 5
});
this.recentVendors = response.vendors || response;
} 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;
}
},
/**
* 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;
}
}
};
}

87
static/js/admin/login.js Normal file
View File

@@ -0,0 +1,87 @@
// Admin Login Component
function adminLogin() {
return {
credentials: {
username: '',
password: ''
},
loading: false,
error: null,
success: null,
errors: {},
init() {
// Check if already logged in
this.checkExistingAuth();
},
checkExistingAuth() {
if (Auth.isAuthenticated() && Auth.isAdmin()) {
window.location.href = '/static/admin/dashboard.html';
}
},
clearErrors() {
this.error = null;
this.errors = {};
},
validateForm() {
this.clearErrors();
let isValid = true;
if (!this.credentials.username.trim()) {
this.errors.username = 'Username is required';
isValid = false;
}
if (!this.credentials.password) {
this.errors.password = 'Password is required';
isValid = false;
}
return isValid;
},
async handleLogin() {
if (!this.validateForm()) {
return;
}
this.loading = true;
this.clearErrors();
try {
const response = await apiClient.post('/admin/auth/login', {
username: this.credentials.username.trim(),
password: this.credentials.password
});
// Check if user is admin
if (response.user.role !== 'admin') {
throw new Error('Access denied. Admin privileges required.');
}
// Store authentication data
localStorage.setItem('admin_token', response.access_token);
localStorage.setItem('admin_user', JSON.stringify(response.user));
// Show success message
this.success = 'Login successful! Redirecting...';
Utils.showToast('Login successful!', 'success', 2000);
// Redirect after short delay
setTimeout(() => {
window.location.href = '/static/admin/dashboard.html';
}, 1000);
} catch (error) {
console.error('Login error:', error);
this.error = error.message || 'Login failed. Please check your credentials.';
Utils.showToast(this.error, 'error');
} finally {
this.loading = false;
}
}
}
}

View File

@@ -0,0 +1,338 @@
// static/js/admin/vendor-edit.js
function vendorEdit() {
return {
currentUser: {},
vendor: {},
formData: {},
errors: {},
loadingVendor: true,
saving: false,
vendorId: null,
// Confirmation modal
confirmModal: {
show: false,
title: '',
message: '',
warning: '',
buttonText: '',
buttonClass: 'btn-primary',
onConfirm: () => {},
onCancel: null
},
// Success modal
successModal: {
show: false,
title: '',
message: '',
details: null,
note: ''
},
// Transfer ownership
showTransferOwnership: false,
transferring: false,
transferData: {
new_owner_user_id: null,
transfer_reason: '',
confirm_transfer: false
},
init() {
console.log('=== Vendor Edit Initialization ===');
// Check authentication
if (!Auth.isAuthenticated() || !Auth.isAdmin()) {
console.log('Not authenticated as admin, redirecting to login');
window.location.href = '/static/admin/login.html';
return;
}
this.currentUser = Auth.getCurrentUser();
console.log('Current user:', this.currentUser.username);
// Get vendor ID from URL
const urlParams = new URLSearchParams(window.location.search);
this.vendorId = urlParams.get('id');
if (!this.vendorId) {
console.error('No vendor ID in URL');
alert('No vendor ID provided');
window.location.href = '/static/admin/dashboard.html#vendors';
return;
}
console.log('Vendor ID:', this.vendorId);
// Load vendor details
this.loadVendor();
},
async loadVendor() {
this.loadingVendor = true;
try {
console.log('Loading vendor with ID:', this.vendorId);
this.vendor = await apiClient.get(`/admin/vendors/${this.vendorId}`);
console.log('✅ Vendor loaded:', this.vendor.vendor_code);
console.log('Owner email:', this.vendor.owner_email);
console.log('Contact email:', this.vendor.contact_email);
// Populate form data
this.formData = {
name: this.vendor.name,
subdomain: this.vendor.subdomain,
description: this.vendor.description || '',
contact_email: this.vendor.contact_email || '',
contact_phone: this.vendor.contact_phone || '',
website: this.vendor.website || '',
business_address: this.vendor.business_address || '',
tax_number: this.vendor.tax_number || ''
};
console.log('Form data populated');
} 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';
} finally {
this.loadingVendor = false;
}
},
formatSubdomain() {
this.formData.subdomain = this.formData.subdomain
.toLowerCase()
.replace(/[^a-z0-9-]/g, '');
},
async handleSubmit() {
console.log('Submitting vendor update...');
this.errors = {};
this.saving = true;
try {
const updatedVendor = await apiClient.put(
`/admin/vendors/${this.vendorId}`,
this.formData
);
console.log('✅ Vendor updated successfully');
Utils.showToast('Vendor updated successfully!', 'success');
this.vendor = updatedVendor;
// Refresh form data with latest values
this.formData.name = updatedVendor.name;
this.formData.subdomain = updatedVendor.subdomain;
this.formData.contact_email = updatedVendor.contact_email;
} catch (error) {
console.error('❌ Failed to update vendor:', error);
Utils.showToast(error.message || 'Failed to update vendor', 'error');
} finally {
this.saving = false;
}
},
showVerificationModal() {
const action = this.vendor.is_verified ? 'unverify' : 'verify';
const actionCap = this.vendor.is_verified ? 'Unverify' : 'Verify';
this.confirmModal = {
show: true,
title: `${actionCap} Vendor`,
message: `Are you sure you want to ${action} this vendor?`,
warning: this.vendor.is_verified
? 'Unverifying this vendor will prevent them from being publicly visible and may affect their operations.'
: 'Verifying this vendor will make them publicly visible and allow them to operate fully.',
buttonText: actionCap,
buttonClass: this.vendor.is_verified ? 'btn-warning' : 'btn-success',
onConfirm: () => this.toggleVerification(),
onCancel: null
};
},
async toggleVerification() {
const action = this.vendor.is_verified ? 'unverify' : 'verify';
console.log(`Toggling verification: ${action}`);
this.saving = true;
try {
const result = await apiClient.put(`/admin/vendors/${this.vendorId}/verify`);
this.vendor.is_verified = result.vendor.is_verified;
console.log('✅ Verification toggled');
Utils.showToast(result.message, 'success');
} catch (error) {
console.error('❌ Failed to toggle verification:', error);
Utils.showToast('Failed to update verification status', 'error');
} finally {
this.saving = false;
}
},
showStatusModal() {
const action = this.vendor.is_active ? 'deactivate' : 'activate';
const actionCap = this.vendor.is_active ? 'Deactivate' : 'Activate';
this.confirmModal = {
show: true,
title: `${actionCap} Vendor`,
message: `Are you sure you want to ${action} this vendor?`,
warning: this.vendor.is_active
? 'Deactivating this vendor will immediately suspend all their operations and make them invisible to customers.'
: 'Activating this vendor will restore their operations and make them visible again.',
buttonText: actionCap,
buttonClass: this.vendor.is_active ? 'btn-danger' : 'btn-success',
onConfirm: () => this.toggleStatus(),
onCancel: null
};
},
async toggleStatus() {
const action = this.vendor.is_active ? 'deactivate' : 'activate';
console.log(`Toggling status: ${action}`);
this.saving = true;
try {
const result = await apiClient.put(`/admin/vendors/${this.vendorId}/status`);
this.vendor.is_active = result.vendor.is_active;
console.log('✅ Status toggled');
Utils.showToast(result.message, 'success');
} catch (error) {
console.error('❌ Failed to toggle status:', error);
Utils.showToast('Failed to update vendor status', 'error');
} finally {
this.saving = false;
}
},
async handleTransferOwnership() {
// Validate inputs
if (!this.transferData.confirm_transfer) {
Utils.showToast('Please confirm the ownership transfer', 'error');
return;
}
if (!this.transferData.new_owner_user_id) {
Utils.showToast('Please enter the new owner user ID', 'error');
return;
}
// Close the transfer modal first
this.showTransferOwnership = false;
// Wait a moment for modal to close
await new Promise(resolve => setTimeout(resolve, 300));
// Show final confirmation modal
this.confirmModal = {
show: true,
title: '⚠️ FINAL CONFIRMATION: Transfer Ownership',
message: `You are about to transfer ownership of "${this.vendor.name}" to user ID ${this.transferData.new_owner_user_id}.`,
warning: `Current Owner: ${this.vendor.owner_username} (${this.vendor.owner_email})\n\n` +
`This action will:\n` +
`• Assign full ownership rights to the new user\n` +
`• Demote the current owner to Manager role\n` +
`• Be permanently logged for audit purposes\n\n` +
`This action cannot be easily undone. Are you absolutely sure?`,
buttonText: '🔄 Yes, Transfer Ownership',
buttonClass: 'btn-danger',
onConfirm: () => this.executeTransferOwnership(),
onCancel: () => {
// If cancelled, reopen the transfer modal with preserved data
this.showTransferOwnership = true;
}
};
},
async executeTransferOwnership() {
console.log('Transferring ownership to user:', this.transferData.new_owner_user_id);
this.transferring = true;
this.saving = true;
try {
const result = await apiClient.post(
`/admin/vendors/${this.vendorId}/transfer-ownership`,
this.transferData
);
console.log('✅ Ownership transferred successfully');
// Show beautiful success modal
this.successModal = {
show: true,
title: 'Ownership Transfer Complete',
message: `The ownership of "${this.vendor.name}" has been successfully transferred.`,
details: {
oldOwner: {
username: result.old_owner.username,
email: result.old_owner.email
},
newOwner: {
username: result.new_owner.username,
email: result.new_owner.email
}
},
note: 'The transfer has been logged for audit purposes. The previous owner has been assigned the Manager role.'
};
Utils.showToast('Ownership transferred successfully', 'success');
// Reload vendor data to reflect new owner
await this.loadVendor();
// Reset transfer form data
this.transferData = {
new_owner_user_id: null,
transfer_reason: '',
confirm_transfer: false
};
} catch (error) {
console.error('❌ Failed to transfer ownership:', error);
const errorMsg = error.message || error.detail || 'Unknown error';
Utils.showToast(`Transfer failed: ${errorMsg}`, 'error');
// Show error in modal format (reuse success modal structure)
alert(`❌ Transfer Failed\n\n${errorMsg}\n\nPlease check the user ID and try again.`);
// Reopen transfer modal so user can try again
this.showTransferOwnership = true;
} finally {
this.transferring = false;
this.saving = false;
}
},
async handleLogout() {
// Show confirmation modal for logout
this.confirmModal = {
show: true,
title: '🚪 Confirm Logout',
message: 'Are you sure you want to logout from the Admin Portal?',
warning: 'You will need to login again to access the admin dashboard.',
buttonText: 'Yes, Logout',
buttonClass: 'btn-danger',
onConfirm: () => this.executeLogout(),
onCancel: null
};
},
async executeLogout() {
console.log('Logging out...');
// Show loading state briefly
this.saving = true;
// Clear authentication
Auth.logout();
// Show success message
Utils.showToast('Logged out successfully', 'success', 1000);
// Redirect to login after brief delay
setTimeout(() => {
window.location.href = '/static/admin/login.html';
}, 500);
},
};
}

View File

@@ -1 +1,241 @@
// Vendor management
// Admin Vendor Creation Component
function vendorCreation() {
return {
currentUser: {},
formData: {
vendor_code: '',
name: '',
subdomain: '',
description: '',
owner_email: '',
business_phone: '',
website: '',
business_address: '',
tax_number: ''
},
loading: false,
errors: {},
showCredentials: false,
credentials: null,
init() {
if (!this.checkAuth()) {
return;
}
},
checkAuth() {
if (!Auth.isAuthenticated()) {
window.location.href = '/static/admin/login.html';
return false;
}
const user = Auth.getCurrentUser();
if (!user || user.role !== 'admin') {
Utils.showToast('Access denied. Admin privileges required.', 'error');
Auth.logout();
window.location.href = '/static/admin/login.html';
return false;
}
this.currentUser = user;
return true;
},
async handleLogout() {
const confirmed = await Utils.confirm(
'Are you sure you want to logout?',
'Confirm Logout'
);
if (confirmed) {
Auth.logout();
Utils.showToast('Logged out successfully', 'success', 2000);
setTimeout(() => {
window.location.href = '/static/admin/login.html';
}, 500);
}
},
// Auto-format vendor code (uppercase)
formatVendorCode() {
this.formData.vendor_code = this.formData.vendor_code
.toUpperCase()
.replace(/[^A-Z0-9_-]/g, '');
},
// Auto-format subdomain (lowercase)
formatSubdomain() {
this.formData.subdomain = this.formData.subdomain
.toLowerCase()
.replace(/[^a-z0-9-]/g, '');
},
clearErrors() {
this.errors = {};
},
validateForm() {
this.clearErrors();
let isValid = true;
// Required fields validation
if (!this.formData.vendor_code.trim()) {
this.errors.vendor_code = 'Vendor code is required';
isValid = false;
}
if (!this.formData.name.trim()) {
this.errors.name = 'Vendor name is required';
isValid = false;
}
if (!this.formData.subdomain.trim()) {
this.errors.subdomain = 'Subdomain is required';
isValid = false;
}
if (!this.formData.owner_email.trim()) {
this.errors.owner_email = 'Owner email is required';
isValid = false;
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (this.formData.owner_email && !emailRegex.test(this.formData.owner_email)) {
this.errors.owner_email = 'Invalid email format';
isValid = false;
}
// Subdomain validation (must start and end with alphanumeric)
const subdomainRegex = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
if (this.formData.subdomain && this.formData.subdomain.length > 1 &&
!subdomainRegex.test(this.formData.subdomain)) {
this.errors.subdomain = 'Subdomain must start and end with a letter or number';
isValid = false;
}
return isValid;
},
async handleSubmit() {
if (!this.validateForm()) {
Utils.showToast('Please fix validation errors', 'error');
window.scrollTo({ top: 0, behavior: 'smooth' });
return;
}
this.loading = true;
try {
// Prepare data (remove empty fields)
const submitData = {};
for (const [key, value] of Object.entries(this.formData)) {
if (value !== '' && value !== null && value !== undefined) {
submitData[key] = value;
}
}
console.log('Submitting vendor data:', submitData);
const response = await apiClient.post('/admin/vendors', submitData);
console.log('Vendor creation response:', response);
// Store credentials - be flexible with response structure
this.credentials = {
vendor_code: response.vendor_code || this.formData.vendor_code,
subdomain: response.subdomain || this.formData.subdomain,
name: response.name || this.formData.name,
owner_username: response.owner_username || `${this.formData.subdomain}_owner`,
owner_email: response.owner_email || this.formData.owner_email,
temporary_password: response.temporary_password || 'PASSWORD_NOT_RETURNED',
login_url: response.login_url ||
`http://localhost:8000/vendor/${this.formData.subdomain}/login` ||
`${this.formData.subdomain}.platform.com/vendor/login`
};
console.log('Stored credentials:', this.credentials);
// Check if password was returned
if (!response.temporary_password) {
console.warn('⚠️ Warning: temporary_password not returned from API');
console.warn('Full API response:', response);
Utils.showToast('Vendor created but password not returned. Check server logs.', 'warning', 5000);
}
// Show credentials display
this.showCredentials = true;
// Success notification
Utils.showToast('Vendor created successfully!', 'success');
// Scroll to top to see credentials
window.scrollTo({ top: 0, behavior: 'smooth' });
} catch (error) {
console.error('Error creating vendor:', error);
// Check for specific validation errors
if (error.message.includes('vendor_code') || error.message.includes('Vendor code')) {
this.errors.vendor_code = 'Vendor code already exists';
} else if (error.message.includes('subdomain')) {
this.errors.subdomain = 'Subdomain already exists';
} else if (error.message.includes('email')) {
this.errors.owner_email = 'Email already in use';
}
Utils.showToast(
error.message || 'Failed to create vendor',
'error'
);
} finally {
this.loading = false;
}
},
resetForm() {
this.formData = {
vendor_code: '',
name: '',
subdomain: '',
description: '',
owner_email: '',
business_phone: '',
website: '',
business_address: '',
tax_number: ''
};
this.clearErrors();
this.showCredentials = false;
this.credentials = null;
},
copyToClipboard(text, label) {
if (!text) {
Utils.showToast('Nothing to copy', 'error');
return;
}
navigator.clipboard.writeText(text).then(() => {
Utils.showToast(`${label} copied to clipboard`, 'success', 2000);
}).catch((err) => {
console.error('Failed to copy:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
Utils.showToast(`${label} copied to clipboard`, 'success', 2000);
} catch (err) {
Utils.showToast('Failed to copy to clipboard', 'error');
}
document.body.removeChild(textArea);
});
}
}
}

View File

@@ -0,0 +1,581 @@
/**
* Alpine.js Components for Multi-Tenant E-commerce Platform
* Universal component system for Admin, Vendor, and Shop sections
*/
// =============================================================================
// BASE MODAL SYSTEM
// Universal modal functions used by all sections
// =============================================================================
window.baseModalSystem = function() {
return {
// Confirmation Modal State
confirmModal: {
show: false,
title: '',
message: '',
warning: '',
buttonText: 'Confirm',
buttonClass: 'btn-danger',
onConfirm: null,
onCancel: null
},
// Success Modal State
successModal: {
show: false,
title: 'Success',
message: '',
redirectUrl: null,
redirectDelay: 2000
},
// Error Modal State
errorModal: {
show: false,
title: 'Error',
message: '',
details: ''
},
// Loading State
loading: false,
/**
* Show confirmation modal
* @param {Object} options - Modal configuration
*/
showConfirmModal(options) {
this.confirmModal = {
show: true,
title: options.title || 'Confirm Action',
message: options.message || 'Are you sure?',
warning: options.warning || '',
buttonText: options.buttonText || 'Confirm',
buttonClass: options.buttonClass || 'btn-danger',
onConfirm: options.onConfirm || null,
onCancel: options.onCancel || null
};
},
/**
* Close confirmation modal
*/
closeConfirmModal() {
if (this.confirmModal.onCancel) {
this.confirmModal.onCancel();
}
this.confirmModal.show = false;
},
/**
* Handle confirmation action
*/
async handleConfirm() {
if (this.confirmModal.onConfirm) {
this.closeConfirmModal();
await this.confirmModal.onConfirm();
}
},
/**
* Show success modal
* @param {Object} options - Modal configuration
*/
showSuccessModal(options) {
this.successModal = {
show: true,
title: options.title || 'Success',
message: options.message || 'Operation completed successfully',
redirectUrl: options.redirectUrl || null,
redirectDelay: options.redirectDelay || 2000
};
// Auto-redirect if URL provided
if (this.successModal.redirectUrl) {
setTimeout(() => {
window.location.href = this.successModal.redirectUrl;
}, this.successModal.redirectDelay);
}
},
/**
* Close success modal
*/
closeSuccessModal() {
this.successModal.show = false;
},
/**
* Show error modal
* @param {Object} options - Modal configuration
*/
showErrorModal(options) {
this.errorModal = {
show: true,
title: options.title || 'Error',
message: options.message || 'An error occurred',
details: options.details || ''
};
},
/**
* Close error modal
*/
closeErrorModal() {
this.errorModal.show = false;
},
/**
* Show loading overlay
*/
showLoading() {
this.loading = true;
},
/**
* Hide loading overlay
*/
hideLoading() {
this.loading = false;
}
};
};
// =============================================================================
// ADMIN LAYOUT COMPONENT
// Header, Sidebar, Navigation, Modals for Admin Section
// =============================================================================
window.adminLayout = function() {
return {
...window.baseModalSystem(),
// Admin-specific state
user: null,
menuOpen: false,
currentPage: '',
/**
* Initialize admin layout
*/
async init() {
this.currentPage = this.getCurrentPage();
await this.loadUserData();
},
/**
* Load current admin user data
*/
async loadUserData() {
try {
const response = await apiClient.get('/admin/auth/me');
this.user = response;
} catch (error) {
console.error('Failed to load user data:', error);
// Redirect to login if not authenticated
if (error.status === 401) {
window.location.href = '/admin/login.html';
}
}
},
/**
* Get current page name from URL
*/
getCurrentPage() {
const path = window.location.pathname;
const page = path.split('/').pop().replace('.html', '');
return page || 'dashboard';
},
/**
* Check if menu item is active
*/
isActive(page) {
return this.currentPage === page;
},
/**
* Toggle mobile menu
*/
toggleMenu() {
this.menuOpen = !this.menuOpen;
},
/**
* Show logout confirmation
*/
confirmLogout() {
this.showConfirmModal({
title: 'Confirm Logout',
message: 'Are you sure you want to logout?',
buttonText: 'Logout',
buttonClass: 'btn-primary',
onConfirm: () => this.logout()
});
},
/**
* Perform logout
*/
async logout() {
try {
this.showLoading();
await apiClient.post('/admin/auth/logout');
window.location.href = '/admin/login.html';
} catch (error) {
this.hideLoading();
this.showErrorModal({
message: 'Logout failed',
details: error.message
});
}
}
};
};
// =============================================================================
// VENDOR LAYOUT COMPONENT
// Header, Sidebar, Navigation, Modals for Vendor Dashboard
// =============================================================================
window.vendorLayout = function() {
return {
...window.baseModalSystem(),
// Vendor-specific state
user: null,
vendor: null,
menuOpen: false,
currentPage: '',
/**
* Initialize vendor layout
*/
async init() {
this.currentPage = this.getCurrentPage();
await this.loadUserData();
},
/**
* Load current vendor user data
*/
async loadUserData() {
try {
const response = await apiClient.get('/vendor/auth/me');
this.user = response.user;
this.vendor = response.vendor;
} catch (error) {
console.error('Failed to load user data:', error);
if (error.status === 401) {
window.location.href = '/vendor/login.html';
}
}
},
/**
* Get current page name from URL
*/
getCurrentPage() {
const path = window.location.pathname;
const page = path.split('/').pop().replace('.html', '');
return page || 'dashboard';
},
/**
* Check if menu item is active
*/
isActive(page) {
return this.currentPage === page;
},
/**
* Toggle mobile menu
*/
toggleMenu() {
this.menuOpen = !this.menuOpen;
},
/**
* Show logout confirmation
*/
confirmLogout() {
this.showConfirmModal({
title: 'Confirm Logout',
message: 'Are you sure you want to logout?',
buttonText: 'Logout',
buttonClass: 'btn-primary',
onConfirm: () => this.logout()
});
},
/**
* Perform logout
*/
async logout() {
try {
this.showLoading();
await apiClient.post('/vendor/auth/logout');
window.location.href = '/vendor/login.html';
} catch (error) {
this.hideLoading();
this.showErrorModal({
message: 'Logout failed',
details: error.message
});
}
}
};
};
// =============================================================================
// SHOP LAYOUT COMPONENT
// Header, Cart, Search, Navigation for Customer-Facing Shop
// =============================================================================
window.shopLayout = function() {
return {
...window.baseModalSystem(),
// Shop-specific state
vendor: null,
cart: null,
cartCount: 0,
sessionId: null,
searchQuery: '',
mobileMenuOpen: false,
/**
* Initialize shop layout
*/
async init() {
this.sessionId = this.getOrCreateSessionId();
await this.detectVendor();
if (this.vendor) {
await this.loadCart();
}
},
/**
* Detect vendor from subdomain or vendor code
*/
async detectVendor() {
try {
const hostname = window.location.hostname;
const subdomain = hostname.split('.')[0];
// Try to get vendor by subdomain first
if (subdomain && subdomain !== 'localhost' && subdomain !== 'www') {
this.vendor = await apiClient.get(`/public/vendors/by-subdomain/${subdomain}`);
} else {
// Fallback: Try to get vendor code from URL or localStorage
const urlParams = new URLSearchParams(window.location.search);
const vendorCode = urlParams.get('vendor') || localStorage.getItem('vendorCode');
if (vendorCode) {
this.vendor = await apiClient.get(`/public/vendors/by-code/${vendorCode}`);
localStorage.setItem('vendorCode', vendorCode);
}
}
} catch (error) {
console.error('Failed to detect vendor:', error);
this.showErrorModal({
message: 'Vendor not found',
details: 'Unable to identify the store. Please check the URL.'
});
}
},
/**
* Get or create session ID for cart
*/
getOrCreateSessionId() {
let sessionId = localStorage.getItem('cartSessionId');
if (!sessionId) {
sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
localStorage.setItem('cartSessionId', sessionId);
}
return sessionId;
},
/**
* Load cart from API
*/
async loadCart() {
if (!this.vendor) return;
try {
this.cart = await apiClient.get(
`/public/vendors/${this.vendor.id}/cart/${this.sessionId}`
);
this.updateCartCount();
} catch (error) {
console.error('Failed to load cart:', error);
this.cart = { items: [] };
this.cartCount = 0;
}
},
/**
* Update cart item count
*/
updateCartCount() {
if (this.cart && this.cart.items) {
this.cartCount = this.cart.items.reduce((sum, item) => sum + item.quantity, 0);
} else {
this.cartCount = 0;
}
},
/**
* Add item to cart
*/
async addToCart(productId, quantity = 1) {
if (!this.vendor) {
this.showErrorModal({ message: 'Vendor not found' });
return;
}
try {
this.showLoading();
await apiClient.post(
`/public/vendors/${this.vendor.id}/cart/${this.sessionId}/items`,
{ product_id: productId, quantity }
);
await this.loadCart();
this.hideLoading();
this.showSuccessModal({
title: 'Added to Cart',
message: 'Product added successfully'
});
} catch (error) {
this.hideLoading();
this.showErrorModal({
message: 'Failed to add to cart',
details: error.message
});
}
},
/**
* Toggle mobile menu
*/
toggleMobileMenu() {
this.mobileMenuOpen = !this.mobileMenuOpen;
},
/**
* Handle search
*/
handleSearch() {
if (this.searchQuery.trim()) {
window.location.href = `/shop/products.html?search=${encodeURIComponent(this.searchQuery)}`;
}
},
/**
* Go to cart page
*/
goToCart() {
window.location.href = '/shop/cart.html';
}
};
};
// =============================================================================
// SHOP ACCOUNT LAYOUT COMPONENT
// Layout for customer account area (orders, profile, addresses)
// =============================================================================
window.shopAccountLayout = function() {
return {
...window.shopLayout(),
// Account-specific state
customer: null,
currentPage: '',
/**
* Initialize shop account layout
*/
async init() {
this.currentPage = this.getCurrentPage();
this.sessionId = this.getOrCreateSessionId();
await this.detectVendor();
await this.loadCustomerData();
if (this.vendor) {
await this.loadCart();
}
},
/**
* Load customer data
*/
async loadCustomerData() {
if (!this.vendor) return;
try {
const response = await apiClient.get(
`/public/vendors/${this.vendor.id}/customers/me`
);
this.customer = response;
} catch (error) {
console.error('Failed to load customer data:', error);
// Redirect to login if not authenticated
if (error.status === 401) {
window.location.href = `/shop/account/login.html?redirect=${encodeURIComponent(window.location.pathname)}`;
}
}
},
/**
* Get current page name from URL
*/
getCurrentPage() {
const path = window.location.pathname;
const page = path.split('/').pop().replace('.html', '');
return page || 'orders';
},
/**
* Check if menu item is active
*/
isActive(page) {
return this.currentPage === page;
},
/**
* Show logout confirmation
*/
confirmLogout() {
this.showConfirmModal({
title: 'Confirm Logout',
message: 'Are you sure you want to logout?',
buttonText: 'Logout',
buttonClass: 'btn-primary',
onConfirm: () => this.logoutCustomer()
});
},
/**
* Perform customer logout
*/
async logoutCustomer() {
if (!this.vendor) return;
try {
this.showLoading();
await apiClient.post(`/public/vendors/${this.vendor.id}/customers/logout`);
window.location.href = '/shop/home.html';
} catch (error) {
this.hideLoading();
this.showErrorModal({
message: 'Logout failed',
details: error.message
});
}
}
};
};

View File

@@ -338,3 +338,40 @@ document.head.appendChild(style);
if (typeof module !== 'undefined' && module.exports) {
module.exports = { APIClient, apiClient, Auth, Utils };
}
// Table scroll detection helper
function initTableScrollDetection() {
const observer = new MutationObserver(() => {
const tables = document.querySelectorAll('.table-responsive');
tables.forEach(table => {
if (!table.hasAttribute('data-scroll-initialized')) {
table.setAttribute('data-scroll-initialized', 'true');
table.addEventListener('scroll', function() {
if (this.scrollLeft > 0) {
this.classList.add('is-scrolled');
} else {
this.classList.remove('is-scrolled');
}
});
// Check initial state
if (table.scrollLeft > 0) {
table.classList.add('is-scrolled');
}
}
});
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTableScrollDetection);
} else {
initTableScrollDetection();
}

View File

@@ -0,0 +1,209 @@
/**
* Modal System Helper Functions
* Utility functions for modal operations across all sections
*/
window.modalHelpers = {
/**
* Show a simple confirmation dialog
* Returns a Promise that resolves with true/false
*/
async confirm(options) {
return new Promise((resolve) => {
const component = Alpine.$data(document.body);
component.showConfirmModal({
title: options.title || 'Confirm Action',
message: options.message || 'Are you sure?',
warning: options.warning || '',
buttonText: options.buttonText || 'Confirm',
buttonClass: options.buttonClass || 'btn-danger',
onConfirm: () => resolve(true),
onCancel: () => resolve(false)
});
});
},
/**
* Show a success message
*/
success(message, options = {}) {
const component = Alpine.$data(document.body);
component.showSuccessModal({
title: options.title || 'Success',
message: message,
redirectUrl: options.redirectUrl || null,
redirectDelay: options.redirectDelay || 2000
});
},
/**
* Show an error message
*/
error(message, details = '') {
const component = Alpine.$data(document.body);
component.showErrorModal({
title: 'Error',
message: message,
details: details
});
},
/**
* Show API error with proper formatting
*/
apiError(error) {
const component = Alpine.$data(document.body);
let message = 'An error occurred';
let details = '';
if (error.message) {
message = error.message;
}
if (error.details) {
details = typeof error.details === 'string'
? error.details
: JSON.stringify(error.details, null, 2);
} else if (error.error_code) {
details = `Error Code: ${error.error_code}`;
}
component.showErrorModal({
title: 'Error',
message: message,
details: details
});
},
/**
* Show loading overlay
*/
showLoading() {
const component = Alpine.$data(document.body);
component.showLoading();
},
/**
* Hide loading overlay
*/
hideLoading() {
const component = Alpine.$data(document.body);
component.hideLoading();
},
/**
* Execute an async operation with loading state
*/
async withLoading(asyncFunction) {
try {
this.showLoading();
const result = await asyncFunction();
return result;
} finally {
this.hideLoading();
}
},
/**
* Execute an async operation with error handling
*/
async withErrorHandling(asyncFunction, errorMessage = 'Operation failed') {
try {
return await asyncFunction();
} catch (error) {
console.error('Operation error:', error);
this.apiError({
message: errorMessage,
details: error.message || error.toString()
});
throw error;
}
},
/**
* Execute an async operation with both loading and error handling
*/
async execute(asyncFunction, options = {}) {
const {
errorMessage = 'Operation failed',
successMessage = null,
redirectUrl = null
} = options;
try {
this.showLoading();
const result = await asyncFunction();
if (successMessage) {
this.success(successMessage, { redirectUrl });
}
return result;
} catch (error) {
console.error('Operation error:', error);
this.apiError({
message: errorMessage,
details: error.message || error.toString()
});
throw error;
} finally {
this.hideLoading();
}
},
/**
* Confirm a destructive action
*/
async confirmDelete(itemName, itemType = 'item') {
return this.confirm({
title: `Delete ${itemType}`,
message: `Are you sure you want to delete "${itemName}"?`,
warning: 'This action cannot be undone.',
buttonText: 'Delete',
buttonClass: 'btn-danger'
});
},
/**
* Confirm logout
*/
async confirmLogout() {
return this.confirm({
title: 'Confirm Logout',
message: 'Are you sure you want to logout?',
buttonText: 'Logout',
buttonClass: 'btn-primary'
});
},
/**
* Show validation errors
*/
validationError(errors) {
let message = 'Please correct the following errors:';
let details = '';
if (Array.isArray(errors)) {
details = errors.join('\n');
} else if (typeof errors === 'object') {
details = Object.entries(errors)
.map(([field, error]) => `${field}: ${error}`)
.join('\n');
} else {
details = errors.toString();
}
this.error(message, details);
}
};
// Shorthand aliases for convenience
window.showConfirm = window.modalHelpers.confirm.bind(window.modalHelpers);
window.showSuccess = window.modalHelpers.success.bind(window.modalHelpers);
window.showError = window.modalHelpers.error.bind(window.modalHelpers);
window.showLoading = window.modalHelpers.showLoading.bind(window.modalHelpers);
window.hideLoading = window.modalHelpers.hideLoading.bind(window.modalHelpers);

View File

@@ -0,0 +1,114 @@
/**
* Universal Modal Templates
* Shared across all sections: Admin, Vendor, and Shop
*/
window.modalTemplates = {
/**
* Confirmation Modal
*/
confirmModal: () => `
<div x-show="confirmModal.show"
x-cloak
class="modal-backdrop"
@click.self="closeConfirmModal()">
<div class="modal-container" @click.stop>
<div class="modal-header">
<h3 class="modal-title" x-text="confirmModal.title"></h3>
<button @click="closeConfirmModal()" class="modal-close">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<p class="modal-message" x-text="confirmModal.message"></p>
<div x-show="confirmModal.warning" class="modal-warning">
<p x-text="confirmModal.warning"></p>
</div>
</div>
<div class="modal-footer">
<button @click="closeConfirmModal()" class="btn btn-outline">
Cancel
</button>
<button @click="handleConfirm()"
class="btn"
:class="confirmModal.buttonClass"
x-text="confirmModal.buttonText">
</button>
</div>
</div>
</div>
`,
/**
* Success Modal
*/
successModal: () => `
<div x-show="successModal.show"
x-cloak
class="modal-backdrop"
@click.self="closeSuccessModal()">
<div class="modal-container" @click.stop>
<div class="modal-header">
<h3 class="modal-title" x-text="successModal.title"></h3>
<button @click="closeSuccessModal()" class="modal-close">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<div class="modal-icon success">
<i class="fas fa-check"></i>
</div>
<p class="modal-message text-center" x-text="successModal.message"></p>
</div>
<div class="modal-footer">
<button @click="closeSuccessModal()" class="btn btn-primary">
OK
</button>
</div>
</div>
</div>
`,
/**
* Error Modal
*/
errorModal: () => `
<div x-show="errorModal.show"
x-cloak
class="modal-backdrop"
@click.self="closeErrorModal()">
<div class="modal-container" @click.stop>
<div class="modal-header">
<h3 class="modal-title" x-text="errorModal.title"></h3>
<button @click="closeErrorModal()" class="modal-close">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<div class="modal-icon error">
<i class="fas fa-exclamation-triangle"></i>
</div>
<p class="modal-message text-center" x-text="errorModal.message"></p>
<div x-show="errorModal.details" class="modal-details" x-text="errorModal.details"></div>
</div>
<div class="modal-footer">
<button @click="closeErrorModal()" class="btn btn-primary">
Close
</button>
</div>
</div>
</div>
`,
/**
* Loading Overlay
*/
loadingOverlay: () => `
<div x-show="loading"
x-cloak
class="loading-overlay">
<div class="loading-spinner"></div>
</div>
`
};

View File

@@ -0,0 +1,103 @@
/**
* Shop Layout Templates
* Header and Navigation specific to Customer-Facing Shop
*/
window.shopLayoutTemplates = {
/**
* Shop Header
*/
header: () => `
<header class="shop-header">
<div class="shop-header-top">
<a href="/shop/home.html" class="shop-logo" x-text="vendor?.name || 'Shop'"></a>
<!-- Search Bar -->
<div class="shop-search">
<form @submit.prevent="handleSearch()" class="search-form">
<input type="text"
x-model="searchQuery"
class="search-input"
placeholder="Search products...">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search"></i>
</button>
</form>
</div>
<!-- Actions -->
<div class="shop-actions">
<button @click="goToCart()" class="cart-button">
<i class="fas fa-shopping-cart"></i>
<span x-show="cartCount > 0"
x-text="cartCount"
class="cart-count">
</span>
</button>
<a href="/shop/account/login.html" class="btn btn-outline">
<i class="fas fa-user"></i> Account
</a>
</div>
</div>
<!-- Navigation -->
<nav class="shop-nav">
<a href="/shop/home.html"
class="shop-nav-item"
:class="{ 'active': isActive('home') }">
Home
</a>
<a href="/shop/products.html"
class="shop-nav-item"
:class="{ 'active': isActive('products') }">
Products
</a>
<a href="/shop/categories.html"
class="shop-nav-item"
:class="{ 'active': isActive('categories') }">
Categories
</a>
<a href="/shop/about.html"
class="shop-nav-item"
:class="{ 'active': isActive('about') }">
About
</a>
</nav>
</header>
`,
/**
* Shop Account Sidebar
*/
accountSidebar: () => `
<aside class="account-sidebar">
<nav>
<a href="/shop/account/orders.html"
class="account-nav-item"
:class="{ 'active': isActive('orders') }">
<i class="fas fa-shopping-bag"></i>
<span>My Orders</span>
</a>
<a href="/shop/account/profile.html"
class="account-nav-item"
:class="{ 'active': isActive('profile') }">
<i class="fas fa-user"></i>
<span>Profile</span>
</a>
<a href="/shop/account/addresses.html"
class="account-nav-item"
:class="{ 'active': isActive('addresses') }">
<i class="fas fa-map-marker-alt"></i>
<span>Addresses</span>
</a>
<button @click="confirmLogout()"
class="account-nav-item"
style="width: 100%; text-align: left; background: none; border: none; cursor: pointer;">
<i class="fas fa-sign-out-alt"></i>
<span>Logout</span>
</button>
</nav>
</aside>
`
};

View File

@@ -1 +1,113 @@
// Vendor dashboard
// Vendor Dashboard Component
function vendorDashboard() {
return {
currentUser: {},
vendor: null,
vendorRole: '',
currentSection: 'dashboard',
loading: false,
stats: {
products_count: 0,
orders_count: 0,
customers_count: 0,
revenue: 0
},
init() {
if (!this.checkAuth()) {
return;
}
this.loadDashboard();
},
checkAuth() {
const token = localStorage.getItem('vendor_token');
const user = localStorage.getItem('vendor_user');
const vendorContext = localStorage.getItem('vendor_context');
const vendorRole = localStorage.getItem('vendor_role');
if (!token || !user || !vendorContext) {
// Get vendor code from URL
const vendorCode = this.getVendorCodeFromUrl();
const redirectUrl = vendorCode ?
`/vendor/${vendorCode}/login` :
'/static/vendor/login.html';
window.location.href = redirectUrl;
return false;
}
try {
this.currentUser = JSON.parse(user);
this.vendor = JSON.parse(vendorContext);
this.vendorRole = vendorRole || 'Member';
return true;
} catch (e) {
console.error('Error parsing stored data:', e);
localStorage.removeItem('vendor_token');
localStorage.removeItem('vendor_user');
localStorage.removeItem('vendor_context');
localStorage.removeItem('vendor_role');
window.location.href = '/static/vendor/login.html';
return false;
}
},
getVendorCodeFromUrl() {
// Try to get vendor code from URL path
const pathParts = window.location.pathname.split('/').filter(p => p);
const vendorIndex = pathParts.indexOf('vendor');
if (vendorIndex !== -1 && pathParts[vendorIndex + 1]) {
const code = pathParts[vendorIndex + 1];
if (!['login', 'dashboard', 'admin', 'products', 'orders'].includes(code.toLowerCase())) {
return code.toUpperCase();
}
}
// Fallback to query parameter
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('vendor');
},
async handleLogout() {
const confirmed = await Utils.confirm(
'Are you sure you want to logout?',
'Confirm Logout'
);
if (confirmed) {
localStorage.removeItem('vendor_token');
localStorage.removeItem('vendor_user');
localStorage.removeItem('vendor_context');
localStorage.removeItem('vendor_role');
Utils.showToast('Logged out successfully', 'success', 2000);
setTimeout(() => {
window.location.href = `/vendor/${this.vendor.vendor_code}/login`;
}, 500);
}
},
async loadDashboard() {
this.loading = true;
try {
// In future slices, load actual dashboard data
// const data = await apiClient.get(`/vendor/dashboard/stats`);
// this.stats = data;
// For now, show placeholder data
this.stats = {
products_count: 0,
orders_count: 0,
customers_count: 0,
revenue: 0
};
} catch (error) {
console.error('Failed to load dashboard:', error);
Utils.showToast('Failed to load dashboard data', 'error');
} finally {
this.loading = false;
}
}
}
}

170
static/js/vendor/login.js vendored Normal file
View File

@@ -0,0 +1,170 @@
// Vendor Login Component
function vendorLogin() {
return {
vendor: null,
credentials: {
username: '',
password: ''
},
loading: false,
checked: false,
error: null,
success: null,
errors: {},
init() {
// Check if already logged in
if (this.checkExistingAuth()) {
return;
}
// Detect vendor from URL
this.detectVendor();
},
checkExistingAuth() {
const token = localStorage.getItem('vendor_token');
const vendorContext = localStorage.getItem('vendor_context');
if (token && vendorContext) {
try {
const vendor = JSON.parse(vendorContext);
window.location.href = `/vendor/${vendor.vendor_code}/dashboard`;
return true;
} catch (e) {
localStorage.removeItem('vendor_token');
localStorage.removeItem('vendor_context');
}
}
return false;
},
async detectVendor() {
this.loading = true;
try {
const vendorCode = this.getVendorCodeFromUrl();
if (!vendorCode) {
this.error = 'Vendor code not found in URL. Please use the correct vendor login link.';
this.checked = true;
this.loading = false;
return;
}
console.log('Detected vendor code:', vendorCode);
// Fetch vendor information
const response = await fetch(`/api/v1/public/vendors/by-code/${vendorCode}`);
if (!response.ok) {
throw new Error('Vendor not found');
}
this.vendor = await response.json();
this.checked = true;
console.log('Loaded vendor:', this.vendor);
} catch (error) {
console.error('Error detecting vendor:', error);
this.error = 'Unable to load vendor information. The vendor may not exist or is inactive.';
this.checked = true;
} finally {
this.loading = false;
}
},
getVendorCodeFromUrl() {
// Try multiple methods to get vendor code
// Method 1: From URL path /vendor/VENDORCODE/login or /vendor/VENDORCODE/
const pathParts = window.location.pathname.split('/').filter(p => p);
const vendorIndex = pathParts.indexOf('vendor');
if (vendorIndex !== -1 && pathParts[vendorIndex + 1]) {
const code = pathParts[vendorIndex + 1];
// Don't return if it's a generic route like 'login', 'dashboard', etc.
if (!['login', 'dashboard', 'admin', 'products', 'orders'].includes(code.toLowerCase())) {
return code.toUpperCase();
}
}
// Method 2: From query parameter ?vendor=VENDORCODE
const urlParams = new URLSearchParams(window.location.search);
const queryVendor = urlParams.get('vendor');
if (queryVendor) {
return queryVendor.toUpperCase();
}
// Method 3: From subdomain (for production)
const hostname = window.location.hostname;
const parts = hostname.split('.');
if (parts.length > 2 && parts[0] !== 'www') {
// Assume subdomain is vendor code
return parts[0].toUpperCase();
}
return null;
},
clearErrors() {
this.error = null;
this.errors = {};
},
validateForm() {
this.clearErrors();
let isValid = true;
if (!this.credentials.username.trim()) {
this.errors.username = 'Username is required';
isValid = false;
}
if (!this.credentials.password) {
this.errors.password = 'Password is required';
isValid = false;
}
return isValid;
},
async handleLogin() {
if (!this.validateForm()) {
return;
}
this.loading = true;
this.clearErrors();
try {
const response = await apiClient.post('/vendor/auth/login', {
username: this.credentials.username.trim(),
password: this.credentials.password,
vendor_code: this.vendor.vendor_code
});
// Store authentication data
localStorage.setItem('vendor_token', response.access_token);
localStorage.setItem('vendor_user', JSON.stringify(response.user));
localStorage.setItem('vendor_context', JSON.stringify(response.vendor));
localStorage.setItem('vendor_role', response.vendor_role);
// Show success message
this.success = 'Login successful! Redirecting...';
Utils.showToast('Login successful!', 'success', 2000);
// Redirect after short delay
setTimeout(() => {
window.location.href = `/vendor/${this.vendor.vendor_code}/dashboard`;
}, 1000);
} catch (error) {
console.error('Login error:', error);
this.error = error.message || 'Login failed. Please check your credentials.';
Utils.showToast(this.error, 'error');
} finally {
this.loading = false;
}
}
}
}

View File

@@ -0,0 +1,67 @@
/**
* Vendor Layout Templates
* Header and Sidebar specific to Vendor Dashboard
*/
window.vendorLayoutTemplates = {
/**
* Vendor Header
*/
header: () => `
<header class="vendor-header">
<div class="header-left">
<button @click="toggleMenu()" class="menu-toggle">
<i class="fas fa-bars"></i>
</button>
<h1 class="header-title">Vendor Dashboard</h1>
</div>
<div class="header-right">
<span class="user-name" x-text="vendor?.name || 'Vendor'"></span>
<button @click="confirmLogout()" class="btn-logout">
<i class="fas fa-sign-out-alt"></i> Logout
</button>
</div>
</header>
`,
/**
* Vendor Sidebar
*/
sidebar: () => `
<aside class="vendor-sidebar" :class="{ 'open': menuOpen }">
<nav class="sidebar-nav">
<a href="/vendor/dashboard.html"
class="nav-item"
:class="{ 'active': isActive('dashboard') }">
<i class="fas fa-tachometer-alt"></i>
<span>Dashboard</span>
</a>
<a href="/vendor/products.html"
class="nav-item"
:class="{ 'active': isActive('products') }">
<i class="fas fa-box"></i>
<span>Products</span>
</a>
<a href="/vendor/orders.html"
class="nav-item"
:class="{ 'active': isActive('orders') }">
<i class="fas fa-shopping-bag"></i>
<span>Orders</span>
</a>
<a href="/vendor/customers.html"
class="nav-item"
:class="{ 'active': isActive('customers') }">
<i class="fas fa-users"></i>
<span>Customers</span>
</a>
<a href="/vendor/settings.html"
class="nav-item"
:class="{ 'active': isActive('settings') }">
<i class="fas fa-cog"></i>
<span>Settings</span>
</a>
</nav>
</aside>
`
};

View File

@@ -3,200 +3,158 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ vendor.vendor_code }} Dashboard</title>
<title>Vendor Dashboard - Multi-Tenant Ecommerce Platform</title>
<link rel="stylesheet" href="/static/css/shared/base.css">
<link rel="stylesheet" href="/static/css/vendor/vendor.css">
<link rel="stylesheet" href="/static/css/admin/admin.css">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
<div x-data="vendorDashboard()"
x-init="loadDashboardData()"
data-vendor-id="{{ vendor.id }}"
data-vendor-code="{{ vendor.vendor_code }}"
data-vendor-name="{{ vendor.name }}"
>
<!-- Header -->
<header class="header">
<div class="header-left">
<h1>🏪 <span x-text="vendorName"></span> Dashboard</h1>
</div>
<div class="header-right">
<span class="user-info">
Welcome, <strong>{{ user.username }}</strong>
</span>
<button class="btn-logout" @click="handleLogout()">Logout</button>
</div>
</header>
<div class="container">
<!-- Welcome Card -->
<div class="welcome-card">
<div class="welcome-icon">🎉</div>
<h2>Welcome to Your Vendor Dashboard!</h2>
<p>Your vendor account has been successfully set up.</p>
<p>This is <strong>Slice 1</strong> - the foundation of your multi-tenant ecommerce platform.</p>
<div class="vendor-info-card">
<div class="info-item">
<span class="info-label">Vendor Code:</span>
<span class="info-value" x-text="vendorCode"></span>
</div>
<div class="info-item">
<span class="info-label">Vendor Name:</span>
<span class="info-value" x-text="vendorName"></span>
</div>
<div class="info-item">
<span class="info-label">Owner:</span>
<span class="info-value">{{ user.email }}</span>
</div>
<div class="info-item">
<span class="info-label">Status:</span>
<span class="info-value">
<span class="badge badge-success">Active</span>
{% if vendor.is_verified %}
<span class="badge badge-success">Verified</span>
{% else %}
<span class="badge badge-warning">Pending Verification</span>
{% endif %}
</span>
</div>
<div class="info-item">
<span class="info-label">Vendor Context:</span>
<span class="info-value" x-text="contextInfo"></span>
</div>
</div>
<div class="coming-soon">
🚀 Coming in Slice 2: Marketplace Product Import
</div>
</div>
<!-- Quick Stats (for future slices) -->
<div class="dashboard-widgets" x-show="stats.loaded">
<div class="widget">
<div class="widget-header">
<span class="widget-title">Total Products</span>
<span class="widget-icon">📦</span>
</div>
<div class="widget-stat" x-text="stats.totalProducts"></div>
<div class="widget-label">Products in catalog</div>
</div>
<div class="widget">
<div class="widget-header">
<span class="widget-title">Total Orders</span>
<span class="widget-icon">🛒</span>
</div>
<div class="widget-stat" x-text="stats.totalOrders"></div>
<div class="widget-label">Orders received</div>
</div>
<div class="widget">
<div class="widget-header">
<span class="widget-title">Customers</span>
<span class="widget-icon">👥</span>
</div>
<div class="widget-stat" x-text="stats.totalCustomers"></div>
<div class="widget-label">Registered customers</div>
</div>
<div class="widget">
<div class="widget-header">
<span class="widget-title">Revenue</span>
<span class="widget-icon">💰</span>
</div>
<div class="widget-stat"><span x-text="stats.totalRevenue"></span></div>
<div class="widget-label">Total revenue</div>
</div>
</div>
<body x-data="vendorDashboard()" x-init="init()" x-cloak>
<!-- Header -->
<header class="admin-header">
<div class="header-left">
<h1>🏪 <span x-text="vendor?.name || 'Vendor Dashboard'"></span></h1>
</div>
<div class="header-right">
<span class="user-info">
Welcome, <strong x-text="currentUser.username"></strong>
<span class="badge badge-primary" x-text="vendorRole" style="margin-left: 8px;"></span>
</span>
<button class="btn-logout" @click="handleLogout">Logout</button>
</div>
</header>
<!-- Main Container -->
<div class="admin-container">
<!-- Sidebar -->
<aside class="admin-sidebar">
<nav>
<ul class="nav-menu">
<li class="nav-item">
<a href="#"
class="nav-link active"
@click.prevent="currentSection = 'dashboard'">
📊 Dashboard
</a>
</li>
<li class="nav-item">
<a href="#"
class="nav-link"
@click.prevent="currentSection = 'products'">
📦 Products
</a>
</li>
<li class="nav-item">
<a href="#"
class="nav-link"
@click.prevent="currentSection = 'orders'">
🛒 Orders
</a>
</li>
<li class="nav-item">
<a href="#"
class="nav-link"
@click.prevent="currentSection = 'customers'">
👥 Customers
</a>
</li>
<li class="nav-item">
<a href="#"
class="nav-link"
@click.prevent="currentSection = 'marketplace'">
🌐 Marketplace
</a>
</li>
</ul>
</nav>
</aside>
<!-- Main Content -->
<main class="admin-content">
<!-- Dashboard View -->
<div x-show="currentSection === 'dashboard'">
<!-- Vendor Info Card -->
<div class="content-section" style="margin-bottom: 24px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h2 style="margin: 0;" x-text="vendor?.name"></h2>
<p style="margin: 4px 0 0 0; color: var(--text-secondary);">
<strong x-text="vendor?.vendor_code"></strong>
<span x-text="vendor?.subdomain"></span>
</p>
</div>
<div>
<span class="badge"
:class="vendor?.is_verified ? 'badge-success' : 'badge-warning'"
x-text="vendor?.is_verified ? 'Verified' : 'Pending Verification'"></span>
<span class="badge"
:class="vendor?.is_active ? 'badge-success' : 'badge-danger'"
x-text="vendor?.is_active ? 'Active' : 'Inactive'"></span>
</div>
</div>
</div>
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-header">
<div class="stat-title">Products</div>
<div class="stat-icon">📦</div>
</div>
<div class="stat-value" x-text="stats.products_count || 0"></div>
<div class="stat-subtitle">in catalog</div>
</div>
<div class="stat-card">
<div class="stat-header">
<div class="stat-title">Orders</div>
<div class="stat-icon">🛒</div>
</div>
<div class="stat-value" x-text="stats.orders_count || 0"></div>
<div class="stat-subtitle">total orders</div>
</div>
<div class="stat-card">
<div class="stat-header">
<div class="stat-title">Customers</div>
<div class="stat-icon">👥</div>
</div>
<div class="stat-value" x-text="stats.customers_count || 0"></div>
<div class="stat-subtitle">registered</div>
</div>
<div class="stat-card">
<div class="stat-header">
<div class="stat-title">Revenue</div>
<div class="stat-icon">💰</div>
</div>
<div class="stat-value"><span x-text="stats.revenue || 0"></span></div>
<div class="stat-subtitle">total revenue</div>
</div>
</div>
<!-- Coming Soon Notice -->
<div class="content-section" style="text-align: center; padding: 48px;">
<h3>🚀 Getting Started</h3>
<p class="text-muted" style="margin: 16px 0;">
Welcome to your vendor dashboard! Start by importing products from the marketplace.
</p>
<button class="btn btn-primary" @click="currentSection = 'marketplace'">
Go to Marketplace Import
</button>
</div>
</div>
<!-- Other sections -->
<div x-show="currentSection !== 'dashboard'">
<div class="content-section">
<h2 x-text="currentSection.charAt(0).toUpperCase() + currentSection.slice(1)"></h2>
<p class="text-muted">This section is coming soon in the next development slice.</p>
</div>
</div>
</main>
</div>
<script>
function vendorDashboard() {
return {
vendorId: null,
vendorCode: '',
vendorName: '',
contextInfo: '',
stats: {
loaded: false,
totalProducts: 0,
totalOrders: 0,
totalCustomers: 0,
totalRevenue: '0.00'
},
// Initialize
init() {
this.vendorId = this.$el.dataset.vendorId;
this.vendorCode = this.$el.dataset.vendorCode;
this.vendorName = this.$el.dataset.vendorName;
this.detectVendorContext();
},
// Detect vendor context (subdomain vs path-based)
detectVendorContext() {
const host = window.location.host;
const path = window.location.pathname;
// Subdomain detection
const parts = host.split('.');
if (parts.length >= 2 && parts[0] !== 'www' && parts[0] !== 'admin' && parts[0] !== 'localhost') {
this.contextInfo = `Subdomain: ${parts[0]}.platform.com`;
return;
}
// Path-based detection
if (path.startsWith('/vendor/')) {
const pathParts = path.split('/');
if (pathParts.length >= 3 && pathParts[2]) {
this.contextInfo = `Path: /vendor/${pathParts[2]}/`;
return;
}
}
this.contextInfo = 'Development Mode';
},
// Load dashboard data
async loadDashboardData() {
try {
const token = localStorage.getItem('vendor_token');
const response = await fetch('/api/v1/vendor/dashboard/stats', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
this.stats = {
loaded: true,
totalProducts: data.total_products || 0,
totalOrders: data.total_orders || 0,
totalCustomers: data.total_customers || 0,
totalRevenue: (data.total_revenue || 0).toFixed(2)
};
}
} catch (error) {
console.error('Failed to load dashboard data:', error);
// Stats will remain at 0
}
},
// Handle logout
handleLogout() {
if (confirm('Are you sure you want to logout?')) {
localStorage.removeItem('vendor_token');
localStorage.removeItem('vendor_user');
window.location.href = '/static/vendor/login.html';
}
}
}
}
</script>
<script src="/static/js/shared/api-client.js"></script>
<script src="/static/js/vendor/dashboard.js"></script>
</body>
</html>

View File

@@ -3,297 +3,119 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vendor Login</title>
<title>Vendor 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="login-container">
<div id="loginContent">
<div class="auth-page" x-data="vendorLogin()" x-init="init()" x-cloak>
<div class="login-container">
<!-- Vendor Info -->
<template x-if="vendor">
<div class="vendor-info" style="margin-bottom: 24px;">
<h2 x-text="vendor.name" style="margin: 0; color: var(--primary-color);"></h2>
<p class="text-muted" style="margin: 4px 0 0 0; font-size: var(--font-sm);">
<strong x-text="vendor.vendor_code"></strong>
</p>
</div>
</template>
<div class="login-header">
<div class="vendor-logo">🏪</div>
<h1>Vendor Login</h1>
<p>Access your store management</p>
<div class="auth-logo">🏪</div>
<h1>Vendor Portal</h1>
<p>Sign in to manage your store</p>
</div>
<div id="vendorInfo" class="vendor-info" style="display: none;">
Logging in to: <strong id="vendorName">-</strong>
</div>
<!-- Alert Messages -->
<div x-show="error"
x-text="error"
class="alert alert-error"
x-transition></div>
<div id="noVendorMessage" class="no-vendor-message" style="display: none;">
<h2>⚠️ Vendor Not Found</h2>
<p>No vendor is associated with this URL.</p>
<a href="/" class="btn-back">← Back to Platform</a>
</div>
<div x-show="success"
x-text="success"
class="alert alert-success"
x-transition></div>
<div id="alertBox" class="alert"></div>
<!-- Login Form (only show if vendor found) -->
<template x-if="vendor">
<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>
<form id="loginForm">
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
id="username"
name="username"
required
autocomplete="username"
placeholder="Enter your username"
>
<div class="error-message" id="usernameError"></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>
</template>
<!-- Vendor Not Found -->
<template x-if="!vendor && !loading && checked">
<div class="empty-state">
<div class="empty-state-icon">🏪</div>
<h3>Vendor Not Found</h3>
<p>The vendor you're trying to access doesn't exist or is inactive.</p>
<a href="/admin/login.html" class="btn btn-primary" style="margin-top: 16px;">
Go to Admin Login
</a>
</div>
</template>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
required
autocomplete="current-password"
placeholder="Enter your password"
>
<div class="error-message" id="passwordError"></div>
</div>
<button type="submit" class="btn-login" id="loginButton">
Sign In
</button>
</form>
<div class="login-footer">
First time? Use the credentials provided by your admin.
<!-- Loading State -->
<div x-show="loading && !vendor" class="loading">
<span class="loading-spinner loading-spinner-lg"></span>
<p class="loading-text">Loading vendor information...</p>
</div>
</div>
</div>
<script src="/static/js/shared/api-client.js"></script>
<script>
const API_BASE_URL = '/api/v1';
// DOM Elements
const loginForm = document.getElementById('loginForm');
const loginButton = document.getElementById('loginButton');
const alertBox = document.getElementById('alertBox');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const usernameError = document.getElementById('usernameError');
const passwordError = document.getElementById('passwordError');
const vendorInfo = document.getElementById('vendorInfo');
const vendorName = document.getElementById('vendorName');
const noVendorMessage = document.getElementById('noVendorMessage');
// Detect vendor context
function detectVendorContext() {
const host = window.location.host;
const path = window.location.pathname;
// Method 1: Subdomain detection (production)
// e.g., techstore.platform.com
const parts = host.split('.');
if (parts.length >= 2 && parts[0] !== 'www' && parts[0] !== 'admin' && parts[0] !== 'localhost') {
return {
subdomain: parts[0],
method: 'subdomain'
};
}
// Method 2: Path-based detection (development)
// e.g., localhost:8000/vendor/techstore/login
if (path.startsWith('/vendor/')) {
const pathParts = path.split('/');
if (pathParts.length >= 3 && pathParts[2]) {
return {
subdomain: pathParts[2],
method: 'path'
};
}
}
return null;
}
// Verify vendor exists
async function verifyVendor(subdomain) {
try {
// Try to fetch vendor info from public API
const response = await fetch(`${API_BASE_URL}/public/vendors/${subdomain}`);
if (response.ok) {
const data = await response.json();
return data;
}
return null;
} catch (error) {
console.error('Error verifying vendor:', error);
return null;
}
}
// Show alert message
function showAlert(message, type = 'error') {
alertBox.textContent = message;
alertBox.className = `alert alert-${type} show`;
}
// Show field error
function showFieldError(field, message) {
const input = field === 'username' ? usernameInput : passwordInput;
const errorDiv = field === 'username' ? usernameError : passwordError;
input.classList.add('error');
errorDiv.textContent = message;
errorDiv.classList.add('show');
}
// Clear field errors
function clearFieldErrors() {
usernameInput.classList.remove('error');
passwordInput.classList.remove('error');
usernameError.classList.remove('show');
passwordError.classList.remove('show');
alertBox.classList.remove('show');
}
// Set loading state
function setLoadingState(loading) {
loginButton.disabled = loading;
if (loading) {
loginButton.innerHTML = '<span class="loading-spinner"></span>Signing in...';
} else {
loginButton.innerHTML = 'Sign In';
}
}
// Handle login
async function handleLogin(event) {
event.preventDefault();
clearFieldErrors();
const username = usernameInput.value.trim();
const password = passwordInput.value;
// Basic validation
if (!username) {
showFieldError('username', 'Username is required');
return;
}
if (!password) {
showFieldError('password', 'Password is required');
return;
}
setLoadingState(true);
try {
const response = await fetch(`${API_BASE_URL}/vendor/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Login failed');
}
// Check if user is not admin (vendors should not be admin)
if (data.user.role === 'admin') {
throw new Error('Admin users should use the admin portal.');
}
// Store token for vendor
localStorage.setItem('vendor_token', data.access_token);
localStorage.setItem('vendor_user', JSON.stringify(data.user));
// Show success message
showAlert('Login successful! Redirecting...', 'success');
// Redirect to vendor dashboard
const context = detectVendorContext();
let dashboardUrl = '/static/vendor/dashboard.html';
if (context && context.method === 'path') {
dashboardUrl = `/vendor/${context.subdomain}/dashboard`;
}
setTimeout(() => {
window.location.href = dashboardUrl;
}, 1000);
} catch (error) {
console.error('Login error:', error);
showAlert(error.message || 'Login failed. Please check your credentials.');
} finally {
setLoadingState(false);
}
}
// Event listeners
loginForm.addEventListener('submit', handleLogin);
// Clear errors on input
usernameInput.addEventListener('input', clearFieldErrors);
passwordInput.addEventListener('input', clearFieldErrors);
// Initialize page
window.addEventListener('DOMContentLoaded', async () => {
// Check if already logged in
const token = localStorage.getItem('vendor_token');
const user = localStorage.getItem('vendor_user');
if (token && user) {
try {
const userData = JSON.parse(user);
if (userData.role !== 'admin') {
const context = detectVendorContext();
let dashboardUrl = '/static/vendor/dashboard.html';
if (context && context.method === 'path') {
dashboardUrl = `/vendor/${context.subdomain}/dashboard`;
}
window.location.href = dashboardUrl;
return;
}
} catch (e) {
localStorage.removeItem('vendor_token');
localStorage.removeItem('vendor_user');
}
}
// Detect and verify vendor
const context = detectVendorContext();
if (!context) {
// No vendor context detected
loginForm.style.display = 'none';
noVendorMessage.style.display = 'block';
return;
}
// Display vendor info (for user confirmation)
vendorName.textContent = context.subdomain;
vendorInfo.style.display = 'block';
// Optionally verify vendor exists via API
// Uncomment when public vendor API is available
/*
const vendor = await verifyVendor(context.subdomain);
if (!vendor) {
loginForm.style.display = 'none';
noVendorMessage.style.display = 'block';
} else {
vendorName.textContent = vendor.name;
}
*/
});
</script>
<script src="/static/js/vendor/login.js"></script>
</body>
</html>