32 KiB
32 KiB
Vendor Admin Frontend - Alpine.js/Jinja2 Page Template Guide
📋 Overview
This guide provides complete templates for creating new vendor admin pages using the established Alpine.js + Jinja2 architecture. Follow these patterns to ensure consistency across the vendor portal.
🎯 Quick Reference
File Structure for New Page
app/
├── templates/vendor/admin/
│ └── [page-name].html # Jinja2 template
├── static/vendor/js/
│ └── [page-name].js # Alpine.js component
└── api/v1/vendor/
└── pages.py # Route registration
Checklist for New Page
- Create Jinja2 template extending base.html
- Create Alpine.js JavaScript component
- Register route in pages.py
- Add navigation link to sidebar.html
- Test authentication
- Test data loading
- Test responsive design
📄 Template Structure
1. Jinja2 Template
File: app/templates/vendor/admin/[page-name].html
{# app/templates/vendor/admin/[page-name].html #}
{% extends "vendor/base.html" %}
{# Page title for browser tab #}
{% block title %}[Page Name]{% endblock %}
{# Alpine.js component name #}
{% block alpine_data %}vendor[PageName](){% endblock %}
{# Page content #}
{% 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">
[Page Name]
</h2>
<!-- Action Buttons -->
<div class="flex items-center space-x-3">
<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"
>
<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>
<button
@click="openCreateModal()"
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>
<span>Add New</span>
</button>
</div>
</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 data...</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</p>
<p class="text-sm" x-text="error"></p>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- FILTERS & SEARCH -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<div x-show="!loading" class="mb-6 bg-white rounded-lg shadow-xs dark:bg-gray-800 p-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Search -->
<div>
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Search</span>
<input
x-model="filters.search"
@input.debounce.300ms="applyFilters()"
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"
placeholder="Search..."
/>
</label>
</div>
<!-- Status Filter -->
<div>
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Status</span>
<select
x-model="filters.status"
@change="applyFilters()"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 form-select focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray"
>
<option value="">All</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</label>
</div>
<!-- Sort -->
<div>
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Sort By</span>
<select
x-model="filters.sortBy"
@change="applyFilters()"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 form-select focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray"
>
<option value="created_at:desc">Newest First</option>
<option value="created_at:asc">Oldest First</option>
<option value="name:asc">Name (A-Z)</option>
<option value="name:desc">Name (Z-A)</option>
</select>
</label>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- DATA TABLE -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<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">Name</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Date</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="items.length === 0">
<tr>
<td colspan="4" 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('inbox', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p>No items found.</p>
</div>
</td>
</tr>
</template>
<!-- Data Rows -->
<template x-for="item in items" :key="item.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">
<div class="flex items-center text-sm">
<div>
<p class="font-semibold" x-text="item.name"></p>
<p class="text-xs text-gray-600 dark:text-gray-400"
x-text="item.description"></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-xs">
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="item.status === 'active'
? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100'
: 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700'"
x-text="item.status"></span>
</td>
<td class="px-4 py-3 text-sm" x-text="formatDate(item.created_at)">
</td>
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<button
@click="viewItem(item.id)"
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-gray-400 dark:hover:bg-gray-700"
title="View"
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</button>
<button
@click="editItem(item.id)"
class="flex items-center justify-center p-2 text-blue-600 rounded-lg hover:bg-blue-50 dark:text-gray-400 dark:hover:bg-gray-700"
title="Edit"
>
<span x-html="$icon('pencil', 'w-5 h-5')"></span>
</button>
<button
@click="deleteItem(item.id)"
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-gray-400 dark:hover:bg-gray-700"
title="Delete"
>
<span x-html="$icon('trash', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="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">
<span class="flex items-center col-span-3">
Showing <span class="mx-1 font-semibold" x-text="pagination.from"></span>-<span class="mx-1 font-semibold" x-text="pagination.to"></span> of <span class="mx-1 font-semibold" x-text="pagination.total"></span>
</span>
<span class="col-span-2"></span>
<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">
<li>
<button
@click="previousPage()"
:disabled="!pagination.hasPrevious"
class="px-3 py-1 rounded-md rounded-l-lg focus:outline-none focus:shadow-outline-purple"
:class="pagination.hasPrevious ? 'hover:bg-gray-100 dark:hover:bg-gray-700' : 'opacity-50 cursor-not-allowed'"
>
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
</button>
</li>
<li>
<span class="px-3 py-1" x-text="`Page ${pagination.currentPage} of ${pagination.totalPages}`"></span>
</li>
<li>
<button
@click="nextPage()"
:disabled="!pagination.hasNext"
class="px-3 py-1 rounded-md rounded-r-lg focus:outline-none focus:shadow-outline-purple"
:class="pagination.hasNext ? 'hover:bg-gray-100 dark:hover:bg-gray-700' : 'opacity-50 cursor-not-allowed'"
>
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
</button>
</li>
</ul>
</nav>
</span>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- MODALS (if needed) -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- Create/Edit Modal -->
<div x-show="showModal"
x-cloak
class="fixed inset-0 z-50 flex items-center justify-center overflow-auto bg-black bg-opacity-50"
@click.self="closeModal()">
<div class="relative w-full max-w-lg p-6 mx-auto bg-white rounded-lg shadow-xl dark:bg-gray-800">
<!-- Modal Header -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200"
x-text="modalTitle"></h3>
<button @click="closeModal()"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('x', 'w-6 h-6')"></span>
</button>
</div>
<!-- Modal Body -->
<form @submit.prevent="saveItem()">
<div class="space-y-4">
<!-- Form fields here -->
<div>
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Name</span>
<input
x-model="formData.name"
type="text"
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"
required
/>
</label>
</div>
</div>
<!-- Modal Footer -->
<div class="flex justify-end mt-6 space-x-3">
<button
type="button"
@click="closeModal()"
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 hover:bg-gray-100 focus:outline-none focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600"
>
Cancel
</button>
<button
type="submit"
:disabled="saving"
class="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"
>
<span x-show="!saving">Save</span>
<span x-show="saving">Saving...</span>
</button>
</div>
</form>
</div>
</div>
{% endblock %}
{# Page-specific JavaScript #}
{% block extra_scripts %}
<script src="{{ url_for('static', path='vendor/js/[page-name].js') }}"></script>
{% endblock %}
2. Alpine.js Component
File: app/static/vendor/js/[page-name].js
// app/static/vendor/js/[page-name].js
/**
* [Page Name] page logic
* Handles data loading, filtering, CRUD operations
*/
function vendor[PageName]() {
return {
// ═══════════════════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════════════════
loading: false,
error: '',
items: [],
// Filters
filters: {
search: '',
status: '',
sortBy: 'created_at:desc'
},
// Pagination
pagination: {
currentPage: 1,
perPage: 10,
total: 0,
totalPages: 0,
from: 0,
to: 0,
hasPrevious: false,
hasNext: false
},
// Modal state
showModal: false,
modalTitle: '',
modalMode: 'create', // 'create' or 'edit'
formData: {},
saving: false,
// ═══════════════════════════════════════════════════════════
// LIFECYCLE
// ═══════════════════════════════════════════════════════════
async init() {
logInfo('[PageName] page initializing...');
await this.loadData();
logInfo('[PageName] page initialized');
},
// ═══════════════════════════════════════════════════════════
// DATA LOADING
// ═══════════════════════════════════════════════════════════
async loadData() {
this.loading = true;
this.error = '';
try {
// Build query params
const params = new URLSearchParams({
page: this.pagination.currentPage,
per_page: this.pagination.perPage,
...this.filters
});
// API call
const response = await apiClient.get(
`/api/v1/vendors/${this.vendorCode}/[endpoint]?${params}`
);
// Update state
this.items = response.items || [];
this.updatePagination(response);
logInfo('[PageName] data loaded', {
items: this.items.length,
total: this.pagination.total
});
} catch (error) {
logError('Failed to load [page] data', error);
this.error = error.message || 'Failed to load data';
} finally {
this.loading = false;
}
},
async refresh() {
await this.loadData();
},
updatePagination(response) {
this.pagination = {
currentPage: response.page || 1,
perPage: response.per_page || 10,
total: response.total || 0,
totalPages: response.pages || 0,
from: ((response.page - 1) * response.per_page) + 1,
to: Math.min(response.page * response.per_page, response.total),
hasPrevious: response.page > 1,
hasNext: response.page < response.pages
};
},
// ═══════════════════════════════════════════════════════════
// FILTERING & PAGINATION
// ═══════════════════════════════════════════════════════════
async applyFilters() {
this.pagination.currentPage = 1; // Reset to first page
await this.loadData();
},
async previousPage() {
if (this.pagination.hasPrevious) {
this.pagination.currentPage--;
await this.loadData();
}
},
async nextPage() {
if (this.pagination.hasNext) {
this.pagination.currentPage++;
await this.loadData();
}
},
// ═══════════════════════════════════════════════════════════
// CRUD OPERATIONS
// ═══════════════════════════════════════════════════════════
openCreateModal() {
this.modalMode = 'create';
this.modalTitle = 'Create New Item';
this.formData = {
name: '',
description: '',
status: 'active'
};
this.showModal = true;
},
async viewItem(id) {
// Navigate to detail page or open view modal
window.location.href = `/vendor/${this.vendorCode}/[endpoint]/${id}`;
},
async editItem(id) {
try {
// Load item data
const item = await apiClient.get(
`/api/v1/vendors/${this.vendorCode}/[endpoint]/${id}`
);
this.modalMode = 'edit';
this.modalTitle = 'Edit Item';
this.formData = { ...item };
this.showModal = true;
} catch (error) {
logError('Failed to load item', error);
alert('Failed to load item details');
}
},
async saveItem() {
this.saving = true;
try {
if (this.modalMode === 'create') {
await apiClient.post(
`/api/v1/vendors/${this.vendorCode}/[endpoint]`,
this.formData
);
logInfo('Item created successfully');
} else {
await apiClient.put(
`/api/v1/vendors/${this.vendorCode}/[endpoint]/${this.formData.id}`,
this.formData
);
logInfo('Item updated successfully');
}
this.closeModal();
await this.loadData();
} catch (error) {
logError('Failed to save item', error);
alert(error.message || 'Failed to save item');
} finally {
this.saving = false;
}
},
async deleteItem(id) {
if (!confirm('Are you sure you want to delete this item?')) {
return;
}
try {
await apiClient.delete(
`/api/v1/vendors/${this.vendorCode}/[endpoint]/${id}`
);
logInfo('Item deleted successfully');
await this.loadData();
} catch (error) {
logError('Failed to delete item', error);
alert(error.message || 'Failed to delete item');
}
},
closeModal() {
this.showModal = false;
this.formData = {};
},
// ═══════════════════════════════════════════════════════════
// UTILITIES
// ═══════════════════════════════════════════════════════════
formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
},
formatCurrency(amount) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount || 0);
}
};
}
// Make available globally
window.vendor[PageName] = vendor[PageName];
3. Route Registration
File: app/api/v1/vendor/pages.py
@router.get("/vendor/{vendor_code}/[page-name]", response_class=HTMLResponse, include_in_schema=False)
async def vendor_[page_name]_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_user)
):
"""
Render [page name] page.
JavaScript loads data via API.
"""
return templates.TemplateResponse(
"vendor/admin/[page-name].html",
{
"request": request,
"user": current_user,
"vendor_code": vendor_code,
}
)
4. Sidebar Navigation
File: app/templates/vendor/partials/sidebar.html
<li class="relative px-6 py-3">
<span x-show="currentPage === '[page-name]'"
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 === '[page-name]' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/vendor/${vendorCode}/[page-name]`">
<span x-html="$icon('[icon-name]', 'w-5 h-5')"></span>
<span class="ml-4">[Page Display Name]</span>
</a>
</li>
🎨 Common Patterns
Pattern 1: Simple Data List
Use for: Product list, order list, customer list
async init() {
await this.loadData();
}
async loadData() {
this.loading = true;
try {
const response = await apiClient.get(`/api/v1/vendors/${this.vendorCode}/items`);
this.items = response.items || [];
} catch (error) {
this.error = error.message;
} finally {
this.loading = false;
}
}
Pattern 2: Dashboard with Stats
Use for: Dashboard, analytics pages
async init() {
await Promise.all([
this.loadStats(),
this.loadRecentActivity()
]);
}
async loadStats() {
const stats = await apiClient.get(`/api/v1/vendors/${this.vendorCode}/stats`);
this.stats = stats;
}
Pattern 3: Detail Page
Use for: Product detail, order detail
async init() {
await this.loadItem();
}
async loadItem() {
const id = this.getItemIdFromUrl();
this.item = await apiClient.get(`/api/v1/vendors/${this.vendorCode}/items/${id}`);
}
Pattern 4: Form with Validation
Use for: Settings, profile edit
formData: {
name: '',
email: ''
},
errors: {},
validateForm() {
this.errors = {};
if (!this.formData.name) this.errors.name = 'Name is required';
if (!this.formData.email) this.errors.email = 'Email is required';
return Object.keys(this.errors).length === 0;
},
async saveForm() {
if (!this.validateForm()) return;
await apiClient.put(`/api/v1/vendors/${this.vendorCode}/settings`, this.formData);
}
🔧 Best Practices
1. Error Handling
try {
await apiClient.get('/endpoint');
} catch (error) {
logError('Operation failed', error);
this.error = error.message || 'An error occurred';
// Don't throw - let UI handle gracefully
}
2. Loading States
// Always set loading at start and end
this.loading = true;
try {
// ... operations
} finally {
this.loading = false; // Always executes
}
3. Data Refresh
async refresh() {
// Clear error before refresh
this.error = '';
await this.loadData();
}
4. Modal Management
openModal() {
this.showModal = true;
// Reset form
this.formData = {};
this.errors = {};
}
closeModal() {
this.showModal = false;
// Clean up
this.formData = {};
}
5. Debouncing
<!-- Debounce search input -->
<input
x-model="filters.search"
@input.debounce.300ms="applyFilters()"
/>
📱 Responsive Design Checklist
- Table scrolls horizontally on mobile
- Modal is scrollable on small screens
- Filters stack vertically on mobile
- Action buttons adapt to screen size
- Text truncates appropriately
- Icons remain visible
✅ Testing Checklist
- Page loads without errors
- Data loads correctly
- Loading state displays
- Error state handles failures
- Empty state shows when no data
- Filters work correctly
- Pagination works
- Create operation works
- Edit operation works
- Delete operation works
- Modal opens/closes
- Form validation works
- Dark mode works
- Mobile responsive
🚀 Quick Start Commands
# Create new page files
touch app/templates/vendor/admin/products.html
touch app/static/vendor/js/products.js
# Copy templates
cp template.html app/templates/vendor/admin/products.html
cp template.js app/static/vendor/js/products.js
# Update files with your page name
# Register route in pages.py
# Add sidebar link
# Test!
📚 Additional Resources
- Icons: Use
$icon('icon-name', 'classes')helper - API Client: Automatically handles auth tokens
- Logging: Use logInfo, logError, logDebug
- Date Formatting: Use formatDate() helper
- Currency: Use formatCurrency() helper
This template provides a complete, production-ready pattern for building vendor admin pages with consistent structure, error handling, and user experience.