Merge pull request 'implemented product and variant creation' (#22) from fardeen-dev into dev

Reviewed-on: https://gitea.maskantech.in/gitea_admin/productcatalogue_frontend/pulls/22
This commit is contained in:
fardeen
2026-08-27 11:16:10 +00:00
25 changed files with 1786 additions and 546 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
import { useNavigate } from "react-router-dom";
import { Bell, ChevronDown, Building2, Globe, LogOut, Shield, User, Settings, CheckCircle2, ChevronRight, Menu, ShieldAlert, ArrowRight } from "lucide-react";
import { Bell, ChevronDown, Building2, Globe, LogOut, Shield, User, Settings, CheckCircle2, ChevronRight, Menu, ShieldAlert } from "lucide-react";
import { useLanguage, type Language } from "../../contexts/LanguageContext";
import { useHeader } from "../../contexts/HeaderContext";
import { useSidebar } from "../../contexts/SidebarContext";
+147 -13
View File
@@ -10,6 +10,7 @@ import { TextArea } from "../../../components/customs/TextArea";
import { Select } from "../../../components/customs/Select";
import { useAssetType } from "../hook/useAssetType";
import { assetTypeSchema } from "../validation/asset-types.schema";
import { notify } from "../../../services/toast";
import type { AssetTypeCreateRequest } from "../types/asset-types.types";
const CATEGORIES = [
@@ -99,6 +100,35 @@ const CATEGORIES = [
},
] as const;
const POPULAR_EXTENSIONS = [
// Images
{ ext: 'jpg', category: 'image', label: 'JPG' },
{ ext: 'jpeg', category: 'image', label: 'JPEG' },
{ ext: 'png', category: 'image', label: 'PNG' },
{ ext: 'webp', category: 'image', label: 'WEBP' },
{ ext: 'gif', category: 'image', label: 'GIF' },
{ ext: 'svg', category: 'image', label: 'SVG' },
// Videos
{ ext: 'mp4', category: 'video', label: 'MP4' },
{ ext: 'mov', category: 'video', label: 'MOV' },
{ ext: 'avi', category: 'video', label: 'AVI' },
{ ext: 'webm', category: 'video', label: 'WEBM' },
// Documents
{ ext: 'pdf', category: 'document', label: 'PDF' },
{ ext: 'doc', category: 'document', label: 'DOC' },
{ ext: 'docx', category: 'document', label: 'DOCX' },
{ ext: 'xls', category: 'document', label: 'XLS' },
{ ext: 'xlsx', category: 'document', label: 'XLSX' },
{ ext: 'ppt', category: 'document', label: 'PPT' },
{ ext: 'pptx', category: 'document', label: 'PPTX' },
{ ext: 'txt', category: 'document', label: 'TXT' },
// Other
{ ext: 'zip', category: 'other', label: 'ZIP' },
{ ext: 'rar', category: 'other', label: 'RAR' },
{ ext: 'csv', category: 'other', label: 'CSV' },
{ ext: 'json', category: 'other', label: 'JSON' },
];
const STEPS = [
{ id: 'basic', label: 'Basic Information', step: 1 },
{ id: 'category', label: 'Asset Category', step: 2 },
@@ -190,6 +220,36 @@ export default function NewAssetType() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id, items]);
// Sync validation errors and switch steps if submit is attempted with errors
useEffect(() => {
if (formik.submitCount > 0 && !formik.isSubmitting) {
const errors = formik.errors;
const errorKeys = Object.keys(errors);
if (errorKeys.length > 0) {
const messages: string[] = [];
if (errors.name) messages.push(errors.name);
if (errors.code) messages.push(errors.code);
if (errors.category) {
messages.push(errors.category);
setActiveStep('category');
} else if (errors.name || errors.code) {
setActiveStep('basic');
} else if (errors.validation) {
setActiveStep('validation');
const valErrors = errors.validation as any;
if (valErrors.maxFileSize) messages.push(valErrors.maxFileSize);
if (valErrors.allowedFileTypes) messages.push(valErrors.allowedFileTypes);
}
notify.error(`Please resolve validation errors: ${messages.join('; ')}`);
formik.setSubmitting(false);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formik.submitCount, formik.isSubmitting]);
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
formik.handleChange(e);
if (!isEdit && !formik.touched.code) {
@@ -199,8 +259,9 @@ export default function NewAssetType() {
};
const handleAddFileType = () => {
if (newFileType.trim() && !formik.values.validation.allowedFileTypes.includes(newFileType.trim().toLowerCase())) {
formik.setFieldValue('validation.allowedFileTypes', [...formik.values.validation.allowedFileTypes, newFileType.trim().toLowerCase()]);
const trimmed = newFileType.trim().toLowerCase().replace(/^\./, '');
if (trimmed && !formik.values.validation.allowedFileTypes.includes(trimmed)) {
formik.setFieldValue('validation.allowedFileTypes', [...formik.values.validation.allowedFileTypes, trimmed]);
setNewFileType('');
}
};
@@ -404,29 +465,92 @@ export default function NewAssetType() {
<div className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="Validation Rules" subtitle="Enforced when assets are uploaded" />
<div className="p-6 space-y-6">
{/* Selected formats badges list */}
<div>
<label className={labelClass}>Allowed File Types <span className="text-red-500">*</span></label>
<div className="min-h-[42px] p-2 border border-primary/10 rounded-lg mb-2 flex flex-wrap gap-2 bg-background">
<label className={labelClass}>Allowed File Types Summary <span className="text-red-500">*</span></label>
<div className="min-h-[42px] p-3 border border-primary/10 rounded-lg mb-4 flex flex-wrap gap-2 bg-background">
{formik.values.validation.allowedFileTypes.length === 0 ? (
<span className="text-sm text-muted-foreground py-1 px-2">No file types added yet</span>
<span className="text-xs text-muted-foreground py-1 px-1">No file types selected yet. Check the boxes below to allow extensions.</span>
) : (
formik.values.validation.allowedFileTypes.map(type => (
<span key={type} className="inline-flex items-center gap-1 px-2 py-1 bg-surface border border-border rounded text-xs font-medium text-foreground">
<span key={type} className="inline-flex items-center gap-1 px-2.5 py-1 bg-surface border border-border rounded-lg text-xs font-semibold text-foreground animate-fade-in shadow-2xs">
.{type}
<button type="button" onClick={() => removeFileType(type)} className="text-muted-foreground hover:text-red-500"><X className="w-3 h-3" /></button>
<button type="button" onClick={() => removeFileType(type)} className="text-muted-foreground hover:text-red-500 ml-1 transition-colors"><X className="w-3 h-3" /></button>
</span>
))
)}
</div>
<div className="flex gap-2">
</div>
{/* Multiselect checkboxes for popular formats */}
<div>
<label className={labelClass}>Select Allowed Formats</label>
<div className="bg-background border border-primary/10 rounded-xl p-5 space-y-5">
{['image', 'video', 'document', 'other'].map(group => {
const exts = POPULAR_EXTENSIONS.filter(e => e.category === group);
const groupLabel = group === 'image' ? 'Image Formats' : group === 'video' ? 'Video Formats' : group === 'document' ? 'Document Formats' : 'Data & Archive Formats';
return (
<div key={group} className="space-y-2">
<div className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider">{groupLabel}</div>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-3">
{exts.map(item => {
const isChecked = formik.values.validation.allowedFileTypes.includes(item.ext);
return (
<label
key={item.ext}
className={`
flex items-center gap-2 px-3 py-2 border rounded-lg cursor-pointer transition-all select-none
${isChecked
? 'bg-primary/5 border-primary text-primary font-bold shadow-2xs'
: 'bg-surface border-border text-foreground hover:border-primary/20 hover:bg-background/20'
}
`}
>
<input
type="checkbox"
checked={isChecked}
onChange={(e) => {
const current = formik.values.validation.allowedFileTypes || [];
if (e.target.checked) {
formik.setFieldValue('validation.allowedFileTypes', [...current, item.ext]);
} else {
formik.setFieldValue('validation.allowedFileTypes', current.filter(t => t !== item.ext));
}
}}
className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
/>
<span className="text-xs">.{item.ext}</span>
</label>
);
})}
</div>
</div>
);
})}
</div>
</div>
{/* Custom Extension Input */}
<div className="pt-2">
<label className="block text-[11px] font-semibold text-muted-foreground mb-1">Add Custom Extension (Optional)</label>
<div className="flex gap-2 max-w-sm">
<Input
value={newFileType}
onChange={(e) => setNewFileType(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddFileType(); } }}
placeholder="Type extension and press Enter (e.g. jpg)"
placeholder="e.g. psd"
className="text-xs"
/>
<button type="button" onClick={handleAddFileType} className="px-3 py-2 border border-primary/10 rounded-lg hover:bg-background">
<Plus className="w-4 h-4 text-muted-foreground" />
<button
type="button"
onClick={handleAddFileType}
className="px-4 py-2 bg-surface hover:bg-background border border-border rounded-lg text-xs font-semibold text-foreground flex items-center justify-center transition-colors"
title="Add custom format"
>
<Plus className="w-4 h-4 text-muted-foreground mr-1" />
Add
</button>
</div>
</div>
@@ -528,12 +652,22 @@ export default function NewAssetType() {
</div>
{/* Bottom navigation */}
<div className="shrink-0 pt-2 flex justify-end gap-2">
<div className="shrink-0 pt-4 flex justify-end gap-2">
{activeIndex > 0 && (
<Button variant="outline" type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)}>Back</Button>
)}
{activeIndex < STEPS.length - 1 && (
{activeIndex < STEPS.length - 1 ? (
<Button variant="primary" type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)}>Next</Button>
) : (
<Button
variant="primary"
type="submit"
form="asset-type-form"
icon={<Save className="w-4 h-4" />}
loading={formik.isSubmitting}
>
{isEdit ? 'Save Changes' : 'Create Asset Type'}
</Button>
)}
</div>
</div>
@@ -14,6 +14,8 @@ export interface AssetType {
description?: string;
status: 'active' | 'inactive';
isRequired: boolean;
isVariantEligible?: boolean;
is_variant_eligible?: boolean;
category: 'image' | 'video' | 'document' | 'certificate' | 'marketing' | 'other' | '';
validation: AssetTypeValidation;
createdAt: string;
+5
View File
@@ -104,6 +104,11 @@ export const assetsApi = {
return res.success;
},
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 || [];
},
// Variant Assets Assignment
getVariantAssets: async (variantId: string): Promise<AssetMapping[]> => {
const res = await apiClient.get<ApiResponse<AssetMapping[]>>(`/api/v1/variants/${variantId}/assets`);
@@ -52,6 +52,7 @@ export const assetsService = {
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),
bulkAssignVariantAsset: (productId: string, body: { asset_id: string; role: string; variant_ids: string[]; is_primary?: boolean }) => assetsApi.bulkAssignVariantAsset(productId, body),
getVariantAssets: (variantId: string) => assetsApi.getVariantAssets(variantId),
assignVariantAsset: (variantId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }) => assetsApi.assignVariantAsset(variantId, body),
updateVariantAsset: (variantId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }) => assetsApi.updateVariantAsset(variantId, assetId, body),
@@ -17,7 +17,7 @@ import { usePermissions } from "../../../hooks/usePermission";
import { Can } from "../../../components/customs/Can";
export default function AttributeList() {
const { canCreate, canEdit, canDelete } = usePermissions("products.attributes");
const { canEdit, canDelete } = usePermissions("products.attributes");
const navigate = useNavigate();
const { attributes, fetchAttributes, deleteAttribute } = useAttribute();
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" });
+2 -2
View File
@@ -55,8 +55,8 @@ export default function BrandList() {
brands={brands}
onRowClick={(row) => navigate(`/brands/${row.id}/edit`)}
onView={(row) => navigate(`/brands/${row.id}/view`)}
onEdit={canEdit ? ((row) => navigate(`/brands/${row.id}/edit`)) : undefined}
onDelete={canDelete ? ((row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })) : undefined}
onEdit={canEdit ? ((row) => navigate(`/brands/${row.id}/edit`)) : () => {}}
onDelete={canDelete ? ((row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })) : () => {}}
/>
</div>
@@ -35,8 +35,8 @@ export default function CategoryList() {
const stats = {
total: categories.length || 0,
active: categories.filter((c) => c.status === "active").length,
products: categories.reduce((sum, c) => sum + (Number(c.productCount) || 0), 0),
families: categories.reduce((sum, c) => sum + (Number(c.familyCount) || 0), 0),
products: categories.reduce((sum, c: any) => sum + (Number(c.productCount) || 0), 0),
families: categories.reduce((sum, c: any) => sum + (Number(c.familyCount) || 0), 0),
};
const handleDeleteConfirm = async () => {
@@ -13,7 +13,7 @@ const COMMON_PIM_ATTRIBUTES = [
{ code: "created_at", label: "Creation Timestamp (created_at)" },
];
const COMMON_CHANNEL_FIELDS = [
export const COMMON_CHANNEL_FIELDS = [
{ code: "title", label: "Storefront Title (title)" },
{ code: "body_html", label: "HTML Body Description (body_html)" },
{ code: "variant_sku", label: "Variant SKU (variant_sku)" },
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Building2, Users, Package, Image, ShieldCheck, Activity, UserCheck, Play, StopCircle } from "lucide-react";
import { Building2, Users, Package, Image, ShieldCheck, Activity, Play, StopCircle } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Plus, Building2, Search, Play, StopCircle, CheckCircle, XCircle, Copy, Check } from "lucide-react";
import { Plus, Building2, Search, CheckCircle, XCircle, Copy, Check } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
@@ -34,6 +34,9 @@ interface DynamicAttributesSectionProps {
onAttributeChange: (code: string, value: any) => void;
onAttributeBlur?: (code: string) => void;
onAddAttributeClick?: (group: any) => void;
onRemoveAttribute?: (id: string) => void;
onRemoveGroup?: (id: string) => void;
customAttributeIds?: Set<string>;
readOnly?: boolean;
}
@@ -46,6 +49,9 @@ export const DynamicAttributesSection: React.FC<DynamicAttributesSectionProps> =
onAttributeChange,
onAttributeBlur,
onAddAttributeClick,
onRemoveAttribute,
onRemoveGroup,
customAttributeIds,
readOnly,
}) => {
if (!hasAttributeSet) {
@@ -95,6 +101,9 @@ export const DynamicAttributesSection: React.FC<DynamicAttributesSectionProps> =
onAttributeChange={onAttributeChange}
onAttributeBlur={onAttributeBlur}
onAddAttributeClick={onAddAttributeClick}
onRemoveAttribute={onRemoveAttribute}
onRemoveGroup={onRemoveGroup}
customAttributeIds={customAttributeIds}
readOnly={readOnly}
/>
))}
@@ -26,6 +26,35 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
refreshProductData
}) => {
const [assignedAssets, setAssignedAssets] = useState<AssetMapping[]>([]);
const [assignedVariantAssets, setAssignedVariantAssets] = useState<any[]>([]);
const [variants, setVariants] = useState<any[]>([]);
const [selectedVariantId, setSelectedVariantId] = useState<string>('global');
const [isBulkMode, setIsBulkMode] = useState(false);
const [selectedVariantIds, setSelectedVariantIds] = useState<string[]>([]);
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() });
}
map.get(val.axis.code)?.values.add(val.value_text);
}
});
});
const result: { code: string; name: string; values: string[] }[] = [];
map.forEach((data, code) => {
result.push({
code,
name: data.name,
values: Array.from(data.values)
});
});
return result;
}, [variants]);
const [loading, setLoading] = useState(false);
// Library picker states
@@ -93,6 +122,13 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
return allAssetTypes.find(at => at.id === selectedAssetTypeId);
}, [allAssetTypes, selectedAssetTypeId]);
// Reset selected variant target if the asset type does not support variants
useEffect(() => {
if (selectedAssetType && !(selectedAssetType.is_variant_eligible || selectedAssetType.isVariantEligible)) {
setSelectedVariantId('global');
}
}, [selectedAssetType]);
// Resolve required asset type ids from family requirements
const requiredAssetTypeIds = useMemo(() => {
const ids: string[] = [];
@@ -112,6 +148,26 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
return [...new Set(ids)];
}, [family, allAssetFamilies]);
const combinedAssets = useMemo(() => {
const list: any[] = [];
assignedAssets.forEach(a => {
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'})`
});
});
return list;
}, [assignedAssets, assignedVariantAssets]);
const isAssetTypeRequiredByFamily = (code: string) => {
const matchingType = allAssetTypes.find(at => at.code === code);
if (!matchingType) return false;
@@ -124,11 +180,34 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
setLoading(true);
try {
const data = await assetsService.getProductAssets(productId);
// Sort by display order
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([]);
}
} catch (err: any) {
toast.error(err?.message || 'Failed to load product assets');
toast.error(err?.message || 'Failed to load assets');
} finally {
setLoading(false);
}
@@ -234,13 +313,29 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
}
}
// Map this asset to current product
await assetsService.assignProductAsset(productId, {
asset_id: newAsset.id,
role,
is_primary: assignedAssets.length === 0 && successCount === 0,
display_order: assignedAssets.length + successCount
});
// Map this asset to current product or variant(s)
if (isBulkMode && selectedVariantIds.length > 0) {
await assetsService.bulkAssignVariantAsset(productId, {
asset_id: newAsset.id,
role,
variant_ids: selectedVariantIds,
is_primary: false
});
} else if (selectedVariantId !== 'global') {
await assetsService.assignVariantAsset(selectedVariantId, {
asset_id: newAsset.id,
role,
is_primary: !assignedVariantAssets.some(m => m.variantId === selectedVariantId && m.is_primary) && successCount === 0,
display_order: assignedVariantAssets.filter(m => m.variantId === selectedVariantId).length + successCount
});
} else {
await assetsService.assignProductAsset(productId, {
asset_id: newAsset.id,
role,
is_primary: assignedAssets.length === 0 && successCount === 0,
display_order: assignedAssets.length + successCount
});
}
successCount++;
} catch (err: any) {
@@ -280,6 +375,10 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
toast.error('Please select an Asset Type first.');
return;
}
if (isBulkMode && selectedVariantIds.length === 0) {
toast.error('Please select at least one variant target for bulk assignment.');
return;
}
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleMultipleFilesUpload(e.dataTransfer.files);
}
@@ -287,11 +386,20 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
// Assign asset from the modal picker
const handleAssignFromLibrary = async (asset: Asset) => {
// Check if already assigned
const alreadyAssigned = assignedAssets.some(m => m.asset_id === asset.id);
if (alreadyAssigned) {
toast.warn('Asset is already assigned to this product');
return;
if (!isBulkMode) {
if (selectedVariantId !== 'global') {
const alreadyAssignedVariant = assignedVariantAssets.some(m => m.variantId === selectedVariantId && m.asset_id === asset.id);
if (alreadyAssignedVariant) {
toast.warn('Asset is already assigned to this variant');
return;
}
} else {
const alreadyAssigned = assignedAssets.some(m => m.asset_id === asset.id);
if (alreadyAssigned) {
toast.warn('Asset is already assigned to this product');
return;
}
}
}
try {
@@ -313,12 +421,28 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
}
}
await assetsService.assignProductAsset(productId, {
asset_id: asset.id,
role,
is_primary: assignedAssets.length === 0,
display_order: assignedAssets.length
});
if (isBulkMode && selectedVariantIds.length > 0) {
await assetsService.bulkAssignVariantAsset(productId, {
asset_id: asset.id,
role,
variant_ids: selectedVariantIds,
is_primary: false
});
} else if (selectedVariantId !== 'global') {
await assetsService.assignVariantAsset(selectedVariantId, {
asset_id: asset.id,
role,
is_primary: !assignedVariantAssets.some(m => m.variantId === selectedVariantId && m.is_primary),
display_order: assignedVariantAssets.filter(m => m.variantId === selectedVariantId).length
});
} else {
await assetsService.assignProductAsset(productId, {
asset_id: asset.id,
role,
is_primary: assignedAssets.length === 0,
display_order: assignedAssets.length
});
}
toast.success('Asset assigned from library');
loadProductAssets();
@@ -329,10 +453,14 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
};
// Unassign asset mapping
const handleUnassign = async (assetId: string) => {
if (!window.confirm('Are you sure you want to unassign this asset from the product?')) return;
const handleUnassign = async (assetId: string, variantId?: string) => {
if (!window.confirm('Are you sure you want to unassign this asset?')) return;
try {
await assetsService.unassignProductAsset(productId, assetId);
if (variantId) {
await assetsService.unassignVariantAsset(variantId, assetId);
} else {
await assetsService.unassignProductAsset(productId, assetId);
}
toast.success('Asset unassigned');
loadProductAssets();
refreshProductData?.();
@@ -344,9 +472,13 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
// Set selected asset as the primary display image
const handleSetPrimary = async (assetId: string) => {
const handleSetPrimary = async (assetId: string, variantId?: string) => {
try {
await assetsService.updateProductAsset(productId, assetId, { is_primary: true });
if (variantId) {
await assetsService.updateVariantAsset(variantId, assetId, { is_primary: true });
} else {
await assetsService.updateProductAsset(productId, assetId, { is_primary: true });
}
toast.success('Primary image updated');
loadProductAssets();
refreshProductData?.();
@@ -461,8 +593,144 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
</Select>
</div>
</div>
{/* Variant Target Selector */}
{selectedAssetType && (selectedAssetType.is_variant_eligible || selectedAssetType.isVariantEligible) && 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>
<button
type="button"
onClick={() => {
setIsBulkMode(!isBulkMode);
setSelectedVariantIds([]);
setSelectedVariantId('global');
}}
className="text-[10px] text-primary hover:underline font-bold cursor-pointer"
>
{isBulkMode ? "Switch to Single Target" : "Switch to Bulk Assignment"}
</button>
</label>
<div className="relative">
{isBulkMode ? (
<div className="bg-primary/5 border border-primary/20 rounded-lg p-2 text-xs font-semibold text-primary-dark">
Bulk Mode Active ({selectedVariantIds.length} selected)
</div>
) : (
<Select
id="variant-target-select"
value={selectedVariantId}
onChange={(e) => setSelectedVariantId(e.target.value)}
className="w-full text-xs font-semibold border-primary/20 bg-primary/5/10 text-primary-dark"
>
<option value="global">Global (Product level)</option>
{variants.map((v) => (
<option key={v.id} value={v.id}>
Variant: {v.name.split(' - ')[1] || v.name} ({v.sku || 'No SKU'})
</option>
))}
</Select>
)}
</div>
</div>
)}
</div>
{/* Bulk Selection and Attribute Filters Panel */}
{isBulkMode && selectedAssetType && (selectedAssetType.is_variant_eligible || selectedAssetType.isVariantEligible) && (
<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>
<div className="flex gap-2">
<button
type="button"
onClick={() => setSelectedVariantIds(variants.map(v => v.id))}
className="px-2 py-1 bg-surface border border-border hover:bg-background rounded text-[10px] font-bold text-muted-foreground cursor-pointer"
>
Select All
</button>
<button
type="button"
onClick={() => setSelectedVariantIds([])}
className="px-2 py-1 bg-surface border border-border hover:bg-background rounded text-[10px] font-bold text-muted-foreground cursor-pointer"
>
Clear All
</button>
</div>
</div>
{/* Dynamic Attribute Filter Pills */}
{variantFilterOptions.length > 0 && (
<div className="space-y-2">
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider block">Auto-Select by Specification:</span>
<div className="flex flex-col gap-2">
{variantFilterOptions.map(option => (
<div key={option.code} className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] font-bold text-foreground min-w-[60px]">{option.name}:</span>
<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))
.map(v => v.id);
const isAllSelected = matchingIds.every(id => selectedVariantIds.includes(id));
return (
<button
type="button"
key={val}
onClick={() => {
if (isAllSelected) {
setSelectedVariantIds(prev => prev.filter(id => !matchingIds.includes(id)));
} else {
setSelectedVariantIds(prev => Array.from(new Set([...prev, ...matchingIds])));
}
}}
className={`px-2 py-0.5 rounded text-[10px] font-semibold border transition-colors cursor-pointer ${
isAllSelected
? 'bg-primary text-white border-primary'
: 'bg-surface hover:bg-background text-foreground border-border'
}`}
>
{val}
</button>
);
})}
</div>
</div>
))}
</div>
</div>
)}
{/* Variants Checkbox Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2 pt-2 border-t border-border max-h-[200px] overflow-y-auto">
{variants.map(v => {
const isChecked = selectedVariantIds.includes(v.id);
return (
<label key={v.id} className="flex items-center gap-2 p-1.5 hover:bg-surface-muted rounded-md cursor-pointer transition-colors text-[11px] font-medium text-foreground">
<input
type="checkbox"
checked={isChecked}
onChange={(e) => {
if (e.target.checked) {
setSelectedVariantIds(prev => [...prev, v.id]);
} else {
setSelectedVariantIds(prev => prev.filter(id => id !== v.id));
}
}}
className="rounded border-border text-primary focus:ring-primary w-3.5 h-3.5 cursor-pointer"
/>
<span className="truncate" title={v.name}>
{v.name.split(' - ')[1] || v.name} ({v.sku || 'No SKU'})
</span>
</label>
);
})}
</div>
</div>
)}
{selectedAssetType && (() => {
const allowedFileTypes =
selectedAssetType.validation?.allowedFileTypes ??
@@ -509,10 +777,15 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
toast.error('Please select an Asset Type first.');
return;
}
if (isBulkMode && selectedVariantIds.length === 0) {
toast.error('Please select at least one variant target for bulk assignment.');
return;
}
fileInputRef.current?.click();
}}
className={`md:col-span-2 border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all flex flex-col items-center justify-center ${dragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/20 hover:bg-background/50 bg-surface'
}`}
className={`md:col-span-2 border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all flex flex-col items-center justify-center ${
dragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/20 hover:bg-background/50 bg-surface'
} ${isBulkMode && selectedVariantIds.length === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<input
type="file"
@@ -543,6 +816,14 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
<p className="text-[10px] mt-1">Classification is required before mapping files to product registry</p>
</div>
</div>
) : isBulkMode && selectedVariantIds.length === 0 ? (
<div className="space-y-2 text-warning">
<Info className="w-8 h-8 mx-auto animate-pulse" />
<div>
<h4 className="font-semibold text-xs">Select Variants Below</h4>
<p className="text-[10px] mt-1">Check at least one variant before uploading files</p>
</div>
</div>
) : (
<div className="space-y-3">
<Upload className="w-8 h-8 text-muted-foreground mx-auto" />
@@ -569,18 +850,29 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
toast.error('Please select an Asset Type first.');
return;
}
if (isBulkMode && selectedVariantIds.length === 0) {
toast.error('Please select at least one variant target for bulk assignment.');
return;
}
fileInputRef.current?.click();
}}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold shadow-2xs cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!selectedAssetType}
disabled={!selectedAssetType || (isBulkMode && selectedVariantIds.length === 0)}
>
<Plus className="w-3.5 h-3.5" />
+ Add Asset
</button>
<button
type="button"
onClick={() => setShowPicker(true)}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-2xs cursor-pointer"
onClick={() => {
if (isBulkMode && selectedVariantIds.length === 0) {
toast.error('Please select at least one variant target for bulk assignment.');
return;
}
setShowPicker(true);
}}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-2xs cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isBulkMode && selectedVariantIds.length === 0}
>
<Search className="w-3.5 h-3.5" />
Browse Library
@@ -589,13 +881,12 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
</div>
</div>
)}
{/* Grid of assigned assets */}
{loading && assignedAssets.length === 0 ? (
{loading && combinedAssets.length === 0 ? (
<div className="flex justify-center py-10">
<Loader size="md" message="Loading assigned files..." />
</div>
) : assignedAssets.length > 0 ? (
) : combinedAssets.length > 0 ? (
<div className="bg-surface rounded-xl border border-border shadow-xs overflow-hidden">
<table className="w-full border-collapse text-left text-xs text-foreground">
<thead>
@@ -608,7 +899,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
</tr>
</thead>
<tbody className="divide-y divide-border">
{assignedAssets.map((mapping, idx) => {
{combinedAssets.map((mapping, idx) => {
const asset = mapping.asset;
if (!asset) return null;
@@ -618,28 +909,32 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
const is3DModel = asset.mime_type?.startsWith('model/') || ['glb', 'gltf', 'usdz'].includes(asset.extension || '');
return (
<tr key={mapping.id} className="hover:bg-background/50 transition-colors">
<tr key={mapping.id || `${mapping.asset_id}-${mapping.variantId || 'global'}`} className="hover:bg-background/50 transition-colors">
{/* Display Order sorting */}
{!readOnly && (
<td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-1">
<button
type="button"
disabled={idx === 0}
onClick={() => handleMoveOrder(idx, 'up')}
className="p-1 hover:bg-surface-muted rounded text-muted-foreground disabled:opacity-30 disabled:hover:bg-transparent"
>
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button
type="button"
disabled={idx === assignedAssets.length - 1}
onClick={() => handleMoveOrder(idx, 'down')}
className="p-1 hover:bg-surface-muted rounded text-muted-foreground disabled:opacity-30 disabled:hover:bg-transparent"
>
<ArrowDown className="w-3.5 h-3.5" />
</button>
</div>
{!mapping.isVariant ? (
<div className="flex items-center justify-center gap-1">
<button
type="button"
disabled={idx === 0}
onClick={() => handleMoveOrder(idx, 'up')}
className="p-1 hover:bg-surface-muted rounded text-muted-foreground disabled:opacity-30 disabled:hover:bg-transparent"
>
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button
type="button"
disabled={idx === assignedAssets.length - 1}
onClick={() => handleMoveOrder(idx, 'down')}
className="p-1 hover:bg-surface-muted rounded text-muted-foreground disabled:opacity-30 disabled:hover:bg-transparent"
>
<ArrowDown className="w-3.5 h-3.5" />
</button>
</div>
) : (
<span className="text-[10px] text-muted-foreground font-mono"></span>
)}
</td>
)}
@@ -662,11 +957,18 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
{/* Meta info */}
<td className="px-4 py-3 font-medium">
<div className="text-foreground font-semibold flex items-center gap-2">
{asset.name}
<div className="text-foreground font-semibold flex items-center gap-2 flex-wrap">
<span>{asset.name}</span>
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-primary/10 text-primary border border-primary/20 uppercase">
{mapping.role ? mapping.role.replace('_', ' ') : 'HERO IMAGE'}
</span>
<span className={`px-2 py-0.5 rounded text-[10px] font-bold border uppercase ${
mapping.isVariant
? 'bg-purple-50 text-purple-700 border-purple-100'
: 'bg-slate-50 text-slate-700 border-slate-100'
}`}>
{mapping.scopeLabel}
</span>
</div>
<div className="text-[10px] text-muted-foreground font-mono mt-1 flex items-center gap-2 flex-wrap">
<span>Size: {asset.file_size ? `${(asset.file_size / 1024).toFixed(1)} KB` : '—'}</span>
@@ -678,8 +980,6 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
</div>
</td>
{/* Primary Badge toggle button */}
<td className="px-4 py-3 text-center">
{mapping.is_primary ? (
@@ -692,7 +992,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
) : (
<button
type="button"
onClick={() => handleSetPrimary(asset.id)}
onClick={() => handleSetPrimary(asset.id, mapping.variantId)}
className="text-[11px] text-muted-foreground hover:text-primary font-semibold"
>
Make Primary
@@ -705,7 +1005,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
<td className="px-4 py-3 text-right">
<button
type="button"
onClick={() => handleUnassign(asset.id)}
onClick={() => handleUnassign(asset.id, mapping.variantId)}
className="p-1.5 hover:bg-red-50 text-muted-foreground hover:text-red-600 rounded-lg transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { ChevronDown, ChevronUp, Plus } from 'lucide-react';
import { ChevronDown, ChevronUp, Plus, X } from 'lucide-react';
import { DynamicAttributeRenderer } from './DynamicAttributeRenderer';
interface AttributeOption {
@@ -35,6 +35,9 @@ interface ProductAttributeGroupProps {
onAttributeChange: (code: string, value: any) => void;
onAttributeBlur?: (code: string) => void;
onAddAttributeClick?: (group: AttributeGroup) => void;
onRemoveAttribute?: (id: string) => void;
onRemoveGroup?: (id: string) => void;
customAttributeIds?: Set<string>;
readOnly?: boolean;
}
@@ -46,6 +49,9 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
onAttributeChange,
onAttributeBlur,
onAddAttributeClick,
onRemoveAttribute,
onRemoveGroup,
customAttributeIds,
readOnly,
}) => {
const [isExpanded, setIsExpanded] = useState(true);
@@ -56,19 +62,34 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
<div className="bg-surface rounded-xl border border-border shadow-xs p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-foreground text-xs uppercase tracking-wider">{group.name}</h3>
{!readOnly && onAddAttributeClick && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAddAttributeClick(group);
}}
className="px-3 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold transition-colors flex items-center justify-center shrink-0 h-[42px] w-[42px]"
title={`Add attribute to ${group.name}`}
>
<Plus className="w-4 h-4" />
</button>
)}
<div className="flex items-center gap-2">
{!readOnly && onRemoveGroup && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveGroup(group.id);
}}
className="text-xs text-red-500 hover:text-red-650 font-semibold px-2 py-1 rounded hover:bg-red-50 transition-colors"
title={`Remove ${group.name} container`}
>
Remove Group
</button>
)}
{!readOnly && onAddAttributeClick && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAddAttributeClick(group);
}}
className="px-3 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold transition-colors flex items-center justify-center shrink-0 h-[42px] w-[42px]"
title={`Add attribute to ${group.name}`}
>
<Plus className="w-4 h-4" />
</button>
)}
</div>
</div>
<div className="text-xs text-muted-foreground italic">No Attributes available.</div>
</div>
@@ -83,6 +104,19 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
>
<h3 className="font-semibold text-foreground text-xs uppercase tracking-wider">{group.name}</h3>
<div className="flex items-center gap-3">
{!readOnly && onRemoveGroup && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveGroup(group.id);
}}
className="text-xs text-red-500 hover:text-red-650 font-semibold px-2.5 py-1 rounded hover:bg-red-50/70 transition-colors"
title={`Remove ${group.name} container`}
>
Remove Group
</button>
)}
{!readOnly && onAddAttributeClick && (
<button
type="button"
@@ -102,18 +136,32 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
{isExpanded && (
<div className="p-6 grid grid-cols-2 gap-6">
{attributes.map((attr) => (
<DynamicAttributeRenderer
key={attr.id}
attribute={attr}
value={values[attr.code]}
onChange={(val) => onAttributeChange(attr.code, val)}
onBlur={() => onAttributeBlur?.(attr.code)}
error={errors[attr.code]}
touched={touched[attr.code]}
readOnly={readOnly}
/>
))}
{attributes.map((attr) => {
const isCustom = customAttributeIds?.has(attr.id);
return (
<div key={attr.id} className="relative border border-border/40 rounded-xl p-5 bg-background/15 group hover:border-border/80 transition-all">
{isCustom && !readOnly && onRemoveAttribute && (
<button
type="button"
onClick={() => onRemoveAttribute(attr.id)}
className="absolute top-2 right-2 p-1.5 hover:bg-red-50 text-muted-foreground hover:text-red-500 rounded transition-colors"
title="Remove custom attribute"
>
<X className="w-3.5 h-3.5" />
</button>
)}
<DynamicAttributeRenderer
attribute={attr}
value={values[attr.code]}
onChange={(val) => onAttributeChange(attr.code, val)}
onBlur={() => onAttributeBlur?.(attr.code)}
error={errors[attr.code]}
touched={touched[attr.code]}
readOnly={readOnly}
/>
</div>
);
})}
</div>
)}
</div>
@@ -7,18 +7,29 @@ interface VariantAxesSelectorProps {
onGenerate: (selected: Record<string, string[]>, skuTemplate: string) => void;
generating: boolean;
parentSku: string;
initialSelectedValues?: Record<string, string[]>;
}
export const VariantAxesSelector: React.FC<VariantAxesSelectorProps> = ({
axes,
onGenerate,
generating,
parentSku
parentSku,
initialSelectedValues
}) => {
const [selectedValues, setSelectedValues] = useState<Record<string, string[]>>({});
const [selectedValues, setSelectedValues] = useState<Record<string, string[]>>(initialSelectedValues || {});
const [skuTemplate, setSkuTemplate] = useState('{PARENT_SKU}-{COMBO}');
const [customInputs, setCustomInputs] = useState<Record<string, string>>({});
React.useEffect(() => {
if (initialSelectedValues) {
setSelectedValues(prev => ({
...prev,
...initialSelectedValues
}));
}
}, [initialSelectedValues]);
// Calculate combinations preview
const activeAxes = axes.filter(axis => (selectedValues[axis.code] || []).length > 0);
const totalCombinations = activeAxes.length > 0
@@ -120,7 +131,7 @@ export const VariantAxesSelector: React.FC<VariantAxesSelectorProps> = ({
// Pre-defined options list checkbox layout
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
{options.map(opt => {
const isChecked = selected.includes(opt.code);
const isChecked = selected.some(sel => sel.toLowerCase().trim() === opt.code.toLowerCase().trim());
return (
<label
key={opt.id}
@@ -1,10 +1,10 @@
import React, { useState } from 'react';
import type { VariantStatus } from '../../types/variant.types';
import { Settings, Check, Trash2, Archive, DollarSign, Package } from 'lucide-react';
import { Settings, Check, Trash2, Archive, DollarSign } from 'lucide-react';
interface VariantBulkActionsProps {
selectedCount: number;
onApplyUpdates: (updates: { price?: number; costPrice?: number; stock?: number; status?: VariantStatus }) => void;
onApplyUpdates: (updates: { price?: number; costPrice?: number; status?: VariantStatus }) => void;
onDeleteSelected: () => void;
onArchiveSelected: () => void;
}
@@ -16,25 +16,22 @@ export const VariantBulkActions: React.FC<VariantBulkActionsProps> = ({
onArchiveSelected
}) => {
const [isOpen, setIsOpen] = useState(false);
const [actionType, setActionType] = useState<'price' | 'stock' | 'status' | null>(null);
const [actionType, setActionType] = useState<'price' | 'status' | null>(null);
// States for bulk inputs
const [bulkPrice, setBulkPrice] = useState('');
const [bulkCostPrice, setBulkCostPrice] = useState('');
const [bulkStock, setBulkStock] = useState('');
const [bulkStatus, setBulkStatus] = useState<VariantStatus>('draft');
if (selectedCount === 0) return null;
const handleApply = (e: React.FormEvent) => {
e.preventDefault();
const updates: { price?: number; costPrice?: number; stock?: number; status?: VariantStatus } = {};
const updates: { price?: number; costPrice?: number; status?: VariantStatus } = {};
if (actionType === 'price') {
if (bulkPrice !== '') updates.price = parseFloat(bulkPrice);
if (bulkCostPrice !== '') updates.costPrice = parseFloat(bulkCostPrice);
} else if (actionType === 'stock') {
if (bulkStock !== '') updates.stock = parseInt(bulkStock, 10);
} else if (actionType === 'status') {
updates.status = bulkStatus;
}
@@ -80,13 +77,6 @@ export const VariantBulkActions: React.FC<VariantBulkActionsProps> = ({
>
<DollarSign className="w-4 h-4 text-muted-foreground" /> Update Price & Cost
</button>
<button
type="button"
onClick={() => setActionType('stock')}
className="flex items-center gap-2 w-full text-left px-3 py-2 hover:bg-background rounded-lg text-xs text-foreground font-medium transition-colors"
>
<Package className="w-4 h-4 text-muted-foreground" /> Update Inventory Stock
</button>
<button
type="button"
onClick={() => setActionType('status')}
@@ -124,19 +114,6 @@ export const VariantBulkActions: React.FC<VariantBulkActionsProps> = ({
</div>
)}
{actionType === 'stock' && (
<div>
<label className="block text-[10px] font-bold text-muted-foreground uppercase">Stock Level</label>
<input
type="number"
placeholder="Enter inventory quantity"
value={bulkStock}
onChange={(e) => setBulkStock(e.target.value)}
className="w-full text-xs border border-border rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary bg-surface"
/>
</div>
)}
{actionType === 'status' && (
<div>
<label className="block text-[10px] font-bold text-muted-foreground uppercase">Lifecycle Status</label>
@@ -0,0 +1,373 @@
import React, { useState, useEffect } from 'react';
import type { Variant, VariantStatus } from '../../types/variant.types';
import {
X, Save, Archive, Trash2, Image as ImageIcon,
Package, Tag, DollarSign, CheckCircle, AlertCircle, Loader2,
ShoppingBag, Hash
} from 'lucide-react';
interface VariantDetailModalProps {
variant: Variant | null;
onClose: () => void;
onUpdate: (id: string, updates: Partial<Variant>) => Promise<any>;
onDelete?: (id: string) => void;
onArchive?: (id: string) => void;
readOnly?: boolean;
}
export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
variant,
onClose,
onUpdate,
onDelete,
onArchive,
readOnly = false
}) => {
const [sku, setSku] = 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');
useEffect(() => {
if (variant) {
setSku(variant.sku || '');
setPrice(String(variant.price ?? ''));
setCostPrice(String(variant.costPrice ?? ''));
setStock(String(variant.stock ?? ''));
setStatus(variant.status || 'draft');
setActiveImageIdx(0);
setSaveState('idle');
}
}, [variant]);
if (!variant) return null;
const images = variant.images || [];
const primaryImage = images.find(i => i.isPrimary) || images[0];
const activeImage = images[activeImageIdx] || primaryImage;
const axisEntries = Object.entries(variant.attributes || {});
const handleSave = async () => {
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,
price: isNaN(pNum) ? 0 : pNum,
costPrice: isNaN(cpNum) ? 0 : cpNum,
stock: isNaN(sNum) ? 0 : sNum,
status
});
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 2000);
} catch {
setSaveState('error');
setTimeout(() => setSaveState('idle'), 3000);
}
};
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',
inactive: 'bg-slate-100 text-slate-600 border-slate-200',
archived: 'bg-red-50 text-red-600 border-red-200'
};
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">
{/* ── Header ── */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Package className="w-4 h-4 text-primary" />
</div>
<div>
<h2 className="font-bold text-foreground text-sm leading-tight">
{variant.name || 'Variant Details'}
</h2>
<p className="text-[11px] text-muted-foreground font-mono mt-0.5">{variant.sku}</p>
</div>
</div>
<div className="flex items-center gap-2">
<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>
<button
type="button"
onClick={onClose}
className="p-1.5 hover:bg-background rounded-lg text-muted-foreground transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* ── Body ── */}
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-5 gap-0 h-full">
{/* 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>
{/* 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>
))}
</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>
</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>
<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>
{readOnly ? (
<p className="text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">${price}</p>
) : (
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm font-semibold">$</span>
<input
type="number"
step="0.01"
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"
/>
</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>
{readOnly ? (
<p className="text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">${costPrice}</p>
) : (
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm font-semibold">$</span>
<input
type="number"
step="0.01"
min="0"
value={costPrice}
onChange={e => setCostPrice(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"
/>
</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>
{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>
) : (
<select
value={status}
onChange={e => setStatus(e.target.value as VariantStatus)}
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 font-medium"
>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="archived">Archived</option>
</select>
)}
</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>
</div>
))}
</div>
{/* Last updated */}
{variant.lastUpdated && (
<p className="text-[10px] text-muted-foreground">
Last updated: {new Date(variant.lastUpdated).toLocaleString()}
</p>
)}
</div>
</div>
</div>
{/* ── Footer ── */}
<div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0 bg-background/50">
{/* Danger actions */}
<div className="flex items-center gap-2">
{!readOnly && onArchive && (
<button
type="button"
onClick={() => { onArchive(variant.id); onClose(); }}
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
</button>
)}
{!readOnly && onDelete && (
<button
type="button"
onClick={() => { if (window.confirm('Delete this variant?')) { 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
</button>
)}
</div>
{/* Primary actions */}
<div className="flex items-center gap-3">
<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"
>
{readOnly ? 'Close' : 'Cancel'}
</button>
{!readOnly && (
<button
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"
>
{saveState === 'saving' && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{saveState === 'saved' && <CheckCircle className="w-3.5 h-3.5 text-white" />}
{saveState === 'error' && <AlertCircle className="w-3.5 h-3.5 text-white" />}
{saveState === 'idle' && <Save className="w-3.5 h-3.5" />}
{saveState === 'saving' ? 'Saving…' : saveState === 'saved' ? 'Saved!' : saveState === 'error' ? 'Error' : 'Save Changes'}
</button>
)}
</div>
</div>
</div>
</div>
);
};
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import type { Variant, VariantStatus } from '../../types/variant.types';
import { Trash2, Archive, Loader, Check, CircleAlert } from 'lucide-react';
import { Trash2, Archive, Loader, Check, CircleAlert, Image as ImageIcon, Eye } from 'lucide-react';
interface VariantEditorRowProps {
variant: Variant;
@@ -10,6 +10,7 @@ interface VariantEditorRowProps {
onUpdate: (id: string, updates: Partial<Variant>) => Promise<any>;
onDelete: (id: string) => void;
onArchive: (id: string) => void;
onViewDetail?: () => void;
readOnly?: boolean;
}
@@ -21,6 +22,7 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
onUpdate,
onDelete,
onArchive,
onViewDetail,
readOnly
}) => {
const [sku, setSku] = useState(variant.sku);
@@ -77,10 +79,24 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
}
};
const images = variant.images || [];
const primaryImg = images.find(i => i.isPrimary) || images[0];
const thumbUrl = primaryImg?.thumbnailUrl || primaryImg?.url;
// ── Read-only row ──────────────────────────────────────────────────────────
if (readOnly) {
return (
<tr className="hover:bg-background/50 transition-colors border-b border-border">
<td className="px-3 py-2 text-center">
{thumbUrl ? (
<img src={thumbUrl} alt={variant.name} className="w-8 h-8 object-cover rounded border border-border mx-auto" />
) : (
<div className="w-8 h-8 rounded border border-border bg-surface-muted flex items-center justify-center mx-auto text-muted-foreground">
<ImageIcon className="w-3.5 h-3.5" />
</div>
)}
</td>
<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}>
@@ -99,10 +115,8 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
</div>
</div>
</td>
<td className="px-4 py-3 font-mono text-xs text-foreground">{sku}</td>
<td className="px-4 py-3 text-right text-xs text-foreground">${price}</td>
<td className="px-4 py-3 text-right text-xs text-foreground">${costPrice}</td>
<td className="px-4 py-3 text-center text-xs text-foreground">{stock}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${
status === 'active' ? 'bg-success/10 text-success' :
@@ -128,6 +142,41 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
/>
</td>
{/* Image Thumbnail */}
<td className="px-3 py-2 text-center">
{onViewDetail ? (
<button type="button" onClick={onViewDetail} className="group relative block mx-auto focus:outline-none">
{thumbUrl ? (
<img src={thumbUrl} alt={variant.name} className="w-9 h-9 object-cover rounded-lg border border-border transition-all group-hover:border-primary" />
) : (
<div className="w-9 h-9 rounded-lg border border-border bg-surface-muted flex items-center justify-center text-muted-foreground transition-all group-hover:border-primary">
<ImageIcon className="w-4 h-4" />
</div>
)}
</button>
) : (
thumbUrl ? (
<img src={thumbUrl} alt={variant.name} className="w-9 h-9 object-cover rounded-lg border border-border mx-auto" />
) : (
<div className="w-9 h-9 rounded-lg border border-border bg-surface-muted flex items-center justify-center mx-auto text-muted-foreground">
<ImageIcon className="w-4 h-4" />
</div>
)
)}
</td>
{/* SKU Input */}
<td className="px-4 py-3">
<input
type="text"
value={sku}
onChange={(e) => setSku(e.target.value)}
onBlur={handleFieldSave}
onKeyDown={handleKeyDown}
className="w-full text-xs border border-border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary font-mono bg-surface"
/>
</td>
{/* Variant Specification */}
<td className="px-4 py-3">
<div className="flex flex-col">
@@ -148,18 +197,6 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
</div>
</td>
{/* SKU Input */}
<td className="px-4 py-3">
<input
type="text"
value={sku}
onChange={(e) => setSku(e.target.value)}
onBlur={handleFieldSave}
onKeyDown={handleKeyDown}
className="w-full text-xs border border-border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary font-mono bg-surface"
/>
</td>
{/* Price Input */}
<td className="px-4 py-3">
<div className="relative">
@@ -192,18 +229,6 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
</div>
</td>
{/* Inventory Stock Input */}
<td className="px-4 py-3">
<input
type="number"
value={stock}
onChange={(e) => setStock(e.target.value)}
onBlur={handleFieldSave}
onKeyDown={handleKeyDown}
className="w-full text-xs border border-border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-center"
/>
</td>
{/* Status dropdown */}
<td className="px-4 py-3">
<select
@@ -229,6 +254,16 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
{/* Row Actions */}
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-1.5">
{onViewDetail && (
<button
type="button"
onClick={onViewDetail}
title="View & Edit Details"
className="p-1 hover:bg-primary/10 text-muted-foreground hover:text-primary rounded transition-colors"
>
<Eye className="w-3.5 h-3.5" />
</button>
)}
<button
type="button"
onClick={() => onArchive(variant.id)}
@@ -1,6 +1,7 @@
import React from 'react';
import React, { useState } from 'react';
import type { Variant } from '../../types/variant.types';
import { VariantEditorRow } from './VariantEditorRow';
import { VariantDetailModal } from './VariantDetailModal';
interface VariantListViewProps {
variants: Variant[];
@@ -25,60 +26,80 @@ export const VariantListView: React.FC<VariantListViewProps> = ({
onArchive,
readOnly
}) => {
const [modalVariant, setModalVariant] = useState<Variant | null>(null);
const allSelected = variants.length > 0 && variants.every(v => selectedIds.has(v.id));
const someSelected = variants.length > 0 && variants.some(v => selectedIds.has(v.id)) && !allSelected;
return (
<div className="overflow-x-auto border border-border rounded-xl bg-surface shadow-xs">
<table className="w-full border-collapse text-left min-w-[800px]">
<thead>
<tr className="bg-background/70 border-b border-border text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
{!readOnly && (
<th className="px-4 py-3 text-center w-12">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={(e) => onSelectAllChange(e.target.checked)}
className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
/>
</th>
)}
<th className="px-4 py-3">Variant Specification</th>
<th className="px-4 py-3 w-48">SKU Code</th>
<th className="px-4 py-3 w-28 text-right">Sale Price</th>
<th className="px-4 py-3 w-28 text-right">Cost Price</th>
<th className="px-4 py-3 w-24 text-center">Stock</th>
<th className="px-4 py-3 w-32">Status</th>
{!readOnly && <th className="px-4 py-3 w-16 text-center">Save</th>}
{!readOnly && <th className="px-4 py-3 w-24 text-right">Actions</th>}
</tr>
</thead>
<tbody className="divide-y divide-border">
{variants.map(variant => (
<VariantEditorRow
key={variant.id}
variant={variant}
axesKeys={axesKeys}
isSelected={selectedIds.has(variant.id)}
onSelect={(checked) => onSelectChange(variant.id, checked)}
onUpdate={onUpdate}
onDelete={onDelete}
onArchive={onArchive}
readOnly={readOnly}
/>
))}
{variants.length === 0 && (
<tr>
<td colSpan={readOnly ? 6 : 9} className="px-6 py-10 text-center text-xs text-muted-foreground font-medium">
No variants found matching criteria.
</td>
<>
<div className="overflow-x-auto border border-border rounded-xl bg-surface shadow-xs">
<table className="w-full border-collapse text-left min-w-[800px]">
<thead>
<tr className="bg-background/70 border-b border-border text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
{!readOnly && (
<th className="px-4 py-3 text-center w-10">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={(e) => onSelectAllChange(e.target.checked)}
className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
/>
</th>
)}
<th className="px-4 py-3 w-14">Image</th>
<th className="px-4 py-3 w-44">SKU Code</th>
<th className="px-4 py-3">Variant Specification</th>
<th className="px-4 py-3 w-28 text-right">Sale Price</th>
<th className="px-4 py-3 w-28 text-right">Cost Price</th>
<th className="px-4 py-3 w-28">Status</th>
{!readOnly && <th className="px-4 py-3 w-14 text-center">Save</th>}
{!readOnly && <th className="px-4 py-3 w-28 text-right">Actions</th>}
</tr>
)}
</tbody>
</table>
</div>
</thead>
<tbody className="divide-y divide-border">
{variants.map(variant => (
<VariantEditorRow
key={variant.id}
variant={variant}
axesKeys={axesKeys}
isSelected={selectedIds.has(variant.id)}
onSelect={(checked) => onSelectChange(variant.id, checked)}
onUpdate={onUpdate}
onDelete={onDelete}
onArchive={onArchive}
onViewDetail={() => setModalVariant(variant)}
readOnly={readOnly}
/>
))}
{variants.length === 0 && (
<tr>
<td colSpan={readOnly ? 6 : 9} className="px-6 py-10 text-center text-xs text-muted-foreground font-medium">
No variants found matching criteria.
</td>
</tr>
)}
</tbody>
</table>
</div>
{modalVariant && (
<VariantDetailModal
variant={modalVariant}
onClose={() => setModalVariant(null)}
onUpdate={async (id, updates) => {
const updated = await onUpdate(id, updates);
if (updated) setModalVariant(prev => prev ? { ...prev, ...updates } : null);
return updated;
}}
onDelete={id => { onDelete(id); setModalVariant(null); }}
onArchive={id => { onArchive(id); setModalVariant(null); }}
readOnly={readOnly}
/>
)}
</>
);
};
@@ -1,5 +1,7 @@
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import type { Variant } from '../../types/variant.types';
import { VariantDetailModal } from './VariantDetailModal';
import { Image as ImageIcon, Tag, Edit2, Trash2, Archive, CheckSquare, Square } from 'lucide-react';
interface VariantMatrixViewProps {
variants: Variant[];
@@ -26,274 +28,218 @@ export const VariantMatrixView: React.FC<VariantMatrixViewProps> = ({
onArchive,
readOnly
}) => {
const [modalVariant, setModalVariant] = useState<Variant | null>(null);
const allSelected = variants.length > 0 && variants.every(v => selectedIds.has(v.id));
const someSelected = variants.length > 0 && variants.some(v => selectedIds.has(v.id)) && !allSelected;
return (
<div className="overflow-x-auto border border-border rounded-xl bg-surface shadow-xs">
<table className="w-full border-collapse text-left min-w-[900px]">
<thead>
<tr className="bg-primary/5/30 border-b border-border text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
{!readOnly && (
<th className="px-4 py-3.5 text-center w-12">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={(e) => onSelectAllChange(e.target.checked)}
className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
/>
</th>
)}
{/* Dynamic columns for each variant axis */}
{axesKeys.map(key => (
<th key={key} className="px-4 py-3.5 font-bold">
{axesNames[key] || key}
</th>
))}
<th className="px-4 py-3.5 w-48">SKU Code</th>
<th className="px-4 py-3.5 w-28 text-right">Sale Price</th>
<th className="px-4 py-3.5 w-28 text-right">Cost Price</th>
<th className="px-4 py-3.5 w-24 text-center">Stock</th>
<th className="px-4 py-3.5 w-32">Status</th>
{!readOnly && <th className="px-4 py-3.5 w-16 text-center">Save</th>}
{!readOnly && <th className="px-4 py-3.5 w-24 text-right">Actions</th>}
</tr>
</thead>
<tbody className="divide-y divide-border">
{variants.map(variant => (
<tr
key={variant.id}
className={`hover:bg-background/50 transition-colors border-b border-border ${
selectedIds.has(variant.id) ? 'bg-primary/5/10' : ''
}`}
>
{/* Checkbox */}
{!readOnly && (
<td className="px-4 py-3 text-center">
<input
type="checkbox"
checked={selectedIds.has(variant.id)}
onChange={(e) => onSelectChange(variant.id, e.target.checked)}
className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
/>
</td>
)}
{/* Dynamic cells for each variant axis */}
{axesKeys.map(key => {
const val = variant.attributes[key];
return (
<td key={key} className="px-4 py-3">
<span className="inline-block bg-primary-light text-primary-dark font-semibold text-xs px-2 py-0.5 rounded-full font-mono">
{val || '—'}
</span>
</td>
);
})}
{/* Delegate fields to VariantEditorRow columns via inline styles or matching markup */}
{/* Note: Instead of nesting a complete table inside a tr, we just render the editor cells directly in the matrix tr. */}
{/* To make it extremely clean and reuse the state, we can let VariantEditorRow handle the cells but structure it to match. */}
{/* But since VariantEditorRow expects specific column layouts, we can render the matching tds right here in VariantMatrixView or adapt it. */}
{/* Adapting: Since a tr cannot easily contain another tr, let's render the editor cells inline here for the matrix view. */}
<InlineEditorCells
variant={variant}
onUpdate={onUpdate}
onDelete={onDelete}
onArchive={onArchive}
readOnly={readOnly}
/>
</tr>
))}
{variants.length === 0 && (
<tr>
<td colSpan={axesKeys.length + (readOnly ? 5 : 8)} className="px-6 py-10 text-center text-xs text-muted-foreground font-medium">
No variants found matching criteria.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
};
// ── Inline Editor Cells Helper ────────────────────────────────────────────────
interface InlineCellsProps {
variant: Variant;
onUpdate: (id: string, updates: Partial<Variant>) => Promise<any>;
onDelete: (id: string) => void;
onArchive: (id: string) => void;
readOnly?: boolean;
}
const InlineEditorCells: React.FC<InlineCellsProps> = ({ variant, onUpdate, onDelete, onArchive, readOnly }) => {
const [sku, setSku] = useState(variant.sku);
const [price, setPrice] = useState(String(variant.price));
const [costPrice, setCostPrice] = useState(String(variant.costPrice));
const [stock, setStock] = useState(String(variant.stock));
const [status, setStatus] = useState(variant.status);
const [savingStatus, setSavingStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
useEffect(() => {
setSku(variant.sku);
setPrice(String(variant.price));
setCostPrice(String(variant.costPrice));
setStock(String(variant.stock));
setStatus(variant.status);
}, [variant]);
const handleSave = async () => {
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;
setSavingStatus('saving');
try {
await onUpdate(variant.id, {
sku,
price: isNaN(pNum) ? 0 : pNum,
costPrice: isNaN(cpNum) ? 0 : cpNum,
stock: isNaN(sNum) ? 0 : sNum,
status
});
setSavingStatus('saved');
setTimeout(() => setSavingStatus('idle'), 1500);
} catch (err) {
setSavingStatus('error');
setTimeout(() => setSavingStatus('idle'), 3000);
const statusStyle = (s: string) => {
switch (s) {
case 'active': return 'bg-emerald-100 text-emerald-700 border-emerald-200';
case 'draft': return 'bg-amber-100 text-amberald-700 border-amber-200';
case 'inactive': return 'bg-slate-100 text-slate-500 border-slate-200';
case 'archived': return 'bg-red-50 text-red-500 border-red-200';
default: return 'bg-surface-muted text-muted-foreground border-border';
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
(e.target as HTMLElement).blur();
}
};
if (readOnly) {
if (variants.length === 0) {
return (
<>
<td className="px-4 py-3 font-mono text-xs text-foreground">{sku}</td>
<td className="px-4 py-3 text-right text-xs text-foreground">${price}</td>
<td className="px-4 py-3 text-right text-xs text-foreground">${costPrice}</td>
<td className="px-4 py-3 text-center text-xs text-foreground">{stock}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${
status === 'active' ? 'bg-success/10 text-success' :
status === 'draft' ? 'bg-warning/10 text-warning' : 'bg-surface-muted text-muted-foreground'
}`}>
{status}
</span>
</td>
</>
<div className="flex flex-col items-center justify-center py-20 bg-surface border border-dashed border-border rounded-xl text-muted-foreground gap-3">
<ImageIcon className="w-10 h-10 opacity-20" />
<p className="text-sm font-medium">No variants found</p>
</div>
);
}
return (
<>
<td className="px-4 py-3">
<input
type="text"
value={sku}
onChange={(e) => setSku(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className="w-full text-xs border border-border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary font-mono bg-surface"
/>
</td>
<td className="px-4 py-3">
<div className="relative">
<span className="absolute left-1.5 top-1/2 -translate-y-1/2 text-muted-foreground text-[10px] font-semibold">$</span>
<input
type="number"
step="0.01"
value={price}
onChange={(e) => setPrice(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className="w-full text-xs border border-border rounded pl-4 pr-1 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-right"
/>
</div>
</td>
<td className="px-4 py-3">
<div className="relative">
<span className="absolute left-1.5 top-1/2 -translate-y-1/2 text-muted-foreground text-[10px] font-semibold">$</span>
<input
type="number"
step="0.01"
value={costPrice}
onChange={(e) => setCostPrice(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className="w-full text-xs border border-border rounded pl-4 pr-1 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-right"
/>
</div>
</td>
<td className="px-4 py-3">
<input
type="number"
value={stock}
onChange={(e) => setStock(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className="w-full text-xs border border-border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-center"
/>
</td>
<td className="px-4 py-3">
<select
value={status}
onChange={(e) => setStatus(e.target.value as any)}
onBlur={handleSave}
className="text-xs border border-border rounded px-1.5 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface font-medium text-foreground"
>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="archived">Archived</option>
</select>
</td>
<td className="px-4 py-3 text-center">
{savingStatus === 'saving' && <span className="inline-block w-3.5 h-3.5 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto" />}
{savingStatus === 'saved' && <span className="text-emerald-500 font-bold text-xs"></span>}
{savingStatus === 'error' && <span className="text-red-500 font-bold text-xs"></span>}
</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-1.5">
{/* Select-all toolbar */}
{!readOnly && (
<div className="flex items-center gap-3 mb-3 px-1">
<button
type="button"
onClick={() => onArchive(variant.id)}
title="Archive variant"
className="p-1 hover:bg-amber-50 text-muted-foreground hover:text-amber-600 rounded transition-colors"
onClick={() => onSelectAllChange(!allSelected)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground font-medium transition-colors"
>
<span className="text-[11px]">Archive</span>
</button>
<button
type="button"
onClick={() => onDelete(variant.id)}
title="Delete variant"
className="p-1 hover:bg-red-50 text-muted-foreground hover:text-red-500 rounded transition-colors"
>
<span className="text-[11px]">Delete</span>
{allSelected ? (
<CheckSquare className="w-3.5 h-3.5 text-primary" />
) : someSelected ? (
<CheckSquare className="w-3.5 h-3.5 text-muted-foreground" />
) : (
<Square className="w-3.5 h-3.5" />
)}
{allSelected ? 'Deselect All' : 'Select All'}
</button>
{selectedIds.size > 0 && (
<span className="text-xs font-semibold text-primary bg-primary/10 border border-primary/20 px-2 py-0.5 rounded-full">
{selectedIds.size} selected
</span>
)}
</div>
</td>
)}
{/* Card grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{variants.map(variant => {
const images = variant.images || [];
const primaryImg = images.find(i => i.isPrimary) || images[0];
const thumbUrl = primaryImg?.thumbnailUrl || primaryImg?.url;
const isSelected = selectedIds.has(variant.id);
const axisEntries = Object.entries(variant.attributes || {});
return (
<div
key={variant.id}
className={`group relative flex flex-col bg-surface border rounded-2xl overflow-hidden shadow-xs transition-all duration-200 hover:shadow-md hover:-translate-y-0.5 ${
isSelected
? 'border-primary ring-2 ring-primary/20'
: 'border-border hover:border-primary/30'
}`}
>
{/* Selection checkbox overlay */}
{!readOnly && (
<div className="absolute top-2.5 left-2.5 z-10">
<button
type="button"
onClick={e => { e.stopPropagation(); onSelectChange(variant.id, !isSelected); }}
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
isSelected
? 'bg-primary border-primary'
: 'bg-white/80 border-border opacity-0 group-hover:opacity-100'
}`}
>
{isSelected && (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 12 12">
<path d="M10 3L5 8.5 2 5.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
</svg>
)}
</button>
</div>
)}
{/* Status badge */}
<div className="absolute top-2.5 right-2.5 z-10">
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold uppercase border ${statusStyle(variant.status)}`}>
{variant.status}
</span>
</div>
{/* Image area */}
<button
type="button"
onClick={() => setModalVariant(variant)}
className="relative w-full aspect-square bg-background overflow-hidden focus:outline-none"
>
{thumbUrl ? (
<img
src={thumbUrl}
alt={variant.name}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-1.5 text-muted-foreground/40">
<ImageIcon className="w-8 h-8" />
<span className="text-[10px] font-medium">No image</span>
</div>
)}
{/* Image count badge */}
{images.length > 1 && (
<div className="absolute bottom-2 right-2 bg-black/60 text-white text-[10px] font-semibold px-1.5 py-0.5 rounded-md backdrop-blur-sm">
+{images.length - 1}
</div>
)}
{/* Hover overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-200 flex items-center justify-center">
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-white/90 backdrop-blur-sm rounded-full p-2 shadow-lg">
<Edit2 className="w-4 h-4 text-foreground" />
</div>
</div>
</button>
{/* Card body */}
<div className="p-3 flex flex-col gap-2 flex-1">
{/* Axis pills */}
<div className="flex flex-wrap gap-1">
{axisEntries.map(([key, val]) => (
<span
key={key}
className="inline-flex items-center gap-0.5 px-2 py-0.5 bg-primary/8 border border-primary/15 rounded-full text-[10px] font-semibold text-primary"
>
<Tag className="w-2.5 h-2.5 opacity-60" />
{val}
</span>
))}
{axisEntries.length === 0 && (
<span className="text-[10px] text-muted-foreground">No axes</span>
)}
</div>
{/* SKU */}
<div className="font-mono text-[10px] text-muted-foreground truncate" title={variant.sku}>
{variant.sku || '—'}
</div>
{/* Price row */}
<div className="flex items-center justify-between mt-auto pt-1 border-t border-border">
<span className="text-sm font-bold text-foreground">
{variant.price > 0 ? `$${variant.price.toFixed(2)}` : <span className="text-muted-foreground text-xs">No price</span>}
</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{!readOnly && (
<>
<button
type="button"
onClick={e => { e.stopPropagation(); setModalVariant(variant); }}
title="Edit"
className="p-1 hover:bg-primary/10 text-muted-foreground hover:text-primary rounded transition-colors"
>
<Edit2 className="w-3 h-3" />
</button>
<button
type="button"
onClick={e => { e.stopPropagation(); onArchive(variant.id); }}
title="Archive"
className="p-1 hover:bg-amber-50 text-muted-foreground hover:text-amber-600 rounded transition-colors"
>
<Archive className="w-3 h-3" />
</button>
<button
type="button"
onClick={e => { e.stopPropagation(); if (window.confirm('Delete variant?')) onDelete(variant.id); }}
title="Delete"
className="p-1 hover:bg-red-50 text-muted-foreground hover:text-red-500 rounded transition-colors"
>
<Trash2 className="w-3 h-3" />
</button>
</>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
{/* Variant Detail Modal */}
{modalVariant && (
<VariantDetailModal
variant={modalVariant}
onClose={() => setModalVariant(null)}
onUpdate={async (id, updates) => {
const updated = await onUpdate(id, updates);
// Reflect updated data in the modal
if (updated) setModalVariant(prev => prev ? { ...prev, ...updates } : null);
return updated;
}}
onDelete={onDelete ? id => { onDelete(id); setModalVariant(null); } : undefined}
onArchive={onArchive ? id => { onArchive(id); setModalVariant(null); } : undefined}
readOnly={readOnly}
/>
)}
</>
);
};
@@ -5,8 +5,9 @@ import { VariantListView } from './VariantListView';
import { VariantMatrixView } from './VariantMatrixView';
import { VariantBulkActions } from './VariantBulkActions';
import type { VariantAxis, VariantStatus, Variant } from '../../types/variant.types';
import { Info, LayoutGrid, List, Plus, RefreshCw, Layers } from 'lucide-react';
import { Info, LayoutGrid, List, Plus, RefreshCw, Layers, X } from 'lucide-react';
import { Loader } from '../../../../components/customs/Loader';
import { notify } from '../../../../services/toast';
interface VariantsTabProps {
productId?: string;
@@ -14,6 +15,8 @@ interface VariantsTabProps {
parentSku: string;
family: any; // Product Family details
readOnly?: boolean;
productAttributes?: Record<string, any>;
availableAttributes?: any[];
}
export const VariantsTab: React.FC<VariantsTabProps> = ({
@@ -21,7 +24,9 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
productType,
parentSku,
family,
readOnly
readOnly,
productAttributes = {},
availableAttributes = []
}) => {
const {
variants,
@@ -39,6 +44,24 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
const [showGenerator, setShowGenerator] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Local state for dynamically configured variant axes (flows from family, reconstructed from variants, or custom selected)
const [localAxes, setLocalAxes] = useState<VariantAxis[]>([]);
// Filter attributes that are marked as variant eligible OR are of eligible types (including type 'color')
const selectableAttributes = useMemo(() => {
return availableAttributes.filter(attr =>
attr.is_variant_eligible === true ||
attr.isVariantEligible === true ||
['select', 'enumeration', 'swatch', 'multiselect', 'color'].includes(attr.type || '')
);
}, [availableAttributes]);
// Form states for axis addition
const [selectedAttrId, setSelectedAttrId] = useState('');
const [customAxisName, setCustomAxisName] = useState('');
const [customAxisCode, setCustomAxisCode] = useState('');
const [isAxesConfigOpen, setIsAxesConfigOpen] = useState(false);
// Load existing variants if product is created
useEffect(() => {
if (productId) {
@@ -46,34 +69,146 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
}
}, [productId, fetchByProduct]);
// Extract configured axes from the family
const variantAxes: VariantAxis[] = useMemo(() => {
if (!family || !Array.isArray(family.variantAxes)) return [];
return family.variantAxes;
}, [family]);
// Resolve and sync axes definitions (from family first, then reconstructed from variants, then auto-detected from productAttributes)
useEffect(() => {
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 || []
});
}
});
const axesKeys = useMemo(() => variantAxes.map(a => a.code), [variantAxes]);
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);
}
}
}
}, [family, variants, selectableAttributes, productAttributes]);
const handleAddAttributeAxis = () => {
if (!selectedAttrId) return;
const attr = selectableAttributes.find(a => a.id === selectedAttrId);
if (!attr) return;
if (localAxes.some(la => la.code === attr.code)) {
notify.error(`Axis with code "${attr.code}" is already added.`);
return;
}
const newAxis: VariantAxis = {
...attr,
id: attr.id,
code: attr.code,
name: attr.name,
type: attr.type || 'select',
optionsList: attr.optionsList || []
};
setLocalAxes(prev => [...prev, newAxis]);
setSelectedAttrId('');
notify.success(`Added axis: ${attr.name}`);
};
const handleAddCustomAxis = () => {
const name = customAxisName.trim();
let code = customAxisCode.trim().toLowerCase().replace(/[^a-z0-9]/g, '_');
if (!name) {
notify.error('Please enter a name for the custom axis.');
return;
}
if (!code) {
code = name.toLowerCase().replace(/[^a-z0-9]/g, '_');
}
if (localAxes.some(la => la.code === code)) {
notify.error(`Axis with code "${code}" is already added.`);
return;
}
const newAxis: VariantAxis = {
id: `custom-${Date.now()}`,
code,
name,
type: 'select',
optionsList: []
};
setLocalAxes(prev => [...prev, newAxis]);
setCustomAxisName('');
setCustomAxisCode('');
notify.success(`Added custom axis: ${name}`);
};
const handleRemoveAxis = (code: string) => {
setLocalAxes(prev => prev.filter(la => la.code !== code));
notify.info(`Removed axis: ${code}`);
};
const initialSelectedValues = useMemo(() => {
const map: Record<string, string[]> = {};
if (!productAttributes) return map;
localAxes.forEach(axis => {
const val = productAttributes[axis.code];
if (val !== undefined && val !== null && val !== '') {
if (Array.isArray(val)) {
map[axis.code] = val.map(String);
} else if (typeof val === 'string' && val.includes(',')) {
map[axis.code] = val.split(',').map(s => s.trim());
} else {
map[axis.code] = [String(val)];
}
}
});
return map;
}, [localAxes, productAttributes]);
const axesKeys = useMemo(() => localAxes.map(a => a.code), [localAxes]);
const axesNames = useMemo(() => {
const map: Record<string, string> = {};
variantAxes.forEach(a => {
localAxes.forEach(a => {
map[a.code] = a.name;
});
return map;
}, [variantAxes]);
// If the family does not support variants
if (variantAxes.length === 0) {
return (
<div className="p-8 text-center bg-surface rounded-xl border border-border shadow-sm">
<Layers className="w-10 h-10 text-muted-foreground mx-auto mb-3" />
<h3 className="font-semibold text-foreground mb-1">This Product Family does not support variants.</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
The assigned Product Family ({family?.name || 'Selected Family'}) has no variant axes configured.
</p>
</div>
);
}
}, [localAxes]);
// If the product is not Configurable (type !== 'variant')
if (productType !== 'variant') {
@@ -103,7 +238,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
const handleGenerate = async (selected: Record<string, string[]>, skuTemplate: string) => {
const formattedAxes = Object.entries(selected).map(([code, values]) => {
const axisInfo = variantAxes.find(a => a.code === code);
const axisInfo = localAxes.find(a => a.code === code);
return {
code,
name: axisInfo?.name || code,
@@ -119,7 +254,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
});
setShowGenerator(false);
} catch (err) {
// toast notification is done inside the hook
// handled in hook
}
};
@@ -143,14 +278,13 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
}
};
const handleBulkUpdates = async (updates: { price?: number; costPrice?: number; stock?: number; status?: VariantStatus }) => {
const handleBulkUpdates = async (updates: { price?: number; costPrice?: number; status?: VariantStatus }) => {
try {
const ids = Array.from(selectedIds);
await bulkUpdate({
ids,
updates
});
// Refresh items
fetchByProduct(productId);
setSelectedIds(new Set());
} catch (err) {}
@@ -176,6 +310,101 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
return updateVariant(id, updates);
};
const renderAxisCreatorControls = () => {
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-2">
{/* Option A: Choose from Attribute Set */}
{selectableAttributes.length > 0 && (
<div className="border border-border/80 rounded-xl p-5 bg-background/25 space-y-4">
<div>
<h4 className="font-semibold text-foreground text-xs uppercase tracking-wider">A. Choose from Attribute Set</h4>
<p className="text-[11px] text-muted-foreground mt-1">Designate a dropdown/select attribute from your assigned Attribute Set.</p>
</div>
<div className="flex gap-2">
<select
value={selectedAttrId}
onChange={(e) => setSelectedAttrId(e.target.value)}
className="w-full text-xs border border-border rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary bg-surface font-medium text-foreground"
>
<option value="">Select Attribute...</option>
{selectableAttributes.map(attr => (
<option key={attr.id} value={attr.id}>
{attr.name} ({attr.code})
</option>
))}
</select>
<button
type="button"
onClick={handleAddAttributeAxis}
disabled={!selectedAttrId}
className="px-3 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
Add Axis
</button>
</div>
</div>
)}
{/* Option B: Create Custom Axis */}
<div className="border border-border/80 rounded-xl p-5 bg-background/25 space-y-4">
<div>
<h4 className="font-semibold text-foreground text-xs uppercase tracking-wider">B. Create Custom Axis</h4>
<p className="text-[11px] text-muted-foreground mt-1">Create a custom variant axis not present in the attribute set (e.g. Size, Color).</p>
</div>
<div className="grid grid-cols-2 gap-2">
<input
type="text"
placeholder="Axis Name (e.g. Size)"
value={customAxisName}
onChange={(e) => setCustomAxisName(e.target.value)}
className="w-full text-xs border border-border rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-foreground"
/>
<input
type="text"
placeholder="Axis Code (e.g. size)"
value={customAxisCode}
onChange={(e) => setCustomAxisCode(e.target.value)}
className="w-full text-xs border border-border rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-foreground"
/>
</div>
<div className="flex justify-end">
<button
type="button"
onClick={handleAddCustomAxis}
className="px-3 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold"
>
Add Custom Axis
</button>
</div>
</div>
</div>
{/* List of currently added local axes */}
{localAxes.length > 0 && (
<div className="border border-border rounded-xl p-5 bg-background/10 space-y-3">
<h4 className="font-semibold text-foreground text-xs uppercase tracking-wider">Designated Variant Axes ({localAxes.length})</h4>
<div className="flex flex-wrap gap-2">
{localAxes.map(axis => (
<span key={axis.code} className="inline-flex items-center gap-1.5 px-3 py-1 bg-surface border border-border rounded-lg text-xs font-semibold text-foreground">
{axis.name} <span className="text-muted-foreground font-mono">({axis.code})</span>
<button
type="button"
onClick={() => handleRemoveAxis(axis.code)}
className="p-0.5 hover:bg-red-50 hover:text-red-500 rounded transition-colors text-muted-foreground"
title="Remove axis"
>
<X className="w-3.5 h-3.5" />
</button>
</span>
))}
</div>
</div>
)}
</div>
);
};
// Loading spinner for variant fetching
if (loading && variants.length === 0) {
return (
@@ -185,26 +414,64 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
);
}
// If local variant axes list is empty, they must add at least one axis
if (localAxes.length === 0) {
return (
<div className="space-y-4 bg-surface rounded-xl border border-border p-6 shadow-sm">
<div className="border-b border-border pb-4">
<div className="flex items-center gap-2">
<Layers className="w-5 h-5 text-primary" />
<h3 className="font-semibold text-foreground text-sm">Configure Variant Axes</h3>
</div>
<p className="text-xs text-muted-foreground mt-1">
This product has no variant axes defined. Designate attributes from your Attribute Set or add custom ones to enable variant generation.
</p>
</div>
{renderAxisCreatorControls()}
</div>
);
}
// Show generator UI if variants list is empty or generator toggled on
if (variants.length === 0 || showGenerator) {
return (
<div className="space-y-4">
{variants.length > 0 && (
<div className="flex justify-start">
<div className="flex justify-between items-center gap-4 flex-wrap">
<div className="flex gap-2">
{variants.length > 0 && (
<button
type="button"
onClick={() => setShowGenerator(false)}
className="px-3 py-1.5 border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold"
>
Cancel and view variants
</button>
)}
<button
type="button"
onClick={() => setShowGenerator(false)}
className="px-3 py-1.5 border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold"
onClick={() => setIsAxesConfigOpen(!isAxesConfigOpen)}
className="px-3 py-1.5 bg-surface hover:bg-background border border-border text-foreground rounded-lg text-xs font-semibold flex items-center gap-1.5"
>
Cancel and view variants
<Layers className="w-3.5 h-3.5 text-muted-foreground" />
{isAxesConfigOpen ? 'Hide Axes Config' : 'Configure Variant Axes'}
</button>
</div>
</div>
{isAxesConfigOpen && (
<div className="bg-surface rounded-xl border border-border p-5 shadow-xs space-y-4">
<h4 className="font-semibold text-foreground text-xs uppercase tracking-wider">Configure Variant Axes</h4>
{renderAxisCreatorControls()}
</div>
)}
<VariantAxesSelector
axes={variantAxes}
axes={localAxes}
onGenerate={handleGenerate}
generating={generating}
parentSku={parentSku}
initialSelectedValues={initialSelectedValues}
/>
</div>
);
+130 -41
View File
@@ -68,6 +68,33 @@ export default function NewProduct() {
const attributeSearchDropdownRef = useRef<HTMLDivElement>(null);
const [customAddedAttributes, setCustomAddedAttributes] = useState<any[]>([]);
const handleRemoveCustomAttribute = (attrId: string) => {
const attr = customAddedAttributes.find(a => a.id === attrId);
setCustomAddedAttributes(prev => prev.filter(a => a.id !== attrId));
if (attr && attr.code) {
formik.setFieldValue(`attributes.${attr.code}`, undefined);
}
};
const [excludedGroupIds, setExcludedGroupIds] = useState<Set<string>>(new Set());
const [productType, setProductType] = useState('simple');
const handleRemoveGroup = (groupId: string) => {
setExcludedGroupIds(prev => {
const next = new Set(prev);
next.add(String(groupId));
return next;
});
const group = filteredAttributeGroups.find((g: any) => String(g.id || g._id) === String(groupId));
if (group && Array.isArray(group.attributes)) {
group.attributes.forEach((attr: any) => {
if (attr && attr.code) {
formik.setFieldValue(`attributes.${attr.code}`, undefined);
}
});
}
};
const [newlyCreatedBrandIds, setNewlyCreatedBrandIds] = useState<string[]>([]);
const [newlyCreatedUnitIds, setNewlyCreatedUnitIds] = useState<string[]>([]);
@@ -128,9 +155,9 @@ export default function NewProduct() {
const displayChannelsList = useMemo(() => {
const defaults = [
{ id: 'ch-shopify', name: 'Shopify Storefront', code: 'shopify', description: 'Direct Shopify e-commerce catalog sync', status: 'active' },
{ id: 'ch-amazon', name: 'Amazon Marketplace', code: 'amazon', description: 'Amazon seller central product listings', status: 'active' },
{ id: 'ch-custom-csv', name: 'Custom CSV Feed', code: 'custom_csv', description: 'Exportable CSV/XML syndication pipeline feed', status: 'active' }
{ id: 'ch-shopify', name: 'Shopify Storefront', code: 'shopify', description: 'Direct Shopify e-commerce catalog sync', status: 'active' as const, createdAt: '' },
{ id: 'ch-amazon', name: 'Amazon Marketplace', code: 'amazon', description: 'Amazon seller central product listings', status: 'active' as const, createdAt: '' },
{ id: 'ch-custom-csv', name: 'Custom CSV Feed', code: 'custom_csv', description: 'Exportable CSV/XML syndication pipeline feed', status: 'active' as const, createdAt: '' }
];
if (!allChannels || allChannels.length === 0) return defaults;
const merged = [...allChannels];
@@ -219,6 +246,7 @@ export default function NewProduct() {
const handleAttributeSetChange = async (setId: string) => {
setSelectedAttributeSetId(setId || null);
setExcludedGroupIds(new Set());
if (!setId) {
setSelectedAttributeSetObj(null);
setCustomAddedAttributes([]);
@@ -242,12 +270,17 @@ export default function NewProduct() {
};
// Resolve list of brands based on allowed list
const activeAllowedBrandsList = useMemo(() => {
if (familyBrands && familyBrands.length > 0) {
return brands.filter(b =>
newlyCreatedBrandIds.includes(b.id) ||
familyBrands.some((fb: any) => (typeof fb === 'string' ? fb === b.id : fb?.id === b.id))
familyBrands.some((fb: any) => {
if (typeof fb === 'string') {
const val = fb.toLowerCase().trim();
return val === b.id || val === (b.code || '').toLowerCase().trim() || val === (b.name || '').toLowerCase().trim();
}
return fb?.id === b.id || (fb?.code || '').toLowerCase().trim() === (b.code || '').toLowerCase().trim();
})
);
}
return brands;
@@ -345,19 +378,36 @@ export default function NewProduct() {
return groupsCopy;
}, [activeAttributeGroups, customAddedAttributes]);
const variantAxesCodes = useMemo(() => {
if (!family || !Array.isArray(family.variantAxes)) return [];
return family.variantAxes.map((a: any) => (a.code || '').toLowerCase().trim());
}, [family]);
const filteredAttributeGroups = useMemo(() => {
if (!unifiedAttributeGroups) return [];
const isVariableProduct = productType === 'variant';
return unifiedAttributeGroups.map((group: any) => ({
...group,
id: group.id || group._id,
attributes: (group.attributes || []).filter((attr: any) => !EXCLUDED_ATTRIBUTE_CODES.includes((attr.code || '').toLowerCase()))
})).filter((group: any) => (group.attributes || []).length > 0);
}, [unifiedAttributeGroups]);
attributes: (group.attributes || []).filter((attr: any) => {
const codeLower = (attr.code || '').toLowerCase().trim();
if (EXCLUDED_ATTRIBUTE_CODES.includes(codeLower)) return false;
if (isVariableProduct && variantAxesCodes.includes(codeLower)) return false;
return true;
})
})).filter((group: any) => (group.attributes || []).length > 0 && !excludedGroupIds.has(String(group.id || group._id)));
}, [unifiedAttributeGroups, excludedGroupIds, variantAxesCodes, productType]);
const filteredAttributesList = useMemo(() => {
if (!activeAttributesList) return [];
return activeAttributesList.filter((attr: any) => !EXCLUDED_ATTRIBUTE_CODES.includes((attr.code || '').toLowerCase()));
}, [activeAttributesList]);
const isVariableProduct = productType === 'variant';
return activeAttributesList.filter((attr: any) => {
const codeLower = (attr.code || '').toLowerCase().trim();
if (EXCLUDED_ATTRIBUTE_CODES.includes(codeLower)) return false;
if (isVariableProduct && variantAxesCodes.includes(codeLower)) return false;
return true;
});
}, [activeAttributesList, variantAxesCodes, productType]);
const filteredRegistryAttributes = useMemo(() => {
if (!allRegistryAttributes) return [];
@@ -614,6 +664,12 @@ export default function NewProduct() {
}
});
useEffect(() => {
if (formik.values.type !== productType) {
setProductType(formik.values.type);
}
}, [formik.values.type, productType]);
const areRequiredAttributesComplete = useMemo(() => {
if (!Array.isArray(filteredAttributesList)) return true;
return !filteredAttributesList.some((attr: any) => {
@@ -628,7 +684,11 @@ export default function NewProduct() {
const currentTabs = useMemo(() => {
const isVariant = formik.values.type === 'variant';
const assetsCount = product?.productAssets?.length ?? 0;
const productAssetsCount = product?.productAssets?.length ?? 0;
const variantAssetsCount = (product?.variants || []).reduce(
(sum: number, v: any) => sum + (v.images?.length ?? 0), 0
);
const assetsCount = productAssetsCount + variantAssetsCount;
const tabsList = [
{ id: 'general', label: 'General', icon: Box },
{ id: 'attributes', label: 'Attributes', icon: LayoutGrid },
@@ -638,7 +698,7 @@ export default function NewProduct() {
{ id: 'review', label: 'Review', icon: Eye },
];
return tabsList.map((t, idx) => ({ ...t, step: idx + 1 }));
}, [formik.values.type, product?.productAssets]);
}, [formik.values.type, product?.productAssets, product?.variants]);
// Redirect if current activeTab is not in available tabs list (e.g. Variants removed)
useEffect(() => {
@@ -891,7 +951,9 @@ export default function NewProduct() {
score += 20;
}
if (product?.productAssets && product.productAssets.length > 0) {
const hasProductAssets = product?.productAssets && product.productAssets.length > 0;
const hasVariantAssets = (product?.variants || []).some((v: any) => (v.images && v.images.length > 0) || (v.variantAssets && v.variantAssets.length > 0));
if (hasProductAssets || hasVariantAssets) {
score += 15;
}
@@ -1302,13 +1364,13 @@ export default function NewProduct() {
stroke="var(--color-primary)"
strokeWidth="10"
strokeDasharray="283"
strokeDashoffset={283 - (283 * (product?.completeness !== undefined && product?.completeness !== null && product.completeness > 0 ? product.completeness : productCompleteness)) / 100}
strokeDashoffset={283 - (283 * (product?.completeness !== undefined && product?.completeness !== null ? product.completeness : productCompleteness)) / 100}
strokeLinecap="round"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-sm font-bold text-foreground">
{product?.completeness !== undefined && product?.completeness !== null && product.completeness > 0 ? product.completeness : productCompleteness}%
{product?.completeness !== undefined && product?.completeness !== null ? product.completeness : productCompleteness}%
</span>
</div>
</div>
@@ -1316,9 +1378,12 @@ export default function NewProduct() {
<div className="space-y-1.5 text-[11px]">
{[
{ label: 'Variants', value: String(product?.variants?.length || 0) },
{ label: 'Assets', value: String(product?.productAssets?.length || 0) },
{ label: 'Assets', value: String(
(product?.productAssets?.length || 0) +
(product?.variants || []).reduce((s: number, v: any) => s + (v.images?.length ?? 0), 0)
)},
{ label: 'Category', value: formik.values.category ? '1' : '0' },
{ label: 'Channels', value: String(product?.metadata?.channels?.length || 0) },
{ label: 'Channels', value: String((formik.values.metadata?.channels?.length || 0) + (family?.channels?.length || 0)) },
].map(({ label, value }) => (
<div key={label} className="flex justify-between items-center">
<span className="text-muted-foreground">{label}</span>
@@ -1548,10 +1613,7 @@ export default function NewProduct() {
<label className={labelClass}>Price ($)</label>
<input name="price" type="number" step="0.01" value={formik.values.price} onChange={formik.handleChange} onBlur={formik.handleBlur} placeholder="0.00" className={inputClass} disabled={isReadOnlyView} />
</div>
<div>
<label className={labelClass}>Initial Stock</label>
<input name="stock" type="number" value={formik.values.stock} onChange={formik.handleChange} onBlur={formik.handleBlur} placeholder="0" className={inputClass} disabled={isReadOnlyView} />
</div>
<div>
<label className={labelClass}>SKU</label>
<input name="sku" value={formik.values.sku} onChange={formik.handleChange} placeholder="Stock Keeping Unit" className={inputClass} disabled={isReadOnlyView} />
@@ -1747,7 +1809,18 @@ export default function NewProduct() {
{/* Attribute Set dropdown */}
<div className="flex-1 min-w-[280px] max-w-md">
<label className="block text-xs font-semibold text-muted-foreground mb-1.5">Attribute Set</label>
<div className="flex items-center justify-between mb-1.5">
<label className="block text-xs font-semibold text-muted-foreground">Attribute Set</label>
{selectedAttributeSetId && !isReadOnlyView && !(family && family.attributeSet) && (
<button
type="button"
onClick={() => handleAttributeSetChange('')}
className="text-[11px] text-red-500 hover:text-red-600 font-semibold transition-colors cursor-pointer"
>
Remove Set
</button>
)}
</div>
{/* Searchable Attribute Set Selector */}
<div ref={attributeSetDropdownRef} className="relative">
@@ -1928,6 +2001,9 @@ export default function NewProduct() {
}}
onAttributeBlur={(code) => formik.setFieldTouched(`attributes.${code}`, true)}
onAddAttributeClick={(group) => handleOpenCreateAttributeModal(group.id || group._id)}
onRemoveAttribute={handleRemoveCustomAttribute}
onRemoveGroup={handleRemoveGroup}
customAttributeIds={new Set(customAddedAttributes.map(a => a.id))}
readOnly={isReadOnlyView}
/>
)}
@@ -1947,6 +2023,8 @@ export default function NewProduct() {
parentSku={formik.values.sku}
family={family}
readOnly={isReadOnlyView}
productAttributes={formik.values.attributes}
availableAttributes={activeAttributesList}
/>
)
)}
@@ -2074,12 +2152,7 @@ export default function NewProduct() {
{formik.values.price !== '' ? `$${formik.values.price}` : (product?.price ? `$${product.price}` : '—')}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">Initial Stock</div>
<div className="font-semibold text-sm text-foreground">
{formik.values.stock !== undefined ? formik.values.stock : (product?.stock ?? 0)} pcs
</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">Unit of Measure</div>
<div className="font-medium text-sm text-foreground">
@@ -2246,8 +2319,21 @@ export default function NewProduct() {
{/* ── Assets ── */}
{(() => {
const assets = product?.productAssets || [];
const ASSET_PREVIEW = 6;
const globalAssets = (product?.productAssets || []).map((pa: any) => ({
...pa,
scope: 'Global'
}));
const variantAssetsList = (product?.variants || []).flatMap((v: any) =>
(v.images || []).map((img: any) => ({
id: img.assetId || img.url,
asset: img,
role: img.role,
is_primary: img.isPrimary,
scope: `Variant: ${v.name?.split(' - ')[1] || v.name || v.sku}`
}))
);
const allReviewAssets = [...globalAssets, ...variantAssetsList];
const ASSET_PREVIEW = 8;
return (
<div className="bg-surface border border-border rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
@@ -2255,7 +2341,7 @@ export default function NewProduct() {
<ImageIcon className="w-4 h-4 text-primary" />
<h3 className="font-semibold text-sm text-foreground">Assets</h3>
<span className="text-xs text-muted-foreground font-medium">
{assets.length > 0 ? `${assets.length} uploaded` : 'None uploaded'}
{allReviewAssets.length > 0 ? `${allReviewAssets.length} uploaded` : 'None uploaded'}
</span>
</div>
<button
@@ -2266,15 +2352,15 @@ export default function NewProduct() {
<Pencil className="w-3 h-3" /> Edit
</button>
</div>
{assets.length > 0 ? (
<div className="grid grid-cols-1 gap-2">
{assets.slice(0, ASSET_PREVIEW).map((pa: any, idx: number) => {
{allReviewAssets.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{allReviewAssets.slice(0, ASSET_PREVIEW).map((pa: any, idx: number) => {
const asset = pa.asset || pa;
const thumb = asset.thumbnail_url || asset.url || null;
const thumb = asset.thumbnail_url || asset.thumbnailUrl || asset.file_url || asset.url || null;
const name = asset.name || asset.original_name || `Asset ${idx + 1}`;
const typeName = asset.assetType?.name || pa.asset_type || null;
const role = pa.role || null;
const isPrimary = pa.is_primary;
const isPrimary = pa.is_primary || pa.isPrimary;
const scope = pa.scope || 'Global';
return (
<div key={pa.id || idx} className="flex items-center gap-3 p-2.5 border border-border rounded-lg bg-background">
{thumb ? (
@@ -2292,16 +2378,19 @@ export default function NewProduct() {
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] font-bold text-primary bg-primary/10 border border-primary/20 px-1.5 py-0.5 rounded uppercase">
{role ? role.replace('_', ' ') : 'HERO IMAGE'}
<span className="text-[9px] font-bold text-primary bg-primary/10 border border-primary/20 px-1.5 py-0.5 rounded uppercase">
{role ? String(role).replace('_', ' ') : 'MEDIA'}
</span>
<span className="text-[9px] font-medium text-muted-foreground bg-surface-muted border border-border px-1.5 py-0.5 rounded truncate">
{scope}
</span>
</div>
</div>
</div>
);
})}
{assets.length > ASSET_PREVIEW && (
<div className="text-xs text-muted-foreground text-center pt-1">+{assets.length - ASSET_PREVIEW} more assets</div>
{allReviewAssets.length > ASSET_PREVIEW && (
<div className="col-span-2 text-xs text-muted-foreground text-center pt-1">+{allReviewAssets.length - ASSET_PREVIEW} more assets</div>
)}
</div>
) : (
+4 -1
View File
@@ -47,16 +47,19 @@ export interface Variant {
barcode?: string;
weight?: number;
dimensions?: { length?: number; width?: number; height?: number };
images?: VariantImageSlot[];
images: VariantImageSlot[];
}
// ─── Image slot (prepared for Phase 3 DAM integration) ────────────────────
export interface VariantImageSlot {
assetId?: string;
url?: string;
thumbnailUrl?: string;
name?: string;
role: 'primary' | 'gallery' | 'swatch';
isPrimary: boolean;
displayOrder: number;
assetType?: { id: string; code: string; name: string } | null;
}
// ─── Axis configuration for batch generation ──────────────────────────────
@@ -2,11 +2,31 @@ import axiosInstance from '../../../api/axiosInstance';
export const settingsService = {
getCategorySettings: async (category: string) => {
const response = await axiosInstance.get(`/settings/by-category/${category}`);
return response.data.data;
const response: any = await axiosInstance.get(`/settings/by-category/${category}`);
return response.data?.data;
},
updateCategorySettings: async (category: string, data: Record<string, any>) => {
const response = await axiosInstance.put(`/settings/by-category/${category}`, data);
const response: any = await axiosInstance.put(`/settings/by-category/${category}`, data);
return response.data;
},
getAll: async (): Promise<any[]> => {
const response: any = await axiosInstance.get('/settings');
return response.data?.data || response.data || [];
},
getById: async (id: string): Promise<any> => {
const response: any = await axiosInstance.get(`/settings/${id}`);
return response.data?.data || response.data;
},
create: async (data: any): Promise<any> => {
const response: any = await axiosInstance.post('/settings', data);
return response.data?.data || response.data;
},
update: async (id: string, data: any): Promise<any> => {
const response: any = await axiosInstance.put(`/settings/${id}`, data);
return response.data?.data || response.data;
},
delete: async (id: string): Promise<any> => {
const response: any = await axiosInstance.delete(`/settings/${id}`);
return response.data;
}
};
-1
View File
@@ -5,7 +5,6 @@ import {
Grid3x3,
Tag,
Layers,
Database,
Ruler,
Award,
Image,