2 Commits
2 changed files with 535 additions and 162 deletions
@@ -86,7 +86,7 @@ export const VariantBulkActions: React.FC<VariantBulkActionsProps> = ({
</button>
</div>
) : (
<form onSubmit={handleApply} className="space-y-3">
<div className="space-y-3">
{actionType === 'price' && (
<div className="space-y-2">
<div>
@@ -138,13 +138,18 @@ export const VariantBulkActions: React.FC<VariantBulkActionsProps> = ({
Back
</button>
<button
type="submit"
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleApply(e);
}}
className="px-2.5 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold"
>
Apply
</button>
</div>
</form>
</div>
)}
</div>
)}
@@ -1,10 +1,15 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import type { Variant, VariantStatus } from '../../types/variant.types';
import { assetsService } from '../../../assets/services/assets.service';
import { integrationsService } from '../../../integrations/services/integrations.service';
import type { Integration } from '../../../integrations/types/integrations.types';
import type { Asset } from '../../../assets/types/assets.types';
import {
X, Save, Archive, Trash2, Image as ImageIcon,
Package, Tag, DollarSign, CheckCircle, AlertCircle, Loader2,
ShoppingBag, Hash
ShoppingBag, Hash, Upload, ExternalLink, Globe, Layers, Plus, Star
} from 'lucide-react';
import { toast } from 'react-toastify';
interface VariantDetailModalProps {
variant: Variant | null;
@@ -23,65 +28,186 @@ export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
onArchive,
readOnly = false
}) => {
const [activeTab, setActiveTab] = useState<'general' | 'media' | 'channels'>('general');
// Form state
const [sku, setSku] = useState('');
const [name, setName] = useState('');
const [price, setPrice] = useState('');
const [costPrice, setCostPrice] = useState('');
const [stock, setStock] = useState('');
const [status, setStatus] = useState<VariantStatus>('draft');
const [activeImageIdx, setActiveImageIdx] = useState(0);
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
// Media Assets state
const [variantAssets, setVariantAssets] = useState<any[]>([]);
const [loadingAssets, setLoadingAssets] = useState(false);
const [uploadingMedia, setUploadingMedia] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Asset Library Picker modal state
const [showAssetPicker, setShowAssetPicker] = useState(false);
const [libraryAssets, setLibraryAssets] = useState<Asset[]>([]);
const [loadingLibrary, setLoadingLibrary] = useState(false);
// Channels state
const [integrations, setIntegrations] = useState<Integration[]>([]);
const [loadingIntegrations, setLoadingIntegrations] = useState(false);
useEffect(() => {
if (variant) {
setSku(variant.sku || '');
setName(variant.name || '');
setPrice(String(variant.price ?? ''));
setCostPrice(String(variant.costPrice ?? ''));
setStock(String(variant.stock ?? ''));
setStatus(variant.status || 'draft');
setActiveImageIdx(0);
setSaveState('idle');
// Load variant assets
loadVariantAssets(variant.id);
// Load integrations / channel status
loadIntegrations();
}
}, [variant]);
if (!variant) return null;
const loadVariantAssets = async (variantId: string) => {
setLoadingAssets(true);
try {
const data = await assetsService.getVariantAssets(variantId);
setVariantAssets(data || []);
} catch {
// fallback to variant.images if API call fails
if (variant?.images) {
setVariantAssets(variant.images);
}
} finally {
setLoadingAssets(false);
}
};
const images = variant.images || [];
const primaryImage = images.find(i => i.isPrimary) || images[0];
const activeImage = images[activeImageIdx] || primaryImage;
const loadIntegrations = async () => {
setLoadingIntegrations(true);
try {
const list = await integrationsService.getAll();
setIntegrations(list || []);
} catch {
setIntegrations([]);
} finally {
setLoadingIntegrations(false);
}
};
if (!variant) return null;
const axisEntries = Object.entries(variant.attributes || {});
const handleSave = async () => {
const handleSave = async (e?: React.MouseEvent) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
const pNum = parseFloat(price);
const cpNum = parseFloat(costPrice);
const sNum = parseInt(stock, 10);
const hasChanges =
sku !== variant.sku ||
pNum !== variant.price ||
cpNum !== variant.costPrice ||
sNum !== variant.stock ||
status !== variant.status;
if (!hasChanges) return;
setSaveState('saving');
try {
await onUpdate(variant.id, {
sku,
name,
price: isNaN(pNum) ? 0 : pNum,
costPrice: isNaN(cpNum) ? 0 : cpNum,
stock: isNaN(sNum) ? 0 : sNum,
status
});
setSaveState('saved');
toast.success('Variant updated successfully');
setTimeout(() => setSaveState('idle'), 2000);
} catch {
} catch (err: any) {
setSaveState('error');
toast.error(err?.message || 'Failed to save variant changes');
setTimeout(() => setSaveState('idle'), 3000);
}
};
// Upload image file directly for this variant
const handleFileUpload = async (files: FileList | null) => {
if (!files || files.length === 0) return;
setUploadingMedia(true);
try {
for (let i = 0; i < files.length; i++) {
const file = files[i];
const uploadedAsset: any = await assetsService.upload(file);
if (uploadedAsset && uploadedAsset.id) {
await assetsService.assignVariantAsset(variant.id, {
asset_id: uploadedAsset.id,
role: 'gallery_image',
is_primary: variantAssets.length === 0
});
}
}
toast.success('Image(s) uploaded and assigned to variant');
await loadVariantAssets(variant.id);
} catch (err: any) {
toast.error(err?.message || 'Failed to upload image asset');
} finally {
setUploadingMedia(false);
}
};
// Open asset library picker
const openAssetPicker = async () => {
setShowAssetPicker(true);
setLoadingLibrary(true);
try {
const res: any = await assetsService.getAll();
const list = Array.isArray(res) ? res : res?.data || [];
setLibraryAssets(list);
} catch {
toast.error('Failed to load asset library');
} finally {
setLoadingLibrary(false);
}
};
// Assign existing asset from library to variant
const handleAssignLibraryAsset = async (assetId: string) => {
try {
await assetsService.assignVariantAsset(variant.id, {
asset_id: assetId,
role: 'gallery_image',
is_primary: variantAssets.length === 0
});
toast.success('Asset assigned to variant');
await loadVariantAssets(variant.id);
} catch (err: any) {
toast.error(err?.message || 'Failed to assign asset');
}
};
// Set primary asset for variant
const handleSetPrimaryAsset = async (assetId: string) => {
try {
await assetsService.updateVariantAsset(variant.id, assetId, { is_primary: true });
toast.success('Primary image updated');
await loadVariantAssets(variant.id);
} catch (err: any) {
toast.error(err?.message || 'Failed to update primary image');
}
};
// Unassign asset from variant
const handleUnassignAsset = async (assetId: string) => {
try {
await assetsService.unassignVariantAsset(variant.id, assetId);
toast.info('Asset removed from variant');
await loadVariantAssets(variant.id);
} catch (err: any) {
toast.error(err?.message || 'Failed to remove asset');
}
};
const statusColor: Record<VariantStatus, string> = {
active: 'bg-emerald-100 text-emerald-700 border-emerald-200',
draft: 'bg-amber-100 text-amber-700 border-amber-200',
@@ -91,22 +217,30 @@ export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-150">
<div className="bg-surface border border-border rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200">
<div className="bg-surface border border-border rounded-2xl shadow-2xl w-full max-w-4xl max-h-[92vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200">
{/* ── Header ── */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0 bg-background/50">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Package className="w-4 h-4 text-primary" />
<Package className="w-5 h-5 text-primary" />
</div>
<div>
<h2 className="font-bold text-foreground text-sm leading-tight">
<h2 className="font-bold text-foreground text-base leading-tight">
{variant.name || 'Variant Details'}
</h2>
<p className="text-[11px] text-muted-foreground font-mono mt-0.5">{variant.sku}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground font-mono">{variant.sku}</span>
{axisEntries.length > 0 && (
<span className="text-[10px] bg-primary/10 text-primary font-semibold px-2 py-0.5 rounded-full">
{axisEntries.map(([k, v]) => `${k}: ${v}`).join(' | ')}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-3">
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-bold uppercase border ${statusColor[variant.status] || statusColor.draft}`}>
{variant.status}
</span>
@@ -115,107 +249,101 @@ export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
onClick={onClose}
className="p-1.5 hover:bg-background rounded-lg text-muted-foreground transition-colors"
>
<X className="w-4 h-4" />
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* ── Body ── */}
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-5 gap-0 h-full">
{/* ── Navigation Tabs ── */}
<div className="flex items-center gap-2 px-6 border-b border-border bg-surface flex-shrink-0">
<button
type="button"
onClick={() => setActiveTab('general')}
className={`flex items-center gap-2 px-4 py-3 text-xs font-semibold border-b-2 transition-all ${
activeTab === 'general'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<Layers className="w-4 h-4" />
General & Pricing
</button>
{/* Left: Image Gallery */}
<div className="col-span-2 border-r border-border p-5 flex flex-col gap-4 bg-background/50">
{/* Main image */}
<div className="aspect-square rounded-xl border border-border overflow-hidden bg-surface flex items-center justify-center">
{activeImage?.url || activeImage?.thumbnailUrl ? (
<img
src={activeImage.thumbnailUrl || activeImage.url!}
alt={activeImage.name || variant.name}
className="w-full h-full object-cover"
/>
) : (
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
<ImageIcon className="w-10 h-10 opacity-30" />
<span className="text-[11px] font-medium">No image</span>
</div>
)}
</div>
<button
type="button"
onClick={() => setActiveTab('media')}
className={`flex items-center gap-2 px-4 py-3 text-xs font-semibold border-b-2 transition-all ${
activeTab === 'media'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<ImageIcon className="w-4 h-4" />
Media Assets ({variantAssets.length})
</button>
{/* Thumbnail strip */}
{images.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-1">
{images.map((img, idx) => (
<button
key={idx}
type="button"
onClick={() => setActiveImageIdx(idx)}
className={`flex-shrink-0 w-12 h-12 rounded-lg border-2 overflow-hidden transition-all ${idx === activeImageIdx ? 'border-primary' : 'border-border hover:border-primary/40'}`}
>
{img.url || img.thumbnailUrl ? (
<img src={img.thumbnailUrl || img.url!} alt={img.name || `Image ${idx + 1}`} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-surface-muted flex items-center justify-center">
<ImageIcon className="w-3 h-3 text-muted-foreground" />
</div>
)}
</button>
))}
<button
type="button"
onClick={() => setActiveTab('channels')}
className={`flex items-center gap-2 px-4 py-3 text-xs font-semibold border-b-2 transition-all ${
activeTab === 'channels'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<Globe className="w-4 h-4" />
Channels & Syndication ({integrations.length})
</button>
</div>
{/* ── Tab Contents ── */}
<div className="flex-1 overflow-y-auto p-6">
{/* ──── Tab 1: General & Pricing ──── */}
{activeTab === 'general' && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
{/* SKU Code */}
<div>
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
<Hash className="inline w-3.5 h-3.5 mr-1" />SKU Code
</label>
{readOnly ? (
<p className="font-mono text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">{sku || '—'}</p>
) : (
<input
type="text"
value={sku}
onChange={e => setSku(e.target.value)}
placeholder="e.g. PROD-RED-M"
className="w-full border border-border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
/>
)}
</div>
)}
{/* Axis pills */}
{axisEntries.length > 0 && (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Variant Axes</p>
<div className="flex flex-wrap gap-1.5">
{axisEntries.map(([key, val]) => (
<span
key={key}
className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/8 border border-primary/20 rounded-full text-[11px] font-semibold text-primary"
>
<Tag className="w-2.5 h-2.5" />
<span className="text-muted-foreground capitalize">{key}:</span>
<span>{val}</span>
</span>
))}
</div>
{/* Variant Name */}
<div>
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
Variant Title / Name
</label>
{readOnly ? (
<p className="text-sm font-medium text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">{name || '—'}</p>
) : (
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="e.g. Red / Medium"
className="w-full border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
/>
)}
</div>
)}
{images.length > 0 && (
<p className="text-[10px] text-muted-foreground text-center">
{images.length} asset{images.length !== 1 ? 's' : ''} uploaded
</p>
)}
</div>
{/* Right: Edit Fields */}
<div className="col-span-3 p-6 space-y-5">
{/* SKU */}
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
<Hash className="inline w-3 h-3 mr-1" />SKU Code
</label>
{readOnly ? (
<p className="font-mono text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">{sku || '—'}</p>
) : (
<input
type="text"
value={sku}
onChange={e => setSku(e.target.value)}
placeholder="e.g. PROD-RED-M"
className="w-full border border-border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
/>
)}
</div>
{/* Price & Cost */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
<DollarSign className="inline w-3 h-3 mr-1" />Sale Price
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
<DollarSign className="inline w-3.5 h-3.5 mr-1" />Sale Price
</label>
{readOnly ? (
<p className="text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">${price}</p>
@@ -228,14 +356,15 @@ export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
min="0"
value={price}
onChange={e => setPrice(e.target.value)}
className="w-full border border-border rounded-lg pl-7 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
className="w-full border border-border rounded-lg pl-7 pr-3 py-2 text-sm font-bold focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
/>
</div>
)}
</div>
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
<DollarSign className="inline w-3 h-3 mr-1" />Cost Price
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
<DollarSign className="inline w-3.5 h-3.5 mr-1" />Cost Price
</label>
{readOnly ? (
<p className="text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">${costPrice}</p>
@@ -253,30 +382,11 @@ export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
</div>
)}
</div>
</div>
{/* Stock & Status */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
<ShoppingBag className="inline w-3 h-3 mr-1" />Stock
</label>
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">Lifecycle Status</label>
{readOnly ? (
<p className="text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">{stock}</p>
) : (
<input
type="number"
min="0"
value={stock}
onChange={e => setStock(e.target.value)}
className="w-full border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
/>
)}
</div>
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">Status</label>
{readOnly ? (
<p className={`inline-flex items-center px-2.5 py-1.5 rounded-lg text-xs font-bold uppercase border ${statusColor[status]}`}>{status}</p>
<p className={`inline-flex items-center px-3 py-2 rounded-lg text-xs font-bold uppercase border ${statusColor[status]}`}>{status}</p>
) : (
<select
value={status}
@@ -292,28 +402,243 @@ export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
</div>
</div>
{/* Stats row */}
<div className="grid grid-cols-3 gap-3">
{[
{ label: 'Available Stock', value: variant.availableStock ?? 0 },
{ label: 'Reserved', value: variant.reservedStock ?? 0 },
{ label: 'Safety Stock', value: variant.safetyStock ?? 0 },
].map(({ label, value }) => (
<div key={label} className="bg-surface-muted border border-border rounded-lg p-3 text-center">
<div className="text-lg font-bold text-foreground">{value}</div>
<div className="text-[10px] text-muted-foreground font-medium mt-0.5">{label}</div>
{/* Inventory Stock */}
<div className="border border-border rounded-xl p-4 bg-background/50 space-y-3">
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
<ShoppingBag className="w-4 h-4 text-primary" /> Inventory & Stock Control
</h4>
<div className="grid grid-cols-4 gap-3">
<div>
<label className="block text-[10px] font-bold text-muted-foreground uppercase mb-1">Total Stock</label>
{readOnly ? (
<p className="text-sm font-bold text-foreground">{stock}</p>
) : (
<input
type="number"
min="0"
value={stock}
onChange={e => setStock(e.target.value)}
className="w-full border border-border rounded-lg px-2.5 py-1.5 text-sm font-bold bg-surface text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
/>
)}
</div>
))}
<div className="bg-surface p-2.5 rounded-lg border border-border text-center">
<div className="text-sm font-bold text-emerald-600">{variant.availableStock ?? stock}</div>
<div className="text-[10px] text-muted-foreground uppercase font-semibold">Available</div>
</div>
<div className="bg-surface p-2.5 rounded-lg border border-border text-center">
<div className="text-sm font-bold text-amber-600">{variant.reservedStock ?? 0}</div>
<div className="text-[10px] text-muted-foreground uppercase font-semibold">Reserved</div>
</div>
<div className="bg-surface p-2.5 rounded-lg border border-border text-center">
<div className="text-sm font-bold text-slate-600">{variant.safetyStock ?? 0}</div>
<div className="text-[10px] text-muted-foreground uppercase font-semibold">Safety Stock</div>
</div>
</div>
</div>
{/* Last updated */}
{variant.lastUpdated && (
<p className="text-[10px] text-muted-foreground">
Last updated: {new Date(variant.lastUpdated).toLocaleString()}
</p>
{/* Variant Attribute Axes Pills */}
{axisEntries.length > 0 && (
<div className="border border-border rounded-xl p-4 bg-surface space-y-2">
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
<Tag className="w-4 h-4 text-primary" /> Configured Variant Attributes
</h4>
<div className="flex flex-wrap gap-2 pt-1">
{axisEntries.map(([key, val]) => (
<div
key={key}
className="flex items-center gap-2 px-3 py-1.5 bg-primary/10 border border-primary/20 rounded-lg text-xs font-semibold text-primary"
>
<span className="text-muted-foreground font-normal capitalize">{key}:</span>
<span className="font-bold">{val}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* ──── Tab 2: Media Assets ──── */}
{activeTab === 'media' && (
<div className="space-y-6">
{/* Controls bar */}
{!readOnly && (
<div className="flex flex-wrap items-center justify-between gap-3 bg-surface border border-border rounded-xl p-4">
<div>
<h4 className="text-xs font-bold text-foreground uppercase tracking-wider">Variant Image Gallery</h4>
<p className="text-[11px] text-muted-foreground mt-0.5">Upload images or select existing assets from PIM library.</p>
</div>
<div className="flex items-center gap-2">
<input
type="file"
ref={fileInputRef}
onChange={e => handleFileUpload(e.target.files)}
multiple
accept="image/*"
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingMedia}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold transition-all shadow-xs disabled:opacity-60"
>
{uploadingMedia ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
{uploadingMedia ? 'Uploading...' : 'Upload Image'}
</button>
<button
type="button"
onClick={openAssetPicker}
className="inline-flex items-center gap-1.5 px-3 py-1.5 border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold transition-all"
>
<Plus className="w-3.5 h-3.5" />
Pick from PIM Library
</button>
</div>
</div>
)}
{/* Asset grid */}
{loadingAssets ? (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 text-primary animate-spin" />
</div>
) : variantAssets.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 bg-surface border border-dashed border-border rounded-xl text-center">
<ImageIcon className="w-12 h-12 text-muted-foreground/30 mb-2" />
<p className="text-sm font-semibold text-foreground">No images assigned to this variant</p>
<p className="text-xs text-muted-foreground max-w-sm mt-1">Upload product photos directly or select existing assets to showcase this variant.</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{variantAssets.map((item, idx) => {
const rawAsset = item.asset || item;
const url = rawAsset.file_url || rawAsset.url || rawAsset.thumbnailUrl;
const isPrimary = item.is_primary || item.isPrimary || idx === 0;
const assetId = rawAsset.id || item.asset_id || item.assetId;
return (
<div
key={idx}
className={`group relative flex flex-col bg-surface border rounded-xl overflow-hidden shadow-xs transition-all ${
isPrimary ? 'border-primary ring-2 ring-primary/20' : 'border-border'
}`}
>
{/* Primary Badge */}
{isPrimary && (
<span className="absolute top-2 left-2 z-10 inline-flex items-center gap-1 bg-primary text-white text-[9px] font-bold px-2 py-0.5 rounded-md shadow-xs">
<Star className="w-2.5 h-2.5 fill-current" /> Primary
</span>
)}
<div className="aspect-square bg-background overflow-hidden relative">
{url ? (
<img src={url} alt={rawAsset.name || `Image ${idx + 1}`} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
<ImageIcon className="w-8 h-8 opacity-30" />
</div>
)}
</div>
{!readOnly && (
<div className="p-2 bg-surface border-t border-border flex items-center justify-between gap-1">
{!isPrimary ? (
<button
type="button"
onClick={() => handleSetPrimaryAsset(assetId)}
className="text-[10px] text-primary hover:underline font-semibold"
>
Set Primary
</button>
) : (
<span className="text-[10px] text-emerald-600 font-bold">Main Image</span>
)}
<button
type="button"
onClick={() => handleUnassignAsset(assetId)}
className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors"
title="Remove image"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
{/* ──── Tab 3: Channels & Syndication ──── */}
{activeTab === 'channels' && (
<div className="space-y-6">
<div className="border border-border rounded-xl p-4 bg-surface space-y-1">
<h4 className="text-xs font-bold text-foreground uppercase tracking-wider">Channel Integration & Syndication</h4>
<p className="text-[11px] text-muted-foreground">Status of this variant across your connected sales channels and marketplaces.</p>
</div>
{loadingIntegrations ? (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 text-primary animate-spin" />
</div>
) : integrations.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 bg-surface border border-dashed border-border rounded-xl text-center">
<Globe className="w-12 h-12 text-muted-foreground/30 mb-2" />
<p className="text-sm font-semibold text-foreground">No channels connected yet</p>
<p className="text-xs text-muted-foreground max-w-sm mt-1">Connect Shopify, Amazon, or Google Merchant in the Integration Hub to publish variants.</p>
</div>
) : (
<div className="space-y-3">
{integrations.map(integ => {
const provider = integ.integration_type || integ.type || integ.name || 'Channel';
return (
<div key={integ.id} className="flex items-center justify-between p-4 bg-surface border border-border rounded-xl">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 border border-primary/20 flex items-center justify-center text-primary font-bold uppercase text-xs">
{String(provider).slice(0, 2)}
</div>
<div>
<div className="flex items-center gap-2">
<h5 className="font-bold text-foreground text-sm">{integ.name}</h5>
<span className="text-[10px] bg-emerald-100 text-emerald-700 font-bold px-2 py-0.5 rounded-full border border-emerald-200 uppercase">
{integ.status}
</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
Channel Type: <span className="capitalize font-medium text-foreground">{provider}</span>
</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className="inline-flex items-center gap-1.5 text-xs text-emerald-600 font-semibold bg-emerald-50 border border-emerald-200 px-3 py-1.5 rounded-lg">
<CheckCircle className="w-3.5 h-3.5" /> Published & Live
</span>
{String(provider).toLowerCase().includes('shopify') && (
<a
href="https://maskcomerce.myshopify.com/admin/products"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 px-3 py-1.5 border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold transition-all"
>
<ExternalLink className="w-3.5 h-3.5 text-muted-foreground" /> View in Shopify
</a>
)}
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
{/* ── Footer ── */}
@@ -327,17 +652,17 @@ export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
className="flex items-center gap-1.5 px-3 py-1.5 border border-amber-200 text-amber-700 bg-amber-50 hover:bg-amber-100 rounded-lg text-xs font-semibold transition-colors"
>
<Archive className="w-3.5 h-3.5" />
Archive
Archive Variant
</button>
)}
{!readOnly && onDelete && (
<button
type="button"
onClick={() => { if (window.confirm('Delete this variant?')) { onDelete(variant.id); onClose(); } }}
onClick={() => { if (window.confirm('Are you sure you want to delete this variant permanently?')) { onDelete(variant.id); onClose(); } }}
className="flex items-center gap-1.5 px-3 py-1.5 border border-red-200 text-red-600 bg-red-50 hover:bg-red-100 rounded-lg text-xs font-semibold transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
Delete
Delete Variant
</button>
)}
</div>
@@ -347,7 +672,7 @@ export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
<button
type="button"
onClick={onClose}
className="px-4 py-2 border border-border text-muted-foreground hover:bg-background rounded-lg text-sm font-medium transition-colors"
className="px-4 py-2 border border-border text-muted-foreground hover:bg-background rounded-lg text-xs font-semibold transition-colors"
>
{readOnly ? 'Close' : 'Cancel'}
</button>
@@ -356,7 +681,7 @@ export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
type="button"
onClick={handleSave}
disabled={saveState === 'saving'}
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold transition-colors disabled:opacity-60"
className="flex items-center gap-2 px-5 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold transition-colors disabled:opacity-60 shadow-xs"
>
{saveState === 'saving' && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{saveState === 'saved' && <CheckCircle className="w-3.5 h-3.5 text-white" />}
@@ -368,6 +693,49 @@ export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
</div>
</div>
</div>
{/* PIM Asset Library Picker Sub-Modal */}
{showAssetPicker && (
<div className="fixed inset-0 z-60 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs">
<div className="bg-surface border border-border rounded-2xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-bold text-foreground text-sm">Select Asset from PIM Library</h3>
<button type="button" onClick={() => setShowAssetPicker(false)} className="p-1 hover:bg-background rounded">
<X className="w-4 h-4 text-muted-foreground" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{loadingLibrary ? (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 text-primary animate-spin" />
</div>
) : libraryAssets.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-8">No assets found in PIM library.</p>
) : (
<div className="grid grid-cols-4 gap-3">
{libraryAssets.map(ast => (
<div
key={ast.id}
onClick={() => { handleAssignLibraryAsset(ast.id); setShowAssetPicker(false); }}
className="group cursor-pointer border border-border hover:border-primary rounded-xl overflow-hidden bg-background p-1.5 transition-all text-center"
>
<div className="aspect-square bg-surface rounded-lg overflow-hidden mb-1 flex items-center justify-center">
{ast.file_url ? (
<img src={ast.file_url} alt={ast.name} className="w-full h-full object-cover" />
) : (
<ImageIcon className="w-6 h-6 text-muted-foreground" />
)}
</div>
<span className="block text-[10px] font-semibold text-foreground truncate" title={ast.name}>{ast.name}</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};