SpectrumInfinite's picture
Add 2 files
68ce2a2 verified
Raw History Blame Contribute Delete
32.2 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Product Card</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-4 {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.aspect-video {
position: relative;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
}
.aspect-video > * {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>
</head>
<body class="bg-gray-50 p-6">
<div id="product-card-container" class="max-w-md mx-auto">
<!-- Product card will be rendered here -->
</div>
<script>
// Initialize Lucide icons
lucide.createIcons();
// Sample product data
const sampleProduct = {
id: 'prod-123',
tempId: 'temp-123',
productName: 'Premium Carabiner',
productNumber: '#2065',
description: 'High-quality aluminum carabiner with safety lock. Perfect for climbing and outdoor activities.',
price: {
min: 12.99,
currency: 'USD',
minimumQuantity: 10
},
heroImage: 'https://images.unsplash.com/photo-1524593689594-aae2f26b75ab?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=800&q=80',
availableColors: ['Black', 'Blue', 'Red'],
status: 'success',
scrapeError: null,
pricingTableHTML: '<table>...</table>'
};
// Mock store functions
const mockStore = {
updateProduct: (id, updates) => {
console.log('Updating product:', id, updates);
// In a real app, this would update the Zustand store
Object.assign(sampleProduct, updates);
renderProductCard(sampleProduct);
}
};
// Format price helper
function formatPrice(priceInfo) {
if (!priceInfo) return '';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: priceInfo.currency || 'USD'
}).format(priceInfo.min);
}
// Toast notification
function showToast(title, description, variant = 'default') {
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 p-4 rounded-md shadow-lg ${
variant === 'destructive' ? 'bg-red-100 text-red-900' : 'bg-green-100 text-green-900'
}`;
toast.innerHTML = `
<div class="font-medium">${title}</div>
<div class="text-sm">${description}</div>
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// Product Card Component
function ProductCard({ product, onUploadClick, onRemoveClick }) {
const [isEditing, setIsEditing] = useState(false);
const [manualData, setManualData] = useState({
productName: product.productName || '',
productNumber: product.productNumber || '',
description: product.description || '',
startingPrice: typeof product.price === 'object' && product.price?.min ? product.price.min.toString() : '',
currency: typeof product.price === 'object' && product.price?.currency ? product.price.currency : 'USD',
minimumQuantity: typeof product.price === 'object' && product.price?.minimumQuantity ? product.price.minimumQuantity.toString() : '',
availableColors: product.availableColors?.join(', ') || '',
});
// Effect to update manualData if product changes
useEffect(() => {
if (!isEditing) {
setManualData({
productName: product.productName || '',
productNumber: product.productNumber || '',
description: product.description || '',
startingPrice: typeof product.price === 'object' && product.price?.min ? product.price.min.toString() : '',
currency: typeof product.price === 'object' && product.price?.currency ? product.price.currency : 'USD',
minimumQuantity: typeof product.price === 'object' && product.price?.minimumQuantity ? product.price.minimumQuantity.toString() : '',
availableColors: product.availableColors?.join(', ') || '',
});
}
}, [product, isEditing]);
const isLoading = product.status === 'pending' || product.status === 'scraping';
const isError = product.status === 'error';
const needsImageUpload = product.status === 'image_upload_required';
const isSuccess = product.status === 'success';
const handleEditToggle = () => {
if (!isEditing) {
setManualData({
productName: product.productName || '',
productNumber: product.productNumber || '',
description: product.description || '',
startingPrice: typeof product.price === 'object' && product.price?.min ? product.price.min.toString() : '',
currency: typeof product.price === 'object' && product.price?.currency ? product.price.currency : 'USD',
minimumQuantity: typeof product.price === 'object' && product.price?.minimumQuantity ? product.price.minimumQuantity.toString() : '',
availableColors: product.availableColors?.join(', ') || '',
});
}
setIsEditing(!isEditing);
};
const handleManualInputChange = (e) => {
const { name, value } = e.target;
setManualData(prev => ({ ...prev, [name]: value }));
};
const handleManualSave = () => {
// Basic validation
if (!manualData.productName || !manualData.productNumber || !manualData.startingPrice || !manualData.availableColors) {
showToast("Missing Information", "Please fill in Product Name, Number, Starting Price, and Colors.", "destructive");
return;
}
const priceValue = parseFloat(manualData.startingPrice);
const minQtyValue = parseInt(manualData.minimumQuantity, 10);
const priceInfo = !isNaN(priceValue) ? {
min: priceValue,
currency: manualData.currency || 'USD',
minimumQuantity: !isNaN(minQtyValue) ? minQtyValue : undefined
} : null;
const colorsArray = manualData.availableColors.split(',').map(color => color.trim()).filter(Boolean);
mockStore.updateProduct(product.id || product.tempId, {
productName: manualData.productName || undefined,
productNumber: manualData.productNumber || undefined,
description: manualData.description || undefined,
price: priceInfo,
availableColors: colorsArray.length > 0 ? colorsArray : undefined,
status: (isError || needsImageUpload)
? (product.heroImage && manualData.productName && manualData.productNumber && priceInfo && colorsArray.length > 0 ? 'success' : 'image_upload_required')
: (product.heroImage ? 'success' : 'image_upload_required'),
scrapeError: undefined,
});
setIsEditing(false);
showToast("Product Updated", "Manual details saved.");
};
const handleImageFileChange = (event) => {
const file = event.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
const imageDataUrl = reader.result;
mockStore.updateProduct(product.id || product.tempId, {
heroImage: imageDataUrl,
status: (product.status === 'image_upload_required' || product.status === 'error') &&
product.productName && product.productNumber && product.price &&
product.availableColors?.length ? 'success' : product.status,
});
showToast("Image Uploaded", "Preview updated.");
};
reader.readAsDataURL(file);
}
event.target.value = '';
};
const needsManualInputHighlight = (field) => {
return isEditing && !manualData[field] && ['productName', 'productNumber', 'startingPrice', 'availableColors'].includes(field);
};
const renderStatusBadge = () => {
switch (product.status) {
case 'pending': return `
<span class="absolute top-2 right-2 inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10">
Pending
</span>`;
case 'scraping': return `
<span class="absolute top-2 right-2 inline-flex items-center rounded-md bg-white px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10 animate-pulse">
Scraping...
</span>`;
case 'success': return `
<span class="absolute top-2 right-2 inline-flex items-center rounded-md bg-green-500 px-2 py-1 text-xs font-medium text-white ring-1 ring-inset ring-green-600/20">
<i data-lucide="check-circle" class="w-3 h-3 mr-1"></i> Ready
</span>`;
case 'error': return `
<span class="absolute top-2 right-2 inline-flex items-center rounded-md bg-red-500 px-2 py-1 text-xs font-medium text-white ring-1 ring-inset ring-red-600/10">
<i data-lucide="alert-circle" class="w-3 h-3 mr-1"></i> Error
</span>`;
case 'image_upload_required': return `
<span class="absolute top-2 right-2 inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10">
<i data-lucide="image-plus" class="w-3 h-3 mr-1"></i> Need Info
</span>`;
default: return '';
}
};
return `
<div class="relative w-full transition-all duration-300 ease-in-out shadow-md hover:shadow-lg flex flex-col h-full overflow-hidden bg-white rounded-lg">
${renderStatusBadge()}
${!isLoading ? `
<button
class="absolute top-10 right-2 h-7 w-7 z-10 rounded-full flex items-center justify-center ${isEditing ? 'bg-blue-500 text-white' : 'bg-white border border-gray-300 text-gray-700'}"
onclick="${isEditing ? 'handleManualSave()' : 'handleEditToggle()'}"
title="${isEditing ? 'Save Changes' : 'Edit Product Details'}"
>
<i data-lucide="${isEditing ? 'save' : 'edit-3'}" class="w-4 h-4"></i>
</button>
` : ''}
${isEditing ? `
<button
class="absolute top-20 right-2 h-7 w-7 z-10 rounded-full flex items-center justify-center bg-white text-gray-500 hover:text-red-500"
onclick="setIsEditing(false)"
title="Cancel Edit"
>
<i data-lucide="x" class="w-4 h-4"></i>
</button>
` : ''}
<div class="p-4 pb-2">
${isLoading ? `
<div class="h-6 w-3/4 mb-1 bg-gray-200 rounded"></div>
<div class="h-4 w-1/2 bg-gray-200 rounded"></div>
` : isEditing ? `
<div class="space-y-1">
<input
name="productNumber"
value="${manualData.productNumber}"
onchange="handleManualInputChange(event)"
placeholder="Product Number (e.g., #2065)"
class="text-sm font-medium w-full px-3 py-2 border ${needsManualInputHighlight('productNumber') ? 'border-red-500 ring-1 ring-red-500' : 'border-gray-300'} rounded-md"
aria-label="Product Number"
/>
<input
name="productName"
value="${manualData.productName}"
onchange="handleManualInputChange(event)"
placeholder="Product Name (e.g., Chaz Carabiner...)"
class="text-lg font-semibold w-full px-3 py-2 border ${needsManualInputHighlight('productName') ? 'border-red-500 ring-1 ring-red-500' : 'border-gray-300'} rounded-md"
aria-label="Product Name"
/>
</div>
` : `
<h3 class="text-lg font-semibold text-gray-900 leading-tight pr-16">
${product.productNumber ? `<span class="text-gray-500 font-medium mr-1">${product.productNumber}</span>` : ''}
${product.productName || '<span class="italic text-gray-500">Product Name Missing</span>'}
</h3>
`}
</div>
<div class="px-4 pt-0 pb-3 flex-grow flex flex-col">
<div class="aspect-video w-full bg-gray-100 rounded-md mb-3 flex items-center justify-center overflow-hidden relative group">
${isLoading ? `
<div class="h-full w-full bg-gray-200 rounded"></div>
` : (isError || needsImageUpload || (isSuccess && !product.heroImage)) && !isEditing ? `
<div class="text-center text-gray-500 p-4 w-full h-full flex flex-col items-center justify-center bg-gray-100/50">
<i data-lucide="image-plus" class="w-10 h-10 mb-2"></i>
<p class="text-sm font-medium mb-2">Image unavailable</p>
<input
type="file"
id="file-upload-${product.id || product.tempId}"
class="hidden"
accept="image/png, image/jpeg, image/webp, image/gif"
onchange="handleImageFileChange(event)"
/>
<label for="file-upload-${product.id || product.tempId}" class="cursor-pointer inline-flex items-center rounded-md bg-gray-200 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-300">
<i data-lucide="image-plus" class="w-4 h-4 mr-1"></i> Upload Image
</label>
<p class="text-xs mt-1">(Recommended: 400x300px or 4:3 ratio)</p>
${isError && product.scrapeError ? `
<p class="text-xs text-red-500 mt-2 px-2">${product.scrapeError}</p>
` : ''}
${!isEditing && (isError || needsImageUpload) ? `
<div class="mt-2 text-xs text-red-500 space-y-0.5">
${!product.productName ? '<p>Product Name required.</p>' : ''}
${!product.productNumber ? '<p>Product Number required.</p>' : ''}
${!product.price ? '<p>Pricing required.</p>' : ''}
${!(product.availableColors && product.availableColors.length > 0) ? '<p>Colors required.</p>' : ''}
</div>
` : ''}
<button onclick="onRemoveClick('${product.id || product.tempId}')" class="absolute bottom-1 right-1 text-xs h-6 px-1 text-gray-500 hover:text-red-500 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
Remove
</button>
</div>
` : product.heroImage ? `
<img
src="${product.heroImage}"
alt="${product.productName || 'Product Image'}"
class="w-full h-full object-cover"
/>
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors duration-200">
${!isEditing ? `
<button onclick="onRemoveClick('${product.id || product.tempId}')" class="absolute bottom-1 right-1 text-xs h-6 px-1 bg-black/50 text-white hover:bg-red-500 hover:text-white opacity-0 group-hover:opacity-100 transition-opacity z-10">
Remove
</button>
` : ''}
</div>
` : `
<div class="h-full w-full bg-gray-200 rounded"></div>
`}
</div>
<div class="mb-3 flex-grow">
${isLoading ? `
<div class="h-4 w-full mb-1 bg-gray-200 rounded"></div>
<div class="h-4 w-5/6 bg-gray-200 rounded"></div>
` : isEditing ? `
<textarea
name="description"
value="${manualData.description}"
onchange="handleManualInputChange(event)"
placeholder="Product Description"
class="text-sm w-full px-3 py-2 border border-gray-300 rounded-md h-24"
rows="3"
></textarea>
` : product.description ? `
<p class="text-sm text-gray-500 line-clamp-4">${product.description}</p>
` : !isError && !isEditing ? `
<p class="text-sm text-gray-500 italic">No description available.</p>
` : ''}
</div>
<div class="mt-auto space-y-1">
${isLoading ? `
<div class="h-5 w-1/2 bg-gray-200 rounded"></div>
` : isEditing ? `
<div class="grid grid-cols-2 gap-2">
<div class="space-y-0.5">
<label class="text-xs text-gray-500">Min. Qty</label>
<input
name="minimumQuantity"
type="number"
value="${manualData.minimumQuantity}"
onchange="handleManualInputChange(event)"
placeholder="e.g. 250"
class="text-sm w-full px-3 py-2 border ${needsManualInputHighlight('minimumQuantity') ? 'border-red-500 ring-1 ring-red-500' : 'border-gray-300'} rounded-md h-9"
aria-label="Minimum Quantity"
/>
</div>
<div class="space-y-0.5">
<label class="text-xs text-gray-500">Starts at</label>
<div class="flex items-center gap-1">
<input
name="startingPrice"
type="number"
step="0.01"
value="${manualData.startingPrice}"
onchange="handleManualInputChange(event)"
placeholder="e.g. 1.73"
class="text-sm font-semibold w-20 px-3 py-2 border ${needsManualInputHighlight('startingPrice') ? 'border-red-500 ring-1 ring-red-500' : 'border-gray-300'} rounded-md h-9"
aria-label="Starting Price"
/>
<input
name="currency"
value="${manualData.currency}"
onchange="handleManualInputChange(event)"
placeholder="USD"
class="text-xs w-12 px-3 py-2 border border-gray-300 rounded-md h-9"
aria-label="Currency"
/>
</div>
</div>
</div>
` : typeof product.price === 'object' && product.price ? `
<p class="text-lg font-semibold text-blue-600">
Starts at ${formatPrice(product.price)}
${product.price.minimumQuantity ? `
<span class="text-sm font-normal text-gray-500 ml-1">(Min. ${product.price.minimumQuantity})</span>
` : ''}
</p>
` : typeof product.price === 'string' ? `
<p class="text-lg font-semibold text-blue-600">${product.price}</p>
` : !isError && !isEditing ? `
<p class="text-sm text-gray-500 italic">Pricing not available.</p>
` : ''}
</div>
<div class="mt-2 space-y-1">
${isLoading ? `
<div class="h-4 w-3/4 bg-gray-200 rounded"></div>
` : isEditing ? `
<div class="flex items-center gap-1">
<i data-lucide="palette" class="w-4 h-4 text-gray-500 flex-shrink-0"></i>
<input
name="availableColors"
value="${manualData.availableColors}"
onchange="handleManualInputChange(event)"
placeholder="Colors (comma-separated)"
class="text-sm w-full px-3 py-2 border ${needsManualInputHighlight('availableColors') ? 'border-red-500 ring-1 ring-red-500' : 'border-gray-300'} rounded-md h-9"
aria-label="Available Colors"
/>
</div>
` : product.availableColors && product.availableColors.length > 0 ? `
<div class="flex items-center gap-1 text-sm text-gray-500">
<i data-lucide="palette" class="w-4 h-4 flex-shrink-0"></i>
<span class="line-clamp-1">${product.availableColors.join(', ')}</span>
</div>
` : !isError && !isEditing ? `
<p class="text-sm text-gray-500 italic">Color info not available.</p>
` : ''}
</div>
${product.pricingTableHTML && !isLoading && !isError && !isEditing ? `
<p class="text-xs text-gray-500 mt-1 italic">
Detailed pricing available.
</p>
` : ''}
</div>
</div>
`;
}
// React-like useState for vanilla JS
function useState(initialValue) {
let value = initialValue;
const listeners = [];
const setValue = (newValue) => {
if (typeof newValue === 'function') {
value = newValue(value);
} else {
value = newValue;
}
listeners.forEach(listener => listener(value));
};
const getValue = () => value;
const subscribe = (listener) => {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener);
if (index !== -1) listeners.splice(index, 1);
};
};
return [getValue, setValue, subscribe];
}
// Render function
function renderProductCard(product) {
const container = document.getElementById('product-card-container');
// Create state variables
const [isEditing, setIsEditing] = useState(false);
// Create the card
container.innerHTML = ProductCard({
product,
onUploadClick: (id) => {
document.getElementById(`file-upload-${id}`)?.click();
},
onRemoveClick: (id) => {
showToast("Product Removed", `Product ${id} has been removed`);
}
});
// Update Lucide icons
lucide.createIcons();
// Set up event handlers
window.handleManualInputChange = (e) => {
const { name, value } = e.target;
setManualData(prev => ({ ...prev, [name]: value }));
};
window.handleImageFileChange = (e) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
const imageDataUrl = reader.result;
mockStore.updateProduct(sampleProduct.id || sampleProduct.tempId, {
heroImage: imageDataUrl,
status: 'success',
});
showToast("Image Uploaded", "Preview updated.");
};
reader.readAsDataURL(file);
}
e.target.value = '';
};
window.handleManualSave = () => {
// Basic validation
if (!manualData.productName || !manualData.productNumber || !manualData.startingPrice || !manualData.availableColors) {
showToast("Missing Information", "Please fill in Product Name, Number, Starting Price, and Colors.", "destructive");
return;
}
const priceValue = parseFloat(manualData.startingPrice);
const minQtyValue = parseInt(manualData.minimumQuantity, 10);
const priceInfo = !isNaN(priceValue) ? {
min: priceValue,
currency: manualData.currency || 'USD',
minimumQuantity: !isNaN(minQtyValue) ? minQtyValue : undefined
} : null;
const colorsArray = manualData.availableColors.split(',').map(color => color.trim()).filter(Boolean);
mockStore.updateProduct(sampleProduct.id || sampleProduct.tempId, {
productName: manualData.productName || undefined,
productNumber: manualData.productNumber || undefined,
description: manualData.description || undefined,
price: priceInfo,
availableColors: colorsArray.length > 0 ? colorsArray : undefined,
status: 'success',
});
setIsEditing(false);
showToast("Product Updated", "Manual details saved.");
};
window.setIsEditing = (value) => {
setIsEditing(value);
renderProductCard(sampleProduct);
};
}
// Initial render
renderProductCard(sampleProduct);
</script>
<p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=SpectrumInfinite/spectrum-infinite-flyerforge" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body>
</html>