major refactoring adding vendor and customer features

This commit is contained in:
2025-10-11 09:09:25 +02:00
parent f569995883
commit dd16198276
126 changed files with 15109 additions and 3747 deletions

604
static/admin/dashboard.html Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}
}

View 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 };
}

View 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>

View 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
View 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
View 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
View 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>