admin panel migration to jinja

This commit is contained in:
2025-10-25 07:31:44 +02:00
parent 13ae656a49
commit 1a43a4250c
21 changed files with 1788 additions and 1599 deletions

View File

@@ -113,6 +113,44 @@ def get_current_admin_user(current_user: User = Depends(get_current_user)):
return auth_manager.require_admin(current_user)
def get_current_vendor_user(current_user: User = Depends(get_current_user)):
"""
Require vendor user (vendor owner or vendor staff).
This dependency ensures the current user has vendor role.
Used for protecting vendor-only routes.
Args:
current_user: User object from get_current_user dependency
Returns:
User: Vendor user object
Raises:
InsufficientPermissionsException: If user is not a vendor user
"""
return auth_manager.require_vendor(current_user)
def get_current_customer_user(current_user: User = Depends(get_current_user)):
"""
Require customer user.
This dependency ensures the current user has customer role.
Used for protecting customer account routes.
Args:
current_user: User object from get_current_user dependency
Returns:
User: Customer user object
Raises:
InsufficientPermissionsException: If user is not a customer
"""
return auth_manager.require_customer(current_user)
def get_user_vendor(
vendor_code: str,
current_user: User = Depends(get_current_user),

View File

@@ -3,17 +3,25 @@
Admin HTML page routes using Jinja2 templates.
These routes return rendered HTML pages (response_class=HTMLResponse).
Separate from other admin routes which return JSON data.
Separate from admin API routes which return JSON data.
All routes require admin authentication except /login.
Authentication failures redirect to /admin/login.
Routes:
- GET / - Admin root (redirects to login)
- GET /login - Admin login page (no auth required)
- GET /dashboard - Admin dashboard (requires auth)
- GET /vendors - Vendor management page (requires auth)
- GET /users - User management page (requires auth)
- GET / → Redirect to /admin/login
- GET /login Admin login page (no auth)
- GET /dashboard Admin dashboard (auth required)
- GET /vendors Vendor list page (auth required)
- GET /vendors/create → Create vendor form (auth required)
- GET /vendors/{vendor_code} → Vendor details (auth required)
- GET /vendors/{vendor_code}/edit → Edit vendor form (auth required)
- GET /users → User management page (auth required)
- GET /imports → Import history page (auth required)
- GET /settings → Settings page (auth required)
"""
from fastapi import APIRouter, Request, Depends
from fastapi import APIRouter, Request, Depends, Path
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
@@ -25,18 +33,18 @@ router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
# ============================================================================
# PUBLIC ROUTES (No Authentication Required)
# ============================================================================
@router.get("/", response_class=RedirectResponse, include_in_schema=False)
async def admin_root():
"""
Redirect /admin/ to /admin/login.
This is the simplest approach:
- Unauthenticated users: see login form
- Authenticated users: login page clears token and shows form
(they can manually navigate to dashboard if needed)
Alternative: Could redirect to /admin/dashboard and let auth
dependency handle the redirect, but that's an extra hop.
Simple approach:
- Unauthenticated users see login form
- Authenticated users login page shows form (they can navigate to dashboard)
"""
return RedirectResponse(url="/admin/login", status_code=302)
@@ -53,15 +61,19 @@ async def admin_login_page(request: Request):
)
# ============================================================================
# AUTHENTICATED ROUTES (Admin Only)
# ============================================================================
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
async def admin_dashboard_page(
request: Request,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
request: Request,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""
Render admin dashboard page.
Requires admin authentication - will redirect to login if not authenticated.
Shows platform statistics and recent activity.
"""
return templates.TemplateResponse(
"admin/dashboard.html",
@@ -72,15 +84,19 @@ async def admin_dashboard_page(
)
# ============================================================================
# VENDOR MANAGEMENT ROUTES
# ============================================================================
@router.get("/vendors", response_class=HTMLResponse, include_in_schema=False)
async def admin_vendors_page(
request: Request,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
async def admin_vendors_list_page(
request: Request,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""
Render vendors management page.
Requires admin authentication.
Shows list of all vendors with stats.
"""
return templates.TemplateResponse(
"admin/vendors.html",
@@ -91,15 +107,78 @@ async def admin_vendors_page(
)
@router.get("/vendors/create", response_class=HTMLResponse, include_in_schema=False)
async def admin_vendor_create_page(
request: Request,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""
Render vendor creation form.
"""
return templates.TemplateResponse(
"admin/vendor-create.html",
{
"request": request,
"user": current_user,
}
)
@router.get("/vendors/{vendor_code}", response_class=HTMLResponse, include_in_schema=False)
async def admin_vendor_detail_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""
Render vendor detail page.
Shows full vendor information.
"""
return templates.TemplateResponse(
"admin/vendor-detail.html",
{
"request": request,
"user": current_user,
"vendor_code": vendor_code,
}
)
@router.get("/vendors/{vendor_code}/edit", response_class=HTMLResponse, include_in_schema=False)
async def admin_vendor_edit_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""
Render vendor edit form.
"""
return templates.TemplateResponse(
"admin/vendor-edit.html",
{
"request": request,
"user": current_user,
"vendor_code": vendor_code,
}
)
# ============================================================================
# USER MANAGEMENT ROUTES
# ============================================================================
@router.get("/users", response_class=HTMLResponse, include_in_schema=False)
async def admin_users_page(
request: Request,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
request: Request,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""
Render users management page.
Requires admin authentication.
Shows list of all platform users.
"""
return templates.TemplateResponse(
"admin/users.html",
@@ -108,3 +187,49 @@ async def admin_users_page(
"user": current_user,
}
)
# ============================================================================
# IMPORT MANAGEMENT ROUTES
# ============================================================================
@router.get("/imports", response_class=HTMLResponse, include_in_schema=False)
async def admin_imports_page(
request: Request,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""
Render imports management page.
Shows import history and status.
"""
return templates.TemplateResponse(
"admin/imports.html",
{
"request": request,
"user": current_user,
}
)
# ============================================================================
# SETTINGS ROUTES
# ============================================================================
@router.get("/settings", response_class=HTMLResponse, include_in_schema=False)
async def admin_settings_page(
request: Request,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""
Render admin settings page.
Platform configuration and preferences.
"""
return templates.TemplateResponse(
"admin/settings.html",
{
"request": request,
"user": current_user,
}
)

View File

@@ -37,24 +37,27 @@
</div>
</div>
<!-- Core Scripts - Loaded in STRICT ORDER -->
<!-- Core Scripts - ORDER MATTERS! -->
<!-- 1. Icons FIRST (defines $icon magic) -->
<!-- 1. FIRST: Log Configuration -->
<script src="{{ url_for('static', path='admin/js/log-config.js') }}"></script>
<!-- 2. SECOND: Icons (before Alpine.js) -->
<script src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
<!-- 2. Base Alpine Data (defines sidebar/header state) -->
<!-- 3. THIRD: Alpine.js Base Data -->
<script src="{{ url_for('static', path='admin/js/init-alpine.js') }}"></script>
<!-- 3. API Client -->
<script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<!-- 4. Utils -->
<!-- 4. FOURTH: Utils (standalone utilities) -->
<script src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
<!-- 5. Alpine.js v3 (deferred to allow DOM to load) -->
<!-- 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>
<!-- 6. Page-specific scripts -->
<!-- 7. LAST: Page-specific scripts -->
{% block extra_scripts %}{% endblock %}
</body>
</html>

View File

@@ -100,10 +100,24 @@
</div>
</div>
<!-- Scripts -->
<!-- Scripts - ORDER MATTERS! -->
<!-- 1. Log Configuration -->
<script src="{{ url_for('static', path='admin/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>
<script src="{{ url_for('static', path='admin/js/login.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='admin/js/login.js') }}"></script>
</body>
</html>

View File

@@ -0,0 +1,270 @@
{% extends "admin/base.html" %}
{% block title %}Users Management - LetzShop Admin{% endblock %}
{% block page_title %}Users Management{% endblock %}
{% block content %}
<div x-data="adminUsers()" x-init="init()">
<!-- Header Section with Search and Actions -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<!-- Search Bar -->
<div class="flex-1 max-w-md">
<div class="relative">
<input
type="text"
x-model="filters.search"
@input="debouncedSearch()"
placeholder="Search users by name, email, or username..."
class="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<div class="absolute left-3 top-2.5" x-html="$icon('search', 'w-5 h-5 text-gray-400')"></div>
</div>
</div>
<!-- Filter and Action Buttons -->
<div class="flex gap-3">
<!-- Role Filter -->
<select
x-model="filters.role"
@change="loadUsers()"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value="">All Roles</option>
<option value="customer">Customer</option>
<option value="vendor">Vendor</option>
<option value="admin">Admin</option>
</select>
<!-- Status Filter -->
<select
x-model="filters.is_active"
@change="loadUsers()"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
<!-- Add User Button -->
<button
@click="openCreateModal()"
class="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<span x-html="$icon('user-plus', 'w-5 h-5')"></span>
<span>Add User</span>
</button>
</div>
</div>
</div>
<!-- Statistics Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Total Users</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white" x-text="stats.total"></p>
</div>
<div class="p-3 bg-blue-100 dark:bg-blue-900 rounded-lg" x-html="$icon('users', 'w-8 h-8 text-blue-600 dark:text-blue-400')"></div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Active Users</p>
<p class="text-2xl font-bold text-green-600 dark:text-green-400" x-text="stats.active"></p>
</div>
<div class="p-3 bg-green-100 dark:bg-green-900 rounded-lg" x-html="$icon('user-check', 'w-8 h-8 text-green-600 dark:text-green-400')"></div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Vendors</p>
<p class="text-2xl font-bold text-purple-600 dark:text-purple-400" x-text="stats.vendors"></p>
</div>
<div class="p-3 bg-purple-100 dark:bg-purple-900 rounded-lg" x-html="$icon('store', 'w-8 h-8 text-purple-600 dark:text-purple-400')"></div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Admins</p>
<p class="text-2xl font-bold text-orange-600 dark:text-orange-400" x-text="stats.admins"></p>
</div>
<div class="p-3 bg-orange-100 dark:bg-orange-900 rounded-lg" x-html="$icon('shield', 'w-8 h-8 text-orange-600 dark:text-orange-400')"></div>
</div>
</div>
</div>
<!-- Users Table -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
<!-- Loading State -->
<div x-show="loading" class="p-8 text-center">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading users...</p>
</div>
<!-- Empty State -->
<div x-show="!loading && users.length === 0" class="p-8 text-center">
<div class="text-gray-400 dark:text-gray-500 mb-4" x-html="$icon('inbox', 'w-16 h-16 mx-auto')"></div>
<p class="text-gray-600 dark:text-gray-400">No users found</p>
</div>
<!-- Users Table -->
<div x-show="!loading && users.length > 0">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
User
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Email
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Role
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Registered
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Last Login
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="user in users" :key="user.id">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<!-- User Info -->
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center">
<span class="text-white font-semibold" x-text="(user.full_name || user.username || 'U').charAt(0).toUpperCase()"></span>
</div>
<div class="ml-4">
<div class="text-sm font-medium text-gray-900 dark:text-white" x-text="user.full_name || 'N/A'"></div>
<div class="text-sm text-gray-500 dark:text-gray-400" x-text="'@' + user.username"></div>
</div>
</div>
</td>
<!-- Email -->
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900 dark:text-white" x-text="user.email"></div>
</td>
<!-- Role -->
<td class="px-6 py-4 whitespace-nowrap">
<span
class="px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full"
:class="{
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200': user.role === 'customer',
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200': user.role === 'vendor',
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200': user.role === 'admin'
}"
x-text="user.role"
></span>
</td>
<!-- Status -->
<td class="px-6 py-4 whitespace-nowrap">
<span
class="px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full"
:class="user.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'"
x-text="user.is_active ? 'Active' : 'Inactive'"
></span>
</td>
<!-- Registered -->
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<span x-text="Utils.formatDate(user.created_at)"></span>
</td>
<!-- Last Login -->
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<span x-text="user.last_login ? Utils.formatDate(user.last_login) : 'Never'"></span>
</td>
<!-- Actions -->
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex justify-end gap-2">
<button
@click="viewUser(user)"
class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
title="View Details"
x-html="$icon('eye', 'w-5 h-5')"
></button>
<button
@click="editUser(user)"
class="text-yellow-600 hover:text-yellow-900 dark:text-yellow-400 dark:hover:text-yellow-300"
title="Edit"
x-html="$icon('edit', 'w-5 h-5')"
></button>
<button
@click="toggleUserStatus(user)"
:class="user.is_active ? 'text-orange-600 hover:text-orange-900 dark:text-orange-400 dark:hover:text-orange-300' : 'text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300'"
:title="user.is_active ? 'Deactivate' : 'Activate'"
x-html="$icon(user.is_active ? 'user-x' : 'user-check', 'w-5 h-5')"
></button>
<button
@click="deleteUser(user)"
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
title="Delete"
x-html="$icon('trash', 'w-5 h-5')"
></button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
<!-- Pagination -->
<div class="bg-gray-50 dark:bg-gray-900 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div class="text-sm text-gray-700 dark:text-gray-300">
Showing <span class="font-medium" x-text="((pagination.page - 1) * pagination.per_page) + 1"></span>
to <span class="font-medium" x-text="Math.min(pagination.page * pagination.per_page, pagination.total)"></span>
of <span class="font-medium" x-text="pagination.total"></span> users
</div>
<div class="flex gap-2">
<button
@click="previousPage()"
:disabled="pagination.page === 1"
:class="pagination.page === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-200 dark:hover:bg-gray-700'"
class="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-700 dark:text-gray-300"
>
Previous
</button>
<button
@click="nextPage()"
:disabled="pagination.page >= pagination.pages"
:class="pagination.page >= pagination.pages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-200 dark:hover:bg-gray-700'"
class="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-700 dark:text-gray-300"
>
Next
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/users.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,257 @@
{# app/templates/admin/vendor-edit.html #}
{% extends "admin/base.html" %}
{% block title %}Edit Vendor{% endblock %}
{% block alpine_data %}adminVendorEdit(){% endblock %}
{% block content %}
<!-- Page Header -->
<div class="flex items-center justify-between my-6">
<div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Edit Vendor
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-show="vendor">
<span x-text="vendor?.name"></span>
<span class="text-gray-400"></span>
<span x-text="vendor?.vendor_code"></span>
</p>
</div>
<a href="/admin/vendors"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-white border border-gray-300 rounded-lg dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800 hover:border-gray-400 focus:outline-none">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
Back to Vendors
</a>
</div>
<!-- Loading State -->
<div x-show="loadingVendor" 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 vendor...</p>
</div>
<!-- Edit Form -->
<div x-show="!loadingVendor && vendor" class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
<!-- Quick Actions -->
<div class="flex items-center gap-3 mb-6 pb-6 border-b dark:border-gray-700">
<button
@click="toggleVerification()"
:disabled="saving"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 rounded-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
:class="vendor?.is_verified ? 'bg-orange-600 hover:bg-orange-700' : 'bg-green-600 hover:bg-green-700'">
<span x-html="$icon(vendor?.is_verified ? 'x-circle' : 'badge-check', 'w-4 h-4 mr-2')"></span>
<span x-text="vendor?.is_verified ? 'Unverify Vendor' : 'Verify Vendor'"></span>
</button>
<button
@click="toggleActive()"
:disabled="saving"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 rounded-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
:class="vendor?.is_active ? 'bg-red-600 hover:bg-red-700' : 'bg-green-600 hover:bg-green-700'">
<span x-html="$icon(vendor?.is_active ? 'lock-closed' : 'lock-open', 'w-4 h-4 mr-2')"></span>
<span x-text="vendor?.is_active ? 'Deactivate' : 'Activate'"></span>
</button>
</div>
<!-- Form -->
<form @submit.prevent="handleSubmit">
<div class="grid gap-6 mb-8 md:grid-cols-2">
<!-- Left Column: Basic Info -->
<div>
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Basic Information
</h3>
<!-- Vendor Code (readonly) -->
<label class="block mb-4">
<span class="text-sm font-medium text-gray-700 dark:text-gray-400">
Vendor Code
</span>
<input
type="text"
x-model="vendor.vendor_code"
disabled
class="block w-full mt-1 text-sm bg-gray-100 border-gray-300 rounded-md dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600 cursor-not-allowed"
>
<span class="text-xs text-gray-600 dark:text-gray-400 mt-1">
Cannot be changed after creation
</span>
</label>
<!-- Name -->
<label class="block mb-4">
<span class="text-sm font-medium text-gray-700 dark:text-gray-400">
Vendor Name <span class="text-red-600">*</span>
</span>
<input
type="text"
x-model="formData.name"
required
maxlength="255"
:disabled="saving"
class="block w-full mt-1 text-sm border-gray-300 rounded-md dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
:class="{ 'border-red-600': errors.name }"
>
<span x-show="errors.name" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="errors.name"></span>
</label>
<!-- Subdomain -->
<label class="block mb-4">
<span class="text-sm font-medium text-gray-700 dark:text-gray-400">
Subdomain <span class="text-red-600">*</span>
</span>
<input
type="text"
x-model="formData.subdomain"
@input="formatSubdomain()"
required
maxlength="100"
:disabled="saving"
class="block w-full mt-1 text-sm border-gray-300 rounded-md dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
:class="{ 'border-red-600': errors.subdomain }"
>
<span class="text-xs text-gray-600 dark:text-gray-400 mt-1">
Lowercase letters, numbers, and hyphens only
</span>
<span x-show="errors.subdomain" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="errors.subdomain"></span>
</label>
<!-- Description -->
<label class="block mb-4">
<span class="text-sm font-medium text-gray-700 dark:text-gray-400">
Description
</span>
<textarea
x-model="formData.description"
rows="3"
:disabled="saving"
class="block w-full mt-1 text-sm border-gray-300 rounded-md dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-textarea"
></textarea>
</label>
</div>
<!-- Right Column: Contact Info -->
<div>
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Contact Information
</h3>
<!-- Owner Email (readonly) -->
<label class="block mb-4">
<span class="text-sm font-medium text-gray-700 dark:text-gray-400">
Owner Email
</span>
<input
type="email"
x-model="vendor.owner_email"
disabled
class="block w-full mt-1 text-sm bg-gray-100 border-gray-300 rounded-md dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600 cursor-not-allowed"
>
</label>
<!-- Contact Email -->
<label class="block mb-4">
<span class="text-sm font-medium text-gray-700 dark:text-gray-400">
Contact Email <span class="text-red-600">*</span>
</span>
<input
type="email"
x-model="formData.contact_email"
required
:disabled="saving"
class="block w-full mt-1 text-sm border-gray-300 rounded-md dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
:class="{ 'border-red-600': errors.contact_email }"
>
<span x-show="errors.contact_email" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="errors.contact_email"></span>
</label>
<!-- Phone -->
<label class="block mb-4">
<span class="text-sm font-medium text-gray-700 dark:text-gray-400">
Phone
</span>
<input
type="tel"
x-model="formData.contact_phone"
:disabled="saving"
class="block w-full mt-1 text-sm border-gray-300 rounded-md dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
>
</label>
<!-- Website -->
<label class="block mb-4">
<span class="text-sm font-medium text-gray-700 dark:text-gray-400">
Website
</span>
<input
type="url"
x-model="formData.website"
:disabled="saving"
placeholder="https://example.com"
class="block w-full mt-1 text-sm border-gray-300 rounded-md dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
>
</label>
</div>
</div>
<!-- Business Details -->
<div class="mb-8">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Business Details
</h3>
<div class="grid gap-6 md:grid-cols-2">
<!-- Business Address -->
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-400">
Business Address
</span>
<textarea
x-model="formData.business_address"
rows="3"
:disabled="saving"
class="block w-full mt-1 text-sm border-gray-300 rounded-md dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-textarea"
></textarea>
</label>
<!-- Tax Number -->
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-400">
Tax Number
</span>
<input
type="text"
x-model="formData.tax_number"
:disabled="saving"
class="block w-full mt-1 text-sm border-gray-300 rounded-md dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
>
</label>
</div>
</div>
<!-- Save Button -->
<div class="flex items-center justify-end gap-3">
<a
href="/admin/vendors"
class="px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-white border border-gray-300 rounded-lg dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800 hover:border-gray-400 focus:outline-none">
Cancel
</a>
<button
type="submit"
:disabled="saving"
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="!saving">Save Changes</span>
<span x-show="saving" class="flex items-center">
<span x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
Saving...
</span>
</button>
</div>
</form>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/vendor-edit.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,263 @@
{# app/templates/admin/vendors.html #}
{% extends "admin/base.html" %}
{% block title %}Vendors{% endblock %}
{% block alpine_data %}adminVendors(){% endblock %}
{% block content %}
<!-- Page Header -->
<div class="flex items-center justify-between my-6">
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Vendor Management
</h2>
<a
href="/admin/vendors/create"
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"
>
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Create Vendor
</a>
</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 vendors...</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 vendors</p>
<p class="text-sm" x-text="error"></p>
</div>
</div>
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Card: Total Vendors -->
<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('user-group', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Vendors
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total || 0">
0
</p>
</div>
</div>
<!-- Card: Verified Vendors -->
<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('badge-check', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Verified Vendors
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.verified || 0">
0
</p>
</div>
</div>
<!-- Card: Pending Verification -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
<span x-html="$icon('clock', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Pending
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.pending || 0">
0
</p>
</div>
</div>
<!-- Card: Inactive Vendors -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Inactive
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.inactive || 0">
0
</p>
</div>
</div>
</div>
<!-- Vendors Table with Pagination -->
<div x-show="!loading" class="w-full 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">Vendor</th>
<th class="px-4 py-3">Subdomain</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Created</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<!-- Empty State -->
<template x-if="paginatedVendors.length === 0">
<tr>
<td colspan="5" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('user-group', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p class="font-medium">No vendors found</p>
<p class="text-xs mt-1">Create your first vendor to get started</p>
</div>
</td>
</tr>
</template>
<!-- Vendor Rows -->
<template x-for="vendor in paginatedVendors" :key="vendor.id || vendor.vendor_code">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<!-- Vendor Info with Avatar -->
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block">
<div class="absolute inset-0 rounded-full bg-purple-100 dark:bg-purple-600 flex items-center justify-center">
<span class="text-xs font-semibold text-purple-600 dark:text-purple-100"
x-text="vendor.name?.charAt(0).toUpperCase() || '?'"></span>
</div>
</div>
<div>
<p class="font-semibold" x-text="vendor.name"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="vendor.vendor_code"></p>
</div>
</div>
</td>
<!-- Subdomain -->
<td class="px-4 py-3 text-sm" x-text="vendor.subdomain"></td>
<!-- Status Badge -->
<td class="px-4 py-3 text-xs">
<span class="inline-flex items-center px-2 py-1 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'">
<span x-show="vendor.is_verified" x-html="$icon('badge-check', 'w-3 h-3 mr-1')"></span>
<span x-text="vendor.is_verified ? 'Verified' : 'Pending'"></span>
</span>
</td>
<!-- Created Date -->
<td class="px-4 py-3 text-sm" x-text="formatDate(vendor.created_at)"></td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<!-- View Button -->
<button
@click="viewVendor(vendor.vendor_code)"
class="flex items-center justify-center p-2 text-blue-600 rounded-lg hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="View details"
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</button>
<!-- Edit Button -->
<button
@click="editVendor(vendor.vendor_code)"
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Edit vendor"
>
<span x-html="$icon('edit', 'w-5 h-5')"></span>
</button>
<!-- Delete Button -->
<button
@click="deleteVendor(vendor)"
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Delete vendor"
>
<span x-html="$icon('delete', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Pagination Footer -->
<div class="grid px-4 py-3 text-xs font-semibold tracking-wide text-gray-500 uppercase border-t dark:border-gray-700 bg-gray-50 sm:grid-cols-9 dark:text-gray-400 dark:bg-gray-800">
<!-- Results Info -->
<span class="flex items-center col-span-3">
Showing <span class="mx-1 font-bold" x-text="startIndex"></span>-<span class="mx-1 font-bold" x-text="endIndex"></span> of <span class="mx-1 font-bold" x-text="vendors.length"></span>
</span>
<span class="col-span-2"></span>
<!-- Pagination Controls -->
<span class="flex col-span-4 mt-2 sm:mt-auto sm:justify-end">
<nav aria-label="Table navigation">
<ul class="inline-flex items-center">
<!-- Previous Button -->
<li>
<button
@click="previousPage()"
:disabled="currentPage === 1"
class="px-3 py-1 rounded-md rounded-l-lg focus:outline-none focus:shadow-outline-purple"
:class="currentPage === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100 dark:hover:bg-gray-700'"
aria-label="Previous"
>
<svg class="w-4 h-4 fill-current" aria-hidden="true" viewBox="0 0 20 20">
<path d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" fill-rule="evenodd"></path>
</svg>
</button>
</li>
<!-- Page Numbers -->
<template x-for="page in pageNumbers" :key="page">
<li>
<button
x-show="page !== '...'"
@click="goToPage(page)"
class="px-3 py-1 rounded-md focus:outline-none focus:shadow-outline-purple"
:class="currentPage === page ? 'text-white bg-purple-600 border border-purple-600' : 'hover:bg-gray-100 dark:hover:bg-gray-700'"
x-text="page"
></button>
<span x-show="page === '...'" class="px-3 py-1">...</span>
</li>
</template>
<!-- Next Button -->
<li>
<button
@click="nextPage()"
:disabled="currentPage === totalPages"
class="px-3 py-1 rounded-md rounded-r-lg focus:outline-none focus:shadow-outline-purple"
:class="currentPage === totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100 dark:hover:bg-gray-700'"
aria-label="Next"
>
<svg class="w-4 h-4 fill-current" aria-hidden="true" viewBox="0 0 20 20">
<path d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" fill-rule="evenodd"></path>
</svg>
</button>
</li>
</ul>
</nav>
</span>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/vendors.js') }}"></script>
{% endblock %}

View File

@@ -1,3 +1,4 @@
<!-- app/templates/partials/header.html -->
<!-- Top header bar with search, theme toggle, notifications, profile -->
<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">
@@ -33,82 +34,128 @@
</li>
<!-- Notifications menu -->
<li class="relative">
<li class="relative" x-data="{ notifOpen: false }">
<button class="relative align-middle rounded-md focus:outline-none focus:shadow-outline-purple"
@click="toggleNotificationsMenu" @keydown.escape="closeNotificationsMenu"
@click="notifOpen = !notifOpen"
@keydown.escape="notifOpen = false"
aria-label="Notifications" aria-haspopup="true">
<span x-html="$icon('bell', 'w-5 h-5')"></span>
<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>Messages</span>
<span class="inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-red-600 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-600">13</span>
</a>
</li>
<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>Sales</span>
<span class="inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-red-600 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-600">2</span>
</a>
</li>
<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>Alerts</span>
</a>
</li>
</ul>
</template>
<ul x-show="notifOpen"
x-cloak
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click.away="notifOpen = false"
@keydown.escape="notifOpen = false"
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>Messages</span>
<span class="inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-red-600 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-600">13</span>
</a>
</li>
<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>Sales</span>
<span class="inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-red-600 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-600">2</span>
</a>
</li>
<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>Alerts</span>
</a>
</li>
</ul>
</li>
<!-- Profile menu -->
<li class="relative">
<!-- Profile menu - ✅ FIXED with self-contained Alpine data -->
<li class="relative" x-data="{ profileOpen: false }">
<button class="align-middle rounded-full focus:shadow-outline-purple focus:outline-none"
@click="toggleProfileMenu" @keydown.escape="closeProfileMenu"
aria-label="Account" aria-haspopup="true">
@click="profileOpen = !profileOpen"
@keydown.escape="profileOpen = false"
aria-label="Account"
aria-haspopup="true">
<img class="object-cover w-8 h-8 rounded-full"
src="https://images.unsplash.com/photo-1502378735452-bc7d86632805?ixlib=rb-0.3.5&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=200&fit=max&s=aa3a807e1bbdfd4364d1f449eaa96d82"
alt="" aria-hidden="true"/>
</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"
aria-label="submenu">
<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="#">
<span x-html="$icon('user', 'w-4 h-4 mr-3')"></span>
<span>Profile</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" href="#">
<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"
href="/admin/login"> <!-- ← Changed from /static/admin/login.html -->
<span x-html="$icon('logout', 'w-4 h-4 mr-3')"></span>
<span>Log out</span>
</a>
</li>
</ul>
</template>
<!-- Use x-show instead of x-if for reliability -->
<ul x-show="profileOpen"
x-cloak
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click.away="profileOpen = false"
@keydown.escape="profileOpen = false"
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 z-50"
style="display: none;"
aria-label="submenu">
<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="#">
<span x-html="$icon('user', 'w-4 h-4 mr-3')"></span>
<span>Profile</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" href="#">
<span x-html="$icon('cog', 'w-4 h-4 mr-3')"></span>
<span>Settings</span>
</a>
</li>
<li class="flex">
<button
@click="handleLogout()"
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 text-left">
<span x-html="$icon('logout', 'w-4 h-4 mr-3')"></span>
<span>Log out</span>
</button>
</li>
</ul>
</li>
</ul>
</div>
</header>
</header>
<!-- Logout handler script -->
<script>
// Add handleLogout function to Alpine data
document.addEventListener('alpine:init', () => {
// Extend the data() function to include logout
const originalData = window.data;
window.data = function() {
const baseData = originalData();
return {
...baseData,
handleLogout() {
console.log('🚪 Logging out...');
// Call logout API
fetch('/api/v1/admin/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('admin_token')}`
}
})
.then(() => {
console.log('✅ Logout API called successfully');
})
.catch((error) => {
console.error('âš ï¸ Logout API error (continuing anyway):', error);
})
.finally(() => {
// Clear all tokens
console.log('🧹 Clearing tokens...');
localStorage.clear();
console.log('🔄 Redirecting to login...');
window.location.href = '/admin/login';
});
}
};
};
});
</script>