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>
This commit is contained in:
@@ -1,150 +1,143 @@
|
|||||||
<!DOCTYPE html>
|
{# app/templates/shop/product.html #}
|
||||||
<html lang="en">
|
{% extends "shop/base.html" %}
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
{% block title %}{{ product.name if product else 'Product' }}{% endblock %}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>{{ product.name if product else 'Product' }} - {{ vendor.name }}</title>
|
{# Alpine.js component #}
|
||||||
<link rel="stylesheet" href="/static/css/shared/base.css">
|
{% block alpine_data %}productDetail(){% endblock %}
|
||||||
<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>
|
{% block content %}
|
||||||
</head>
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"
|
||||||
<body>
|
|
||||||
<div x-data="productDetail()"
|
|
||||||
x-init="loadProduct()"
|
|
||||||
data-vendor-id="{{ vendor.id }}"
|
data-vendor-id="{{ vendor.id }}"
|
||||||
data-product-id="{{ product_id }}"
|
data-product-id="{{ product_id }}"
|
||||||
>
|
>
|
||||||
<!-- Header -->
|
{# Breadcrumbs #}
|
||||||
<header class="header">
|
<div class="breadcrumb mb-6">
|
||||||
<div class="header-left">
|
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
|
||||||
<a href="{{ base_url }}shop/products" class="btn-back">← Back to Products</a>
|
<span>/</span>
|
||||||
<h1>{{ vendor.name }}</h1>
|
<a href="{{ base_url }}shop/products" class="hover:text-primary">Products</a>
|
||||||
</div>
|
<span>/</span>
|
||||||
<div class="header-right">
|
<span class="text-gray-900 dark:text-gray-200 font-medium" x-text="product?.marketplace_product?.title || 'Product'">Product</span>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Product Detail -->
|
{# Loading State #}
|
||||||
<div x-show="!loading && product" class="container">
|
<div x-show="loading" class="flex justify-center items-center py-12">
|
||||||
<div class="product-detail-container">
|
<div class="spinner"></div>
|
||||||
<!-- Product Images -->
|
</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="product-images">
|
||||||
<div class="main-image">
|
<div class="main-image bg-white dark:bg-gray-800 rounded-lg overflow-hidden mb-4">
|
||||||
<img
|
<img
|
||||||
:src="selectedImage || '/static/images/placeholder.png'"
|
:src="selectedImage || '/static/shop/img/placeholder.jpg'"
|
||||||
:alt="product?.marketplace_product?.title"
|
:alt="product?.marketplace_product?.title"
|
||||||
class="product-main-image"
|
class="w-full h-auto object-contain"
|
||||||
|
style="max-height: 600px;"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Thumbnail Gallery -->
|
{# Thumbnail Gallery #}
|
||||||
<div class="image-gallery" x-show="product?.marketplace_product?.images?.length > 1">
|
<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">
|
<template x-for="(image, index) in product?.marketplace_product?.images" :key="index">
|
||||||
<img
|
<img
|
||||||
:src="image"
|
:src="image"
|
||||||
:alt="`Product image ${index + 1}`"
|
:alt="`Product image ${index + 1}`"
|
||||||
class="thumbnail"
|
class="w-full aspect-square object-cover rounded-lg cursor-pointer border-2 transition-all"
|
||||||
:class="{ 'active': selectedImage === image }"
|
:class="selectedImage === image ? 'border-primary' : 'border-transparent hover:border-gray-300'"
|
||||||
@click="selectedImage = image"
|
@click="selectedImage = image"
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Product Info -->
|
{# Product Info #}
|
||||||
<div class="product-info-detail">
|
<div class="product-info-detail">
|
||||||
<h1 x-text="product?.marketplace_product?.title" class="product-title-detail"></h1>
|
<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 -->
|
{# Brand & Category #}
|
||||||
<div class="product-meta">
|
<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="meta-item">
|
<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>
|
<strong>Brand:</strong> <span x-text="product?.marketplace_product?.brand"></span>
|
||||||
</span>
|
</span>
|
||||||
<span x-show="product?.marketplace_product?.google_product_category" class="meta-item">
|
<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>
|
<strong>Category:</strong> <span x-text="product?.marketplace_product?.google_product_category"></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="meta-item">
|
<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>
|
<strong>SKU:</strong> <span x-text="product?.product_id || product?.marketplace_product?.mpn"></span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Price -->
|
{# Price #}
|
||||||
<div class="product-pricing">
|
<div class="mb-6">
|
||||||
<div x-show="product?.sale_price && product?.sale_price < product?.price">
|
<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="text-xl text-gray-500 line-through mr-3">€<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="text-4xl font-bold text-red-600">€<span x-text="parseFloat(product?.sale_price).toFixed(2)"></span></span>
|
||||||
<span class="price-badge">SALE</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>
|
||||||
<div x-show="!product?.sale_price || product?.sale_price >= product?.price">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Availability -->
|
{# Availability #}
|
||||||
<div class="product-availability">
|
<div class="mb-6">
|
||||||
<span
|
<span
|
||||||
x-show="product?.available_inventory > 0"
|
x-show="product?.available_inventory > 0"
|
||||||
class="availability-badge in-stock"
|
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)
|
✓ In Stock (<span x-text="product?.available_inventory"></span> available)
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
x-show="!product?.available_inventory || product?.available_inventory <= 0"
|
x-show="!product?.available_inventory || product?.available_inventory <= 0"
|
||||||
class="availability-badge out-of-stock"
|
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
|
✗ Out of Stock
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
{# Description #}
|
||||||
<div class="product-description">
|
<div class="mb-6 p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
<h3>Description</h3>
|
<h3 class="text-xl font-semibold mb-3">Description</h3>
|
||||||
<p x-text="product?.marketplace_product?.description || 'No description available'"></p>
|
<p class="text-gray-600 dark:text-gray-400 leading-relaxed" x-text="product?.marketplace_product?.description || 'No description available'"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Additional Details -->
|
{# Additional Details #}
|
||||||
<div class="product-details" x-show="hasAdditionalDetails">
|
<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>Product Details</h3>
|
<h3 class="text-xl font-semibold mb-3">Product Details</h3>
|
||||||
<ul>
|
<ul class="space-y-2">
|
||||||
<li x-show="product?.marketplace_product?.gtin">
|
<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>
|
<strong>GTIN:</strong> <span x-text="product?.marketplace_product?.gtin"></span>
|
||||||
</li>
|
</li>
|
||||||
<li x-show="product?.condition">
|
<li x-show="product?.condition" class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
<strong>Condition:</strong> <span x-text="product?.condition"></span>
|
<strong>Condition:</strong> <span x-text="product?.condition"></span>
|
||||||
</li>
|
</li>
|
||||||
<li x-show="product?.marketplace_product?.color">
|
<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>
|
<strong>Color:</strong> <span x-text="product?.marketplace_product?.color"></span>
|
||||||
</li>
|
</li>
|
||||||
<li x-show="product?.marketplace_product?.size">
|
<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>
|
<strong>Size:</strong> <span x-text="product?.marketplace_product?.size"></span>
|
||||||
</li>
|
</li>
|
||||||
<li x-show="product?.marketplace_product?.material">
|
<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>
|
<strong>Material:</strong> <span x-text="product?.marketplace_product?.material"></span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add to Cart Section -->
|
{# Add to Cart Section #}
|
||||||
<div class="add-to-cart-section">
|
<div class="p-6 bg-white dark:bg-gray-800 rounded-lg border-2 border-primary">
|
||||||
<!-- Quantity Selector -->
|
{# Quantity Selector #}
|
||||||
<div class="quantity-selector">
|
<div class="mb-4">
|
||||||
<label>Quantity:</label>
|
<label class="block font-semibold text-lg mb-2">Quantity:</label>
|
||||||
<div class="quantity-controls">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@click="decreaseQuantity()"
|
@click="decreaseQuantity()"
|
||||||
:disabled="quantity <= (product?.min_quantity || 1)"
|
:disabled="quantity <= (product?.min_quantity || 1)"
|
||||||
class="btn-quantity"
|
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>
|
</button>
|
||||||
@@ -153,60 +146,59 @@
|
|||||||
x-model.number="quantity"
|
x-model.number="quantity"
|
||||||
:min="product?.min_quantity || 1"
|
:min="product?.min_quantity || 1"
|
||||||
:max="product?.max_quantity || product?.available_inventory"
|
:max="product?.max_quantity || product?.available_inventory"
|
||||||
class="quantity-input"
|
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()"
|
@change="validateQuantity()"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@click="increaseQuantity()"
|
@click="increaseQuantity()"
|
||||||
:disabled="quantity >= (product?.max_quantity || product?.available_inventory)"
|
:disabled="quantity >= (product?.max_quantity || product?.available_inventory)"
|
||||||
class="btn-quantity"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add to Cart Button -->
|
{# Add to Cart Button #}
|
||||||
<button
|
<button
|
||||||
@click="addToCart()"
|
@click="addToCart()"
|
||||||
:disabled="!canAddToCart || addingToCart"
|
:disabled="!canAddToCart || addingToCart"
|
||||||
class="btn-add-to-cart"
|
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">
|
<span x-show="!addingToCart">
|
||||||
🛒 Add to Cart
|
🛒 Add to Cart
|
||||||
</span>
|
</span>
|
||||||
<span x-show="addingToCart">
|
<span x-show="addingToCart">
|
||||||
<span class="loading-spinner"></span> Adding...
|
<span class="inline-block animate-spin">⏳</span> Adding...
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Total Price -->
|
{# Total Price #}
|
||||||
<div class="total-price">
|
<div class="mt-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg text-center">
|
||||||
<strong>Total:</strong> €<span x-text="totalPrice.toFixed(2)"></span>
|
<strong class="text-xl">Total:</strong> <span class="text-2xl font-bold">€<span x-text="totalPrice.toFixed(2)"></span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Related Products / You May Also Like -->
|
{# Related Products #}
|
||||||
<div class="related-products" x-show="relatedProducts.length > 0">
|
<div x-show="relatedProducts.length > 0" class="mt-12 pt-12 border-t-2 border-gray-200 dark:border-gray-700">
|
||||||
<h2>You May Also Like</h2>
|
<h2 class="text-3xl font-bold mb-6">You May Also Like</h2>
|
||||||
<div class="product-grid">
|
<div class="product-grid">
|
||||||
<template x-for="related in relatedProducts" :key="related.id">
|
<template x-for="related in relatedProducts" :key="related.id">
|
||||||
<div class="product-card">
|
<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
|
<img
|
||||||
:src="related.image_url || '/static/images/placeholder.png'"
|
:src="related.marketplace_product?.image_link || '/static/shop/img/placeholder.jpg'"
|
||||||
:alt="related.name"
|
:alt="related.marketplace_product?.title"
|
||||||
class="product-image"
|
class="w-full h-48 object-cover"
|
||||||
@click="viewProduct(related.id)"
|
|
||||||
>
|
>
|
||||||
<div class="product-info">
|
</a>
|
||||||
<h3
|
<div class="p-4">
|
||||||
class="product-title"
|
<a :href="`{{ base_url }}shop/products/${related.id}`">
|
||||||
@click="viewProduct(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>
|
||||||
x-text="related.name"
|
</a>
|
||||||
></h3>
|
<p class="text-2xl font-bold text-primary">
|
||||||
<p class="product-price">
|
|
||||||
€<span x-text="parseFloat(related.price).toFixed(2)"></span>
|
€<span x-text="parseFloat(related.price).toFixed(2)"></span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,20 +206,16 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Toast Notification -->
|
|
||||||
<div
|
|
||||||
x-show="toast.show"
|
|
||||||
x-transition
|
|
||||||
:class="'toast toast-' + toast.type"
|
|
||||||
x-text="toast.message"
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
<script>
|
<script>
|
||||||
function productDetail() {
|
document.addEventListener('alpine:init', () => {
|
||||||
return {
|
Alpine.data('productDetail', () => ({
|
||||||
|
...shopLayoutData(),
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
product: null,
|
product: null,
|
||||||
relatedProducts: [],
|
relatedProducts: [],
|
||||||
@@ -235,17 +223,8 @@
|
|||||||
addingToCart: false,
|
addingToCart: false,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
selectedImage: null,
|
selectedImage: null,
|
||||||
cartCount: 0,
|
|
||||||
vendorId: null,
|
vendorId: null,
|
||||||
productId: null,
|
productId: null,
|
||||||
sessionId: null,
|
|
||||||
|
|
||||||
// Toast notification
|
|
||||||
toast: {
|
|
||||||
show: false,
|
|
||||||
type: 'success',
|
|
||||||
message: ''
|
|
||||||
},
|
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
get canAddToCart() {
|
get canAddToCart() {
|
||||||
@@ -269,21 +248,11 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
init() {
|
async init() {
|
||||||
|
console.log('[SHOP] Product detail page initializing...');
|
||||||
this.vendorId = this.$el.dataset.vendorId;
|
this.vendorId = this.$el.dataset.vendorId;
|
||||||
this.productId = this.$el.dataset.productId;
|
this.productId = this.$el.dataset.productId;
|
||||||
this.sessionId = this.getOrCreateSessionId();
|
await this.loadProduct();
|
||||||
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
|
// Load product details
|
||||||
@@ -291,15 +260,15 @@
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
console.log(`[SHOP] Loading product ${this.productId}...`);
|
||||||
`/api/v1/shop/products/${this.productId}`
|
const response = await fetch(`/api/v1/shop/products/${this.productId}`);
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Product not found');
|
throw new Error('Product not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.product = await response.json();
|
this.product = await response.json();
|
||||||
|
console.log('[SHOP] Product loaded:', this.product);
|
||||||
|
|
||||||
// Set default image
|
// Set default image
|
||||||
if (this.product?.marketplace_product?.image_link) {
|
if (this.product?.marketplace_product?.image_link) {
|
||||||
@@ -309,27 +278,25 @@
|
|||||||
// Set initial quantity
|
// Set initial quantity
|
||||||
this.quantity = this.product?.min_quantity || 1;
|
this.quantity = this.product?.min_quantity || 1;
|
||||||
|
|
||||||
// Load related products (optional)
|
// Load related products
|
||||||
await this.loadRelatedProducts();
|
await this.loadRelatedProducts();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load product:', error);
|
console.error('[SHOP] Failed to load product:', error);
|
||||||
this.showToast('Failed to load product', 'error');
|
this.showToast('Failed to load product', 'error');
|
||||||
// Redirect back to products after error
|
// Redirect back to products after error
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = '/shop/products';
|
window.location.href = '{{ base_url }}shop/products';
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Load related products (same category or brand)
|
// Load related products
|
||||||
async loadRelatedProducts() {
|
async loadRelatedProducts() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`/api/v1/shop/products?limit=4`);
|
||||||
`/api/v1/shop/products?limit=4`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -337,27 +304,11 @@
|
|||||||
this.relatedProducts = data.products
|
this.relatedProducts = data.products
|
||||||
.filter(p => p.id !== parseInt(this.productId))
|
.filter(p => p.id !== parseInt(this.productId))
|
||||||
.slice(0, 4);
|
.slice(0, 4);
|
||||||
|
|
||||||
|
console.log('[SHOP] Loaded related products:', this.relatedProducts.length);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load related products:', error);
|
console.error('[SHOP] 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);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -394,6 +345,8 @@
|
|||||||
this.addingToCart = true;
|
this.addingToCart = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('[SHOP] Adding to cart:', { productId: this.productId, quantity: this.quantity });
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/v1/shop/cart/${this.sessionId}/items`,
|
`/api/v1/shop/cart/${this.sessionId}/items`,
|
||||||
{
|
{
|
||||||
@@ -422,350 +375,13 @@
|
|||||||
throw new Error(error.detail || 'Failed to add to cart');
|
throw new Error(error.detail || 'Failed to add to cart');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Add to cart error:', error);
|
console.error('[SHOP] Add to cart error:', error);
|
||||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.addingToCart = false;
|
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>
|
</script>
|
||||||
|
{% endblock %}
|
||||||
<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>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user