Files
orion/static/shop/product.html

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="/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>