Merge branch 'dev' into mahir_frontend
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user