Files
orion/app/templates/storefront/product.html
Samir Boulahtit 7245f79f7b refactor: rename shop to storefront for consistency
Rename all "shop" directories and references to "storefront" to match
the API and route naming convention already in use.

Renamed directories:
- app/templates/shop/ → app/templates/storefront/
- static/shop/ → static/storefront/
- app/templates/shared/macros/shop/ → .../macros/storefront/
- docs/frontend/shop/ → docs/frontend/storefront/

Renamed files:
- shop.css → storefront.css
- shop-layout.js → storefront-layout.js

Updated references in:
- app/routes/storefront_pages.py (21 template references)
- app/modules/cms/routes/pages/vendor.py
- app/templates/storefront/base.html (static paths)
- All storefront templates (extends/includes)
- docs/architecture/frontend-structure.md

This aligns the template/static naming with:
- Route file: storefront_pages.py
- API directory: app/api/v1/storefront/
- Module routes: */routes/api/storefront.py
- URL paths: /storefront/*

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:58:28 +01:00

427 lines
19 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.
{# app/templates/storefront/product.html #}
{% extends "storefront/base.html" %}
{% block title %}{{ product.name if product else 'Product' }}{% endblock %}
{# Alpine.js component #}
{% block alpine_data %}productDetail(){% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{# Breadcrumbs #}
<div class="breadcrumb mb-6">
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
<span>/</span>
<a href="{{ base_url }}shop/products" class="hover:text-primary">Products</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium" x-text="product?.marketplace_product?.title || 'Product'">Product</span>
</div>
{# Loading State #}
<div x-show="loading" class="flex justify-center items-center py-12">
<div class="spinner"></div>
</div>
{# Product Detail #}
<div x-show="!loading && product" class="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12">
{# Product Images #}
<div class="product-images">
<div class="main-image bg-white dark:bg-gray-800 rounded-lg overflow-hidden mb-4">
<img
:src="selectedImage || '/static/shop/img/placeholder.svg'"
@error="selectedImage = '/static/shop/img/placeholder.svg'"
:alt="product?.marketplace_product?.title"
class="w-full h-auto object-contain"
style="max-height: 600px;"
>
</div>
{# Thumbnail Gallery #}
<div x-show="product?.marketplace_product?.images?.length > 1" class="grid grid-cols-4 gap-2">
<template x-for="(image, index) in product?.marketplace_product?.images" :key="index">
<img
:src="image"
:alt="`Product image ${index + 1}`"
class="w-full aspect-square object-cover rounded-lg cursor-pointer border-2 transition-all"
:class="selectedImage === image ? 'border-primary' : 'border-transparent hover:border-gray-300'"
@click="selectedImage = image"
>
</template>
</div>
</div>
{# Product Info #}
<div class="product-info-detail">
<h1 x-text="product?.marketplace_product?.title" class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-4">
Product
</h1>
{# Brand & Category #}
<div class="flex flex-wrap gap-4 mb-6 pb-6 border-b border-gray-200 dark:border-gray-700">
<span x-show="product?.marketplace_product?.brand" class="text-sm text-gray-600 dark:text-gray-400">
<strong>Brand:</strong> <span x-text="product?.marketplace_product?.brand"></span>
</span>
<span x-show="product?.marketplace_product?.google_product_category" class="text-sm text-gray-600 dark:text-gray-400">
<strong>Category:</strong> <span x-text="product?.marketplace_product?.google_product_category"></span>
</span>
<span class="text-sm text-gray-600 dark:text-gray-400">
<strong>SKU:</strong> <span x-text="product?.product_id || product?.marketplace_product?.mpn"></span>
</span>
</div>
{# Price #}
<div class="mb-6">
<div x-show="product?.sale_price && product?.sale_price < product?.price">
<span class="text-xl text-gray-500 line-through mr-3"><span x-text="parseFloat(product?.price).toFixed(2)"></span></span>
<span class="text-4xl font-bold text-red-600"><span x-text="parseFloat(product?.sale_price).toFixed(2)"></span></span>
<span class="ml-2 inline-block bg-red-600 text-white px-3 py-1 rounded-full text-sm font-semibold">SALE</span>
</div>
<div x-show="!product?.sale_price || product?.sale_price >= product?.price">
<span class="text-4xl font-bold text-gray-800 dark:text-gray-200"><span x-text="parseFloat(product?.price || 0).toFixed(2)"></span></span>
</div>
</div>
{# Availability #}
<div class="mb-6">
<span
x-show="product?.available_inventory > 0"
class="inline-block px-4 py-2 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 rounded-lg font-semibold"
>
✓ In Stock (<span x-text="product?.available_inventory"></span> available)
</span>
<span
x-show="!product?.available_inventory || product?.available_inventory <= 0"
class="inline-block px-4 py-2 bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 rounded-lg font-semibold"
>
✗ Out of Stock
</span>
</div>
{# Description #}
<div class="mb-6 p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<h3 class="text-xl font-semibold mb-3">Description</h3>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed" x-text="product?.marketplace_product?.description || 'No description available'"></p>
</div>
{# Additional Details #}
<div x-show="hasAdditionalDetails" class="mb-6 p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<h3 class="text-xl font-semibold mb-3">Product Details</h3>
<ul class="space-y-2">
<li x-show="product?.marketplace_product?.gtin" class="text-sm text-gray-600 dark:text-gray-400">
<strong>GTIN:</strong> <span x-text="product?.marketplace_product?.gtin"></span>
</li>
<li x-show="product?.condition" class="text-sm text-gray-600 dark:text-gray-400">
<strong>Condition:</strong> <span x-text="product?.condition"></span>
</li>
<li x-show="product?.marketplace_product?.color" class="text-sm text-gray-600 dark:text-gray-400">
<strong>Color:</strong> <span x-text="product?.marketplace_product?.color"></span>
</li>
<li x-show="product?.marketplace_product?.size" class="text-sm text-gray-600 dark:text-gray-400">
<strong>Size:</strong> <span x-text="product?.marketplace_product?.size"></span>
</li>
<li x-show="product?.marketplace_product?.material" class="text-sm text-gray-600 dark:text-gray-400">
<strong>Material:</strong> <span x-text="product?.marketplace_product?.material"></span>
</li>
</ul>
</div>
{# Add to Cart Section #}
<div class="p-6 bg-white dark:bg-gray-800 rounded-lg border-2 border-primary">
{# Quantity Selector #}
{# noqa: FE-008 - Custom quantity stepper with dynamic product-based min/max and validateQuantity() handler #}
<div class="mb-4">
<label class="block font-semibold text-lg mb-2">Quantity:</label>
<div class="flex items-center gap-2">
<button
@click="decreaseQuantity()"
:disabled="quantity <= (product?.min_quantity || 1)"
class="w-10 h-10 flex items-center justify-center border-2 border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<input
type="number"
x-model.number="quantity"
:min="product?.min_quantity || 1"
:max="product?.max_quantity || product?.available_inventory"
class="w-20 text-center px-3 py-2 border-2 border-gray-300 dark:border-gray-600 rounded-lg font-semibold dark:bg-gray-700 dark:text-white"
@change="validateQuantity()"
>
<button
@click="increaseQuantity()"
:disabled="quantity >= (product?.max_quantity || product?.available_inventory)"
class="w-10 h-10 flex items-center justify-center border-2 border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
+
</button>
</div>
</div>
{# Add to Cart Button #}
<button
@click="addToCart()"
:disabled="!canAddToCart || addingToCart"
class="w-full px-6 py-4 bg-primary text-white rounded-lg font-semibold text-lg hover:bg-primary-dark transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!addingToCart">
🛒 Add to Cart
</span>
<span x-show="addingToCart">
<span class="inline-block animate-spin"></span> Adding...
</span>
</button>
{# Total Price #}
<div class="mt-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg text-center">
<strong class="text-xl">Total:</strong> <span class="text-2xl font-bold"><span x-text="totalPrice.toFixed(2)"></span></span>
</div>
</div>
</div>
</div>
{# Related Products #}
<div x-show="relatedProducts.length > 0" class="mt-12 pt-12 border-t-2 border-gray-200 dark:border-gray-700">
<h2 class="text-3xl font-bold mb-6">You May Also Like</h2>
<div class="product-grid">
<template x-for="related in relatedProducts" :key="related.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer">
<a :href="`{{ base_url }}shop/products/${related.id}`">
<img
:src="related.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
:alt="related.marketplace_product?.title"
class="w-full h-48 object-cover"
>
</a>
<div class="p-4">
<a :href="`{{ base_url }}shop/products/${related.id}`">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="related.marketplace_product?.title"></h3>
</a>
<p class="text-2xl font-bold text-primary">
<span x-text="parseFloat(related.price).toFixed(2)"></span>
</p>
</div>
</div>
</template>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Pass product ID from template to JavaScript
window.PRODUCT_ID = {{ product_id }};
window.VENDOR_ID = {{ vendor.id }};
document.addEventListener('alpine:init', () => {
Alpine.data('productDetail', () => {
const baseData = shopLayoutData();
return {
...baseData,
// Data
product: null,
relatedProducts: [],
loading: false,
addingToCart: false,
quantity: 1,
selectedImage: null,
vendorId: window.VENDOR_ID,
productId: window.PRODUCT_ID,
// 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
async init() {
console.log('[SHOP] Product detail page initializing...');
// Call parent init to set up sessionId
if (baseData.init) {
baseData.init.call(this);
}
console.log('[SHOP] Product ID:', this.productId);
console.log('[SHOP] Vendor ID:', this.vendorId);
console.log('[SHOP] Session ID:', this.sessionId);
await this.loadProduct();
},
// Load product details
async loadProduct() {
this.loading = true;
try {
console.log(`[SHOP] Loading product ${this.productId}...`);
const response = await fetch(`/api/v1/shop/products/${this.productId}`);
if (!response.ok) {
throw new Error('Product not found');
}
this.product = await response.json();
console.log('[SHOP] Product loaded:', this.product);
// 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
await this.loadRelatedProducts();
} catch (error) {
console.error('[SHOP] Failed to load product:', error);
this.showToast('Failed to load product', 'error');
// Redirect back to products after error
setTimeout(() => {
window.location.href = '{{ base_url }}shop/products';
}, 2000);
} finally {
this.loading = false;
}
},
// Load related products
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);
console.log('[SHOP] Loaded related products:', this.relatedProducts.length);
}
} catch (error) {
console.error('[SHOP] Failed to load related products:', 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) {
console.warn('[SHOP] Cannot add to cart:', {
canAddToCart: this.canAddToCart,
isActive: this.product?.is_active,
inventory: this.product?.available_inventory,
quantity: this.quantity
});
return;
}
this.addingToCart = true;
try {
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
const payload = {
product_id: parseInt(this.productId),
quantity: this.quantity
};
console.log('[SHOP] Adding to cart:', {
url,
sessionId: this.sessionId,
productId: this.productId,
quantity: this.quantity,
payload
});
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
console.log('[SHOP] Add to cart response:', {
status: response.status,
ok: response.ok
});
if (response.ok) {
const result = await response.json();
console.log('[SHOP] Add to cart success:', result);
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();
console.error('[SHOP] Add to cart error response:', error);
throw new Error(error.detail || 'Failed to add to cart');
}
} catch (error) {
console.error('[SHOP] Add to cart exception:', error);
this.showToast(error.message || 'Failed to add to cart', 'error');
} finally {
this.addingToCart = false;
}
}
};
});
});
</script>
{% endblock %}