Files
orion/app/templates/shop/product.html
Samir Boulahtit b7bf505a61 feat: implement vendor landing pages with multi-template support and fix shop routing
Major improvements to shop URL routing and vendor landing page system:

## Landing Page System
- Add template field to ContentPage model for flexible landing page designs
- Create 4 landing page templates: default, minimal, modern, and full
- Implement smart root handler to serve landing pages or redirect to shop
- Add create_landing_page.py script for easy landing page management
- Support both domain/subdomain and path-based vendor access
- Add comprehensive landing page documentation

## Route Fixes
- Fix duplicate /shop prefix in shop_pages.py routes
- Correct product detail page routing (was /shop/shop/products/{id})
- Update all shop routes to work with router prefix mounting
- Remove unused public vendor endpoints (/api/v1/public/vendors)

## Template Link Corrections
- Fix all shop template links to include /shop/ prefix
- Update breadcrumb 'Home' links to point to vendor root (landing page)
- Update header navigation 'Home' link to point to vendor root
- Correct CMS page links in footer navigation
- Fix account, cart, and error page navigation links

## Navigation Architecture
- Establish two-tier navigation: landing page (/) and shop (/shop/)
- Document complete navigation flow and URL hierarchy
- Support for vendors with or without landing pages (auto-redirect fallback)
- Consistent breadcrumb and header navigation behavior

## Documentation
- Add vendor-landing-pages.md feature documentation
- Add navigation-flow.md with complete URL hierarchy
- Update shop architecture docs with error handling section
- Add orphaned docs to mkdocs.yml navigation
- Document multi-access routing patterns

## Database
- Migration f68d8da5315a: add template field to content_pages table
- Support template values: default, minimal, modern, full

This establishes a complete landing page system allowing vendors to have
custom marketing homepages separate from their e-commerce shop, with
flexible template options and proper navigation hierarchy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 00:10:45 +01:00

771 lines
28 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ product.name if product else 'Product' }} - {{ vendor.name }}</title>
<link rel="stylesheet" href="/static/css/shared/base.css">
<link rel="stylesheet" href="/static/css/vendor/vendor.css">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
<div x-data="productDetail()"
x-init="loadProduct()"
data-vendor-id="{{ vendor.id }}"
data-product-id="{{ product_id }}"
>
<!-- Header -->
<header class="header">
<div class="header-left">
<a href="{{ base_url }}shop/products" class="btn-back">← Back to Products</a>
<h1>{{ vendor.name }}</h1>
</div>
<div class="header-right">
<a href="{{ base_url }}shop/cart" class="btn-primary">
🛒 Cart (<span x-text="cartCount"></span>)
</a>
</div>
</header>
<!-- Loading State -->
<div x-show="loading" class="container">
<div class="loading">
<div class="loading-spinner-lg"></div>
<p>Loading product...</p>
</div>
</div>
<!-- Product Detail -->
<div x-show="!loading && product" class="container">
<div class="product-detail-container">
<!-- Product Images -->
<div class="product-images">
<div class="main-image">
<img
:src="selectedImage || '/static/images/placeholder.png'"
:alt="product?.marketplace_product?.title"
class="product-main-image"
>
</div>
<!-- Thumbnail Gallery -->
<div class="image-gallery" x-show="product?.marketplace_product?.images?.length > 1">
<template x-for="(image, index) in product?.marketplace_product?.images" :key="index">
<img
:src="image"
:alt="`Product image ${index + 1}`"
class="thumbnail"
:class="{ 'active': selectedImage === image }"
@click="selectedImage = image"
>
</template>
</div>
</div>
<!-- Product Info -->
<div class="product-info-detail">
<h1 x-text="product?.marketplace_product?.title" class="product-title-detail"></h1>
<!-- Brand & Category -->
<div class="product-meta">
<span x-show="product?.marketplace_product?.brand" class="meta-item">
<strong>Brand:</strong> <span x-text="product?.marketplace_product?.brand"></span>
</span>
<span x-show="product?.marketplace_product?.google_product_category" class="meta-item">
<strong>Category:</strong> <span x-text="product?.marketplace_product?.google_product_category"></span>
</span>
<span class="meta-item">
<strong>SKU:</strong> <span x-text="product?.product_id || product?.marketplace_product?.mpn"></span>
</span>
</div>
<!-- Price -->
<div class="product-pricing">
<div x-show="product?.sale_price && product?.sale_price < product?.price">
<span class="price-original"><span x-text="parseFloat(product?.price).toFixed(2)"></span></span>
<span class="price-sale"><span x-text="parseFloat(product?.sale_price).toFixed(2)"></span></span>
<span class="price-badge">SALE</span>
</div>
<div x-show="!product?.sale_price || product?.sale_price >= product?.price">
<span class="price-current"><span x-text="parseFloat(product?.price || 0).toFixed(2)"></span></span>
</div>
</div>
<!-- Availability -->
<div class="product-availability">
<span
x-show="product?.available_inventory > 0"
class="availability-badge in-stock"
>
✓ In Stock (<span x-text="product?.available_inventory"></span> available)
</span>
<span
x-show="!product?.available_inventory || product?.available_inventory <= 0"
class="availability-badge out-of-stock"
>
✗ Out of Stock
</span>
</div>
<!-- Description -->
<div class="product-description">
<h3>Description</h3>
<p x-text="product?.marketplace_product?.description || 'No description available'"></p>
</div>
<!-- Additional Details -->
<div class="product-details" x-show="hasAdditionalDetails">
<h3>Product Details</h3>
<ul>
<li x-show="product?.marketplace_product?.gtin">
<strong>GTIN:</strong> <span x-text="product?.marketplace_product?.gtin"></span>
</li>
<li x-show="product?.condition">
<strong>Condition:</strong> <span x-text="product?.condition"></span>
</li>
<li x-show="product?.marketplace_product?.color">
<strong>Color:</strong> <span x-text="product?.marketplace_product?.color"></span>
</li>
<li x-show="product?.marketplace_product?.size">
<strong>Size:</strong> <span x-text="product?.marketplace_product?.size"></span>
</li>
<li x-show="product?.marketplace_product?.material">
<strong>Material:</strong> <span x-text="product?.marketplace_product?.material"></span>
</li>
</ul>
</div>
<!-- Add to Cart Section -->
<div class="add-to-cart-section">
<!-- Quantity Selector -->
<div class="quantity-selector">
<label>Quantity:</label>
<div class="quantity-controls">
<button
@click="decreaseQuantity()"
:disabled="quantity <= (product?.min_quantity || 1)"
class="btn-quantity"
>
</button>
<input
type="number"
x-model.number="quantity"
:min="product?.min_quantity || 1"
:max="product?.max_quantity || product?.available_inventory"
class="quantity-input"
@change="validateQuantity()"
>
<button
@click="increaseQuantity()"
:disabled="quantity >= (product?.max_quantity || product?.available_inventory)"
class="btn-quantity"
>
+
</button>
</div>
</div>
<!-- Add to Cart Button -->
<button
@click="addToCart()"
:disabled="!canAddToCart || addingToCart"
class="btn-add-to-cart"
>
<span x-show="!addingToCart">
🛒 Add to Cart
</span>
<span x-show="addingToCart">
<span class="loading-spinner"></span> Adding...
</span>
</button>
<!-- Total Price -->
<div class="total-price">
<strong>Total:</strong><span x-text="totalPrice.toFixed(2)"></span>
</div>
</div>
</div>
</div>
<!-- Related Products / You May Also Like -->
<div class="related-products" x-show="relatedProducts.length > 0">
<h2>You May Also Like</h2>
<div class="product-grid">
<template x-for="related in relatedProducts" :key="related.id">
<div class="product-card">
<img
:src="related.image_url || '/static/images/placeholder.png'"
:alt="related.name"
class="product-image"
@click="viewProduct(related.id)"
>
<div class="product-info">
<h3
class="product-title"
@click="viewProduct(related.id)"
x-text="related.name"
></h3>
<p class="product-price">
<span x-text="parseFloat(related.price).toFixed(2)"></span>
</p>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- Toast Notification -->
<div
x-show="toast.show"
x-transition
:class="'toast toast-' + toast.type"
x-text="toast.message"
></div>
</div>
<script>
function productDetail() {
return {
// Data
product: null,
relatedProducts: [],
loading: false,
addingToCart: false,
quantity: 1,
selectedImage: null,
cartCount: 0,
vendorId: null,
productId: null,
sessionId: null,
// Toast notification
toast: {
show: false,
type: 'success',
message: ''
},
// Computed properties
get canAddToCart() {
return this.product?.is_active &&
this.product?.available_inventory > 0 &&
this.quantity > 0 &&
this.quantity <= this.product?.available_inventory;
},
get totalPrice() {
const price = this.product?.sale_price || this.product?.price || 0;
return price * this.quantity;
},
get hasAdditionalDetails() {
return this.product?.marketplace_product?.gtin ||
this.product?.condition ||
this.product?.marketplace_product?.color ||
this.product?.marketplace_product?.size ||
this.product?.marketplace_product?.material;
},
// Initialize
init() {
this.vendorId = this.$el.dataset.vendorId;
this.productId = this.$el.dataset.productId;
this.sessionId = this.getOrCreateSessionId();
this.loadCartCount();
},
// Get or create session ID
getOrCreateSessionId() {
let sessionId = localStorage.getItem('cart_session_id');
if (!sessionId) {
sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
localStorage.setItem('cart_session_id', sessionId);
}
return sessionId;
},
// Load product details
async loadProduct() {
this.loading = true;
try {
const response = await fetch(
`/api/v1/shop/products/${this.productId}`
);
if (!response.ok) {
throw new Error('Product not found');
}
this.product = await response.json();
// Set default image
if (this.product?.marketplace_product?.image_link) {
this.selectedImage = this.product.marketplace_product.image_link;
}
// Set initial quantity
this.quantity = this.product?.min_quantity || 1;
// Load related products (optional)
await this.loadRelatedProducts();
} catch (error) {
console.error('Failed to load product:', error);
this.showToast('Failed to load product', 'error');
// Redirect back to products after error
setTimeout(() => {
window.location.href = '/shop/products';
}, 2000);
} finally {
this.loading = false;
}
},
// Load related products (same category or brand)
async loadRelatedProducts() {
try {
const response = await fetch(
`/api/v1/shop/products?limit=4`
);
if (response.ok) {
const data = await response.json();
// Filter out current product
this.relatedProducts = data.products
.filter(p => p.id !== parseInt(this.productId))
.slice(0, 4);
}
} catch (error) {
console.error('Failed to load related products:', error);
}
},
// Load cart count
async loadCartCount() {
try {
const response = await fetch(
`/api/v1/shop/cart/${this.sessionId}`
);
if (response.ok) {
const data = await response.json();
this.cartCount = (data.items || []).reduce((sum, item) =>
sum + item.quantity, 0
);
}
} catch (error) {
console.error('Failed to load cart count:', error);
}
},
// Quantity controls
increaseQuantity() {
const max = this.product?.max_quantity || this.product?.available_inventory;
if (this.quantity < max) {
this.quantity++;
}
},
decreaseQuantity() {
const min = this.product?.min_quantity || 1;
if (this.quantity > min) {
this.quantity--;
}
},
validateQuantity() {
const min = this.product?.min_quantity || 1;
const max = this.product?.max_quantity || this.product?.available_inventory;
if (this.quantity < min) {
this.quantity = min;
} else if (this.quantity > max) {
this.quantity = max;
}
},
// Add to cart
async addToCart() {
if (!this.canAddToCart) return;
this.addingToCart = true;
try {
const response = await fetch(
`/api/v1/shop/cart/${this.sessionId}/items`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
product_id: parseInt(this.productId),
quantity: this.quantity
})
}
);
if (response.ok) {
this.cartCount += this.quantity;
this.showToast(
`${this.quantity} item(s) added to cart!`,
'success'
);
// Reset quantity to minimum
this.quantity = this.product?.min_quantity || 1;
} else {
const error = await response.json();
throw new Error(error.detail || 'Failed to add to cart');
}
} catch (error) {
console.error('Add to cart error:', error);
this.showToast(error.message || 'Failed to add to cart', 'error');
} finally {
this.addingToCart = false;
}
},
// View other product
viewProduct(productId) {
window.location.href = `/shop/products/${productId}`;
},
// Show toast notification
showToast(message, type = 'success') {
this.toast = {
show: true,
type: type,
message: message
};
setTimeout(() => {
this.toast.show = false;
}, 3000);
}
}
}
</script>
<style>
/* Product Detail Styles */
.product-detail-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-2xl);
margin-top: var(--spacing-xl);
margin-bottom: var(--spacing-2xl);
}
.product-images {
position: sticky;
top: var(--spacing-lg);
height: fit-content;
}
.main-image {
width: 100%;
aspect-ratio: 1;
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--gray-50);
margin-bottom: var(--spacing-md);
}
.product-main-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.image-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: var(--spacing-sm);
}
.thumbnail {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: var(--radius-md);
cursor: pointer;
border: 2px solid transparent;
transition: all var(--transition-base);
}
.thumbnail:hover {
border-color: var(--primary-color);
}
.thumbnail.active {
border-color: var(--primary-color);
box-shadow: var(--shadow-md);
}
.product-info-detail {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.product-title-detail {
font-size: var(--font-4xl);
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.product-meta {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-md);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border-color);
}
.meta-item {
font-size: var(--font-sm);
color: var(--text-secondary);
}
.product-pricing {
padding: var(--spacing-lg);
background: var(--gray-50);
border-radius: var(--radius-lg);
}
.price-original {
font-size: var(--font-xl);
color: var(--text-muted);
text-decoration: line-through;
margin-right: var(--spacing-md);
}
.price-sale {
font-size: var(--font-4xl);
font-weight: 700;
color: var(--danger-color);
}
.price-current {
font-size: var(--font-4xl);
font-weight: 700;
color: var(--text-primary);
}
.price-badge {
display: inline-block;
background: var(--danger-color);
color: white;
padding: 4px 12px;
border-radius: var(--radius-full);
font-size: var(--font-sm);
font-weight: 600;
margin-left: var(--spacing-sm);
}
.product-availability {
padding: var(--spacing-md);
background: white;
border-radius: var(--radius-md);
}
.availability-badge {
display: inline-block;
padding: 8px 16px;
border-radius: var(--radius-md);
font-weight: 600;
font-size: var(--font-base);
}
.availability-badge.in-stock {
background: #d4edda;
color: #155724;
}
.availability-badge.out-of-stock {
background: #f8d7da;
color: #721c24;
}
.product-description {
padding: var(--spacing-lg);
background: white;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
}
.product-description h3 {
margin-bottom: var(--spacing-md);
font-size: var(--font-xl);
}
.product-description p {
line-height: 1.6;
color: var(--text-secondary);
}
.product-details {
padding: var(--spacing-lg);
background: white;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
}
.product-details h3 {
margin-bottom: var(--spacing-md);
font-size: var(--font-xl);
}
.product-details ul {
list-style: none;
padding: 0;
margin: 0;
}
.product-details li {
padding: var(--spacing-sm) 0;
border-bottom: 1px solid var(--border-color);
}
.product-details li:last-child {
border-bottom: none;
}
.add-to-cart-section {
padding: var(--spacing-xl);
background: white;
border-radius: var(--radius-lg);
border: 2px solid var(--primary-color);
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.quantity-selector {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.quantity-selector label {
font-weight: 600;
font-size: var(--font-lg);
}
.quantity-controls {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
.btn-quantity {
width: 40px;
height: 40px;
border: 2px solid var(--border-color);
background: white;
border-radius: var(--radius-md);
cursor: pointer;
font-size: var(--font-xl);
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-base);
}
.btn-quantity:hover:not(:disabled) {
background: var(--gray-50);
border-color: var(--primary-color);
}
.btn-quantity:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.quantity-input {
width: 80px;
text-align: center;
padding: 10px;
border: 2px solid var(--border-color);
border-radius: var(--radius-md);
font-size: var(--font-lg);
font-weight: 600;
}
.btn-add-to-cart {
width: 100%;
padding: 16px 32px;
background: var(--primary-color);
color: white;
border: none;
border-radius: var(--radius-lg);
font-size: var(--font-xl);
font-weight: 600;
cursor: pointer;
transition: all var(--transition-base);
}
.btn-add-to-cart:hover:not(:disabled) {
background: var(--primary-dark);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.btn-add-to-cart:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.total-price {
padding: var(--spacing-md);
background: var(--gray-50);
border-radius: var(--radius-md);
text-align: center;
font-size: var(--font-2xl);
}
.related-products {
margin-top: var(--spacing-2xl);
padding-top: var(--spacing-2xl);
border-top: 2px solid var(--border-color);
}
.related-products h2 {
margin-bottom: var(--spacing-xl);
font-size: var(--font-3xl);
}
/* Responsive */
@media (max-width: 1024px) {
.product-detail-container {
grid-template-columns: 1fr;
}
.product-images {
position: static;
}
}
@media (max-width: 768px) {
.product-title-detail {
font-size: var(--font-2xl);
}
.price-current,
.price-sale {
font-size: var(--font-3xl);
}
.add-to-cart-section {
padding: var(--spacing-lg);
}
}
</style>
</body>
</html>