migrating vendor frontend to new architecture
This commit is contained in:
38
app/api/v1/vendor/__init__.py
vendored
38
app/api/v1/vendor/__init__.py
vendored
@@ -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
106
app/api/v1/vendor/info.py
vendored
Normal 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,
|
||||
)
|
||||
181
app/api/v1/vendor/pages.py
vendored
181
app/api/v1/vendor/pages.py
vendored
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
63
app/templates/vendor/base.html
vendored
63
app/templates/vendor/base.html
vendored
@@ -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
168
app/templates/vendor/dashboard.html
vendored
Normal 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 %}
|
||||
257
app/templates/vendor/login.html
vendored
257
app/templates/vendor/login.html
vendored
@@ -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>
|
||||
116
app/templates/vendor/partials/header.html
vendored
116
app/templates/vendor/partials/header.html
vendored
@@ -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>
|
||||
195
app/templates/vendor/partials/sidebar.html
vendored
195
app/templates/vendor/partials/sidebar.html
vendored
@@ -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>
|
||||
66
app/templates/vendor/partials/vendor_info.html
vendored
66
app/templates/vendor/partials/vendor_info.html
vendored
@@ -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
61
main.py
@@ -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)
|
||||
@@ -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
|
||||
|
||||
160
scripts/route_diagnostics.py
Normal file
160
scripts/route_diagnostics.py
Normal 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()
|
||||
6
static/shared/img/about.txt
Normal file
6
static/shared/img/about.txt
Normal 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)
|
||||
BIN
static/shared/img/android-chrome-192x192.png
Normal file
BIN
static/shared/img/android-chrome-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
BIN
static/shared/img/android-chrome-512x512.png
Normal file
BIN
static/shared/img/android-chrome-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
static/shared/img/apple-touch-icon.png
Normal file
BIN
static/shared/img/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
BIN
static/shared/img/favicon-16x16.png
Normal file
BIN
static/shared/img/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 501 B |
BIN
static/shared/img/favicon-32x32.png
Normal file
BIN
static/shared/img/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
static/shared/img/favicon.ico
Normal file
BIN
static/shared/img/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
1
static/shared/img/site.webmanifest
Normal file
1
static/shared/img/site.webmanifest
Normal 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
81
static/vendor/js/dashboard.js
vendored
Normal 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
104
static/vendor/js/init-alpine.js
vendored
Normal 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
110
static/vendor/js/login.js
vendored
Normal 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 = {};
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user