migrating vendor frontend to new architecture

This commit is contained in:
2025-10-31 20:51:30 +01:00
parent 9420483ae6
commit 9611c03a36
25 changed files with 1618 additions and 286 deletions

View File

@@ -2,12 +2,19 @@
"""
Vendor API endpoints.
This module aggregates all vendor-related endpoints with proper prefixes.
This module aggregates all vendor-related JSON API endpoints.
IMPORTANT:
- This router is for JSON API endpoints only
- HTML page routes are mounted separately in main.py at /vendor/*
- Do NOT include pages.router here - it causes route conflicts
"""
from fastapi import APIRouter
# Import all sub-routers
# Import all sub-routers (JSON API only)
from . import (
info, # NEW: Vendor info endpoint
auth,
dashboard,
profile,
@@ -22,28 +29,51 @@ from . import (
media,
notifications,
analytics,
# NOTE: pages is NOT imported here - it's mounted separately in main.py
)
# Create vendor router
router = APIRouter()
# Include all vendor sub-routers with their prefixes and tags
# Note: prefixes are already defined in each router file
# ============================================================================
# JSON API ROUTES ONLY
# ============================================================================
# These routes return JSON and are mounted at /api/v1/vendor/*
# Vendor info endpoint - must be first to handle GET /{vendor_code}
router.include_router(info.router, tags=["vendor-info"])
# Authentication
router.include_router(auth.router, tags=["vendor-auth"])
# Vendor management
router.include_router(dashboard.router, tags=["vendor-dashboard"])
router.include_router(profile.router, tags=["vendor-profile"])
router.include_router(settings.router, tags=["vendor-settings"])
# Business operations
router.include_router(products.router, tags=["vendor-products"])
router.include_router(orders.router, tags=["vendor-orders"])
router.include_router(customers.router, tags=["vendor-customers"])
router.include_router(teams.router, tags=["vendor-teams"])
router.include_router(inventory.router, tags=["vendor-inventory"])
router.include_router(marketplace.router, tags=["vendor-marketplace"])
# Services
router.include_router(payments.router, tags=["vendor-payments"])
router.include_router(media.router, tags=["vendor-media"])
router.include_router(notifications.router, tags=["vendor-notifications"])
router.include_router(analytics.router, tags=["vendor-analytics"])
# ============================================================================
# NOTE: HTML Page Routes
# ============================================================================
# HTML page routes (pages.router) are NOT included here.
# They are mounted separately in main.py at /vendor/* to avoid conflicts.
#
# This separation ensures:
# - JSON API: /api/v1/vendor/* (this router)
# - HTML Pages: /vendor/* (mounted in main.py)
__all__ = ["router"]

106
app/api/v1/vendor/info.py vendored Normal file
View File

@@ -0,0 +1,106 @@
# app/api/v1/vendor/info.py
"""
Vendor information endpoints.
This module provides:
- Public vendor information lookup (no auth required)
- Used by vendor login pages to display vendor details
"""
import logging
from fastapi import APIRouter, Path, Depends
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.core.database import get_db
from app.exceptions import VendorNotFoundException
from models.schema.vendor import VendorResponse, VendorDetailResponse
from models.database.vendor import Vendor
router = APIRouter()
logger = logging.getLogger(__name__)
def _get_vendor_by_code(db: Session, vendor_code: str) -> Vendor:
"""
Helper to get active vendor by vendor_code.
Args:
db: Database session
vendor_code: Vendor code (case-insensitive)
Returns:
Vendor object
Raises:
VendorNotFoundException: If vendor not found or inactive
"""
vendor = db.query(Vendor).filter(
func.upper(Vendor.vendor_code) == vendor_code.upper(),
Vendor.is_active == True
).first()
if not vendor:
logger.warning(f"Vendor not found or inactive: {vendor_code}")
raise VendorNotFoundException(vendor_code, identifier_type="code")
return vendor
@router.get("/{vendor_code}", response_model=VendorDetailResponse)
def get_vendor_info(
vendor_code: str = Path(..., description="Vendor code"),
db: Session = Depends(get_db)
):
"""
Get public vendor information by vendor code.
This endpoint is used by the vendor login page to display vendor info.
No authentication required - this is public information.
**Use Case:**
- Vendor login page loads vendor info to display branding
- Shows vendor name, description, logo, etc.
**Returns only active vendors** to prevent access to disabled accounts.
Args:
vendor_code: The vendor's unique code (e.g., 'WIZAMART')
db: Database session
Returns:
VendorResponse: Public vendor information
Raises:
VendorNotFoundException (404): Vendor not found or inactive
"""
logger.info(f"Public vendor info request: {vendor_code}")
vendor = _get_vendor_by_code(db, vendor_code)
logger.info(f"Vendor info retrieved: {vendor.name} ({vendor.vendor_code})")
return VendorDetailResponse(
# Vendor fields
id=vendor.id,
vendor_code=vendor.vendor_code,
subdomain=vendor.subdomain,
name=vendor.name,
description=vendor.description,
owner_user_id=vendor.owner_user_id,
contact_email=vendor.contact_email,
contact_phone=vendor.contact_phone,
website=vendor.website,
business_address=vendor.business_address,
tax_number=vendor.tax_number,
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
is_active=vendor.is_active,
is_verified=vendor.is_verified,
created_at=vendor.created_at,
updated_at=vendor.updated_at,
# Owner details
owner_email=vendor.owner.email,
owner_username=vendor.owner.username,
)

View File

@@ -3,7 +3,7 @@
Vendor HTML page routes using Jinja2 templates.
These routes serve HTML pages for vendor-facing interfaces.
Supports both path-based (/vendor/{vendor_code}/) and subdomain-based access.
Follows the same minimal server-side rendering pattern as admin routes.
All routes except /login require vendor authentication.
Authentication failures redirect to /vendor/{vendor_code}/login.
@@ -24,9 +24,8 @@ Routes:
from fastapi import APIRouter, Request, Depends, Path
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_user, get_db
from app.api.deps import get_current_vendor_user
from models.database.user import User
router = APIRouter()
@@ -37,22 +36,37 @@ templates = Jinja2Templates(directory="app/templates")
# PUBLIC ROUTES (No Authentication Required)
# ============================================================================
@router.get("/vendor/{vendor_code}/", response_class=RedirectResponse, include_in_schema=False)
async def vendor_root(vendor_code: str = Path(..., description="Vendor code")):
@router.get("/{vendor_code}", response_class=RedirectResponse, include_in_schema=False)
async def vendor_root_no_slash(vendor_code: str = Path(..., description="Vendor code")):
"""
Redirect /vendor/{code}/ to login page.
Redirect /vendor/{code} (no trailing slash) to login page.
Handles requests without trailing slash.
"""
return RedirectResponse(url=f"/vendor/{vendor_code}/login", status_code=302)
@router.get("/vendor/{vendor_code}/login", response_class=HTMLResponse, include_in_schema=False)
@router.get("/{vendor_code}/", response_class=RedirectResponse, include_in_schema=False)
async def vendor_root(vendor_code: str = Path(..., description="Vendor code")):
"""
Redirect /vendor/{code}/ to login page.
Simple approach - let login page handle authenticated redirects.
"""
return RedirectResponse(url=f"/vendor/{vendor_code}/login", status_code=302)
@router.get("/{vendor_code}/login", response_class=HTMLResponse, include_in_schema=False)
async def vendor_login_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code")
request: Request,
vendor_code: str = Path(..., description="Vendor code")
):
"""
Render vendor login page.
No authentication required.
JavaScript will:
- Load vendor info via API
- Handle login form submission
- Redirect to dashboard on success
"""
return templates.TemplateResponse(
"vendor/login.html",
@@ -67,19 +81,23 @@ async def vendor_login_page(
# AUTHENTICATED ROUTES (Vendor Users Only)
# ============================================================================
@router.get("/vendor/{vendor_code}/dashboard", response_class=HTMLResponse, include_in_schema=False)
@router.get("/{vendor_code}/dashboard", response_class=HTMLResponse, include_in_schema=False)
async def vendor_dashboard_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_user),
db: Session = Depends(get_db)
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_user)
):
"""
Render vendor dashboard.
Shows sales metrics, recent orders, and quick actions.
JavaScript will:
- Load vendor info via API
- Load dashboard stats via API
- Load recent orders via API
- Handle all interactivity
"""
return templates.TemplateResponse(
"vendor/dashboard.html",
"vendor/admin/dashboard.html",
{
"request": request,
"user": current_user,
@@ -92,17 +110,15 @@ async def vendor_dashboard_page(
# PRODUCT MANAGEMENT
# ============================================================================
@router.get("/vendor/{vendor_code}/products", response_class=HTMLResponse, include_in_schema=False)
@router.get("/vendor/{vendor_code}/admin/products", response_class=HTMLResponse, include_in_schema=False)
@router.get("/{vendor_code}/products", response_class=HTMLResponse, include_in_schema=False)
async def vendor_products_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_user),
db: Session = Depends(get_db)
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_user)
):
"""
Render products management page.
List, create, edit, and manage products.
JavaScript loads product list via API.
"""
return templates.TemplateResponse(
"vendor/admin/products.html",
@@ -118,17 +134,15 @@ async def vendor_products_page(
# ORDER MANAGEMENT
# ============================================================================
@router.get("/vendor/{vendor_code}/orders", response_class=HTMLResponse, include_in_schema=False)
@router.get("/vendor/{vendor_code}/admin/orders", response_class=HTMLResponse, include_in_schema=False)
@router.get("/{vendor_code}/orders", response_class=HTMLResponse, include_in_schema=False)
async def vendor_orders_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_user),
db: Session = Depends(get_db)
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_user)
):
"""
Render orders management page.
View and process orders.
JavaScript loads order list via API.
"""
return templates.TemplateResponse(
"vendor/admin/orders.html",
@@ -144,17 +158,15 @@ async def vendor_orders_page(
# CUSTOMER MANAGEMENT
# ============================================================================
@router.get("/vendor/{vendor_code}/customers", response_class=HTMLResponse, include_in_schema=False)
@router.get("/vendor/{vendor_code}/admin/customers", response_class=HTMLResponse, include_in_schema=False)
@router.get("/{vendor_code}/customers", response_class=HTMLResponse, include_in_schema=False)
async def vendor_customers_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_user),
db: Session = Depends(get_db)
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_user)
):
"""
Render customers management page.
View and manage customer relationships.
JavaScript loads customer list via API.
"""
return templates.TemplateResponse(
"vendor/admin/customers.html",
@@ -170,17 +182,15 @@ async def vendor_customers_page(
# INVENTORY MANAGEMENT
# ============================================================================
@router.get("/vendor/{vendor_code}/inventory", response_class=HTMLResponse, include_in_schema=False)
@router.get("/vendor/{vendor_code}/admin/inventory", response_class=HTMLResponse, include_in_schema=False)
@router.get("/{vendor_code}/inventory", response_class=HTMLResponse, include_in_schema=False)
async def vendor_inventory_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_user),
db: Session = Depends(get_db)
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_user)
):
"""
Render inventory management page.
Track and manage stock levels.
JavaScript loads inventory data via API.
"""
return templates.TemplateResponse(
"vendor/admin/inventory.html",
@@ -196,17 +206,15 @@ async def vendor_inventory_page(
# MARKETPLACE IMPORTS
# ============================================================================
@router.get("/vendor/{vendor_code}/marketplace", response_class=HTMLResponse, include_in_schema=False)
@router.get("/vendor/{vendor_code}/admin/marketplace", response_class=HTMLResponse, include_in_schema=False)
@router.get("/{vendor_code}/marketplace", response_class=HTMLResponse, include_in_schema=False)
async def vendor_marketplace_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_user),
db: Session = Depends(get_db)
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_user)
):
"""
Render marketplace import page.
Import products from external marketplaces.
JavaScript loads import jobs and products via API.
"""
return templates.TemplateResponse(
"vendor/admin/marketplace.html",
@@ -222,17 +230,15 @@ async def vendor_marketplace_page(
# TEAM MANAGEMENT
# ============================================================================
@router.get("/vendor/{vendor_code}/team", response_class=HTMLResponse, include_in_schema=False)
@router.get("/vendor/{vendor_code}/admin/team", response_class=HTMLResponse, include_in_schema=False)
@router.get("/{vendor_code}/team", response_class=HTMLResponse, include_in_schema=False)
async def vendor_team_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_user),
db: Session = Depends(get_db)
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_user)
):
"""
Render team management page.
Manage vendor staff and permissions.
JavaScript loads team members via API.
"""
return templates.TemplateResponse(
"vendor/admin/team.html",
@@ -248,16 +254,15 @@ async def vendor_team_page(
# SETTINGS
# ============================================================================
@router.get("/vendor/{vendor_code}/settings", response_class=HTMLResponse, include_in_schema=False)
@router.get("/{vendor_code}/settings", response_class=HTMLResponse, include_in_schema=False)
async def vendor_settings_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_user),
db: Session = Depends(get_db)
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_user)
):
"""
Render vendor settings page.
Configure vendor preferences and integrations.
JavaScript loads settings via API.
"""
return templates.TemplateResponse(
"vendor/settings.html",
@@ -267,51 +272,3 @@ async def vendor_settings_page(
"vendor_code": vendor_code,
}
)
# ============================================================================
# FALLBACK ROUTES (Query Parameter Based - For backward compatibility)
# ============================================================================
@router.get("/vendor/", response_class=RedirectResponse, include_in_schema=False)
async def vendor_fallback_root():
"""
Redirect to main site if no vendor code in path.
User should use /vendor/{code}/ format.
"""
return RedirectResponse(url="/", status_code=302)
@router.get("/vendor/login", response_class=HTMLResponse, include_in_schema=False)
async def vendor_fallback_login(request: Request):
"""
Fallback vendor login page (query parameter based).
For backward compatibility - new code should use /vendor/{code}/login
"""
return templates.TemplateResponse(
"vendor/login.html",
{
"request": request,
"vendor_code": None, # Will be retrieved from query param or localStorage
}
)
@router.get("/vendor/dashboard", response_class=HTMLResponse, include_in_schema=False)
async def vendor_fallback_dashboard(
request: Request,
current_user: User = Depends(get_current_vendor_user),
db: Session = Depends(get_db)
):
"""
Fallback vendor dashboard (query parameter based).
For backward compatibility - new code should use /vendor/{code}/dashboard
"""
return templates.TemplateResponse(
"vendor/dashboard.html",
{
"request": request,
"user": current_user,
"vendor_code": None, # Will be retrieved from token or localStorage
}
)

View File

@@ -170,6 +170,97 @@ def setup_exception_handlers(app):
"""Handle all 404 errors with consistent format."""
logger.warning(f"404 Not Found: {request.method} {request.url}")
# Check if this is a browser request (wants HTML)
accept_header = request.headers.get("accept", "")
# Browser requests typically have "text/html" in accept header
# API requests typically have "application/json"
if "text/html" in accept_header:
# Return simple HTML 404 page for browser
from fastapi.responses import HTMLResponse
html_content = f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - Page Not Found</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: white;
}}
.container {{
text-align: center;
padding: 2rem;
max-width: 600px;
}}
h1 {{
font-size: 8rem;
font-weight: 700;
margin-bottom: 1rem;
text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
}}
h2 {{
font-size: 2rem;
font-weight: 400;
margin-bottom: 1rem;
}}
p {{
font-size: 1.2rem;
margin-bottom: 2rem;
opacity: 0.9;
}}
.btn {{
display: inline-block;
padding: 1rem 2.5rem;
background: white;
color: #667eea;
text-decoration: none;
border-radius: 50px;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
}}
.btn:hover {{
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0,0,0,0.3);
}}
.path {{
margin-top: 2rem;
font-size: 0.9rem;
opacity: 0.7;
font-family: 'Courier New', monospace;
word-break: break-all;
}}
</style>
</head>
<body>
<div class="container">
<h1>404</h1>
<h2>Page Not Found</h2>
<p>Sorry, the page you're looking for doesn't exist or has been moved.</p>
<a href="/" class="btn">Go Home</a>
<div class="path">Path: {request.url.path}</div>
</div>
</body>
</html>
"""
return HTMLResponse(content=html_content, status_code=404)
# Return JSON for API requests
return JSONResponse(
status_code=404,
content={
@@ -250,4 +341,4 @@ def raise_auth_error(message: str = "Authentication failed") -> None:
def raise_permission_error(message: str = "Access denied") -> None:
"""Convenience function to raise AuthorizationException."""
from .base import AuthorizationException
raise AuthorizationException(message)
raise AuthorizationException(message)

View File

@@ -79,7 +79,7 @@ class UnauthorizedVendorAccessException(AuthorizationException):
class InvalidVendorDataException(ValidationException):
"""Raised when vendor data is invalid."""
"""Raised when vendor data is invalid or incomplete."""
def __init__(
self,
@@ -95,21 +95,6 @@ class InvalidVendorDataException(ValidationException):
self.error_code = "INVALID_VENDOR_DATA"
class MaxVendorsReachedException(BusinessLogicException):
"""Raised when user tries to create more vendors than allowed."""
def __init__(self, max_vendors: int, user_id: Optional[int] = None):
details = {"max_vendors": max_vendors}
if user_id:
details["user_id"] = user_id
super().__init__(
message=f"Maximum number of vendors reached ({max_vendors})",
error_code="MAX_VENDORS_REACHED",
details=details,
)
class VendorValidationException(ValidationException):
"""Raised when vendor validation fails."""
@@ -129,3 +114,36 @@ class VendorValidationException(ValidationException):
details=details,
)
self.error_code = "VENDOR_VALIDATION_FAILED"
class IncompleteVendorDataException(ValidationException):
"""Raised when vendor data is missing required fields."""
def __init__(
self,
vendor_code: str,
missing_fields: list,
):
super().__init__(
message=f"Vendor '{vendor_code}' is missing required fields: {', '.join(missing_fields)}",
details={
"vendor_code": vendor_code,
"missing_fields": missing_fields,
},
)
self.error_code = "INCOMPLETE_VENDOR_DATA"
class MaxVendorsReachedException(BusinessLogicException):
"""Raised when user tries to create more vendors than allowed."""
def __init__(self, max_vendors: int, user_id: Optional[int] = None):
details = {"max_vendors": max_vendors}
if user_id:
details["user_id"] = user_id
super().__init__(
message=f"Maximum number of vendors reached ({max_vendors})",
error_code="MAX_VENDORS_REACHED",
details=details,
)

View File

@@ -1,10 +1,63 @@
{# app/templates/vendor/base.html #}
<!DOCTYPE html>
<html lang="en">
<html :class="{ 'theme-dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}Vendor Panel{% endblock %} - {{ vendor.name if vendor else 'Multi-Tenant Platform' }}</title>
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<!-- Tailwind CSS -->
<link rel="stylesheet" href="{{ url_for('static', path='vendor/css/tailwind.output.css') }}" />
<!-- Alpine Cloak -->
<style>
[x-cloak] { display: none !important; }
</style>
{% block extra_head %}{% endblock %}
</head>
<body x-cloak>
<div class="flex h-screen bg-gray-50 dark:bg-gray-900" :class="{ 'overflow-hidden': isSideMenuOpen }">
<!-- Sidebar (server-side included) -->
{% include 'vendor/partials/sidebar.html' %}
<div class="flex flex-col flex-1 w-full">
<!-- Header (server-side included) -->
{% include 'vendor/partials/header.html' %}
<!-- Main Content -->
<main class="h-full overflow-y-auto">
<div class="container px-6 mx-auto grid">
{% block content %}{% endblock %}
</div>
</main>
</div>
</div>
<!-- Core Scripts - ORDER MATTERS! -->
<!-- 1. FIRST: Log Configuration -->
<script src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
<!-- 2. SECOND: Icons (before Alpine.js) -->
<script src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
<!-- 3. THIRD: Alpine.js Base Data -->
<script src="{{ url_for('static', path='vendor/js/init-alpine.js') }}"></script>
<!-- 4. FOURTH: Utils (standalone utilities) -->
<script src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
<!-- 5. FIFTH: API Client (depends on Utils) -->
<script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<!-- 6. SIXTH: Alpine.js v3 (with defer) -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
<!-- 7. LAST: Page-specific scripts -->
{% block extra_scripts %}{% endblock %}
</body>
</html>

168
app/templates/vendor/dashboard.html vendored Normal file
View File

@@ -0,0 +1,168 @@
{# app/templates/vendor/admin/dashboard.html #}
{% extends "vendor/base.html" %}
{% block title %}Dashboard{% endblock %}
{% block alpine_data %}vendorDashboard(){% endblock %}
{% block content %}
<!-- Page Header with Refresh Button -->
<div class="flex items-center justify-between my-6">
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Dashboard
</h2>
<button
@click="refresh()"
:disabled="loading"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
</button>
</div>
<!-- Loading State -->
<div x-show="loading" class="text-center py-12">
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading dashboard...</p>
</div>
<!-- Error State -->
<div x-show="error && !loading" class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
<div>
<p class="font-semibold">Error loading dashboard</p>
<p class="text-sm" x-text="error"></p>
</div>
</div>
<!-- Vendor Info Card -->
{% include 'vendor/partials/vendor_info.html' %}
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Card: Total Products -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('shopping-bag', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Products
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.products_count">
0
</p>
</div>
</div>
<!-- Card: Total Orders -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Orders
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.orders_count">
0
</p>
</div>
</div>
<!-- Card: Total Customers -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('users', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Customers
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.customers_count">
0
</p>
</div>
</div>
<!-- Card: Revenue -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-teal-500 bg-teal-100 rounded-full dark:text-teal-100 dark:bg-teal-500">
<span x-html="$icon('currency-euro', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Revenue
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatCurrency(stats.revenue)">
€0
</p>
</div>
</div>
</div>
<!-- Recent Orders Table -->
<div x-show="!loading && recentOrders.length > 0" class="w-full mb-8 overflow-hidden rounded-lg shadow-xs">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Order ID</th>
<th class="px-4 py-3">Customer</th>
<th class="px-4 py-3">Amount</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Date</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="order in recentOrders" :key="order.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td class="px-4 py-3">
<span class="text-xs" x-text="'#' + order.id"></span>
</td>
<td class="px-4 py-3 text-sm" x-text="order.customer_name">
</td>
<td class="px-4 py-3 text-sm font-semibold" x-text="formatCurrency(order.total_amount)">
</td>
<td class="px-4 py-3 text-xs">
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="{
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': order.status === 'completed',
'text-orange-700 bg-orange-100 dark:text-white dark:bg-orange-600': order.status === 'pending',
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': order.status === 'processing'
}"
x-text="order.status"></span>
</td>
<td class="px-4 py-3 text-sm" x-text="formatDate(order.created_at)">
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- Getting Started Section -->
<div x-show="!loading && recentOrders.length === 0" class="w-full mb-8 overflow-hidden rounded-lg shadow-xs">
<div class="w-full p-8 bg-white dark:bg-gray-800 text-center">
<div class="text-6xl mb-4">🚀</div>
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
Welcome to Your Vendor Dashboard!
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-6">
Start by importing products from the marketplace to build your catalog.
</p>
<a href="/vendor/{{ vendor_code }}/marketplace"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
<span x-html="$icon('download', 'w-4 h-4 mr-2')"></span>
Go to Marketplace Import
</a>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='vendor/js/dashboard.js') }}"></script>
{% endblock %}

View File

@@ -1,121 +1,164 @@
{# app/templates/vendor/login.html #}
<!DOCTYPE html>
<html lang="en">
<html :class="{ 'theme-dark': dark }" x-data="vendorLogin()" lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vendor Login - Multi-Tenant Ecommerce Platform</title>
<link rel="stylesheet" href="/static/css/shared/base.css">
<link rel="stylesheet" href="/static/css/shared/auth.css">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vendor Login - Multi-Tenant Platform</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="{{ url_for('static', path='vendor/css/tailwind.output.css') }}" />
<style>
[x-cloak] { display: none !important; }
</style>
</head>
<body>
<div class="auth-page" x-data="vendorLogin()" x-init="init()" x-cloak>
<div class="login-container">
<!-- Vendor Info -->
<template x-if="vendor">
<div class="vendor-info" style="margin-bottom: 24px;">
<h2 x-text="vendor.name" style="margin: 0; color: var(--primary-color);"></h2>
<p class="text-muted" style="margin: 4px 0 0 0; font-size: var(--font-sm);">
<strong x-text="vendor.vendor_code"></strong>
</p>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
<div class="flex-1 h-full max-w-4xl mx-auto overflow-hidden bg-white rounded-lg shadow-xl dark:bg-gray-800">
<div class="flex flex-col overflow-y-auto md:flex-row">
<div class="h-32 md:h-auto md:w-1/2">
<img aria-hidden="true" class="object-cover w-full h-full dark:hidden"
src="{{ url_for('static', path='vendor/img/login-office.jpeg') }}" alt="Office" />
<img aria-hidden="true" class="hidden object-cover w-full h-full dark:block"
src="{{ url_for('static', path='vendor/img/login-office-dark.jpeg') }}" alt="Office" />
</div>
</template>
<div class="login-header">
<div class="auth-logo">🏪</div>
<h1>Vendor Portal</h1>
<p>Sign in to manage your store</p>
</div>
<!-- Alert Messages -->
<div x-show="error"
x-text="error"
class="alert alert-error"
x-transition></div>
<div x-show="success"
x-text="success"
class="alert alert-success"
x-transition></div>
<!-- Login Form (only show if vendor found) -->
<template x-if="vendor">
<form @submit.prevent="handleLogin">
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
id="username"
x-model="credentials.username"
:class="{ 'error': errors.username }"
required
autocomplete="username"
placeholder="Enter your username"
:disabled="loading"
@input="clearErrors"
>
<div x-show="errors.username"
x-text="errors.username"
class="error-message show"
x-transition></div>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
x-model="credentials.password"
:class="{ 'error': errors.password }"
required
autocomplete="current-password"
placeholder="Enter your password"
:disabled="loading"
@input="clearErrors"
>
<div x-show="errors.password"
x-text="errors.password"
class="error-message show"
x-transition></div>
</div>
<button type="submit"
class="btn-login"
:disabled="loading">
<template x-if="!loading">
<span>Sign In</span>
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
<div class="w-full">
<!-- Vendor Info -->
<template x-if="vendor">
<div class="mb-6 text-center">
<div class="inline-flex items-center justify-center w-16 h-16 mb-4 rounded-full bg-purple-100 dark:bg-purple-600">
<span class="text-2xl font-bold text-purple-600 dark:text-purple-100"
x-text="vendor.name?.charAt(0).toUpperCase() || '🏪'"></span>
</div>
<h2 class="text-xl font-semibold text-gray-700 dark:text-gray-200" x-text="vendor.name"></h2>
<p class="text-sm text-gray-600 dark:text-gray-400">
<strong x-text="vendor.vendor_code"></strong>
</p>
</div>
</template>
<template x-if="loading">
<span>
<span class="loading-spinner"></span>
Signing in...
</span>
</template>
</button>
</form>
</template>
<!-- Vendor Not Found -->
<template x-if="!vendor && !loading && checked">
<div class="empty-state">
<div class="empty-state-icon">🏪</div>
<h3>Vendor Not Found</h3>
<p>The vendor you're trying to access doesn't exist or is inactive.</p>
<a href="/admin/login.html" class="btn btn-primary" style="margin-top: 16px;">
Go to Admin Login
</a>
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
Vendor Portal Login
</h1>
<!-- Alert Messages -->
<div x-show="error" x-text="error"
class="px-4 py-3 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800"
x-transition></div>
<div x-show="success" x-text="success"
class="px-4 py-3 mb-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800"
x-transition></div>
<!-- Login Form (only show if vendor found) -->
<template x-if="vendor">
<form @submit.prevent="handleLogin">
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Username</span>
<input x-model="credentials.username"
:disabled="loading"
@input="clearErrors"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
:class="{ 'border-red-600': errors.username }"
placeholder="Enter your username"
autocomplete="username"
required />
<span x-show="errors.username" x-text="errors.username"
class="text-xs text-red-600 dark:text-red-400"></span>
</label>
<label class="block mt-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">Password</span>
<input x-model="credentials.password"
:disabled="loading"
@input="clearErrors"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
:class="{ 'border-red-600': errors.password }"
placeholder="***************"
type="password"
autocomplete="current-password"
required />
<span x-show="errors.password" x-text="errors.password"
class="text-xs text-red-600 dark:text-red-400"></span>
</label>
<button type="submit" :disabled="loading"
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!loading">Log in</span>
<span x-show="loading">
<svg class="inline w-4 h-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Signing in...
</span>
</button>
</form>
</template>
<!-- Vendor Not Found -->
<template x-if="!vendor && !loading && checked">
<div class="text-center py-8">
<div class="text-6xl mb-4">🏪</div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-2">
Vendor Not Found
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
The vendor you're trying to access doesn't exist or is inactive.
</p>
<a href="/" class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
Go to Platform Home
</a>
</div>
</template>
<!-- Loading State -->
<div x-show="loading && !vendor" class="text-center py-8">
<svg class="inline w-8 h-8 animate-spin text-purple-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Loading vendor information...</p>
</div>
<hr class="my-8" />
<p class="mt-4">
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline"
href="#">
Forgot your password?
</a>
</p>
<p class="mt-2">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="/">
← Back to Platform
</a>
</p>
</div>
</div>
</template>
<!-- Loading State -->
<div x-show="loading && !vendor" class="loading">
<span class="loading-spinner loading-spinner-lg"></span>
<p class="loading-text">Loading vendor information...</p>
</div>
</div>
</div>
<script src="/static/shared/js/api-client.js"></script>
<script src="/static/js/vendor/login.js"></script>
<!-- Scripts - ORDER MATTERS! -->
<!-- 1. Log Configuration -->
<script src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
<!-- 2. Icons -->
<script src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
<!-- 3. Utils -->
<script src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
<!-- 4. API Client -->
<script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<!-- 5. Alpine.js v3 -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
<!-- 6. Login Logic -->
<script src="{{ url_for('static', path='vendor/js/login.js') }}"></script>
</body>
</html>

View File

@@ -1,10 +1,108 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
{# app/templates/vendor/partials/header.html #}
<header class="z-10 py-4 bg-white shadow-md dark:bg-gray-800">
<div class="container flex items-center justify-between h-full px-6 mx-auto text-purple-600 dark:text-purple-300">
<!-- Mobile hamburger -->
<button class="p-1 mr-5 -ml-1 rounded-md md:hidden focus:outline-none focus:shadow-outline-purple"
@click="toggleSideMenu"
aria-label="Menu">
<span x-html="$icon('menu', 'w-6 h-6')"></span>
</button>
</body>
</html>
<!-- Search input -->
<div class="flex justify-center flex-1 lg:mr-32">
<div class="relative w-full max-w-xl mr-6 focus-within:text-purple-500">
<div class="absolute inset-y-0 flex items-center pl-2">
<span x-html="$icon('search', 'w-4 h-4')"></span>
</div>
<input class="w-full pl-8 pr-2 text-sm text-gray-700 placeholder-gray-600 bg-gray-100 border-0 rounded-md dark:placeholder-gray-500 dark:focus:shadow-outline-gray dark:focus:placeholder-gray-600 dark:bg-gray-700 dark:text-gray-200 focus:placeholder-gray-500 focus:bg-white focus:border-purple-300 focus:outline-none focus:shadow-outline-purple form-input"
type="text"
placeholder="Search products, orders..."
aria-label="Search" />
</div>
</div>
<ul class="flex items-center flex-shrink-0 space-x-6">
<!-- Theme toggler -->
<li class="flex">
<button class="rounded-md focus:outline-none focus:shadow-outline-purple"
@click="toggleTheme"
aria-label="Toggle color mode">
<template x-if="!dark">
<span x-html="$icon('moon', 'w-5 h-5')"></span>
</template>
<template x-if="dark">
<span x-html="$icon('sun', 'w-5 h-5')"></span>
</template>
</button>
</li>
<!-- Notifications menu -->
<li class="relative">
<button class="relative align-middle rounded-md focus:outline-none focus:shadow-outline-purple"
@click="toggleNotificationsMenu"
@keydown.escape="closeNotificationsMenu"
aria-label="Notifications"
aria-haspopup="true">
<span x-html="$icon('bell', 'w-5 h-5')"></span>
<!-- Notification badge -->
<span aria-hidden="true"
class="absolute top-0 right-0 inline-block w-3 h-3 transform translate-x-1 -translate-y-1 bg-red-600 border-2 border-white rounded-full dark:border-gray-800"></span>
</button>
<template x-if="isNotificationsMenuOpen">
<ul x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click.away="closeNotificationsMenu"
@keydown.escape="closeNotificationsMenu"
class="absolute right-0 w-56 p-2 mt-2 space-y-2 text-gray-600 bg-white border border-gray-100 rounded-md shadow-md dark:text-gray-300 dark:border-gray-700 dark:bg-gray-700">
<li class="flex">
<a class="inline-flex items-center justify-between w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200"
href="#">
<span>New order received</span>
</a>
</li>
</ul>
</template>
</li>
<!-- Profile menu -->
<li class="relative">
<button class="align-middle rounded-full focus:shadow-outline-purple focus:outline-none"
@click="toggleProfileMenu"
@keydown.escape="closeProfileMenu"
aria-label="Account"
aria-haspopup="true">
<div class="w-8 h-8 rounded-full bg-purple-600 flex items-center justify-center text-white font-semibold">
<span x-text="currentUser.username?.charAt(0).toUpperCase() || '?'"></span>
</div>
</button>
<template x-if="isProfileMenuOpen">
<ul x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click.away="closeProfileMenu"
@keydown.escape="closeProfileMenu"
class="absolute right-0 w-56 p-2 mt-2 space-y-2 text-gray-600 bg-white border border-gray-100 rounded-md shadow-md dark:border-gray-700 dark:text-gray-300 dark:bg-gray-700">
<li class="flex">
<a class="inline-flex items-center w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200"
href="/vendor/{{ vendor_code }}/settings">
<span x-html="$icon('cog', 'w-4 h-4 mr-3')"></span>
<span>Settings</span>
</a>
</li>
<li class="flex">
<a class="inline-flex items-center w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200"
@click.prevent="handleLogout"
href="#">
<span x-html="$icon('logout', 'w-4 h-4 mr-3')"></span>
<span>Log out</span>
</a>
</li>
</ul>
</template>
</li>
</ul>
</div>
</header>

View File

@@ -1,10 +1,187 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
{# app/templates/vendor/partials/sidebar.html #}
{#
Vendor sidebar - loads vendor data client-side via JavaScript
Follows same pattern as admin sidebar
#}
</body>
</html>
<!-- Desktop sidebar -->
<aside class="z-20 hidden w-64 overflow-y-auto bg-white dark:bg-gray-800 md:block flex-shrink-0">
<div class="py-4 text-gray-500 dark:text-gray-400">
<!-- Vendor Branding -->
<a class="ml-6 text-lg font-bold text-gray-800 dark:text-gray-200 flex items-center"
:href="`/vendor/${vendorCode}/dashboard`">
<span class="text-2xl mr-2">🏪</span>
<span x-text="vendor?.name || 'Vendor Portal'"></span>
</a>
<!-- Main Navigation -->
<ul class="mt-6">
<li class="relative px-6 py-3">
<span x-show="currentPage === 'dashboard'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'dashboard' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/dashboard`">
<span x-html="$icon('home', 'w-5 h-5')"></span>
<span class="ml-4">Dashboard</span>
</a>
</li>
</ul>
<!-- Products & Inventory Section -->
<div class="px-6 my-6">
<div class="flex items-center">
<span x-html="$icon('cube', 'w-5 h-5 text-gray-400')"></span>
<span class="ml-2 text-xs font-semibold text-gray-500 uppercase dark:text-gray-400">
Products
</span>
</div>
</div>
<ul>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'products'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'products' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/products`">
<span x-html="$icon('shopping-bag', 'w-5 h-5')"></span>
<span class="ml-4">All Products</span>
</a>
</li>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'marketplace'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'marketplace' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/marketplace`">
<span x-html="$icon('download', 'w-5 h-5')"></span>
<span class="ml-4">Marketplace Import</span>
</a>
</li>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'inventory'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'inventory' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/inventory`">
<span x-html="$icon('clipboard-list', 'w-5 h-5')"></span>
<span class="ml-4">Inventory</span>
</a>
</li>
</ul>
<!-- Sales Section -->
<div class="px-6 my-6">
<div class="flex items-center">
<span x-html="$icon('chart-bar', 'w-5 h-5 text-gray-400')"></span>
<span class="ml-2 text-xs font-semibold text-gray-500 uppercase dark:text-gray-400">
Sales
</span>
</div>
</div>
<ul>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'orders'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'orders' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/orders`">
<span x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
<span class="ml-4">Orders</span>
</a>
</li>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'customers'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'customers' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/customers`">
<span x-html="$icon('users', 'w-5 h-5')"></span>
<span class="ml-4">Customers</span>
</a>
</li>
</ul>
<!-- Settings Section -->
<div class="px-6 my-6">
<div class="flex items-center">
<span x-html="$icon('cog', 'w-5 h-5 text-gray-400')"></span>
<span class="ml-2 text-xs font-semibold text-gray-500 uppercase dark:text-gray-400">
Settings
</span>
</div>
</div>
<ul>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'team'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'team' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/team`">
<span x-html="$icon('user-group', 'w-5 h-5')"></span>
<span class="ml-4">Team</span>
</a>
</li>
<li class="relative px-6 py-3">
<span x-show="currentPage === 'settings'"
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'settings' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/settings`">
<span x-html="$icon('adjustments', 'w-5 h-5')"></span>
<span class="ml-4">Settings</span>
</a>
</li>
</ul>
<!-- Quick Actions -->
<div class="px-6 my-6">
<button class="flex items-center justify-between w-full px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
@click="$dispatch('open-add-product-modal')">
Add Product
<span class="ml-2" aria-hidden="true">+</span>
</button>
</div>
</div>
</aside>
<!-- Mobile sidebar -->
<div x-show="isSideMenuOpen"
x-transition:enter="transition ease-in-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in-out duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-10 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"></div>
<aside class="fixed inset-y-0 z-20 flex-shrink-0 w-64 mt-16 overflow-y-auto bg-white dark:bg-gray-800 md:hidden"
x-show="isSideMenuOpen"
x-transition:enter="transition ease-in-out duration-150"
x-transition:enter-start="opacity-0 transform -translate-x-20"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in-out duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0 transform -translate-x-20"
@click.away="closeSideMenu"
@keydown.escape="closeSideMenu">
<!-- Mobile navigation - same structure as desktop -->
<div class="py-4 text-gray-500 dark:text-gray-400">
<a class="ml-6 text-lg font-bold text-gray-800 dark:text-gray-200 flex items-center"
:href="`/vendor/${vendorCode}/dashboard`">
<span class="text-2xl mr-2">🏪</span>
<span x-text="vendor?.name || 'Vendor Portal'"></span>
</a>
<!-- Same menu structure as desktop (omitted for brevity) -->
<!-- Copy the entire menu structure from desktop sidebar above -->
</div>
</aside>

View File

@@ -1,10 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
{# app/templates/vendor/partials/vendor_info.html #}
{#
This component loads vendor data client-side via JavaScript
No server-side vendor data required - follows same pattern as admin pages
#}
<div class="p-4 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
<!-- Loading State -->
<div x-show="!vendor && !error" class="flex items-center">
<div class="w-12 h-12 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center mr-4 animate-pulse">
<span class="text-xl">🏪</span>
</div>
<div class="flex-1">
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/3 mb-2 animate-pulse"></div>
<div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/2 animate-pulse"></div>
</div>
</div>
</body>
</html>
<!-- Vendor Data (loaded via JavaScript) -->
<div x-show="vendor" class="flex items-center justify-between">
<div class="flex items-center">
<div class="w-12 h-12 rounded-full bg-purple-100 dark:bg-purple-600 flex items-center justify-center mr-4">
<span class="text-xl font-bold text-purple-600 dark:text-purple-100"
x-text="vendor?.name?.charAt(0).toUpperCase() || '?'"></span>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200"
x-text="vendor?.name || 'Loading...'"></h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
<span class="font-medium" x-text="vendor?.vendor_code"></span>
<template x-if="vendor?.subdomain">
<span><span x-text="vendor.subdomain"></span>.platform.com</span>
</template>
</p>
</div>
</div>
<div class="flex items-center space-x-2">
<template x-if="vendor">
<span class="px-2 py-1 text-xs font-semibold leading-tight rounded-full"
:class="vendor.is_verified
? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100'
: 'text-orange-700 bg-orange-100 dark:text-white dark:bg-orange-600'"
x-text="vendor.is_verified ? 'Verified' : 'Pending'"></span>
</template>
<template x-if="vendor">
<span class="px-2 py-1 text-xs font-semibold leading-tight rounded-full"
:class="vendor.is_active
? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100'
: 'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100'"
x-text="vendor.is_active ? 'Active' : 'Inactive'"></span>
</template>
</div>
</div>
<!-- Error State -->
<div x-show="error" class="text-center py-4">
<p class="text-sm text-red-600 dark:text-red-400" x-text="error"></p>
</div>
</div>

61
main.py
View File

@@ -1,11 +1,19 @@
# main.py
import sys
import io
# Fix Windows console encoding issues (must be at the very top)
if sys.platform == 'win32':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
import logging
from datetime import datetime, timezone
from pathlib import Path
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
@@ -73,10 +81,49 @@ else:
# Include API router (JSON endpoints at /api/*)
app.include_router(api_router, prefix="/api")
# ============================================================================
# FAVICON ROUTES (Must be registered BEFORE page routers)
# ============================================================================
# OLD: Keep frontend router for now (we'll phase it out)
# app.include_router(frontend_router)
def serve_favicon() -> Response:
"""
Serve favicon with caching headers.
Checks multiple possible locations for the favicon.
"""
# Possible favicon locations (in priority order)
possible_paths = [
STATIC_DIR / "favicon.ico",
STATIC_DIR / "images" / "favicon.ico",
STATIC_DIR / "assets" / "favicon.ico",
]
# Find first existing favicon
for favicon_path in possible_paths:
if favicon_path.exists():
return FileResponse(
favicon_path,
media_type="image/x-icon",
headers={
"Cache-Control": "public, max-age=86400", # Cache for 1 day
}
)
# No favicon found - return 204 No Content
return Response(status_code=204)
@app.get("/favicon.ico", include_in_schema=False)
async def favicon():
"""Serve favicon from root path."""
return serve_favicon()
@app.get("/vendor/favicon.ico", include_in_schema=False)
async def vendor_favicon():
"""Handle vendor-prefixed favicon requests."""
return serve_favicon()
# ============================================================================
# HTML PAGE ROUTES (Jinja2 Templates)
@@ -93,6 +140,7 @@ app.include_router(
# Vendor pages
app.include_router(
vendor_pages.router,
prefix="/vendor",
tags=["vendor-pages"],
include_in_schema=False # Don't show HTML pages in API docs
)
@@ -100,10 +148,12 @@ app.include_router(
# Shop pages
app.include_router(
shop_pages.router,
prefix="/shop",
tags=["shop-pages"],
include_in_schema=False # Don't show HTML pages in API docs
)
# ============================================================================
# API ROUTES (JSON Responses)
# ============================================================================
@@ -163,4 +213,5 @@ async def documentation():
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

View File

@@ -30,7 +30,7 @@ class VendorContextManager:
host = request.headers.get("host", "")
path = request.url.path
# Remove port from host if present (e.g., localhost:8000 localhost)
# Remove port from host if present (e.g., localhost:8000 -> localhost)
if ":" in host:
host = host.split(":")[0]
@@ -118,7 +118,7 @@ class VendorContextManager:
logger.warning(f"Vendor for domain {domain} is not active")
return None
logger.info(f" Vendor found via custom domain: {domain} {vendor.name}")
logger.info(f"[OK] Vendor found via custom domain: {domain} -> {vendor.name}")
return vendor
else:
logger.warning(f"No active vendor found for custom domain: {domain}")
@@ -137,7 +137,7 @@ class VendorContextManager:
if vendor:
method = context.get("detection_method", "unknown")
logger.info(f" Vendor found via {method}: {subdomain} {vendor.name}")
logger.info(f"[OK] Vendor found via {method}: {subdomain} -> {vendor.name}")
else:
logger.warning(f"No active vendor found for subdomain: {subdomain}")
@@ -184,19 +184,49 @@ class VendorContextManager:
"""Check if request is for API endpoints."""
return request.url.path.startswith("/api/")
@staticmethod
def is_static_file_request(request: Request) -> bool:
"""Check if request is for static files."""
path = request.url.path.lower()
# Static file extensions
static_extensions = (
'.ico', '.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.svg',
'.woff', '.woff2', '.ttf', '.eot', '.webp', '.map', '.json',
'.xml', '.txt', '.pdf', '.webmanifest'
)
# Static paths
static_paths = ('/static/', '/media/', '/assets/', '/.well-known/')
# Check if it's a static file by extension
if path.endswith(static_extensions):
return True
# Check if it's in a static directory
if any(path.startswith(static_path) for static_path in static_paths):
return True
# Special case: favicon.ico at any level
if 'favicon.ico' in path:
return True
return False
async def vendor_context_middleware(request: Request, call_next):
"""
Middleware to inject vendor context into request state.
Handles three routing modes:
1. Custom domains (customdomain1.com Vendor 1)
2. Subdomains (vendor1.platform.com Vendor 1)
3. Path-based (/vendor/vendor1/ Vendor 1)
1. Custom domains (customdomain1.com -> Vendor 1)
2. Subdomains (vendor1.platform.com -> Vendor 1)
3. Path-based (/vendor/vendor1/ -> Vendor 1)
"""
# Skip vendor detection for admin, API, and system requests
# Skip vendor detection for admin, API, static files, and system requests
if (VendorContextManager.is_admin_request(request) or
VendorContextManager.is_api_request(request) or
VendorContextManager.is_static_file_request(request) or
request.url.path in ["/", "/health", "/docs", "/redoc", "/openapi.json"]):
return await call_next(request)
@@ -217,12 +247,12 @@ async def vendor_context_middleware(request: Request, call_next):
)
logger.debug(
f"🏪 Vendor context: {vendor.name} ({vendor.subdomain}) "
f"[VENDOR] Vendor context: {vendor.name} ({vendor.subdomain}) "
f"via {vendor_context['detection_method']}"
)
else:
logger.warning(
f"⚠️ Vendor not found for context: {vendor_context}"
f"[WARNING] Vendor not found for context: {vendor_context}"
)
request.state.vendor = None
request.state.vendor_context = vendor_context

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""
FastAPI Route Diagnostics Script
Run this script to check if your vendor routes are properly configured.
Usage: python route_diagnostics.py
"""
import sys
from typing import List, Dict
def check_route_order():
"""Check if routes are registered in the correct order."""
print("🔍 Checking FastAPI Route Configuration...\n")
try:
# Try to import the vendor router
from app.api.v1.vendor import router as vendor_router
print("✅ Successfully imported vendor router\n")
# Check if routes are registered
routes = vendor_router.routes
print(f"📊 Total routes registered: {len(routes)}\n")
# Analyze route order
json_routes = []
html_routes = []
for route in routes:
if hasattr(route, 'path'):
path = route.path
methods = getattr(route, 'methods', set())
# Determine if JSON or HTML based on common patterns
if 'login' in path or 'dashboard' in path or 'products' in path:
# Check response class if available
if hasattr(route, 'response_class'):
response_class = str(route.response_class)
if 'HTML' in response_class:
html_routes.append((path, methods))
else:
json_routes.append((path, methods))
else:
# Check endpoint name/tags
endpoint = getattr(route, 'endpoint', None)
if endpoint and 'page' in endpoint.__name__.lower():
html_routes.append((path, methods))
else:
json_routes.append((path, methods))
print("📋 Route Analysis:")
print(f" JSON API routes: {len(json_routes)}")
print(f" HTML page routes: {len(html_routes)}\n")
# Check for specific vendor info route
vendor_info_found = False
for route in routes:
if hasattr(route, 'path'):
if '/{vendor_code}' == route.path and 'GET' in getattr(route, 'methods', set()):
vendor_info_found = True
print("✅ Found vendor info endpoint: GET /{vendor_code}")
break
if not vendor_info_found:
print("❌ WARNING: Vendor info endpoint (GET /{vendor_code}) not found!")
print(" This is likely causing your JSON parsing error.")
print(" Action required: Add the vendor info endpoint\n")
return False
print("\n✅ Route configuration looks good!")
return True
except ImportError as e:
print(f"❌ Error importing vendor router: {e}")
print(" Make sure you're running this from your project root")
return False
except Exception as e:
print(f"❌ Unexpected error: {e}")
return False
def test_vendor_endpoint():
"""Test if the vendor endpoint returns JSON."""
print("\n🧪 Testing Vendor Endpoint...\n")
try:
import requests
# Test with a sample vendor code
vendor_code = "WIZAMART"
url = f"http://localhost:8000/api/v1/vendor/{vendor_code}"
print(f"📡 Making request to: {url}")
response = requests.get(url, timeout=5)
print(f" Status Code: {response.status_code}")
print(f" Content-Type: {response.headers.get('content-type', 'N/A')}")
# Check if response is JSON
content_type = response.headers.get('content-type', '')
if 'application/json' in content_type:
print("✅ Response is JSON")
data = response.json()
print(f" Vendor: {data.get('name', 'N/A')}")
print(f" Code: {data.get('vendor_code', 'N/A')}")
return True
elif 'text/html' in content_type:
print("❌ ERROR: Response is HTML, not JSON!")
print(" This confirms the route ordering issue")
print(" The HTML page route is catching the API request")
return False
else:
print(f"⚠️ Unknown content type: {content_type}")
return False
except requests.exceptions.ConnectionError:
print("⚠️ Cannot connect to server. Is FastAPI running on localhost:8000?")
return None
except Exception as e:
print(f"❌ Error testing endpoint: {e}")
return False
def main():
"""Run all diagnostics."""
print("=" * 60)
print("FastAPI Vendor Route Diagnostics")
print("=" * 60 + "\n")
# Check route configuration
config_ok = check_route_order()
# Test actual endpoint
endpoint_ok = test_vendor_endpoint()
# Summary
print("\n" + "=" * 60)
print("SUMMARY")
print("=" * 60)
if config_ok and endpoint_ok:
print("✅ All checks passed! Your vendor routes are configured correctly.")
elif config_ok is False:
print("❌ Route configuration issues detected.")
print(" Action: Add vendor info endpoint and check route order")
elif endpoint_ok is False:
print("❌ Endpoint is returning HTML instead of JSON.")
print(" Action: Check route registration order in vendor/__init__.py")
elif endpoint_ok is None:
print("⚠️ Could not test endpoint (server not running)")
print(" Action: Start your FastAPI server and run this script again")
print("\n📚 See TROUBLESHOOTING_GUIDE.md for detailed solutions")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,6 @@
This favicon was generated using the following font:
- Font Title: Kotta One
- Font Author: undefined
- Font Source: https://fonts.gstatic.com/s/kottaone/v20/S6u_w41LXzPc_jlfNWqPHA3s5dwt7w.ttf
- Font License: undefined)

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

81
static/vendor/js/dashboard.js vendored Normal file
View File

@@ -0,0 +1,81 @@
// app/static/vendor/js/dashboard.js
/**
* Vendor dashboard page logic
*/
function vendorDashboard() {
return {
loading: false,
error: '',
stats: {
products_count: 0,
orders_count: 0,
customers_count: 0,
revenue: 0
},
recentOrders: [],
recentProducts: [],
async init() {
await this.loadDashboardData();
},
async loadDashboardData() {
this.loading = true;
this.error = '';
try {
// Load stats
const statsResponse = await apiClient.get(
`/api/v1/vendors/${this.vendorCode}/stats`
);
this.stats = statsResponse;
// Load recent orders
const ordersResponse = await apiClient.get(
`/api/v1/vendors/${this.vendorCode}/orders?limit=5&sort=created_at:desc`
);
this.recentOrders = ordersResponse.items || [];
// Load recent products
const productsResponse = await apiClient.get(
`/api/v1/vendors/${this.vendorCode}/products?limit=5&sort=created_at:desc`
);
this.recentProducts = productsResponse.items || [];
logInfo('Dashboard data loaded', {
stats: this.stats,
orders: this.recentOrders.length,
products: this.recentProducts.length
});
} catch (error) {
logError('Failed to load dashboard data', error);
this.error = 'Failed to load dashboard data. Please try refreshing the page.';
} finally {
this.loading = false;
}
},
async refresh() {
await this.loadDashboardData();
},
formatCurrency(amount) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount || 0);
},
formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
};
}

104
static/vendor/js/init-alpine.js vendored Normal file
View File

@@ -0,0 +1,104 @@
// app/static/vendor/js/init-alpine.js
/**
* Alpine.js initialization for vendor pages
* Provides common data and methods for all vendor pages
*/
function data() {
return {
dark: false,
isSideMenuOpen: false,
isNotificationsMenuOpen: false,
isProfileMenuOpen: false,
currentPage: '',
currentUser: {},
vendor: null,
vendorCode: null,
init() {
// Set current page from URL
const path = window.location.pathname;
const segments = path.split('/').filter(Boolean);
this.currentPage = segments[segments.length - 1] || 'dashboard';
// Get vendor code from URL
if (segments[0] === 'vendor' && segments[1]) {
this.vendorCode = segments[1];
}
// Load user from localStorage
const user = localStorage.getItem('currentUser');
if (user) {
this.currentUser = JSON.parse(user);
}
// Load theme preference
const theme = localStorage.getItem('theme');
if (theme === 'dark') {
this.dark = true;
}
// Load vendor info
this.loadVendorInfo();
},
async loadVendorInfo() {
if (!this.vendorCode) return;
try {
const response = await apiClient.get(`/api/v1/vendors/${this.vendorCode}`);
this.vendor = response;
logDebug('Vendor info loaded', this.vendor);
} catch (error) {
logError('Failed to load vendor info', error);
}
},
toggleSideMenu() {
this.isSideMenuOpen = !this.isSideMenuOpen;
},
closeSideMenu() {
this.isSideMenuOpen = false;
},
toggleNotificationsMenu() {
this.isNotificationsMenuOpen = !this.isNotificationsMenuOpen;
if (this.isNotificationsMenuOpen) {
this.isProfileMenuOpen = false;
}
},
closeNotificationsMenu() {
this.isNotificationsMenuOpen = false;
},
toggleProfileMenu() {
this.isProfileMenuOpen = !this.isProfileMenuOpen;
if (this.isProfileMenuOpen) {
this.isNotificationsMenuOpen = false;
}
},
closeProfileMenu() {
this.isProfileMenuOpen = false;
},
toggleTheme() {
this.dark = !this.dark;
localStorage.setItem('theme', this.dark ? 'dark' : 'light');
},
async handleLogout() {
try {
await apiClient.post('/api/v1/vendor/auth/logout');
} catch (error) {
logError('Logout error', error);
} finally {
localStorage.removeItem('accessToken');
localStorage.removeItem('currentUser');
window.location.href = `/vendor/${this.vendorCode}/login`;
}
}
};
}

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

@@ -0,0 +1,110 @@
// app/static/vendor/js/login.js
/**
* Vendor login page logic
*/
// ✅ Use centralized logger - ONE LINE!
// Create custom logger for login page
const loginLog = window.LogConfig.createLogger('LOGIN');
function vendorLogin() {
return {
credentials: {
username: '',
password: ''
},
vendor: null,
vendorCode: null,
loading: false,
checked: false,
error: '',
success: '',
errors: {},
dark: false,
async init() {
// Load theme
const theme = localStorage.getItem('theme');
if (theme === 'dark') {
this.dark = true;
}
// Get vendor code from URL path
const pathSegments = window.location.pathname.split('/').filter(Boolean);
if (pathSegments[0] === 'vendor' && pathSegments[1]) {
this.vendorCode = pathSegments[1];
await this.loadVendor();
}
this.checked = true;
},
async loadVendor() {
this.loading = true;
try {
const response = await apiClient.get(`/vendor/${this.vendorCode}`);
this.vendor = response;
logInfo('Vendor loaded', this.vendor);
} catch (error) {
logError('Failed to load vendor', error);
this.error = 'Failed to load vendor information';
} finally {
this.loading = false;
}
},
async handleLogin() {
this.clearErrors();
this.loading = true;
try {
if (!this.credentials.username) {
this.errors.username = 'Username is required';
}
if (!this.credentials.password) {
this.errors.password = 'Password is required';
}
if (Object.keys(this.errors).length > 0) {
this.loading = false;
return;
}
const response = await apiClient.post('/vendor/auth/login', {
username: this.credentials.username,
password: this.credentials.password,
vendor_code: this.vendorCode
});
logInfo('Login successful', response);
localStorage.setItem('accessToken', response.access_token);
localStorage.setItem('currentUser', JSON.stringify(response.user));
localStorage.setItem('vendorCode', this.vendorCode);
this.success = 'Login successful! Redirecting...';
setTimeout(() => {
window.location.href = `/vendor/${this.vendorCode}/dashboard`;
}, 1000);
} catch (error) {
logError('Login failed', error);
if (error.status === 401) {
this.error = 'Invalid username or password';
} else if (error.status === 403) {
this.error = 'Your account does not have access to this vendor';
} else {
this.error = error.message || 'Login failed. Please try again.';
}
} finally {
this.loading = false;
}
},
clearErrors() {
this.error = '';
this.errors = {};
}
};
}