Compare commits
2
Commits
dev
...
fardeen-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19ffa4f978 | ||
|
|
d7b5093693 |
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user