major refactoring adding vendor and customer features
This commit is contained in:
604
static/admin/dashboard.html
Normal file
604
static/admin/dashboard.html
Normal file
@@ -0,0 +1,604 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Dashboard - Multi-Tenant Ecommerce Platform</title>
|
||||
<link rel="stylesheet" href="/static/css/shared/base.css">
|
||||
<link rel="stylesheet" href="/static/css/admin/admin.css">
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
</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>
|
||||
</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>
|
||||
<div id="recentVendorsList">
|
||||
<div class="loading">Loading recent vendors...</div>
|
||||
</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>
|
||||
<div id="recentImportsList">
|
||||
<div class="loading">Loading recent imports...</div>
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
<div id="vendorsList">
|
||||
<div class="loading">Loading vendors...</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</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>
|
||||
</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 = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📦</div>
|
||||
<p>No import jobs yet</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Display vendors list
|
||||
function displayVendorsList(vendors) {
|
||||
const container = document.getElementById('vendorsList');
|
||||
|
||||
if (vendors.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">🏪</div>
|
||||
<p>No vendors found</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Display users list
|
||||
function displayUsersList(users) {
|
||||
const container = document.getElementById('usersList');
|
||||
|
||||
if (users.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">👥</div>
|
||||
<p>No users found</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Display imports list
|
||||
function displayImportsList(imports) {
|
||||
const container = document.getElementById('importsList');
|
||||
|
||||
if (imports.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📦</div>
|
||||
<p>No import jobs found</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
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>
|
||||
`;
|
||||
|
||||
container.innerHTML = tableHTML;
|
||||
}
|
||||
|
||||
// Initialize
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
if (checkAuth()) {
|
||||
loadDashboard();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
200
static/admin/login.html
Normal file
200
static/admin/login.html
Normal file
@@ -0,0 +1,200 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Login - Multi-Tenant Ecommerce Platform</title>
|
||||
<link rel="stylesheet" href="/static/css/shared/base.css">
|
||||
<link rel="stylesheet" href="/static/css/shared/auth.css">
|
||||
</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>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
347
static/admin/vendors.html
Normal file
347
static/admin/vendors.html
Normal file
@@ -0,0 +1,347 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Create Vendor - Admin Portal</title>
|
||||
<link rel="stylesheet" href="/static/css/shared/base.css">
|
||||
<link rel="stylesheet" href="/static/css/admin/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1>Create New Vendor</h1>
|
||||
<a href="/static/admin/dashboard.html" class="btn-back">← Back to Dashboard</a>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<div class="form-card">
|
||||
<h2 class="form-title">Vendor Information</h2>
|
||||
|
||||
<div id="alertBox" class="alert"></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>
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<div class="credential-item">
|
||||
<label>Subdomain:</label>
|
||||
<span class="value" id="displaySubdomain"></span>
|
||||
</div>
|
||||
|
||||
<div class="credential-item">
|
||||
<label>Owner Username:</label>
|
||||
<span class="value" id="displayUsername"></span>
|
||||
</div>
|
||||
|
||||
<div class="credential-item">
|
||||
<label>Owner Email:</label>
|
||||
<span class="value" id="displayEmail"></span>
|
||||
</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
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="location.reload()">
|
||||
Create Another Vendor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
556
static/css/admin/admin.css
Normal file
556
static/css/admin/admin.css
Normal file
@@ -0,0 +1,556 @@
|
||||
/* static/css/admin/admin.css */
|
||||
/* Admin-specific styles */
|
||||
|
||||
/* Admin Header */
|
||||
.admin-header {
|
||||
background: white;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
font-size: var(--font-2xl);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
font-size: var(--font-base);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.user-info strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
padding: 8px 16px;
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-base);
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
background: #c0392b;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Admin Container */
|
||||
.admin-container {
|
||||
display: flex;
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
/* Admin Sidebar */
|
||||
.admin-sidebar {
|
||||
width: 250px;
|
||||
background: white;
|
||||
border-right: 1px solid var(--border-color);
|
||||
padding: var(--spacing-lg) 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 24px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: var(--font-base);
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-base);
|
||||
border-right: 3px solid transparent;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: var(--gray-50);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-right-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
margin-right: var(--spacing-sm);
|
||||
font-size: var(--font-lg);
|
||||
}
|
||||
|
||||
/* Admin Content */
|
||||
.admin-content {
|
||||
flex: 1;
|
||||
padding: var(--spacing-lg);
|
||||
overflow-y: auto;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.stat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 32px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: var(--font-base);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-subtitle {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 600;
|
||||
margin-left: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.stat-change.positive {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.stat-change.negative {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
/* Content Sections */
|
||||
.content-section {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-md);
|
||||
border-bottom: 2px solid var(--gray-100);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--font-xl);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Data Tables */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table thead {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: var(--font-base);
|
||||
}
|
||||
|
||||
.data-table tbody tr {
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.data-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.table-actions .btn {
|
||||
padding: 6px 12px;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-2xl) var(--spacing-lg);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: var(--spacing-md);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: var(--font-xl);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: var(--font-base);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: var(--spacing-2xl);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: var(--spacing-md);
|
||||
font-size: var(--font-base);
|
||||
}
|
||||
|
||||
/* Search and Filters */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
flex: 2;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding-left: 40px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Modal/Dialog */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--bg-overlay);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: var(--font-xl);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: var(--font-2xl);
|
||||
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);
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: var(--gray-100);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: var(--spacing-lg);
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: white;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled) {
|
||||
background: var(--gray-50);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination-btn.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1024px) {
|
||||
.admin-sidebar {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-group,
|
||||
.search-box {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.admin-header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
font-size: var(--font-lg);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: var(--font-3xl);
|
||||
}
|
||||
|
||||
.content-section {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* Make table scrollable on small screens */
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
min-width: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print Styles */
|
||||
@media print {
|
||||
.admin-sidebar,
|
||||
.admin-header .header-right,
|
||||
.section-actions,
|
||||
.table-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
622
static/css/shared/auth.css
Normal file
622
static/css/shared/auth.css
Normal file
@@ -0,0 +1,622 @@
|
||||
/* static/css/shared/auth.css */
|
||||
/* Authentication pages (login, register) styles */
|
||||
|
||||
/* Auth Page Layout */
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.auth-page::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 80%, rgba(255,255,255,0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(255,255,255,0.1) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Login Container */
|
||||
.login-container,
|
||||
.auth-container {
|
||||
background: white;
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: var(--spacing-xl);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
animation: slideUp 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Login Header */
|
||||
.login-header,
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto var(--spacing-md);
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
|
||||
border-radius: var(--radius-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
color: white;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.login-header h1,
|
||||
.auth-header h1 {
|
||||
font-size: var(--font-3xl);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.login-header p,
|
||||
.auth-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-base);
|
||||
}
|
||||
|
||||
/* Vendor Info Display */
|
||||
.vendor-info {
|
||||
background: var(--gray-50);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
text-align: center;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.vendor-info strong {
|
||||
color: var(--primary-color);
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* No Vendor Message */
|
||||
.no-vendor-message {
|
||||
text-align: center;
|
||||
padding: var(--spacing-2xl) var(--spacing-lg);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.no-vendor-message h2 {
|
||||
font-size: var(--font-2xl);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.no-vendor-message p {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Auth Form */
|
||||
.auth-form,
|
||||
.login-form {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
font-size: var(--font-base);
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--font-md);
|
||||
transition: all var(--transition-base);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.form-group input.error {
|
||||
border-color: var(--danger-color);
|
||||
}
|
||||
|
||||
.form-group input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Password Toggle */
|
||||
.password-group {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: var(--spacing-sm);
|
||||
font-size: var(--font-lg);
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Remember Me & Forgot Password */
|
||||
.form-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.remember-me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.remember-me input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.forgot-password {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.forgot-password:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Submit Button */
|
||||
.btn-login,
|
||||
.btn-auth {
|
||||
width: 100%;
|
||||
padding: 14px 24px;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.btn-login:hover:not(:disabled),
|
||||
.btn-auth:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-login:active:not(:disabled),
|
||||
.btn-auth:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-login:disabled,
|
||||
.btn-auth:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Social Login Buttons */
|
||||
.social-login {
|
||||
margin-top: var(--spacing-lg);
|
||||
padding-top: var(--spacing-lg);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.social-login-text {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.social-buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.btn-social {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: 2px solid var(--border-color);
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
font-size: var(--font-base);
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.btn-social:hover {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
/* Login Footer */
|
||||
.login-footer,
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
margin-top: var(--spacing-lg);
|
||||
padding-top: var(--spacing-lg);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.login-footer a,
|
||||
.auth-footer a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-size: var(--font-base);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.login-footer a:hover,
|
||||
.auth-footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.auth-footer-text {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Back Button */
|
||||
.btn-back {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
background: var(--secondary-color);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background: #5a6268;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Error and Success Messages */
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
font-size: var(--font-base);
|
||||
display: none;
|
||||
animation: slideDown 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.alert.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffeaa7;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
|
||||
/* Field Errors */
|
||||
.error-message {
|
||||
color: var(--danger-color);
|
||||
font-size: var(--font-sm);
|
||||
margin-top: var(--spacing-xs);
|
||||
display: none;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.error-message.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
border-top-color: transparent;
|
||||
animation: spinner 0.6s linear infinite;
|
||||
margin-right: var(--spacing-sm);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spinner {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.divider span {
|
||||
padding: 0 var(--spacing-md);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
/* Credentials Display (for vendor creation success) */
|
||||
.credentials-card {
|
||||
background: #fff3cd;
|
||||
border: 2px solid var(--warning-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.credentials-card h3 {
|
||||
margin-bottom: var(--spacing-md);
|
||||
color: #856404;
|
||||
font-size: var(--font-xl);
|
||||
}
|
||||
|
||||
.credential-item {
|
||||
background: white;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.credential-item label {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-base);
|
||||
}
|
||||
|
||||
.credential-item .value {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: var(--gray-50);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-base);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: #856404;
|
||||
font-size: var(--font-sm);
|
||||
margin-top: var(--spacing-md);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.warning-text::before {
|
||||
content: '⚠️';
|
||||
font-size: var(--font-lg);
|
||||
}
|
||||
|
||||
/* Copy Button */
|
||||
.btn-copy {
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-xs);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.btn-copy:hover {
|
||||
background: var(--gray-50);
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 480px) {
|
||||
.login-container,
|
||||
.auth-container {
|
||||
padding: var(--spacing-lg);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.login-header h1,
|
||||
.auth-header h1 {
|
||||
font-size: var(--font-2xl);
|
||||
}
|
||||
|
||||
.btn-login,
|
||||
.btn-auth {
|
||||
padding: 12px 20px;
|
||||
font-size: var(--font-base);
|
||||
}
|
||||
|
||||
.social-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.credential-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.credential-item .value {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Mode Support (optional) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.auth-page {
|
||||
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
|
||||
}
|
||||
|
||||
.login-container,
|
||||
.auth-container {
|
||||
background: #2d3748;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.login-header h1,
|
||||
.auth-header h1,
|
||||
.form-group label {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.login-header p,
|
||||
.auth-header p {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.form-group input::placeholder {
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.vendor-info {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
.credential-item {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
.credential-item .value {
|
||||
background: #2d3748;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print Styles */
|
||||
@media print {
|
||||
.auth-page::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-container,
|
||||
.auth-container {
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-login,
|
||||
.btn-auth,
|
||||
.social-login,
|
||||
.login-footer,
|
||||
.auth-footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
510
static/css/shared/base.css
Normal file
510
static/css/shared/base.css
Normal file
@@ -0,0 +1,510 @@
|
||||
/* static/css/shared/base.css */
|
||||
/* Base styles shared across all pages */
|
||||
|
||||
:root {
|
||||
/* Color Palette */
|
||||
--primary-color: #667eea;
|
||||
--primary-dark: #764ba2;
|
||||
--secondary-color: #6c757d;
|
||||
--success-color: #28a745;
|
||||
--danger-color: #e74c3c;
|
||||
--warning-color: #ffc107;
|
||||
--info-color: #17a2b8;
|
||||
|
||||
/* Grays */
|
||||
--gray-50: #f9fafb;
|
||||
--gray-100: #f5f7fa;
|
||||
--gray-200: #e1e8ed;
|
||||
--gray-300: #d1d9e0;
|
||||
--gray-400: #b0bac5;
|
||||
--gray-500: #8796a5;
|
||||
--gray-600: #687785;
|
||||
--gray-700: #4a5568;
|
||||
--gray-800: #2d3748;
|
||||
--gray-900: #1a202c;
|
||||
|
||||
/* Text Colors */
|
||||
--text-primary: #333333;
|
||||
--text-secondary: #666666;
|
||||
--text-muted: #999999;
|
||||
|
||||
/* Background Colors */
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f5f7fa;
|
||||
--bg-overlay: rgba(0, 0, 0, 0.5);
|
||||
|
||||
/* Border Colors */
|
||||
--border-color: #e1e8ed;
|
||||
--border-focus: #667eea;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||
--shadow-xl: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 0.15s ease;
|
||||
--transition-base: 0.2s ease;
|
||||
--transition-slow: 0.3s ease;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
--spacing-2xl: 48px;
|
||||
|
||||
/* Font Sizes */
|
||||
--font-xs: 12px;
|
||||
--font-sm: 13px;
|
||||
--font-base: 14px;
|
||||
--font-md: 15px;
|
||||
--font-lg: 16px;
|
||||
--font-xl: 18px;
|
||||
--font-2xl: 20px;
|
||||
--font-3xl: 24px;
|
||||
--font-4xl: 32px;
|
||||
}
|
||||
|
||||
/* Reset and Base Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: var(--font-base);
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
h1 { font-size: var(--font-4xl); }
|
||||
h2 { font-size: var(--font-3xl); }
|
||||
h3 { font-size: var(--font-2xl); }
|
||||
h4 { font-size: var(--font-xl); }
|
||||
h5 { font-size: var(--font-lg); }
|
||||
h6 { font-size: var(--font-base); }
|
||||
|
||||
p {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-base);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
font-size: var(--font-base);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
text-decoration: none;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--secondary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: var(--warning-color);
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 2px solid var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-outline:hover:not(:disabled) {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 14px 28px;
|
||||
font-size: var(--font-lg);
|
||||
}
|
||||
|
||||
/* Form Elements */
|
||||
.form-group {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-base);
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
font-size: var(--font-base);
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
background: white;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color var(--transition-base);
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-input:focus,
|
||||
.form-select:focus,
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
.form-control.error,
|
||||
.form-input.error {
|
||||
border-color: var(--danger-color);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
display: block;
|
||||
margin-top: var(--spacing-xs);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: none;
|
||||
margin-top: var(--spacing-xs);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.error-message.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: var(--spacing-lg);
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
font-size: var(--font-xs);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
border-radius: var(--radius-full);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background: var(--gray-200);
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
font-size: var(--font-base);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border-color: #c3e6cb;
|
||||
}
|
||||
|
||||
.alert-danger,
|
||||
.alert-error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border-color: #f5c6cb;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border-color: #ffeaa7;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border-color: #bee5eb;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table th {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
background: var(--gray-100);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: var(--font-base);
|
||||
}
|
||||
|
||||
.table tr:hover {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.table-striped tbody tr:nth-child(odd) {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.text-center { text-align: center; }
|
||||
.text-left { text-align: left; }
|
||||
.text-right { text-align: right; }
|
||||
|
||||
.text-muted { color: var(--text-muted); }
|
||||
.text-primary { color: var(--primary-color); }
|
||||
.text-success { color: var(--success-color); }
|
||||
.text-danger { color: var(--danger-color); }
|
||||
.text-warning { color: var(--warning-color); }
|
||||
|
||||
.font-bold { font-weight: 700; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
.font-normal { font-weight: 400; }
|
||||
|
||||
.mt-0 { margin-top: 0; }
|
||||
.mt-1 { margin-top: var(--spacing-sm); }
|
||||
.mt-2 { margin-top: var(--spacing-md); }
|
||||
.mt-3 { margin-top: var(--spacing-lg); }
|
||||
.mt-4 { margin-top: var(--spacing-xl); }
|
||||
|
||||
.mb-0 { margin-bottom: 0; }
|
||||
.mb-1 { margin-bottom: var(--spacing-sm); }
|
||||
.mb-2 { margin-bottom: var(--spacing-md); }
|
||||
.mb-3 { margin-bottom: var(--spacing-lg); }
|
||||
.mb-4 { margin-bottom: var(--spacing-xl); }
|
||||
|
||||
.p-0 { padding: 0; }
|
||||
.p-1 { padding: var(--spacing-sm); }
|
||||
.p-2 { padding: var(--spacing-md); }
|
||||
.p-3 { padding: var(--spacing-lg); }
|
||||
.p-4 { padding: var(--spacing-xl); }
|
||||
|
||||
.d-none { display: none; }
|
||||
.d-block { display: block; }
|
||||
.d-inline { display: inline; }
|
||||
.d-inline-block { display: inline-block; }
|
||||
.d-flex { display: flex; }
|
||||
|
||||
.flex-column { flex-direction: column; }
|
||||
.flex-row { flex-direction: row; }
|
||||
.justify-start { justify-content: flex-start; }
|
||||
.justify-end { justify-content: flex-end; }
|
||||
.justify-center { justify-content: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.align-start { align-items: flex-start; }
|
||||
.align-end { align-items: flex-end; }
|
||||
.align-center { align-items: center; }
|
||||
|
||||
.gap-1 { gap: var(--spacing-sm); }
|
||||
.gap-2 { gap: var(--spacing-md); }
|
||||
.gap-3 { gap: var(--spacing-lg); }
|
||||
|
||||
/* Loading Spinner */
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
border-top-color: transparent;
|
||||
animation: spinner 0.6s linear infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spinner {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--bg-overlay);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.loading-spinner-lg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-width: 4px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--font-base: 14px;
|
||||
--font-lg: 15px;
|
||||
--font-xl: 16px;
|
||||
--font-2xl: 18px;
|
||||
--font-3xl: 20px;
|
||||
--font-4xl: 24px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
601
static/css/vendor/vendor.css
vendored
Normal file
601
static/css/vendor/vendor.css
vendored
Normal file
@@ -0,0 +1,601 @@
|
||||
/* static/css/vendor/vendor.css */
|
||||
/* Vendor-specific styles */
|
||||
|
||||
/* Vendor Header */
|
||||
.vendor-header {
|
||||
background: white;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.vendor-header h1 {
|
||||
font-size: var(--font-2xl);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vendor-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.vendor-logo-img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-md);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.vendor-name {
|
||||
font-size: var(--font-xl);
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Vendor Container */
|
||||
.vendor-container {
|
||||
display: flex;
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
/* Vendor Sidebar */
|
||||
.vendor-sidebar {
|
||||
width: 260px;
|
||||
background: white;
|
||||
border-right: 1px solid var(--border-color);
|
||||
padding: var(--spacing-lg) 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.vendor-sidebar-header {
|
||||
padding: 0 var(--spacing-lg) var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.vendor-status {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Vendor Content */
|
||||
.vendor-content {
|
||||
flex: 1;
|
||||
padding: var(--spacing-lg);
|
||||
overflow-y: auto;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Vendor Dashboard Widgets */
|
||||
.dashboard-widgets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.widget {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.widget-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.widget-title {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.widget-icon {
|
||||
font-size: var(--font-2xl);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.widget-content {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.widget-footer {
|
||||
padding-top: var(--spacing-md);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.widget-stat {
|
||||
font-size: var(--font-4xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.widget-label {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Welcome Card */
|
||||
.welcome-card {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
|
||||
color: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.welcome-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.welcome-card h2 {
|
||||
color: white;
|
||||
font-size: var(--font-3xl);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.welcome-card p {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: var(--font-lg);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Vendor Info Card */
|
||||
.vendor-info-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.info-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-base);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-base);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Coming Soon Badge */
|
||||
.coming-soon {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
display: inline-block;
|
||||
font-weight: 600;
|
||||
margin-top: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* Product Grid */
|
||||
.product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.product-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all var(--transition-base);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.product-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
background: var(--gray-100);
|
||||
}
|
||||
|
||||
.product-info {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.product-title {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: var(--font-xl);
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.product-description {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-actions {
|
||||
padding: var(--spacing-md);
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Order List */
|
||||
.order-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.order-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.order-card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.order-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.order-number {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.order-date {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.order-body {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.order-items {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.order-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.order-total {
|
||||
font-size: var(--font-xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 12px 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: var(--font-base);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
transition: all var(--transition-base);
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary-color);
|
||||
border-bottom-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* File Upload */
|
||||
.upload-area {
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-xl);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.upload-area:hover,
|
||||
.upload-area.dragover {
|
||||
border-color: var(--primary-color);
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 48px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: var(--font-base);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.file-list {
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-md);
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: var(--font-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.file-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--danger-color);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-lg);
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.progress {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--gray-200);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
|
||||
transition: width var(--transition-base);
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
text-align: center;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Settings Form */
|
||||
.settings-section {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-md);
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
font-size: var(--font-xl);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.settings-description {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1024px) {
|
||||
.vendor-sidebar {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.dashboard-widgets {
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
}
|
||||
|
||||
.product-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.vendor-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vendor-sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: var(--spacing-md) 0;
|
||||
}
|
||||
|
||||
.vendor-content {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.dashboard-widgets,
|
||||
.product-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.welcome-card {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.welcome-icon {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.welcome-card h2 {
|
||||
font-size: var(--font-2xl);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.tab {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.vendor-header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.vendor-header h1 {
|
||||
font-size: var(--font-lg);
|
||||
}
|
||||
|
||||
.vendor-name {
|
||||
font-size: var(--font-lg);
|
||||
}
|
||||
|
||||
.widget-stat {
|
||||
font-size: var(--font-3xl);
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.info-item {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print Styles */
|
||||
@media print {
|
||||
.vendor-sidebar,
|
||||
.vendor-header .header-right,
|
||||
.product-actions,
|
||||
.order-footer .btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vendor-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.settings-section,
|
||||
.order-card,
|
||||
.product-card {
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--border-color);
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
340
static/js/shared/api-client.js
Normal file
340
static/js/shared/api-client.js
Normal file
@@ -0,0 +1,340 @@
|
||||
// static/js/shared/api-client.js
|
||||
/**
|
||||
* API Client for Multi-Tenant Ecommerce Platform
|
||||
*
|
||||
* Provides utilities for:
|
||||
* - Making authenticated API calls
|
||||
* - Token management
|
||||
* - Error handling
|
||||
* - Request/response interceptors
|
||||
*/
|
||||
|
||||
const API_BASE_URL = '/api/v1';
|
||||
|
||||
/**
|
||||
* API Client Class
|
||||
*/
|
||||
class APIClient {
|
||||
constructor(baseURL = API_BASE_URL) {
|
||||
this.baseURL = baseURL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored authentication token
|
||||
*/
|
||||
getToken() {
|
||||
return localStorage.getItem('admin_token') || localStorage.getItem('vendor_token');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default headers with authentication
|
||||
*/
|
||||
getHeaders(additionalHeaders = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...additionalHeaders
|
||||
};
|
||||
|
||||
const token = this.getToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make API request
|
||||
*/
|
||||
async request(endpoint, options = {}) {
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
|
||||
const config = {
|
||||
...options,
|
||||
headers: this.getHeaders(options.headers)
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
// Handle 401 Unauthorized
|
||||
if (response.status === 401) {
|
||||
this.handleUnauthorized();
|
||||
throw new Error('Unauthorized - please login again');
|
||||
}
|
||||
|
||||
// Parse response
|
||||
const data = await response.json();
|
||||
|
||||
// Handle non-OK responses
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || data.message || 'Request failed');
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
console.error('API request failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request
|
||||
*/
|
||||
async get(endpoint, params = {}) {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const url = queryString ? `${endpoint}?${queryString}` : endpoint;
|
||||
|
||||
return this.request(url, {
|
||||
method: 'GET'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request
|
||||
*/
|
||||
async post(endpoint, data = {}) {
|
||||
return this.request(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT request
|
||||
*/
|
||||
async put(endpoint, data = {}) {
|
||||
return this.request(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request
|
||||
*/
|
||||
async delete(endpoint) {
|
||||
return this.request(endpoint, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle unauthorized access
|
||||
*/
|
||||
handleUnauthorized() {
|
||||
localStorage.removeItem('admin_token');
|
||||
localStorage.removeItem('admin_user');
|
||||
localStorage.removeItem('vendor_token');
|
||||
localStorage.removeItem('vendor_user');
|
||||
|
||||
// Redirect to appropriate login page
|
||||
if (window.location.pathname.includes('/admin/')) {
|
||||
window.location.href = '/static/admin/login.html';
|
||||
} else if (window.location.pathname.includes('/vendor/')) {
|
||||
window.location.href = '/static/vendor/login.html';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create global API client instance
|
||||
const apiClient = new APIClient();
|
||||
|
||||
/**
|
||||
* Authentication helpers
|
||||
*/
|
||||
const Auth = {
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
isAuthenticated() {
|
||||
const token = localStorage.getItem('admin_token') || localStorage.getItem('vendor_token');
|
||||
return !!token;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current user
|
||||
*/
|
||||
getCurrentUser() {
|
||||
const userStr = localStorage.getItem('admin_user') || localStorage.getItem('vendor_user');
|
||||
if (!userStr) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(userStr);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user is admin
|
||||
*/
|
||||
isAdmin() {
|
||||
const user = this.getCurrentUser();
|
||||
return user && user.role === 'admin';
|
||||
},
|
||||
|
||||
/**
|
||||
* Login
|
||||
*/
|
||||
async login(username, password) {
|
||||
const response = await apiClient.post('/auth/login', {
|
||||
username,
|
||||
password
|
||||
});
|
||||
|
||||
// Store token and user
|
||||
if (response.user.role === 'admin') {
|
||||
localStorage.setItem('admin_token', response.access_token);
|
||||
localStorage.setItem('admin_user', JSON.stringify(response.user));
|
||||
} else {
|
||||
localStorage.setItem('vendor_token', response.access_token);
|
||||
localStorage.setItem('vendor_user', JSON.stringify(response.user));
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
logout() {
|
||||
localStorage.removeItem('admin_token');
|
||||
localStorage.removeItem('admin_user');
|
||||
localStorage.removeItem('vendor_token');
|
||||
localStorage.removeItem('vendor_user');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility functions
|
||||
*/
|
||||
const Utils = {
|
||||
/**
|
||||
* Format date
|
||||
*/
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Format datetime
|
||||
*/
|
||||
formatDateTime(dateString) {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Format currency
|
||||
*/
|
||||
formatCurrency(amount, currency = 'EUR') {
|
||||
if (amount === null || amount === undefined) return '-';
|
||||
return new Intl.NumberFormat('en-GB', {
|
||||
style: 'currency',
|
||||
currency: currency
|
||||
}).format(amount);
|
||||
},
|
||||
|
||||
/**
|
||||
* Debounce function
|
||||
*/
|
||||
debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Show toast notification
|
||||
*/
|
||||
showToast(message, type = 'info', duration = 3000) {
|
||||
// Create toast element
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
|
||||
// Style
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 16px 24px;
|
||||
background: ${type === 'success' ? '#4caf50' : type === 'error' ? '#f44336' : '#2196f3'};
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
z-index: 10000;
|
||||
animation: slideIn 0.3s ease;
|
||||
max-width: 400px;
|
||||
`;
|
||||
|
||||
// Add to page
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// Remove after duration
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'slideOut 0.3s ease';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, duration);
|
||||
},
|
||||
|
||||
/**
|
||||
* Confirm dialog
|
||||
*/
|
||||
async confirm(message, title = 'Confirm') {
|
||||
return window.confirm(`${title}\n\n${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Add animation styles
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOut {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Export for use in other scripts
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { APIClient, apiClient, Auth, Utils };
|
||||
}
|
||||
249
static/shop/account/login.html
Normal file
249
static/shop/account/login.html
Normal file
@@ -0,0 +1,249 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title><!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - {{ vendor.name }}</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 class="auth-page">
|
||||
<div class="login-container"
|
||||
x-data="customerLogin()"
|
||||
x-init="checkRegistrationSuccess()"
|
||||
data-vendor-id="{{ vendor.id }}"
|
||||
data-vendor-name="{{ vendor.name }}"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="login-header">
|
||||
{% if vendor.logo_url %}
|
||||
<img src="{{ vendor.logo_url }}" alt="{{ vendor.name }}" class="auth-logo">
|
||||
{% else %}
|
||||
<div class="auth-logo">🛒</div>
|
||||
{% endif %}
|
||||
<h1>Welcome Back</h1>
|
||||
<p>Sign in to {{ vendor.name }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Alert Box -->
|
||||
<div x-show="alert.show"
|
||||
x-transition
|
||||
:class="'alert alert-' + alert.type"
|
||||
x-text="alert.message"
|
||||
></div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form @submit.prevent="handleLogin">
|
||||
<!-- Email -->
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
x-model="credentials.email"
|
||||
required
|
||||
placeholder="your@email.com"
|
||||
:class="{ 'error': errors.email }"
|
||||
@input="clearAllErrors()"
|
||||
>
|
||||
<div x-show="errors.email"
|
||||
x-text="errors.email"
|
||||
class="error-message show"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<div class="password-group">
|
||||
<input
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
id="password"
|
||||
x-model="credentials.password"
|
||||
required
|
||||
placeholder="Enter your password"
|
||||
:class="{ 'error': errors.password }"
|
||||
@input="clearAllErrors()"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="password-toggle"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<span x-text="showPassword ? '👁️' : '👁️🗨️'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div x-show="errors.password"
|
||||
x-text="errors.password"
|
||||
class="error-message show"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Remember Me & Forgot Password -->
|
||||
<div class="form-options">
|
||||
<div class="remember-me">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="rememberMe"
|
||||
x-model="rememberMe"
|
||||
>
|
||||
<label for="rememberMe">Remember me</label>
|
||||
</div>
|
||||
<a href="/shop/account/forgot-password" class="forgot-password">
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-login"
|
||||
:disabled="loading"
|
||||
>
|
||||
<span x-show="loading" class="loading-spinner"></span>
|
||||
<span x-text="loading ? 'Signing in...' : 'Sign In'"></span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Register Link -->
|
||||
<div class="login-footer">
|
||||
<div class="auth-footer-text">Don't have an account?</div>
|
||||
<a href="/shop/account/register">Create an account</a>
|
||||
</div>
|
||||
|
||||
<!-- Back to Shop -->
|
||||
<div class="login-footer" style="border-top: none; padding-top: 0;">
|
||||
<a href="/shop">← Continue shopping</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function customerLogin() {
|
||||
return {
|
||||
// Data
|
||||
credentials: {
|
||||
email: '',
|
||||
password: ''
|
||||
},
|
||||
rememberMe: false,
|
||||
showPassword: false,
|
||||
loading: false,
|
||||
errors: {},
|
||||
alert: {
|
||||
show: false,
|
||||
type: 'error',
|
||||
message: ''
|
||||
},
|
||||
|
||||
// Get vendor data
|
||||
get vendorId() {
|
||||
return this.$el.dataset.vendorId;
|
||||
},
|
||||
|
||||
get vendorName() {
|
||||
return this.$el.dataset.vendorName;
|
||||
},
|
||||
|
||||
// Check if redirected after registration
|
||||
checkRegistrationSuccess() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('registered') === 'true') {
|
||||
this.showAlert(
|
||||
'Account created successfully! Please sign in.',
|
||||
'success'
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// Clear errors
|
||||
clearAllErrors() {
|
||||
this.errors = {};
|
||||
this.alert.show = false;
|
||||
},
|
||||
|
||||
// Show alert
|
||||
showAlert(message, type = 'error') {
|
||||
this.alert = {
|
||||
show: true,
|
||||
type: type,
|
||||
message: message
|
||||
};
|
||||
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
// Handle login
|
||||
async handleLogin() {
|
||||
this.clearAllErrors();
|
||||
|
||||
// Basic validation
|
||||
if (!this.credentials.email) {
|
||||
this.errors.email = 'Email is required';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.credentials.password) {
|
||||
this.errors.password = 'Password is required';
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/public/vendors/${this.vendorId}/customers/login`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: this.credentials.email, // API expects username
|
||||
password: this.credentials.password
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Login failed');
|
||||
}
|
||||
|
||||
// Store token and user data
|
||||
localStorage.setItem('customer_token', data.access_token);
|
||||
localStorage.setItem('customer_user', JSON.stringify(data.user));
|
||||
|
||||
// Store vendor context
|
||||
localStorage.setItem('customer_vendor_id', this.vendorId);
|
||||
|
||||
this.showAlert('Login successful! Redirecting...', 'success');
|
||||
|
||||
// Redirect to account page or cart
|
||||
setTimeout(() => {
|
||||
const returnUrl = new URLSearchParams(window.location.search).get('return') || '/shop/account';
|
||||
window.location.href = returnUrl;
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
this.showAlert(error.message || 'Invalid email or password');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html></title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
341
static/shop/account/register.html
Normal file
341
static/shop/account/register.html
Normal file
@@ -0,0 +1,341 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Create Account - {{ vendor.name }}</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 class="auth-page">
|
||||
<div class="login-container"
|
||||
x-data="customerRegistration()"
|
||||
data-vendor-id="{{ vendor.id }}"
|
||||
data-vendor-name="{{ vendor.name }}"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="login-header">
|
||||
{% if vendor.logo_url %}
|
||||
<img src="{{ vendor.logo_url }}" alt="{{ vendor.name }}" class="auth-logo">
|
||||
{% else %}
|
||||
<div class="auth-logo">🛒</div>
|
||||
{% endif %}
|
||||
<h1>Create Account</h1>
|
||||
<p>Join {{ vendor.name }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Alert Box -->
|
||||
<div x-show="alert.show"
|
||||
x-transition
|
||||
:class="'alert alert-' + alert.type"
|
||||
x-text="alert.message"
|
||||
></div>
|
||||
|
||||
<!-- Registration Form -->
|
||||
<form @submit.prevent="handleRegister">
|
||||
<!-- First Name -->
|
||||
<div class="form-group">
|
||||
<label for="firstName">First Name <span class="required">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
id="firstName"
|
||||
x-model="formData.first_name"
|
||||
required
|
||||
placeholder="Enter your first name"
|
||||
:class="{ 'error': errors.first_name }"
|
||||
@input="clearError('first_name')"
|
||||
>
|
||||
<div x-show="errors.first_name"
|
||||
x-text="errors.first_name"
|
||||
class="error-message show"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Last Name -->
|
||||
<div class="form-group">
|
||||
<label for="lastName">Last Name <span class="required">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
id="lastName"
|
||||
x-model="formData.last_name"
|
||||
required
|
||||
placeholder="Enter your last name"
|
||||
:class="{ 'error': errors.last_name }"
|
||||
@input="clearError('last_name')"
|
||||
>
|
||||
<div x-show="errors.last_name"
|
||||
x-text="errors.last_name"
|
||||
class="error-message show"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address <span class="required">*</span></label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
x-model="formData.email"
|
||||
required
|
||||
placeholder="your@email.com"
|
||||
:class="{ 'error': errors.email }"
|
||||
@input="clearError('email')"
|
||||
>
|
||||
<div x-show="errors.email"
|
||||
x-text="errors.email"
|
||||
class="error-message show"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Phone (Optional) -->
|
||||
<div class="form-group">
|
||||
<label for="phone">Phone Number</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
x-model="formData.phone"
|
||||
placeholder="+352 123 456 789"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="form-group">
|
||||
<label for="password">Password <span class="required">*</span></label>
|
||||
<div class="password-group">
|
||||
<input
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
id="password"
|
||||
x-model="formData.password"
|
||||
required
|
||||
placeholder="At least 8 characters"
|
||||
:class="{ 'error': errors.password }"
|
||||
@input="clearError('password')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="password-toggle"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<span x-text="showPassword ? '👁️' : '👁️🗨️'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-help">
|
||||
Must contain at least 8 characters, one letter, and one number
|
||||
</div>
|
||||
<div x-show="errors.password"
|
||||
x-text="errors.password"
|
||||
class="error-message show"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">Confirm Password <span class="required">*</span></label>
|
||||
<input
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
id="confirmPassword"
|
||||
x-model="confirmPassword"
|
||||
required
|
||||
placeholder="Re-enter your password"
|
||||
:class="{ 'error': errors.confirmPassword }"
|
||||
@input="clearError('confirmPassword')"
|
||||
>
|
||||
<div x-show="errors.confirmPassword"
|
||||
x-text="errors.confirmPassword"
|
||||
class="error-message show"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Marketing Consent -->
|
||||
<div class="form-group">
|
||||
<div class="remember-me">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="marketingConsent"
|
||||
x-model="formData.marketing_consent"
|
||||
>
|
||||
<label for="marketingConsent" style="font-weight: normal;">
|
||||
I'd like to receive news and special offers
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-login"
|
||||
:disabled="loading"
|
||||
>
|
||||
<span x-show="loading" class="loading-spinner"></span>
|
||||
<span x-text="loading ? 'Creating Account...' : 'Create Account'"></span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Login Link -->
|
||||
<div class="login-footer">
|
||||
<div class="auth-footer-text">Already have an account?</div>
|
||||
<a href="/shop/account/login">Sign in instead</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function customerRegistration() {
|
||||
return {
|
||||
// Data
|
||||
formData: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
marketing_consent: false
|
||||
},
|
||||
confirmPassword: '',
|
||||
showPassword: false,
|
||||
loading: false,
|
||||
errors: {},
|
||||
alert: {
|
||||
show: false,
|
||||
type: 'error',
|
||||
message: ''
|
||||
},
|
||||
|
||||
// Get vendor data from element
|
||||
get vendorId() {
|
||||
return this.$el.dataset.vendorId;
|
||||
},
|
||||
|
||||
get vendorName() {
|
||||
return this.$el.dataset.vendorName;
|
||||
},
|
||||
|
||||
// Clear specific error
|
||||
clearError(field) {
|
||||
delete this.errors[field];
|
||||
},
|
||||
|
||||
// Clear all errors
|
||||
clearAllErrors() {
|
||||
this.errors = {};
|
||||
this.alert.show = false;
|
||||
},
|
||||
|
||||
// Show alert
|
||||
showAlert(message, type = 'error') {
|
||||
this.alert = {
|
||||
show: true,
|
||||
type: type,
|
||||
message: message
|
||||
};
|
||||
|
||||
// Auto-hide success messages
|
||||
if (type === 'success') {
|
||||
setTimeout(() => {
|
||||
this.alert.show = false;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Scroll to top
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
// Validate form
|
||||
validateForm() {
|
||||
this.clearAllErrors();
|
||||
let isValid = true;
|
||||
|
||||
// First name
|
||||
if (!this.formData.first_name.trim()) {
|
||||
this.errors.first_name = 'First name is required';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Last name
|
||||
if (!this.formData.last_name.trim()) {
|
||||
this.errors.last_name = 'Last name is required';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Email
|
||||
if (!this.formData.email.trim()) {
|
||||
this.errors.email = 'Email is required';
|
||||
isValid = false;
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.formData.email)) {
|
||||
this.errors.email = 'Please enter a valid email address';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Password
|
||||
if (!this.formData.password) {
|
||||
this.errors.password = 'Password is required';
|
||||
isValid = false;
|
||||
} else if (this.formData.password.length < 8) {
|
||||
this.errors.password = 'Password must be at least 8 characters';
|
||||
isValid = false;
|
||||
} else if (!/[a-zA-Z]/.test(this.formData.password)) {
|
||||
this.errors.password = 'Password must contain at least one letter';
|
||||
isValid = false;
|
||||
} else if (!/[0-9]/.test(this.formData.password)) {
|
||||
this.errors.password = 'Password must contain at least one number';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Confirm password
|
||||
if (this.formData.password !== this.confirmPassword) {
|
||||
this.errors.confirmPassword = 'Passwords do not match';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
},
|
||||
|
||||
// Handle registration
|
||||
async handleRegister() {
|
||||
if (!this.validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/public/vendors/${this.vendorId}/customers/register`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(this.formData)
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Registration failed');
|
||||
}
|
||||
|
||||
// Success!
|
||||
this.showAlert(
|
||||
'Account created successfully! Redirecting to login...',
|
||||
'success'
|
||||
);
|
||||
|
||||
// Redirect to login after 2 seconds
|
||||
setTimeout(() => {
|
||||
window.location.href = '/shop/account/login?registered=true';
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
this.showAlert(error.message || 'Registration failed. Please try again.');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
489
static/shop/cart.html
Normal file
489
static/shop/cart.html
Normal file
@@ -0,0 +1,489 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Shopping Cart - {{ vendor.name }}</title>
|
||||
<link rel="stylesheet" href="/static/css/shared/base.css">
|
||||
<link rel="stylesheet" href="/static/css/vendor/vendor.css">
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div x-data="shoppingCart()"
|
||||
x-init="loadCart()"
|
||||
data-vendor-id="{{ vendor.id }}"
|
||||
>
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<h1>🛒 Shopping Cart</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<a href="/shop" class="btn-secondary">← Continue Shopping</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading && items.length === 0" class="loading">
|
||||
<div class="loading-spinner-lg"></div>
|
||||
<p>Loading your cart...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty Cart -->
|
||||
<div x-show="!loading && items.length === 0" class="empty-state">
|
||||
<div class="empty-state-icon">🛒</div>
|
||||
<h3>Your cart is empty</h3>
|
||||
<p>Add some products to get started!</p>
|
||||
<a href="/shop/products" class="btn-primary">Browse Products</a>
|
||||
</div>
|
||||
|
||||
<!-- Cart Items -->
|
||||
<div x-show="items.length > 0" class="cart-content">
|
||||
<!-- Cart Items List -->
|
||||
<div class="cart-items">
|
||||
<template x-for="item in items" :key="item.product_id">
|
||||
<div class="cart-item-card">
|
||||
<div class="item-image">
|
||||
<img :src="item.image_url || '/static/images/placeholder.png'"
|
||||
:alt="item.name">
|
||||
</div>
|
||||
|
||||
<div class="item-details">
|
||||
<h3 class="item-name" x-text="item.name"></h3>
|
||||
<p class="item-sku" x-text="'SKU: ' + item.sku"></p>
|
||||
<p class="item-price">
|
||||
€<span x-text="parseFloat(item.price).toFixed(2)"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="item-quantity">
|
||||
<label>Quantity:</label>
|
||||
<div class="quantity-controls">
|
||||
<button
|
||||
@click="updateQuantity(item.product_id, item.quantity - 1)"
|
||||
:disabled="item.quantity <= 1 || updating"
|
||||
class="btn-quantity"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
:value="item.quantity"
|
||||
@change="updateQuantity(item.product_id, $event.target.value)"
|
||||
min="1"
|
||||
max="99"
|
||||
:disabled="updating"
|
||||
class="quantity-input"
|
||||
>
|
||||
<button
|
||||
@click="updateQuantity(item.product_id, item.quantity + 1)"
|
||||
:disabled="updating"
|
||||
class="btn-quantity"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-total">
|
||||
<label>Subtotal:</label>
|
||||
<p class="item-total-price">
|
||||
€<span x-text="(parseFloat(item.price) * item.quantity).toFixed(2)"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="item-actions">
|
||||
<button
|
||||
@click="removeItem(item.product_id)"
|
||||
:disabled="updating"
|
||||
class="btn-remove"
|
||||
title="Remove from cart"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Cart Summary -->
|
||||
<div class="cart-summary">
|
||||
<div class="summary-card">
|
||||
<h3>Order Summary</h3>
|
||||
|
||||
<div class="summary-row">
|
||||
<span>Subtotal (<span x-text="totalItems"></span> items):</span>
|
||||
<span>€<span x-text="subtotal.toFixed(2)"></span></span>
|
||||
</div>
|
||||
|
||||
<div class="summary-row">
|
||||
<span>Shipping:</span>
|
||||
<span x-text="shipping > 0 ? '€' + shipping.toFixed(2) : 'FREE'"></span>
|
||||
</div>
|
||||
|
||||
<div class="summary-row summary-total">
|
||||
<span>Total:</span>
|
||||
<span class="total-amount">€<span x-text="total.toFixed(2)"></span></span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="proceedToCheckout()"
|
||||
:disabled="updating || items.length === 0"
|
||||
class="btn-primary btn-checkout"
|
||||
>
|
||||
Proceed to Checkout
|
||||
</button>
|
||||
|
||||
<a href="/shop/products" class="btn-outline">
|
||||
Continue Shopping
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function shoppingCart() {
|
||||
return {
|
||||
items: [],
|
||||
loading: false,
|
||||
updating: false,
|
||||
vendorId: null,
|
||||
sessionId: null,
|
||||
|
||||
// Computed properties
|
||||
get totalItems() {
|
||||
return this.items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
},
|
||||
|
||||
get subtotal() {
|
||||
return this.items.reduce((sum, item) =>
|
||||
sum + (parseFloat(item.price) * item.quantity), 0
|
||||
);
|
||||
},
|
||||
|
||||
get shipping() {
|
||||
// Free shipping over €50
|
||||
return this.subtotal >= 50 ? 0 : 5.99;
|
||||
},
|
||||
|
||||
get total() {
|
||||
return this.subtotal + this.shipping;
|
||||
},
|
||||
|
||||
// Initialize
|
||||
init() {
|
||||
this.vendorId = this.$el.dataset.vendorId;
|
||||
this.sessionId = this.getOrCreateSessionId();
|
||||
},
|
||||
|
||||
// Get or create session ID
|
||||
getOrCreateSessionId() {
|
||||
let sessionId = localStorage.getItem('cart_session_id');
|
||||
if (!sessionId) {
|
||||
sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
localStorage.setItem('cart_session_id', sessionId);
|
||||
}
|
||||
return sessionId;
|
||||
},
|
||||
|
||||
// Load cart from API
|
||||
async loadCart() {
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/public/vendors/${this.vendorId}/cart/${this.sessionId}`
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.items = data.items || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load cart:', error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Update item quantity
|
||||
async updateQuantity(productId, newQuantity) {
|
||||
newQuantity = parseInt(newQuantity);
|
||||
|
||||
if (newQuantity < 1 || newQuantity > 99) return;
|
||||
|
||||
this.updating = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/public/vendors/${this.vendorId}/cart/${this.sessionId}/items/${productId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ quantity: newQuantity })
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
await this.loadCart();
|
||||
} else {
|
||||
throw new Error('Failed to update quantity');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update quantity error:', error);
|
||||
alert('Failed to update quantity. Please try again.');
|
||||
} finally {
|
||||
this.updating = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Remove item from cart
|
||||
async removeItem(productId) {
|
||||
if (!confirm('Remove this item from your cart?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updating = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/public/vendors/${this.vendorId}/cart/${this.sessionId}/items/${productId}`,
|
||||
{
|
||||
method: 'DELETE'
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
await this.loadCart();
|
||||
} else {
|
||||
throw new Error('Failed to remove item');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Remove item error:', error);
|
||||
alert('Failed to remove item. Please try again.');
|
||||
} finally {
|
||||
this.updating = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Proceed to checkout
|
||||
proceedToCheckout() {
|
||||
// Check if customer is logged in
|
||||
const token = localStorage.getItem('customer_token');
|
||||
|
||||
if (!token) {
|
||||
// Redirect to login with return URL
|
||||
window.location.href = '/shop/account/login?return=/shop/checkout';
|
||||
} else {
|
||||
window.location.href = '/shop/checkout';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Cart-specific styles */
|
||||
.cart-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 350px;
|
||||
gap: var(--spacing-lg);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.cart-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.cart-item-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr auto auto auto;
|
||||
gap: var(--spacing-lg);
|
||||
align-items: center;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.item-image img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.item-sku {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.item-price {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.quantity-controls {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-quantity {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: white;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-quantity:hover:not(:disabled) {
|
||||
background: var(--gray-50);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-quantity:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.quantity-input {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.item-total-price {
|
||||
font-size: var(--font-xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: white;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-lg);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.btn-remove:hover:not(:disabled) {
|
||||
background: var(--danger-color);
|
||||
border-color: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cart-summary {
|
||||
position: sticky;
|
||||
top: var(--spacing-lg);
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.summary-card h3 {
|
||||
font-size: var(--font-xl);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-md);
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-md);
|
||||
font-size: var(--font-base);
|
||||
}
|
||||
|
||||
.summary-total {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 700;
|
||||
padding-top: var(--spacing-md);
|
||||
border-top: 2px solid var(--border-color);
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
.total-amount {
|
||||
color: var(--primary-color);
|
||||
font-size: var(--font-2xl);
|
||||
}
|
||||
|
||||
.btn-checkout {
|
||||
width: 100%;
|
||||
margin-top: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
width: 100%;
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.cart-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cart-summary {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cart-item-card {
|
||||
grid-template-columns: 80px 1fr;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.item-image img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.item-quantity,
|
||||
.item-total {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
grid-column: 2;
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
771
static/shop/product.html
Normal file
771
static/shop/product.html
Normal file
@@ -0,0 +1,771 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ product.name if product else 'Product' }} - {{ vendor.name }}</title>
|
||||
<link rel="stylesheet" href="/static/css/shared/base.css">
|
||||
<link rel="stylesheet" href="/static/css/vendor/vendor.css">
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div x-data="productDetail()"
|
||||
x-init="loadProduct()"
|
||||
data-vendor-id="{{ vendor.id }}"
|
||||
data-product-id="{{ product_id }}"
|
||||
>
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<a href="/shop/products" class="btn-back">← Back to Products</a>
|
||||
<h1>{{ vendor.name }}</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<a href="/shop/cart" class="btn-primary">
|
||||
🛒 Cart (<span x-text="cartCount"></span>)
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="container">
|
||||
<div class="loading">
|
||||
<div class="loading-spinner-lg"></div>
|
||||
<p>Loading product...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Detail -->
|
||||
<div x-show="!loading && product" class="container">
|
||||
<div class="product-detail-container">
|
||||
<!-- Product Images -->
|
||||
<div class="product-images">
|
||||
<div class="main-image">
|
||||
<img
|
||||
:src="selectedImage || '/static/images/placeholder.png'"
|
||||
:alt="product?.marketplace_product?.title"
|
||||
class="product-main-image"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Thumbnail Gallery -->
|
||||
<div class="image-gallery" x-show="product?.marketplace_product?.images?.length > 1">
|
||||
<template x-for="(image, index) in product?.marketplace_product?.images" :key="index">
|
||||
<img
|
||||
:src="image"
|
||||
:alt="`Product image ${index + 1}`"
|
||||
class="thumbnail"
|
||||
:class="{ 'active': selectedImage === image }"
|
||||
@click="selectedImage = image"
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Info -->
|
||||
<div class="product-info-detail">
|
||||
<h1 x-text="product?.marketplace_product?.title" class="product-title-detail"></h1>
|
||||
|
||||
<!-- Brand & Category -->
|
||||
<div class="product-meta">
|
||||
<span x-show="product?.marketplace_product?.brand" class="meta-item">
|
||||
<strong>Brand:</strong> <span x-text="product?.marketplace_product?.brand"></span>
|
||||
</span>
|
||||
<span x-show="product?.marketplace_product?.google_product_category" class="meta-item">
|
||||
<strong>Category:</strong> <span x-text="product?.marketplace_product?.google_product_category"></span>
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<strong>SKU:</strong> <span x-text="product?.product_id || product?.marketplace_product?.mpn"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Price -->
|
||||
<div class="product-pricing">
|
||||
<div x-show="product?.sale_price && product?.sale_price < product?.price">
|
||||
<span class="price-original">€<span x-text="parseFloat(product?.price).toFixed(2)"></span></span>
|
||||
<span class="price-sale">€<span x-text="parseFloat(product?.sale_price).toFixed(2)"></span></span>
|
||||
<span class="price-badge">SALE</span>
|
||||
</div>
|
||||
<div x-show="!product?.sale_price || product?.sale_price >= product?.price">
|
||||
<span class="price-current">€<span x-text="parseFloat(product?.price || 0).toFixed(2)"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Availability -->
|
||||
<div class="product-availability">
|
||||
<span
|
||||
x-show="product?.available_inventory > 0"
|
||||
class="availability-badge in-stock"
|
||||
>
|
||||
✓ In Stock (<span x-text="product?.available_inventory"></span> available)
|
||||
</span>
|
||||
<span
|
||||
x-show="!product?.available_inventory || product?.available_inventory <= 0"
|
||||
class="availability-badge out-of-stock"
|
||||
>
|
||||
✗ Out of Stock
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="product-description">
|
||||
<h3>Description</h3>
|
||||
<p x-text="product?.marketplace_product?.description || 'No description available'"></p>
|
||||
</div>
|
||||
|
||||
<!-- Additional Details -->
|
||||
<div class="product-details" x-show="hasAdditionalDetails">
|
||||
<h3>Product Details</h3>
|
||||
<ul>
|
||||
<li x-show="product?.marketplace_product?.gtin">
|
||||
<strong>GTIN:</strong> <span x-text="product?.marketplace_product?.gtin"></span>
|
||||
</li>
|
||||
<li x-show="product?.condition">
|
||||
<strong>Condition:</strong> <span x-text="product?.condition"></span>
|
||||
</li>
|
||||
<li x-show="product?.marketplace_product?.color">
|
||||
<strong>Color:</strong> <span x-text="product?.marketplace_product?.color"></span>
|
||||
</li>
|
||||
<li x-show="product?.marketplace_product?.size">
|
||||
<strong>Size:</strong> <span x-text="product?.marketplace_product?.size"></span>
|
||||
</li>
|
||||
<li x-show="product?.marketplace_product?.material">
|
||||
<strong>Material:</strong> <span x-text="product?.marketplace_product?.material"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Add to Cart Section -->
|
||||
<div class="add-to-cart-section">
|
||||
<!-- Quantity Selector -->
|
||||
<div class="quantity-selector">
|
||||
<label>Quantity:</label>
|
||||
<div class="quantity-controls">
|
||||
<button
|
||||
@click="decreaseQuantity()"
|
||||
:disabled="quantity <= (product?.min_quantity || 1)"
|
||||
class="btn-quantity"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="quantity"
|
||||
:min="product?.min_quantity || 1"
|
||||
:max="product?.max_quantity || product?.available_inventory"
|
||||
class="quantity-input"
|
||||
@change="validateQuantity()"
|
||||
>
|
||||
<button
|
||||
@click="increaseQuantity()"
|
||||
:disabled="quantity >= (product?.max_quantity || product?.available_inventory)"
|
||||
class="btn-quantity"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add to Cart Button -->
|
||||
<button
|
||||
@click="addToCart()"
|
||||
:disabled="!canAddToCart || addingToCart"
|
||||
class="btn-add-to-cart"
|
||||
>
|
||||
<span x-show="!addingToCart">
|
||||
🛒 Add to Cart
|
||||
</span>
|
||||
<span x-show="addingToCart">
|
||||
<span class="loading-spinner"></span> Adding...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Total Price -->
|
||||
<div class="total-price">
|
||||
<strong>Total:</strong> €<span x-text="totalPrice.toFixed(2)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Related Products / You May Also Like -->
|
||||
<div class="related-products" x-show="relatedProducts.length > 0">
|
||||
<h2>You May Also Like</h2>
|
||||
<div class="product-grid">
|
||||
<template x-for="related in relatedProducts" :key="related.id">
|
||||
<div class="product-card">
|
||||
<img
|
||||
:src="related.image_url || '/static/images/placeholder.png'"
|
||||
:alt="related.name"
|
||||
class="product-image"
|
||||
@click="viewProduct(related.id)"
|
||||
>
|
||||
<div class="product-info">
|
||||
<h3
|
||||
class="product-title"
|
||||
@click="viewProduct(related.id)"
|
||||
x-text="related.name"
|
||||
></h3>
|
||||
<p class="product-price">
|
||||
€<span x-text="parseFloat(related.price).toFixed(2)"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notification -->
|
||||
<div
|
||||
x-show="toast.show"
|
||||
x-transition
|
||||
:class="'toast toast-' + toast.type"
|
||||
x-text="toast.message"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function productDetail() {
|
||||
return {
|
||||
// Data
|
||||
product: null,
|
||||
relatedProducts: [],
|
||||
loading: false,
|
||||
addingToCart: false,
|
||||
quantity: 1,
|
||||
selectedImage: null,
|
||||
cartCount: 0,
|
||||
vendorId: null,
|
||||
productId: null,
|
||||
sessionId: null,
|
||||
|
||||
// Toast notification
|
||||
toast: {
|
||||
show: false,
|
||||
type: 'success',
|
||||
message: ''
|
||||
},
|
||||
|
||||
// Computed properties
|
||||
get canAddToCart() {
|
||||
return this.product?.is_active &&
|
||||
this.product?.available_inventory > 0 &&
|
||||
this.quantity > 0 &&
|
||||
this.quantity <= this.product?.available_inventory;
|
||||
},
|
||||
|
||||
get totalPrice() {
|
||||
const price = this.product?.sale_price || this.product?.price || 0;
|
||||
return price * this.quantity;
|
||||
},
|
||||
|
||||
get hasAdditionalDetails() {
|
||||
return this.product?.marketplace_product?.gtin ||
|
||||
this.product?.condition ||
|
||||
this.product?.marketplace_product?.color ||
|
||||
this.product?.marketplace_product?.size ||
|
||||
this.product?.marketplace_product?.material;
|
||||
},
|
||||
|
||||
// Initialize
|
||||
init() {
|
||||
this.vendorId = this.$el.dataset.vendorId;
|
||||
this.productId = this.$el.dataset.productId;
|
||||
this.sessionId = this.getOrCreateSessionId();
|
||||
this.loadCartCount();
|
||||
},
|
||||
|
||||
// Get or create session ID
|
||||
getOrCreateSessionId() {
|
||||
let sessionId = localStorage.getItem('cart_session_id');
|
||||
if (!sessionId) {
|
||||
sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
localStorage.setItem('cart_session_id', sessionId);
|
||||
}
|
||||
return sessionId;
|
||||
},
|
||||
|
||||
// Load product details
|
||||
async loadProduct() {
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/public/vendors/${this.vendorId}/products/${this.productId}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Product not found');
|
||||
}
|
||||
|
||||
this.product = await response.json();
|
||||
|
||||
// Set default image
|
||||
if (this.product?.marketplace_product?.image_link) {
|
||||
this.selectedImage = this.product.marketplace_product.image_link;
|
||||
}
|
||||
|
||||
// Set initial quantity
|
||||
this.quantity = this.product?.min_quantity || 1;
|
||||
|
||||
// Load related products (optional)
|
||||
await this.loadRelatedProducts();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load product:', error);
|
||||
this.showToast('Failed to load product', 'error');
|
||||
// Redirect back to products after error
|
||||
setTimeout(() => {
|
||||
window.location.href = '/shop/products';
|
||||
}, 2000);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Load related products (same category or brand)
|
||||
async loadRelatedProducts() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/public/vendors/${this.vendorId}/products?limit=4`
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Filter out current product
|
||||
this.relatedProducts = data.products
|
||||
.filter(p => p.id !== parseInt(this.productId))
|
||||
.slice(0, 4);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load related products:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Load cart count
|
||||
async loadCartCount() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/public/vendors/${this.vendorId}/cart/${this.sessionId}`
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.cartCount = (data.items || []).reduce((sum, item) =>
|
||||
sum + item.quantity, 0
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load cart count:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Quantity controls
|
||||
increaseQuantity() {
|
||||
const max = this.product?.max_quantity || this.product?.available_inventory;
|
||||
if (this.quantity < max) {
|
||||
this.quantity++;
|
||||
}
|
||||
},
|
||||
|
||||
decreaseQuantity() {
|
||||
const min = this.product?.min_quantity || 1;
|
||||
if (this.quantity > min) {
|
||||
this.quantity--;
|
||||
}
|
||||
},
|
||||
|
||||
validateQuantity() {
|
||||
const min = this.product?.min_quantity || 1;
|
||||
const max = this.product?.max_quantity || this.product?.available_inventory;
|
||||
|
||||
if (this.quantity < min) {
|
||||
this.quantity = min;
|
||||
} else if (this.quantity > max) {
|
||||
this.quantity = max;
|
||||
}
|
||||
},
|
||||
|
||||
// Add to cart
|
||||
async addToCart() {
|
||||
if (!this.canAddToCart) return;
|
||||
|
||||
this.addingToCart = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/public/vendors/${this.vendorId}/cart/${this.sessionId}/items`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
product_id: parseInt(this.productId),
|
||||
quantity: this.quantity
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
this.cartCount += this.quantity;
|
||||
this.showToast(
|
||||
`${this.quantity} item(s) added to cart!`,
|
||||
'success'
|
||||
);
|
||||
|
||||
// Reset quantity to minimum
|
||||
this.quantity = this.product?.min_quantity || 1;
|
||||
} else {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to add to cart');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Add to cart error:', error);
|
||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||
} finally {
|
||||
this.addingToCart = false;
|
||||
}
|
||||
},
|
||||
|
||||
// View other product
|
||||
viewProduct(productId) {
|
||||
window.location.href = `/shop/products/${productId}`;
|
||||
},
|
||||
|
||||
// Show toast notification
|
||||
showToast(message, type = 'success') {
|
||||
this.toast = {
|
||||
show: true,
|
||||
type: type,
|
||||
message: message
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
this.toast.show = false;
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Product Detail Styles */
|
||||
.product-detail-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-2xl);
|
||||
margin-top: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.product-images {
|
||||
position: sticky;
|
||||
top: var(--spacing-lg);
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.main-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--gray-50);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.product-main-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.image-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.thumbnail:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.thumbnail.active {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.product-info-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.product-title-detail {
|
||||
font-size: var(--font-4xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.product-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.product-pricing {
|
||||
padding: var(--spacing-lg);
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.price-original {
|
||||
font-size: var(--font-xl);
|
||||
color: var(--text-muted);
|
||||
text-decoration: line-through;
|
||||
margin-right: var(--spacing-md);
|
||||
}
|
||||
|
||||
.price-sale {
|
||||
font-size: var(--font-4xl);
|
||||
font-weight: 700;
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.price-current {
|
||||
font-size: var(--font-4xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.price-badge {
|
||||
display: inline-block;
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 600;
|
||||
margin-left: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.product-availability {
|
||||
padding: var(--spacing-md);
|
||||
background: white;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.availability-badge {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 600;
|
||||
font-size: var(--font-base);
|
||||
}
|
||||
|
||||
.availability-badge.in-stock {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.availability-badge.out-of-stock {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.product-description {
|
||||
padding: var(--spacing-lg);
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.product-description h3 {
|
||||
margin-bottom: var(--spacing-md);
|
||||
font-size: var(--font-xl);
|
||||
}
|
||||
|
||||
.product-description p {
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.product-details {
|
||||
padding: var(--spacing-lg);
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.product-details h3 {
|
||||
margin-bottom: var(--spacing-md);
|
||||
font-size: var(--font-xl);
|
||||
}
|
||||
|
||||
.product-details ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.product-details li {
|
||||
padding: var(--spacing-sm) 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.product-details li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.add-to-cart-section {
|
||||
padding: var(--spacing-xl);
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 2px solid var(--primary-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.quantity-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.quantity-selector label {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-lg);
|
||||
}
|
||||
|
||||
.quantity-controls {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-quantity {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 2px solid var(--border-color);
|
||||
background: white;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-xl);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.btn-quantity:hover:not(:disabled) {
|
||||
background: var(--gray-50);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-quantity:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.quantity-input {
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-add-to-cart {
|
||||
width: 100%;
|
||||
padding: 16px 32px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--font-xl);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.btn-add-to-cart:hover:not(:disabled) {
|
||||
background: var(--primary-dark);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.btn-add-to-cart:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.total-price {
|
||||
padding: var(--spacing-md);
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
font-size: var(--font-2xl);
|
||||
}
|
||||
|
||||
.related-products {
|
||||
margin-top: var(--spacing-2xl);
|
||||
padding-top: var(--spacing-2xl);
|
||||
border-top: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.related-products h2 {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
font-size: var(--font-3xl);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.product-detail-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.product-images {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.product-title-detail {
|
||||
font-size: var(--font-2xl);
|
||||
}
|
||||
|
||||
.price-current,
|
||||
.price-sale {
|
||||
font-size: var(--font-3xl);
|
||||
}
|
||||
|
||||
.add-to-cart-section {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
459
static/shop/products.html
Normal file
459
static/shop/products.html
Normal file
@@ -0,0 +1,459 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Products - {{ vendor.name }}</title>
|
||||
<link rel="stylesheet" href="/static/css/shared/base.css">
|
||||
<link rel="stylesheet" href="/static/css/vendor/vendor.css">
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div x-data="productCatalog()"
|
||||
x-init="loadProducts()"
|
||||
data-vendor-id="{{ vendor.id }}"
|
||||
>
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<h1>{{ vendor.name }} - Products</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<a href="/shop/cart" class="btn-primary">
|
||||
🛒 Cart (<span x-text="cartCount"></span>)
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<!-- Filters & Search -->
|
||||
<div class="filter-bar">
|
||||
<div class="search-box">
|
||||
<input
|
||||
type="text"
|
||||
x-model="filters.search"
|
||||
@input.debounce.500ms="loadProducts()"
|
||||
placeholder="Search products..."
|
||||
class="search-input"
|
||||
>
|
||||
<span class="search-icon">🔍</span>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<select
|
||||
x-model="filters.sort"
|
||||
@change="loadProducts()"
|
||||
class="form-select"
|
||||
>
|
||||
<option value="name_asc">Name (A-Z)</option>
|
||||
<option value="name_desc">Name (Z-A)</option>
|
||||
<option value="price_asc">Price (Low to High)</option>
|
||||
<option value="price_desc">Price (High to Low)</option>
|
||||
<option value="newest">Newest First</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="clearFilters()"
|
||||
class="btn-secondary"
|
||||
x-show="hasActiveFilters"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="loading">
|
||||
<div class="loading-spinner-lg"></div>
|
||||
<p>Loading products...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && products.length === 0" class="empty-state">
|
||||
<div class="empty-state-icon">📦</div>
|
||||
<h3>No products found</h3>
|
||||
<p x-show="hasActiveFilters">Try adjusting your filters</p>
|
||||
<p x-show="!hasActiveFilters">Check back soon for new products!</p>
|
||||
</div>
|
||||
|
||||
<!-- Product Grid -->
|
||||
<div x-show="!loading && products.length > 0" class="product-grid">
|
||||
<template x-for="product in products" :key="product.id">
|
||||
<div class="product-card">
|
||||
<!-- Product Image -->
|
||||
<div class="product-image-wrapper">
|
||||
<img
|
||||
:src="product.image_url || '/static/images/placeholder.png'"
|
||||
:alt="product.name"
|
||||
class="product-image"
|
||||
@click="viewProduct(product.id)"
|
||||
>
|
||||
<div
|
||||
x-show="!product.is_active || product.inventory_level <= 0"
|
||||
class="out-of-stock-badge"
|
||||
>
|
||||
Out of Stock
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Info -->
|
||||
<div class="product-info">
|
||||
<h3
|
||||
class="product-title"
|
||||
@click="viewProduct(product.id)"
|
||||
x-text="product.name"
|
||||
></h3>
|
||||
|
||||
<p class="product-sku" x-text="'SKU: ' + product.sku"></p>
|
||||
|
||||
<p class="product-price">
|
||||
€<span x-text="parseFloat(product.price).toFixed(2)"></span>
|
||||
</p>
|
||||
|
||||
<p
|
||||
class="product-description"
|
||||
x-text="product.description || 'No description available'"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<!-- Product Actions -->
|
||||
<div class="product-actions">
|
||||
<button
|
||||
@click="viewProduct(product.id)"
|
||||
class="btn-outline btn-sm"
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="addToCart(product)"
|
||||
:disabled="!product.is_active || product.inventory_level <= 0 || addingToCart[product.id]"
|
||||
class="btn-primary btn-sm"
|
||||
>
|
||||
<span x-show="!addingToCart[product.id]">Add to Cart</span>
|
||||
<span x-show="addingToCart[product.id]">
|
||||
<span class="loading-spinner"></span> Adding...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div x-show="totalPages > 1" class="pagination">
|
||||
<button
|
||||
@click="changePage(currentPage - 1)"
|
||||
:disabled="currentPage === 1"
|
||||
class="pagination-btn"
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
|
||||
<span class="pagination-info">
|
||||
Page <span x-text="currentPage"></span> of <span x-text="totalPages"></span>
|
||||
</span>
|
||||
|
||||
<button
|
||||
@click="changePage(currentPage + 1)"
|
||||
:disabled="currentPage === totalPages"
|
||||
class="pagination-btn"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notification -->
|
||||
<div
|
||||
x-show="toast.show"
|
||||
x-transition
|
||||
:class="'toast toast-' + toast.type"
|
||||
class="toast"
|
||||
x-text="toast.message"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function productCatalog() {
|
||||
return {
|
||||
products: [],
|
||||
loading: false,
|
||||
addingToCart: {},
|
||||
cartCount: 0,
|
||||
vendorId: null,
|
||||
sessionId: null,
|
||||
|
||||
// Filters
|
||||
filters: {
|
||||
search: '',
|
||||
sort: 'name_asc',
|
||||
category: ''
|
||||
},
|
||||
|
||||
// Pagination
|
||||
currentPage: 1,
|
||||
perPage: 12,
|
||||
totalProducts: 0,
|
||||
|
||||
// Toast notification
|
||||
toast: {
|
||||
show: false,
|
||||
type: 'success',
|
||||
message: ''
|
||||
},
|
||||
|
||||
// Computed properties
|
||||
get totalPages() {
|
||||
return Math.ceil(this.totalProducts / this.perPage);
|
||||
},
|
||||
|
||||
get hasActiveFilters() {
|
||||
return this.filters.search !== '' ||
|
||||
this.filters.category !== '' ||
|
||||
this.filters.sort !== 'name_asc';
|
||||
},
|
||||
|
||||
// Initialize
|
||||
init() {
|
||||
this.vendorId = this.$el.dataset.vendorId;
|
||||
this.sessionId = this.getOrCreateSessionId();
|
||||
this.loadCartCount();
|
||||
},
|
||||
|
||||
// Get or create session ID
|
||||
getOrCreateSessionId() {
|
||||
let sessionId = localStorage.getItem('cart_session_id');
|
||||
if (!sessionId) {
|
||||
sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
localStorage.setItem('cart_session_id', sessionId);
|
||||
}
|
||||
return sessionId;
|
||||
},
|
||||
|
||||
// Load products from API
|
||||
async loadProducts() {
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: this.currentPage,
|
||||
per_page: this.perPage,
|
||||
search: this.filters.search,
|
||||
sort: this.filters.sort
|
||||
});
|
||||
|
||||
if (this.filters.category) {
|
||||
params.append('category', this.filters.category);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/v1/public/vendors/${this.vendorId}/products?${params}`
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.products = data.products || [];
|
||||
this.totalProducts = data.total || 0;
|
||||
} else {
|
||||
throw new Error('Failed to load products');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load products error:', error);
|
||||
this.showToast('Failed to load products', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Load cart count
|
||||
async loadCartCount() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/public/vendors/${this.vendorId}/cart/${this.sessionId}`
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.cartCount = (data.items || []).reduce((sum, item) =>
|
||||
sum + item.quantity, 0
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load cart count:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Add product to cart
|
||||
async addToCart(product) {
|
||||
this.addingToCart[product.id] = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/public/vendors/${this.vendorId}/cart/${this.sessionId}/items`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
product_id: product.id,
|
||||
quantity: 1
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
this.cartCount++;
|
||||
this.showToast(`${product.name} added to cart!`, 'success');
|
||||
} else {
|
||||
throw new Error('Failed to add to cart');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Add to cart error:', error);
|
||||
this.showToast('Failed to add to cart. Please try again.', 'error');
|
||||
} finally {
|
||||
this.addingToCart[product.id] = false;
|
||||
}
|
||||
},
|
||||
|
||||
// View product details
|
||||
viewProduct(productId) {
|
||||
window.location.href = `/shop/products/${productId}`;
|
||||
},
|
||||
|
||||
// Change page
|
||||
changePage(page) {
|
||||
if (page >= 1 && page <= this.totalPages) {
|
||||
this.currentPage = page;
|
||||
this.loadProducts();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
},
|
||||
|
||||
// Clear filters
|
||||
clearFilters() {
|
||||
this.filters = {
|
||||
search: '',
|
||||
sort: 'name_asc',
|
||||
category: ''
|
||||
};
|
||||
this.currentPage = 1;
|
||||
this.loadProducts();
|
||||
},
|
||||
|
||||
// Show toast notification
|
||||
showToast(message, type = 'success') {
|
||||
this.toast = {
|
||||
show: true,
|
||||
type: type,
|
||||
message: message
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
this.toast.show = false;
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Product-specific styles */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex: 2;
|
||||
min-width: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding-left: 40px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: var(--font-lg);
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.product-image-wrapper {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.out-of-stock-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.product-title {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.product-title:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.product-sku {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Toast notification */
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 16px 24px;
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
z-index: 10000;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
border-left: 4px solid var(--success-color);
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
border-left: 4px solid var(--danger-color);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-box,
|
||||
.filter-group {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user