Some checks failed
Align Alpine.js base component naming with storefront terminology. Updated across all storefront JS, templates, and documentation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
995 lines
32 KiB
Markdown
995 lines
32 KiB
Markdown
# Shop Frontend - Alpine.js/Jinja2 Page Template Guide
|
|
|
|
## 📋 Overview
|
|
|
|
This guide provides complete templates for creating new customer-facing shop pages using the established Alpine.js + Jinja2 + Multi-Theme architecture. Follow these patterns to ensure consistency across all store shops while maintaining unique branding.
|
|
|
|
---
|
|
|
|
## 🔐 Authentication Pages (Available)
|
|
|
|
Three fully-implemented authentication pages are available for reference:
|
|
|
|
- **Login** (`app/templates/shop/account/login.html`) - Customer sign-in with email/password
|
|
- **Register** (`app/templates/shop/account/register.html`) - New customer account creation
|
|
- **Forgot Password** (`app/templates/shop/account/forgot-password.html`) - Password reset flow
|
|
|
|
All authentication pages feature:
|
|
- ✅ Tailwind CSS styling
|
|
- ✅ Alpine.js interactivity
|
|
- ✅ Theme integration (store colors, logos, fonts)
|
|
- ✅ Dark mode support
|
|
- ✅ Mobile responsive design
|
|
- ✅ Form validation
|
|
- ✅ Loading states
|
|
- ✅ Error handling
|
|
|
|
See the [Shop Architecture Documentation](./architecture.md) (Authentication Pages section) for complete details.
|
|
|
|
---
|
|
|
|
## 🎯 Quick Reference
|
|
|
|
### File Structure for New Page
|
|
```
|
|
app/
|
|
├── templates/shop/
|
|
│ └── [page-name].html # Jinja2 template
|
|
├── static/shop/js/
|
|
│ └── [page-name].js # Alpine.js component
|
|
└── api/v1/shop/
|
|
└── pages.py # Route registration
|
|
```
|
|
|
|
### Checklist for New Page
|
|
- [ ] Create Jinja2 template extending shop/base.html
|
|
- [ ] Create Alpine.js JavaScript component
|
|
- [ ] Register route in pages.py
|
|
- [ ] Test with multiple store themes
|
|
- [ ] Test responsive design (mobile/tablet/desktop)
|
|
- [ ] Test dark mode
|
|
- [ ] Test cart integration (if applicable)
|
|
- [ ] Verify theme CSS variables work
|
|
- [ ] Check image optimization
|
|
|
|
---
|
|
|
|
## 📄 Template Structure
|
|
|
|
### 1. Jinja2 Template
|
|
|
|
**File:** `app/templates/shop/[page-name].html`
|
|
|
|
```jinja2
|
|
{# app/templates/shop/[page-name].html #}
|
|
{% extends "shop/base.html" %}
|
|
|
|
{# Page title for browser tab - includes store name #}
|
|
{% block title %}[Page Name] - {{ store.name }}{% endblock %}
|
|
|
|
{# Meta description for SEO #}
|
|
{% block meta_description %}[Page description for SEO]{% endblock %}
|
|
|
|
{# Alpine.js component name #}
|
|
{% block alpine_data %}shop[PageName](){% endblock %}
|
|
|
|
{# Page content #}
|
|
{% block content %}
|
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
|
<!-- PAGE HEADER -->
|
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
|
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
|
|
<!-- Breadcrumb -->
|
|
<nav class="flex mb-6 text-sm" aria-label="Breadcrumb">
|
|
<ol class="inline-flex items-center space-x-1 md:space-x-3">
|
|
<li class="inline-flex items-center">
|
|
<a href="/" class="text-gray-700 hover:text-primary dark:text-gray-400">
|
|
Home
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<div class="flex items-center">
|
|
<svg class="w-4 h-4 text-gray-400 mx-1" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" 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"></path>
|
|
</svg>
|
|
<span class="text-gray-500 dark:text-gray-400">[Page Name]</span>
|
|
</div>
|
|
</li>
|
|
</ol>
|
|
</nav>
|
|
|
|
<!-- Page Title -->
|
|
<div class="flex items-center justify-between mb-8">
|
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white"
|
|
style="font-family: var(--font-heading)">
|
|
[Page Name]
|
|
</h1>
|
|
|
|
<!-- Optional action button -->
|
|
<button
|
|
@click="someAction()"
|
|
class="px-6 py-2 text-white rounded-lg transition-colors"
|
|
style="background-color: var(--color-primary)"
|
|
:style="{ 'background-color': 'var(--color-primary)' }"
|
|
>
|
|
Action
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
|
<!-- LOADING STATE -->
|
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
|
<div x-show="loading" class="text-center py-20">
|
|
<div class="inline-block animate-spin rounded-full h-12 w-12 border-4 border-gray-200"
|
|
:style="{ 'border-top-color': 'var(--color-primary)' }">
|
|
</div>
|
|
<p class="mt-4 text-gray-600 dark:text-gray-400">Loading...</p>
|
|
</div>
|
|
|
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
|
<!-- ERROR STATE -->
|
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
|
<div x-show="error && !loading"
|
|
class="mb-6 p-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
|
<div class="flex items-start">
|
|
<svg class="w-6 h-6 text-red-600 dark:text-red-400 mr-3 flex-shrink-0"
|
|
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
</svg>
|
|
<div>
|
|
<h3 class="text-red-800 dark:text-red-200 font-semibold">Error</h3>
|
|
<p class="text-red-700 dark:text-red-300 text-sm mt-1" x-text="error"></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
|
<!-- MAIN CONTENT -->
|
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
|
<div x-show="!loading">
|
|
|
|
<!-- Empty State -->
|
|
<div x-show="items.length === 0" class="text-center py-20">
|
|
<svg class="w-24 h-24 mx-auto text-gray-300 dark:text-gray-600 mb-4"
|
|
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1"
|
|
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
|
</svg>
|
|
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
|
No items found
|
|
</h3>
|
|
<p class="text-gray-500 dark:text-gray-400">
|
|
Try adjusting your filters or check back later.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Grid Layout (for products, items, etc.) -->
|
|
<div x-show="items.length > 0"
|
|
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
|
|
|
<template x-for="item in items" :key="item.id">
|
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-lg transition-shadow border border-gray-200 dark:border-gray-700">
|
|
|
|
<!-- Item Image -->
|
|
<div class="aspect-w-1 aspect-h-1 w-full overflow-hidden rounded-t-lg bg-gray-100 dark:bg-gray-700">
|
|
<img :src="item.image || '/static/shop/img/placeholder-product.png'"
|
|
:alt="item.name"
|
|
class="w-full h-full object-cover object-center hover:scale-105 transition-transform"
|
|
loading="lazy">
|
|
</div>
|
|
|
|
<!-- Item Info -->
|
|
<div class="p-4">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2"
|
|
x-text="item.name"></h3>
|
|
<p class="text-gray-600 dark:text-gray-400 text-sm mb-4 line-clamp-2"
|
|
x-text="item.description"></p>
|
|
|
|
<!-- Price -->
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-2xl font-bold"
|
|
:style="{ color: 'var(--color-primary)' }"
|
|
x-text="formatPrice(item.price)"></span>
|
|
|
|
<button @click="addToCart(item)"
|
|
class="px-4 py-2 text-white rounded-lg hover:opacity-90 transition-opacity"
|
|
:style="{ 'background-color': 'var(--color-primary)' }">
|
|
Add
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- ═════════════════════════════════════════════════════════════ -->
|
|
<!-- PAGINATION -->
|
|
<!-- ═════════════════════════════════════════════════════════════ -->
|
|
<div x-show="pagination.totalPages > 1"
|
|
class="flex justify-center items-center space-x-2 mt-12">
|
|
|
|
<!-- Previous Button -->
|
|
<button
|
|
@click="goToPage(pagination.currentPage - 1)"
|
|
:disabled="pagination.currentPage === 1"
|
|
class="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Previous
|
|
</button>
|
|
|
|
<!-- Page Numbers -->
|
|
<template x-for="page in paginationRange" :key="page">
|
|
<button
|
|
@click="goToPage(page)"
|
|
:class="page === pagination.currentPage
|
|
? 'text-white'
|
|
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'"
|
|
:style="page === pagination.currentPage ? { 'background-color': 'var(--color-primary)' } : {}"
|
|
class="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600"
|
|
x-text="page"
|
|
></button>
|
|
</template>
|
|
|
|
<!-- Next Button -->
|
|
<button
|
|
@click="goToPage(pagination.currentPage + 1)"
|
|
:disabled="pagination.currentPage === pagination.totalPages"
|
|
class="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{# Page-specific JavaScript #}
|
|
{% block extra_scripts %}
|
|
<script src="{{ url_for('static', path='shop/js/[page-name].js') }}"></script>
|
|
{% endblock %}
|
|
```
|
|
|
|
---
|
|
|
|
### 2. Alpine.js Component
|
|
|
|
**File:** `app/static/shop/js/[page-name].js`
|
|
|
|
```javascript
|
|
// static/shop/js/[page-name].js
|
|
/**
|
|
* [Page Name] Component
|
|
* Handles [describe functionality]
|
|
*/
|
|
|
|
const pageLog = {
|
|
info: (...args) => console.info('🛍️ [PAGE]', ...args),
|
|
warn: (...args) => console.warn('⚠️ [PAGE]', ...args),
|
|
error: (...args) => console.error('❌ [PAGE]', ...args),
|
|
debug: (...args) => console.log('🔍 [PAGE]', ...args)
|
|
};
|
|
|
|
/**
|
|
* Main Alpine.js component for [page name]
|
|
*/
|
|
function shop[PageName]() {
|
|
return {
|
|
// ─────────────────────────────────────────────────────
|
|
// STATE
|
|
// ─────────────────────────────────────────────────────
|
|
loading: false,
|
|
error: '',
|
|
items: [],
|
|
|
|
// Pagination
|
|
pagination: {
|
|
currentPage: 1,
|
|
totalPages: 1,
|
|
perPage: 12,
|
|
total: 0
|
|
},
|
|
|
|
// Filters
|
|
filters: {
|
|
search: '',
|
|
category: '',
|
|
sortBy: 'created_at:desc'
|
|
},
|
|
|
|
// Store info (from template)
|
|
storeCode: '{{ store.code }}',
|
|
|
|
// ─────────────────────────────────────────────────────
|
|
// LIFECYCLE
|
|
// ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Initialize component
|
|
*/
|
|
async init() {
|
|
pageLog.info('[PageName] initializing...');
|
|
await this.loadData();
|
|
pageLog.info('[PageName] initialized');
|
|
},
|
|
|
|
// ─────────────────────────────────────────────────────
|
|
// DATA LOADING
|
|
// ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Load main data from API
|
|
*/
|
|
async loadData() {
|
|
this.loading = true;
|
|
this.error = '';
|
|
|
|
try {
|
|
const params = new URLSearchParams({
|
|
page: this.pagination.currentPage,
|
|
per_page: this.pagination.perPage,
|
|
...this.filters
|
|
});
|
|
|
|
const response = await fetch(
|
|
`/api/v1/shop/${this.storeCode}/items?${params}`
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Update state
|
|
this.items = data.items || [];
|
|
this.pagination.total = data.total || 0;
|
|
this.pagination.totalPages = Math.ceil(
|
|
this.pagination.total / this.pagination.perPage
|
|
);
|
|
|
|
pageLog.info('Data loaded:', this.items.length, 'items');
|
|
|
|
} catch (error) {
|
|
pageLog.error('Failed to load data:', error);
|
|
this.error = error.message || 'Failed to load data';
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Refresh data
|
|
*/
|
|
async refresh() {
|
|
pageLog.info('Refreshing data...');
|
|
this.error = '';
|
|
await this.loadData();
|
|
},
|
|
|
|
// ─────────────────────────────────────────────────────
|
|
// FILTERS & SEARCH
|
|
// ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Apply filters and reload data
|
|
*/
|
|
async applyFilters() {
|
|
pageLog.debug('Applying filters:', this.filters);
|
|
this.pagination.currentPage = 1; // Reset to first page
|
|
await this.loadData();
|
|
},
|
|
|
|
/**
|
|
* Reset filters to default
|
|
*/
|
|
async resetFilters() {
|
|
this.filters = {
|
|
search: '',
|
|
category: '',
|
|
sortBy: 'created_at:desc'
|
|
};
|
|
await this.applyFilters();
|
|
},
|
|
|
|
// ─────────────────────────────────────────────────────
|
|
// PAGINATION
|
|
// ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Navigate to specific page
|
|
*/
|
|
async goToPage(page) {
|
|
if (page < 1 || page > this.pagination.totalPages) return;
|
|
|
|
this.pagination.currentPage = page;
|
|
await this.loadData();
|
|
|
|
// Scroll to top
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
},
|
|
|
|
/**
|
|
* Get pagination range for display
|
|
*/
|
|
get paginationRange() {
|
|
const current = this.pagination.currentPage;
|
|
const total = this.pagination.totalPages;
|
|
const range = [];
|
|
|
|
// Show max 7 page numbers
|
|
let start = Math.max(1, current - 3);
|
|
let end = Math.min(total, start + 6);
|
|
|
|
// Adjust start if we're near the end
|
|
if (end - start < 6) {
|
|
start = Math.max(1, end - 6);
|
|
}
|
|
|
|
for (let i = start; i <= end; i++) {
|
|
range.push(i);
|
|
}
|
|
|
|
return range;
|
|
},
|
|
|
|
// ─────────────────────────────────────────────────────
|
|
// CART INTEGRATION
|
|
// ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Add item to cart
|
|
*/
|
|
addToCart(item, quantity = 1) {
|
|
pageLog.info('Adding to cart:', item.name);
|
|
|
|
// Get cart from shop layout
|
|
const shopLayout = Alpine.store('shop') || window.storefrontLayoutData();
|
|
|
|
if (shopLayout && typeof shopLayout.addToCart === 'function') {
|
|
shopLayout.addToCart(item, quantity);
|
|
this.showToast(`${item.name} added to cart`, 'success');
|
|
} else {
|
|
pageLog.error('Shop layout not available');
|
|
}
|
|
},
|
|
|
|
// ─────────────────────────────────────────────────────
|
|
// UI HELPERS
|
|
// ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Show toast notification
|
|
*/
|
|
showToast(message, type = 'info') {
|
|
const shopLayout = Alpine.store('shop') || window.storefrontLayoutData();
|
|
if (shopLayout && typeof shopLayout.showToast === 'function') {
|
|
shopLayout.showToast(message, type);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Format price as currency
|
|
*/
|
|
formatPrice(price) {
|
|
return new Intl.NumberFormat('en-US', {
|
|
style: 'currency',
|
|
currency: 'USD'
|
|
}).format(price);
|
|
},
|
|
|
|
/**
|
|
* Format date
|
|
*/
|
|
formatDate(dateString) {
|
|
if (!dateString) return '-';
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric'
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Truncate text
|
|
*/
|
|
truncate(text, length = 100) {
|
|
if (!text) return '';
|
|
if (text.length <= length) return text;
|
|
return text.substring(0, length) + '...';
|
|
}
|
|
};
|
|
}
|
|
|
|
// Make available globally
|
|
window.shop[PageName] = shop[PageName];
|
|
|
|
pageLog.info('[PageName] module loaded');
|
|
```
|
|
|
|
---
|
|
|
|
### 3. Route Registration
|
|
|
|
**File:** `app/api/v1/shop/pages.py`
|
|
|
|
```python
|
|
from fastapi import APIRouter, Request, Depends
|
|
from sqlalchemy.orm import Session
|
|
from app.core.database import get_db
|
|
|
|
router = APIRouter()
|
|
|
|
@router.get("/[page-route]")
|
|
async def [page_name]_page(
|
|
request: Request,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
[Page Name] page
|
|
Displays [description]
|
|
"""
|
|
# Store and theme come from middleware
|
|
store = request.state.store
|
|
theme = request.state.theme
|
|
|
|
return templates.TemplateResponse(
|
|
"shop/[page-name].html",
|
|
{
|
|
"request": request,
|
|
"store": store,
|
|
"theme": theme,
|
|
}
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## 🎨 Common Page Patterns
|
|
|
|
### Pattern 1: Product Grid Page (Homepage, Category)
|
|
|
|
**Use for:** Homepage, category pages, search results
|
|
|
|
```javascript
|
|
async init() {
|
|
await this.loadProducts();
|
|
}
|
|
|
|
async loadProducts() {
|
|
this.loading = true;
|
|
try {
|
|
const response = await fetch(
|
|
`/api/v1/shop/${this.storeCode}/products?category=${this.category}`
|
|
);
|
|
const data = await response.json();
|
|
this.products = data.products || [];
|
|
} catch (error) {
|
|
this.error = error.message;
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Template:**
|
|
```html
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
<template x-for="product in products" :key="product.id">
|
|
{% include 'shop/partials/product-card.html' %}
|
|
</template>
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
### Pattern 2: Product Detail Page
|
|
|
|
**Use for:** Single product pages
|
|
|
|
```javascript
|
|
async init() {
|
|
const productId = this.getProductIdFromUrl();
|
|
await this.loadProduct(productId);
|
|
await this.loadRelatedProducts(productId);
|
|
}
|
|
|
|
async loadProduct(id) {
|
|
const product = await fetch(
|
|
`/api/v1/shop/${this.storeCode}/products/${id}`
|
|
).then(r => r.json());
|
|
|
|
this.product = product;
|
|
this.selectedImage = product.images[0];
|
|
}
|
|
|
|
addToCartWithQuantity() {
|
|
const shopLayout = window.storefrontLayoutData();
|
|
shopLayout.addToCart(this.product, this.quantity);
|
|
}
|
|
```
|
|
|
|
**Template:**
|
|
```html
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
|
<!-- Image Gallery -->
|
|
<div>
|
|
<img :src="selectedImage" class="w-full rounded-lg">
|
|
<div class="grid grid-cols-4 gap-2 mt-4">
|
|
<template x-for="img in product.images">
|
|
<img @click="selectedImage = img"
|
|
:src="img"
|
|
class="cursor-pointer rounded border-2"
|
|
:class="selectedImage === img ? 'border-primary' : 'border-gray-200'">
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Product Info -->
|
|
<div>
|
|
<h1 class="text-3xl font-bold mb-4" x-text="product.name"></h1>
|
|
<p class="text-2xl font-bold mb-6"
|
|
:style="{ color: 'var(--color-primary)' }"
|
|
x-text="formatPrice(product.price)"></p>
|
|
<p class="text-gray-600 mb-8" x-text="product.description"></p>
|
|
|
|
<!-- Quantity -->
|
|
<div class="flex items-center space-x-4 mb-6">
|
|
<label>Quantity:</label>
|
|
<input type="number" x-model="quantity" min="1" class="w-20 px-3 py-2 border rounded">
|
|
</div>
|
|
|
|
<!-- Add to Cart -->
|
|
<button @click="addToCartWithQuantity()"
|
|
class="w-full py-3 text-white rounded-lg text-lg font-semibold"
|
|
:style="{ 'background-color': 'var(--color-primary)' }">
|
|
Add to Cart
|
|
</button>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
### Pattern 3: Cart Page
|
|
|
|
**Use for:** Shopping cart
|
|
|
|
```javascript
|
|
async init() {
|
|
this.loadCart();
|
|
}
|
|
|
|
loadCart() {
|
|
const shopLayout = window.storefrontLayoutData();
|
|
this.cart = shopLayout.cart;
|
|
this.calculateTotals();
|
|
}
|
|
|
|
updateQuantity(productId, quantity) {
|
|
const shopLayout = window.storefrontLayoutData();
|
|
shopLayout.updateCartItem(productId, quantity);
|
|
this.loadCart();
|
|
}
|
|
|
|
removeItem(productId) {
|
|
const shopLayout = window.storefrontLayoutData();
|
|
shopLayout.removeFromCart(productId);
|
|
this.loadCart();
|
|
}
|
|
|
|
get subtotal() {
|
|
return this.cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
|
}
|
|
|
|
get shipping() {
|
|
return this.subtotal > 50 ? 0 : 9.99;
|
|
}
|
|
|
|
get total() {
|
|
return this.subtotal + this.shipping;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Pattern 4: Search & Filter Page
|
|
|
|
**Use for:** Search results, filtered product lists
|
|
|
|
```javascript
|
|
filters: {
|
|
search: '',
|
|
category: '',
|
|
minPrice: 0,
|
|
maxPrice: 1000,
|
|
sortBy: 'relevance',
|
|
inStock: true
|
|
},
|
|
|
|
async performSearch() {
|
|
this.loading = true;
|
|
try {
|
|
const response = await fetch(
|
|
`/api/v1/shop/${this.storeCode}/search`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(this.filters)
|
|
}
|
|
);
|
|
const data = await response.json();
|
|
this.results = data.results || [];
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🔧 Best Practices
|
|
|
|
### 1. Theme Integration
|
|
|
|
Always use CSS variables for store colors:
|
|
|
|
```html
|
|
<!-- ✅ GOOD: Uses theme variable -->
|
|
<button :style="{ 'background-color': 'var(--color-primary)' }">
|
|
Buy Now
|
|
</button>
|
|
|
|
<!-- ❌ BAD: Hardcoded color -->
|
|
<button class="bg-blue-500">
|
|
Buy Now
|
|
</button>
|
|
```
|
|
|
|
### 2. Cart Integration
|
|
|
|
Always use the shop layout's cart methods:
|
|
|
|
```javascript
|
|
// ✅ GOOD: Uses shop layout
|
|
const shopLayout = window.storefrontLayoutData();
|
|
shopLayout.addToCart(product, quantity);
|
|
|
|
// ❌ BAD: Direct localStorage manipulation
|
|
localStorage.setItem('cart', JSON.stringify(cart));
|
|
```
|
|
|
|
### 3. Loading States
|
|
|
|
Always show loading indicators:
|
|
|
|
```javascript
|
|
this.loading = true;
|
|
try {
|
|
// ... async operation
|
|
} finally {
|
|
this.loading = false; // Always executes
|
|
}
|
|
```
|
|
|
|
### 4. Error Handling
|
|
|
|
Always handle errors gracefully:
|
|
|
|
```javascript
|
|
try {
|
|
await this.loadData();
|
|
} catch (error) {
|
|
console.error('Load failed:', error);
|
|
this.error = 'Unable to load products. Please try again.';
|
|
// Don't throw - let UI handle gracefully
|
|
}
|
|
```
|
|
|
|
### 5. Responsive Images
|
|
|
|
Use lazy loading and responsive images:
|
|
|
|
```html
|
|
<img :src="product.image"
|
|
:alt="product.name"
|
|
loading="lazy"
|
|
class="w-full h-full object-cover">
|
|
```
|
|
|
|
### 6. Dark Mode
|
|
|
|
Support both light and dark modes:
|
|
|
|
```html
|
|
<div class="bg-white dark:bg-gray-800 text-gray-900 dark:text-white">
|
|
Content
|
|
</div>
|
|
```
|
|
|
|
### 7. Accessibility
|
|
|
|
Add proper ARIA labels and keyboard navigation:
|
|
|
|
```html
|
|
<button @click="addToCart(product)"
|
|
aria-label="Add to cart"
|
|
role="button">
|
|
Add to Cart
|
|
</button>
|
|
```
|
|
|
|
---
|
|
|
|
## 📱 Responsive Design Checklist
|
|
|
|
- [ ] Mobile (< 640px): Single column layout
|
|
- [ ] Tablet (640px - 1024px): 2-3 column layout
|
|
- [ ] Desktop (> 1024px): 4 column layout
|
|
- [ ] Images scale properly on all devices
|
|
- [ ] Touch targets are at least 44x44px
|
|
- [ ] Text is readable without zooming
|
|
- [ ] Navigation adapts to screen size
|
|
- [ ] Modals are scrollable on small screens
|
|
- [ ] Forms are easy to fill on mobile
|
|
|
|
---
|
|
|
|
## ✅ Testing Checklist
|
|
|
|
### Functionality
|
|
- [ ] 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
|
|
- [ ] Cart integration works
|
|
|
|
### Theme Integration
|
|
- [ ] Store colors display correctly
|
|
- [ ] Store logo displays
|
|
- [ ] Custom fonts load
|
|
- [ ] Custom CSS applies
|
|
- [ ] Dark mode works with store colors
|
|
|
|
### Responsive Design
|
|
- [ ] Mobile layout works
|
|
- [ ] Tablet layout works
|
|
- [ ] Desktop layout works
|
|
- [ ] Images are responsive
|
|
- [ ] Touch interactions work
|
|
|
|
### Performance
|
|
- [ ] Page loads quickly
|
|
- [ ] Images load progressively
|
|
- [ ] No console errors
|
|
- [ ] No memory leaks
|
|
|
|
### Accessibility
|
|
- [ ] Keyboard navigation works
|
|
- [ ] Screen reader compatible
|
|
- [ ] Color contrast sufficient
|
|
- [ ] ARIA labels present
|
|
|
|
---
|
|
|
|
## 🎯 Component Library
|
|
|
|
### Reusable Partials
|
|
|
|
Create reusable components in `templates/shop/partials/`:
|
|
|
|
**product-card.html:**
|
|
```html
|
|
<div class="product-card bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg">
|
|
<img :src="product.image" :alt="product.name" class="w-full h-64 object-cover rounded-t-lg">
|
|
<div class="p-4">
|
|
<h3 class="font-semibold text-lg" x-text="product.name"></h3>
|
|
<p class="text-2xl font-bold"
|
|
:style="{ color: 'var(--color-primary)' }"
|
|
x-text="formatPrice(product.price)"></p>
|
|
<button @click="addToCart(product)"
|
|
class="w-full mt-4 py-2 text-white rounded"
|
|
:style="{ 'background-color': 'var(--color-primary)' }">
|
|
Add to Cart
|
|
</button>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
**filter-sidebar.html:**
|
|
```html
|
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
<h3 class="font-semibold mb-4">Filters</h3>
|
|
|
|
<!-- Category -->
|
|
<div class="mb-6">
|
|
<label class="block mb-2 font-medium">Category</label>
|
|
<select x-model="filters.category" @change="applyFilters()"
|
|
class="w-full px-3 py-2 border rounded">
|
|
<option value="">All Categories</option>
|
|
<template x-for="cat in categories">
|
|
<option :value="cat.id" x-text="cat.name"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Price Range -->
|
|
<div class="mb-6">
|
|
<label class="block mb-2 font-medium">Price Range</label>
|
|
<input type="range" x-model="filters.maxPrice"
|
|
min="0" max="1000" step="10"
|
|
class="w-full">
|
|
<div class="flex justify-between text-sm">
|
|
<span>$0</span>
|
|
<span x-text="'$' + filters.maxPrice"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Apply Button -->
|
|
<button @click="applyFilters()"
|
|
class="w-full py-2 text-white rounded"
|
|
:style="{ 'background-color': 'var(--color-primary)' }">
|
|
Apply Filters
|
|
</button>
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
## 🚀 Quick Start Commands
|
|
|
|
```bash
|
|
# Create new page files
|
|
touch app/templates/shop/new-page.html
|
|
touch app/static/shop/js/new-page.js
|
|
|
|
# Copy templates
|
|
cp template.html app/templates/shop/new-page.html
|
|
cp template.js app/static/shop/js/new-page.js
|
|
|
|
# Update placeholders:
|
|
# - Replace [page-name] with actual name
|
|
# - Replace [PageName] with PascalCase name
|
|
# - Add route in pages.py
|
|
# - Test with multiple store themes!
|
|
```
|
|
|
|
---
|
|
|
|
## 📚 Additional Resources
|
|
|
|
### Theme System
|
|
- **CSS Variables**: All store colors in `var(--color-name)` format
|
|
- **Fonts**: `var(--font-heading)` and `var(--font-body)`
|
|
- **Logo**: Available in both light and dark versions
|
|
- **Custom CSS**: Store-specific styles automatically injected
|
|
|
|
### Shop Layout Functions
|
|
- `addToCart(product, quantity)`: Add item to cart
|
|
- `showToast(message, type)`: Show notification
|
|
- `formatPrice(amount)`: Format as currency
|
|
- `formatDate(date)`: Format date string
|
|
|
|
### Icons
|
|
Use the global icon helper:
|
|
```html
|
|
<span x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
|
|
<span x-html="$icon('heart', 'w-6 h-6 text-red-500')"></span>
|
|
```
|
|
|
|
### API Client
|
|
Shared API wrapper for authenticated requests:
|
|
```javascript
|
|
const data = await apiClient.get('/endpoint');
|
|
await apiClient.post('/endpoint', { data });
|
|
```
|
|
|
|
---
|
|
|
|
This template provides a complete, theme-aware pattern for building shop pages with consistent structure, store branding, cart integration, and excellent user experience across all devices.
|