Adding jinja shop templates

This commit is contained in:
2025-10-26 19:59:27 +01:00
parent 49890d4cbe
commit d79817f069
11 changed files with 2375 additions and 0 deletions

View File

@@ -0,0 +1,771 @@
<!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="/shop/products" class="btn-back">← Back to Products</a>
<h1>{{ vendor.name }}</h1>
</div>
<div class="header-right">
<a href="/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/public/vendors/${this.vendorId}/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/public/vendors/${this.vendorId}/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/public/vendors/${this.vendorId}/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/public/vendors/${this.vendorId}/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>