Files
orion/app/templates/shop/product.html
Samir Boulahtit 1df4f12e92 fix: refactor product detail page to extend base template and use correct paths
Issues resolved:
- Product detail page was standalone HTML without proper styling
- Referenced non-existent CSS files (/static/css/shared/base.css, /static/css/vendor/vendor.css)
- Used wrong image path (/static/images/ instead of /static/shop/img/)
- Missing header, footer, and navigation
- Not integrated with shop layout system

Changes:
- Extend shop/base.html for consistent styling and layout
- Remove hardcoded CSS references (now inherited from base)
- Update image paths to /static/shop/img/placeholder.jpg
- Integrate with shopLayoutData() for cart and toast functionality
- Add proper breadcrumbs with landing page navigation
- Use Tailwind classes consistent with other shop pages
- Use theme CSS variables from base template

The product detail page now has proper styling, navigation, and
integrates seamlessly with the rest of the shop frontend.

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

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

388 lines
17 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/shop/product.html #}
{% extends "shop/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"
data-vendor-id="{{ vendor.id }}"
data-product-id="{{ product_id }}"
>
{# 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.jpg'"
: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 #}
<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.jpg'"
: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>
document.addEventListener('alpine:init', () => {
Alpine.data('productDetail', () => ({
...shopLayoutData(),
// Data
product: null,
relatedProducts: [],
loading: false,
addingToCart: false,
quantity: 1,
selectedImage: null,
vendorId: null,
productId: null,
// 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...');
this.vendorId = this.$el.dataset.vendorId;
this.productId = this.$el.dataset.productId;
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) return;
this.addingToCart = true;
try {
console.log('[SHOP] Adding to cart:', { productId: this.productId, quantity: this.quantity });
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('[SHOP] Add to cart error:', error);
this.showToast(error.message || 'Failed to add to cart', 'error');
} finally {
this.addingToCart = false;
}
}
}));
});
</script>
{% endblock %}