resolved the variant and the assets issue

This commit is contained in:
Mohammed-Fardeen-02
2026-08-29 15:30:58 +05:30
parent 06bafc288c
commit ed816920e2
7 changed files with 307 additions and 127 deletions
+24 -10
View File
@@ -86,54 +86,68 @@ export const assetsApi = {
// Product Assets Assignment
getProductAssets: async (productId: string): Promise<AssetMapping[]> => {
const res = await apiClient.get<ApiResponse<AssetMapping[]>>(`/api/v1/products/${productId}/assets`);
return res.data || [];
const raw: any = res.data;
return (Array.isArray(raw) ? raw : raw?.data) || [];
},
getAllVariantAssets: async (productId: string): Promise<any[]> => {
const res = await apiClient.get<ApiResponse<any[]>>(`/api/v1/products/${productId}/all-variant-assets`);
const raw: any = res.data;
return (Array.isArray(raw) ? raw : raw?.data) || [];
},
assignProductAsset: async (productId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
const res = await apiClient.post<ApiResponse<AssetMapping>>(`/api/v1/products/${productId}/assets`, body);
return res.data;
const raw: any = res.data;
return raw?.data ? raw.data : raw;
},
updateProductAsset: async (productId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
const res = await apiClient.put<ApiResponse<AssetMapping>>(`/api/v1/products/${productId}/assets/${assetId}`, body);
return res.data;
const raw: any = res.data;
return raw?.data ? raw.data : raw;
},
unassignProductAsset: async (productId: string, assetId: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/products/${productId}/assets/${assetId}`);
return res.success;
return res.data?.success ?? true;
},
bulkAssignVariantAsset: async (productId: string, body: { asset_id: string; role: string; variant_ids: string[]; is_primary?: boolean }): Promise<any[]> => {
const res = await apiClient.post<ApiResponse<any[]>>(`/api/v1/products/${productId}/assets/bulk-assign`, body);
return res.data || [];
const raw: any = res.data;
return (Array.isArray(raw) ? raw : raw?.data) || [];
},
// Variant Assets Assignment
getVariantAssets: async (variantId: string): Promise<AssetMapping[]> => {
const res = await apiClient.get<ApiResponse<AssetMapping[]>>(`/api/v1/variants/${variantId}/assets`);
return res.data || [];
const raw: any = res.data;
return (Array.isArray(raw) ? raw : raw?.data) || [];
},
assignVariantAsset: async (variantId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
const res = await apiClient.post<ApiResponse<AssetMapping>>(`/api/v1/variants/${variantId}/assets`, body);
return res.data;
const raw: any = res.data;
return raw?.data ? raw.data : raw;
},
updateVariantAsset: async (variantId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
const res = await apiClient.put<ApiResponse<AssetMapping>>(`/api/v1/variants/${variantId}/assets/${assetId}`, body);
return res.data;
const raw: any = res.data;
return raw?.data ? raw.data : raw;
},
unassignVariantAsset: async (variantId: string, assetId: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/variants/${variantId}/assets/${assetId}`);
return res.success;
return res.data?.success ?? true;
},
// Product variants list for the variant select dropdown
getProductVariants: async (productId: string): Promise<any[]> => {
const res = await apiClient.get<ApiResponse<any[]>>('/api/v1/variants', { params: { parentProductId: productId } });
return res.data || [];
const raw: any = res.data;
return (Array.isArray(raw) ? raw : raw?.data) || [];
}
};
@@ -49,6 +49,7 @@ export const assetsService = {
getFolders: () => assetsApi.getFolders(),
getTags: () => assetsApi.getTags(),
getProductAssets: (productId: string) => assetsApi.getProductAssets(productId),
getAllVariantAssets: (productId: string) => assetsApi.getAllVariantAssets(productId),
assignProductAsset: (productId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }) => assetsApi.assignProductAsset(productId, body),
updateProductAsset: (productId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }) => assetsApi.updateProductAsset(productId, assetId, body),
unassignProductAsset: (productId: string, assetId: string) => assetsApi.unassignProductAsset(productId, assetId),
@@ -11,6 +11,7 @@ import { toast } from 'react-toastify';
import { Loader } from '../../../components/customs/Loader';
import { getAssetUrl, isImageFile } from '../../../lib/utils';
import { Select } from '../../../components/customs/Select';
import { variantService } from '../services/variant.service';
interface ProductAssetsTabProps {
productId?: string;
@@ -35,14 +36,27 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
const variantFilterOptions = useMemo(() => {
const map = new Map<string, { name: string; values: Set<string> }>();
variants.forEach(v => {
(v.values || []).forEach((val: any) => {
if (val.axis) {
if (!map.has(val.axis.code)) {
map.set(val.axis.code, { name: val.axis.name || val.axis.code, values: new Set() });
if (v.attributes && typeof v.attributes === 'object') {
Object.entries(v.attributes).forEach(([code, val]) => {
if (val !== undefined && val !== null && val !== '') {
if (!map.has(code)) {
map.set(code, { name: code.toUpperCase().replace(/_/g, ' '), values: new Set() });
}
map.get(code)?.values.add(String(val));
}
map.get(val.axis.code)?.values.add(val.value_text);
}
});
});
}
if (Array.isArray(v.values)) {
v.values.forEach((val: any) => {
if (val.axis) {
const code = val.axis.code;
if (!map.has(code)) {
map.set(code, { name: val.axis.name || code, values: new Set() });
}
map.get(code)?.values.add(String(val.value_text));
}
});
}
});
const result: { code: string; name: string; values: string[] }[] = [];
map.forEach((data, code) => {
@@ -95,7 +109,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
}
const matchedFamily = allAssetFamilies.find(af => af.id === selectedAssetFamilyId);
if (!matchedFamily) {
return [];
return allAssetTypes;
}
const allowedTypeIds =
Array.isArray(matchedFamily.assetTypeIds) && matchedFamily.assetTypeIds.length > 0
@@ -104,22 +118,22 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
.map((at: any) => at.id || at.assetTypeId)
.filter(Boolean);
return allAssetTypes.filter(at => allowedTypeIds.includes(at.id));
const filtered = allAssetTypes.filter(at => allowedTypeIds.includes(at.id));
return filtered.length > 0 ? filtered : allAssetTypes;
}, [allAssetTypes, allAssetFamilies, selectedAssetFamilyId]);
// Reset selected asset type if it is no longer allowed by the newly selected family
// Auto-select initial asset type if none selected or if selected is no longer allowed
useEffect(() => {
if (selectedAssetTypeId && selectedAssetFamilyId) {
const isStillAllowed = filteredAssetTypes.some(at => at.id === selectedAssetTypeId);
if (!isStillAllowed) {
setSelectedAssetTypeId('');
if (filteredAssetTypes.length > 0) {
if (!selectedAssetTypeId || !filteredAssetTypes.some(at => at.id === selectedAssetTypeId)) {
setSelectedAssetTypeId(filteredAssetTypes[0].id);
}
}
}, [selectedAssetFamilyId, filteredAssetTypes, selectedAssetTypeId]);
}, [filteredAssetTypes, selectedAssetTypeId]);
// Resolve selected asset type details
const selectedAssetType = useMemo(() => {
return allAssetTypes.find(at => at.id === selectedAssetTypeId);
return allAssetTypes.find(at => at.id === selectedAssetTypeId) || allAssetTypes[0] || null;
}, [allAssetTypes, selectedAssetTypeId]);
// Reset selected variant target if the asset type does not support variants
@@ -150,23 +164,50 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
const combinedAssets = useMemo(() => {
const list: any[] = [];
const validVariantIds = new Set(variants.map(v => v.id));
assignedAssets.forEach(a => {
list.push({
...a,
isVariant: false,
variantId: undefined,
scopeLabel: 'Global'
});
const aProdId = a.product_id || (a as any).productId;
if (!aProdId || aProdId === productId) {
list.push({
...a,
isVariant: false,
variantId: undefined,
scopeLabel: 'Global'
});
}
});
assignedVariantAssets.forEach(va => {
list.push({
...va,
isVariant: true,
scopeLabel: `Variant: ${va.variantName.split(' - ')[1] || va.variantName} (${va.variantSku || 'No SKU'})`
});
const vId = va.variantId || va.variant_id || va.variant?.id;
if (vId && (validVariantIds.size === 0 || validVariantIds.has(vId))) {
const matchedVariant = variants.find(v => v.id === vId);
const vName = va.variantName || va.variant?.name || matchedVariant?.name || 'Variant';
const vSku = va.variantSku || va.variant?.sku || matchedVariant?.sku || '';
list.push({
...va,
variantId: vId,
variant_id: vId,
isVariant: true,
scopeLabel: `Variant: ${vName.split(' - ')[1] || vName} (${vSku || 'No SKU'})`
});
}
});
return list;
}, [assignedAssets, assignedVariantAssets]);
}, [assignedAssets, assignedVariantAssets, variants, productId]);
const [scopeFilter, setScopeFilter] = useState<string>('all');
const displayedAssets = useMemo(() => {
if (scopeFilter === 'global') {
return combinedAssets.filter(a => !a.isVariant);
}
if (scopeFilter !== 'all') {
return combinedAssets.filter(a => a.isVariant && a.variantId === scopeFilter);
}
return combinedAssets;
}, [combinedAssets, scopeFilter]);
const isAssetTypeRequiredByFamily = (code: string) => {
const matchingType = allAssetTypes.find(at => at.code === code);
@@ -176,36 +217,26 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
// Load product assets
const loadProductAssets = async () => {
if (!productId) return;
if (!productId || productId === 'new' || productId === 'null' || productId === 'undefined') {
setAssignedAssets([]);
setVariants([]);
setAssignedVariantAssets([]);
return;
}
setLoading(true);
try {
const data = await assetsService.getProductAssets(productId);
const [data, productVariants, allVariantAssets] = await Promise.all([
assetsService.getProductAssets(productId),
variantService.getByProduct(productId),
assetsService.getAllVariantAssets(productId)
]);
const sorted = [...data].sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
setAssignedAssets(sorted);
const productVariants = await assetsService.getProductVariants(productId);
setVariants(productVariants || []);
if (productVariants && productVariants.length > 0) {
const variantAssetsPromises = productVariants.map(async (v: any) => {
try {
const vAssets = await assetsService.getVariantAssets(v.id);
return vAssets.map((va: any) => ({
...va,
variantId: v.id,
variantName: v.name,
variantSku: v.sku
}));
} catch (err) {
console.error(`Failed to load assets for variant ${v.id}`, err);
return [];
}
});
const allVariantAssets = (await Promise.all(variantAssetsPromises)).flat();
setAssignedVariantAssets(allVariantAssets);
} else {
setAssignedVariantAssets([]);
}
const rawVars = Array.isArray(productVariants) ? productVariants : (productVariants as any)?.data || [];
setVariants(Array.isArray(rawVars) ? rawVars : []);
const rawVarAssets = Array.isArray(allVariantAssets) ? allVariantAssets : (allVariantAssets as any)?.data || [];
setAssignedVariantAssets(Array.isArray(rawVarAssets) ? rawVarAssets : []);
} catch (err: any) {
toast.error(err?.message || 'Failed to load assets');
} finally {
@@ -214,6 +245,10 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
};
useEffect(() => {
setSelectedVariantId('global');
setSelectedVariantIds([]);
setIsBulkMode(false);
setScopeFilter('all');
loadProductAssets();
}, [productId]);
@@ -512,9 +547,14 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
const filteredLibrary = libraryAssets.filter(asset =>
asset.name.toLowerCase().includes(pickerSearch.toLowerCase())
);
const filteredLibrary = libraryAssets.filter(asset => {
const matchesSearch = asset.name.toLowerCase().includes(pickerSearch.toLowerCase());
if (!matchesSearch) return false;
if (selectedAssetTypeId && asset.asset_type_id) {
if (asset.asset_type_id !== selectedAssetTypeId) return false;
}
return true;
});
return (
<div className="space-y-6">
@@ -595,7 +635,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
</div>
{/* Variant Target Selector */}
{selectedAssetType && (selectedAssetType.is_variant_eligible || selectedAssetType.isVariantEligible) && variants.length > 0 && (
{variants.length > 0 && (
<div className="flex-1 min-w-[240px]">
<label className="text-xs font-bold text-foreground block mb-2 flex items-center justify-between">
<span>Variant Target Scope</span>
@@ -637,7 +677,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
</div>
{/* Bulk Selection and Attribute Filters Panel */}
{isBulkMode && selectedAssetType && (selectedAssetType.is_variant_eligible || selectedAssetType.isVariantEligible) && (
{isBulkMode && (
<div className="bg-background border border-border rounded-lg p-4 space-y-3 animate-fade-in">
<div className="flex justify-between items-center border-b border-border pb-2">
<span className="text-xs font-bold text-foreground">Bulk Variant Target Selection</span>
@@ -670,7 +710,11 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
<div className="flex gap-1.5 flex-wrap">
{option.values.map(val => {
const matchingIds = variants
.filter(v => (v.values || []).some((av: any) => av.axis?.code === option.code && av.value_text === val))
.filter(v => {
if (v.attributes && String(v.attributes[option.code]) === val) return true;
if (Array.isArray(v.values) && v.values.some((av: any) => av.axis?.code === option.code && String(av.value_text) === val)) return true;
return false;
})
.map(v => v.id);
const isAllSelected = matchingIds.every(id => selectedVariantIds.includes(id));
@@ -888,6 +932,63 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
</div>
) : combinedAssets.length > 0 ? (
<div className="bg-surface rounded-xl border border-border shadow-xs overflow-hidden">
{/* Scope Filter Toolbar */}
<div className="flex items-center justify-between px-4 py-2.5 bg-background/50 border-b border-border text-xs">
<div className="flex items-center gap-2 overflow-x-auto">
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider shrink-0 mr-1">Filter Scope:</span>
<button
type="button"
onClick={() => setScopeFilter('all')}
className={`px-2.5 py-1 rounded-full font-semibold transition-colors shrink-0 ${
scopeFilter === 'all'
? 'bg-primary text-white'
: 'bg-surface hover:bg-background border border-border text-muted-foreground'
}`}
>
All Assets ({combinedAssets.length})
</button>
<button
type="button"
onClick={() => setScopeFilter('global')}
className={`px-2.5 py-1 rounded-full font-semibold transition-colors shrink-0 ${
scopeFilter === 'global'
? 'bg-primary text-white'
: 'bg-surface hover:bg-background border border-border text-muted-foreground'
}`}
>
Global ({assignedAssets.length})
</button>
{variants.map(v => {
const count = assignedVariantAssets.filter(va => va.variantId === v.id).length;
if (count === 0) return null;
const isSelected = scopeFilter === v.id;
return (
<button
key={v.id}
type="button"
onClick={() => setScopeFilter(v.id)}
className={`px-2.5 py-1 rounded-full font-semibold transition-colors shrink-0 ${
isSelected
? 'bg-primary text-white'
: 'bg-surface hover:bg-background border border-border text-muted-foreground'
}`}
>
Variant: {v.name.split(' - ')[1] || v.name} ({count})
</button>
);
})}
</div>
{scopeFilter !== 'all' && (
<button
type="button"
onClick={() => setScopeFilter('all')}
className="text-[10px] text-muted-foreground hover:text-foreground font-semibold shrink-0"
>
Reset Filter
</button>
)}
</div>
<table className="w-full border-collapse text-left text-xs text-foreground">
<thead>
<tr className="bg-background border-b border-border text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
@@ -899,7 +1000,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
</tr>
</thead>
<tbody className="divide-y divide-border">
{combinedAssets.map((mapping, idx) => {
{displayedAssets.map((mapping, idx) => {
const asset = mapping.asset;
if (!asset) return null;
@@ -1062,7 +1163,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
</div>
) : filteredLibrary.length > 0 ? (
filteredLibrary.map(asset => {
const isAssigned = assignedAssets.some(a => a.asset_id === asset.id);
const isAssigned = assignedAssets.some(a => a.asset_id === asset.id) || assignedVariantAssets.some(va => va.asset_id === asset.id);
const isImage = isImageFile(asset.mime_type, asset.file_url);
const isVideo = asset.mime_type?.startsWith('video/');
@@ -123,9 +123,35 @@ export const VariantAxesSelector: React.FC<VariantAxesSelectorProps> = ({
return (
<div key={axis.id} className="space-y-2">
<label className="block text-xs font-bold text-foreground uppercase tracking-wider">
{axis.name} <span className="text-muted-foreground">({axis.code})</span>
</label>
<div className="flex items-center justify-between">
<label className="block text-xs font-bold text-foreground uppercase tracking-wider">
{axis.name} <span className="text-muted-foreground font-mono text-[10px]">({axis.code})</span>
{selected.length > 0 && (
<span className="ml-2 text-[10px] text-primary font-semibold bg-primary/10 px-2 py-0.5 rounded-full border border-primary/20">
{selected.length} selected
</span>
)}
</label>
{options.length > 0 && (
<div className="flex items-center gap-2 text-[11px]">
<button
type="button"
onClick={() => setSelectedValues(prev => ({ ...prev, [axis.code]: options.map(o => o.code) }))}
className="text-primary font-medium hover:underline"
>
Select All
</button>
<span className="text-muted-foreground/40"></span>
<button
type="button"
onClick={() => setSelectedValues(prev => ({ ...prev, [axis.code]: [] }))}
className="text-muted-foreground hover:text-foreground font-medium transition-colors"
>
Clear
</button>
</div>
)}
</div>
{options.length > 0 ? (
// Pre-defined options list checkbox layout
@@ -99,8 +99,8 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
<td className="px-4 py-3 font-mono text-xs text-foreground">{sku}</td>
<td className="px-4 py-3">
<div className="flex flex-col">
<span className="font-semibold text-foreground text-xs truncate max-w-[200px]" title={variant.name}>
{variant.name.split(' - ')[1] || variant.name}
<span className="font-semibold text-foreground text-xs truncate max-w-[200px]" title={variant.name || ''}>
{(variant.name || '').split(' - ')[1] || variant.name || variant.sku}
</span>
<div className="flex flex-wrap gap-1 mt-1">
{axesKeys.map(key => {
@@ -180,8 +180,8 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
{/* Variant Specification */}
<td className="px-4 py-3">
<div className="flex flex-col">
<span className="font-semibold text-foreground text-xs truncate max-w-[200px]" title={variant.name}>
{variant.name.split(' - ')[1] || variant.name}
<span className="font-semibold text-foreground text-xs truncate max-w-[200px]" title={variant.name || ''}>
{(variant.name || '').split(' - ')[1] || variant.name || variant.sku}
</span>
<div className="flex flex-wrap gap-1 mt-1">
{axesKeys.map(key => {
@@ -69,55 +69,66 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
}
}, [productId, fetchByProduct]);
// Resolve and sync axes definitions (from family first, then reconstructed from variants, then auto-detected from productAttributes)
// Resolve relevant variant axes for this product (prioritized hierarchy: family axes -> existing variants -> product attributes with values)
useEffect(() => {
const axesMap = new Map<string, VariantAxis>();
// Priority 1: Family blueprint variant axes (if explicitly configured)
if (family && Array.isArray(family.variantAxes) && family.variantAxes.length > 0) {
setLocalAxes(family.variantAxes);
} else if (variants.length > 0) {
const first = variants[0];
if (first && first.attributes) {
const reconstructed = Object.keys(first.attributes).map(key => {
const exists = localAxes.find(la => la.code === key);
if (exists) return exists;
const foundAttr = selectableAttributes.find(a => a.code === key);
return {
id: foundAttr?.id || key,
code: key,
name: foundAttr?.name || key.toUpperCase().replace(/_VARIANT/g, '').replace(/_/g, ' '),
type: foundAttr?.type || 'select',
optionsList: foundAttr?.optionsList || []
};
});
const currentKeys = localAxes.map(la => la.code).sort().join(',');
const newKeys = reconstructed.map(r => r.code).sort().join(',');
if (currentKeys !== newKeys) {
setLocalAxes(reconstructed as VariantAxis[]);
}
}
} else if (selectableAttributes.length > 0 && productAttributes) {
// Auto-detect variant axes from available attributes that have values selected
const detectedAxes: VariantAxis[] = [];
selectableAttributes.forEach(attr => {
const val = productAttributes[attr.code];
if (val !== undefined && val !== null && val !== '' && !(Array.isArray(val) && val.length === 0)) {
detectedAxes.push({
...attr,
id: attr.id,
code: attr.code,
name: attr.name,
type: attr.type || 'select',
optionsList: attr.optionsList || []
family.variantAxes.forEach((fa: any) => axesMap.set(fa.code, fa));
}
// Priority 2: Existing variants' actual attribute keys
if (variants.length > 0) {
variants.forEach(v => {
if (v.attributes) {
Object.keys(v.attributes).forEach(key => {
if (!axesMap.has(key)) {
const foundAttr = selectableAttributes.find(a => a.code === key);
axesMap.set(key, {
id: foundAttr?.id || key,
code: key,
name: foundAttr?.name || key.toUpperCase().replace(/_VARIANT/g, '').replace(/_/g, ' '),
type: foundAttr?.type || 'select',
optionsList: foundAttr?.optionsList || []
});
}
});
}
});
}
if (detectedAxes.length > 0) {
const currentKeys = localAxes.map(la => la.code).sort().join(',');
const newKeys = detectedAxes.map(d => d.code).sort().join(',');
if (currentKeys !== newKeys) {
setLocalAxes(detectedAxes);
// Priority 3: Only if no axes found yet, look at product attributes that have values set
if (axesMap.size === 0 && productAttributes && selectableAttributes.length > 0) {
selectableAttributes.forEach(attr => {
const val = productAttributes[attr.code];
if (val !== undefined && val !== null && val !== '' && !(Array.isArray(val) && val.length === 0)) {
if (!axesMap.has(attr.code)) {
axesMap.set(attr.code, {
...attr,
id: attr.id,
code: attr.code,
name: attr.name,
type: attr.type || 'select',
optionsList: attr.optionsList || []
});
}
}
});
}
// Preserve manually added local axes
localAxes.forEach(la => {
if (!axesMap.has(la.code)) {
axesMap.set(la.code, la);
}
});
const resolved = Array.from(axesMap.values());
const currentKeys = localAxes.map(la => la.code).sort().join(',');
const newKeys = resolved.map(r => r.code).sort().join(',');
if (currentKeys !== newKeys && resolved.length > 0) {
setLocalAxes(resolved);
}
}, [family, variants, selectableAttributes, productAttributes]);
@@ -200,11 +211,34 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
return map;
}, [localAxes, productAttributes]);
const axesKeys = useMemo(() => localAxes.map(a => a.code), [localAxes]);
// Filter out unconfigured simple master variants (0 attributes) and ensure strict parent productId matching
const configuredVariants = useMemo(() => {
if (!Array.isArray(variants) || !productId) return [];
return variants.filter(v => {
if (!v) return false;
const vParentId = v.parentProductId || (v as any).product_id || (v as any).productId;
if (vParentId && vParentId !== productId) return false;
return v.attributes && typeof v.attributes === 'object' && Object.keys(v.attributes).length > 0;
});
}, [variants, productId]);
// Derive axesKeys dynamically from actual configured variants if present, or fallback to localAxes
const axesKeys = useMemo(() => {
const keysSet = new Set<string>();
configuredVariants.forEach(v => {
if (v.attributes) {
Object.keys(v.attributes).forEach(k => keysSet.add(k));
}
});
if (keysSet.size > 0) {
return Array.from(keysSet);
}
return (localAxes || []).map(a => a.code);
}, [configuredVariants, localAxes]);
const axesNames = useMemo(() => {
const map: Record<string, string> = {};
localAxes.forEach(a => {
(localAxes || []).forEach(a => {
map[a.code] = a.name;
});
return map;
@@ -433,13 +467,13 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
);
}
// Show generator UI if variants list is empty or generator toggled on
if (variants.length === 0 || showGenerator) {
// Show generator UI if configured variants list is empty or generator toggled on
if (configuredVariants.length === 0 || showGenerator) {
return (
<div className="space-y-4">
<div className="flex justify-between items-center gap-4 flex-wrap">
<div className="flex gap-2">
{variants.length > 0 && (
{configuredVariants.length > 0 && (
<button
type="button"
onClick={() => setShowGenerator(false)}
@@ -542,7 +576,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
{/* Primary variants view switcher */}
{viewLayout === 'matrix' ? (
<VariantMatrixView
variants={variants}
variants={configuredVariants}
axesKeys={axesKeys}
axesNames={axesNames}
selectedIds={selectedIds}
@@ -555,7 +589,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
/>
) : (
<VariantListView
variants={variants}
variants={configuredVariants}
axesKeys={axesKeys}
selectedIds={selectedIds}
onSelectChange={handleSelectChange}
+4
View File
@@ -14,6 +14,10 @@ export const useVariant = () => {
const [generating, setGenerating] = useState(false);
const fetchByProduct = useCallback(async (productId: string) => {
if (!productId || productId === 'new' || productId === 'null' || productId === 'undefined') {
setVariants([]);
return;
}
setLoading(true);
try {
const data = await variantService.getByProduct(productId);