5 Commits
25 changed files with 4650 additions and 1596 deletions
+42 -21
View File
@@ -86,7 +86,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -302,6 +301,27 @@
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
"license": "MIT"
},
"node_modules/@emnapi/core": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
@@ -625,6 +645,7 @@
"integrity": "sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
@@ -1361,6 +1382,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -1378,6 +1400,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -1395,6 +1418,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -1412,6 +1436,7 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -1429,6 +1454,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -1446,6 +1472,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -1463,6 +1490,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -1480,6 +1508,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -1497,6 +1526,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -1514,6 +1544,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -1531,6 +1562,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -1548,6 +1580,7 @@
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -1562,6 +1595,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/core": "1.11.2",
"@emnapi/runtime": "1.11.2",
@@ -1578,6 +1612,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
@@ -1590,6 +1625,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -1601,6 +1637,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -1618,6 +1655,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -1635,6 +1673,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -1645,7 +1684,6 @@
"integrity": "sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"picomatch": "^4.0.4"
},
@@ -2121,7 +2159,6 @@
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.18.0"
}
@@ -2131,7 +2168,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2142,7 +2178,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -2198,7 +2233,6 @@
"integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.61.1",
"@typescript-eslint/types": "8.61.1",
@@ -2429,7 +2463,6 @@
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2512,7 +2545,6 @@
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/types": "^7.26.0"
}
@@ -2573,7 +2605,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -3025,7 +3056,6 @@
"integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==",
"dev": true,
"license": "MIT",
"peer": true,
"workspaces": [
"packages/*"
],
@@ -4289,7 +4319,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -4325,7 +4354,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -4344,7 +4372,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.81.0.tgz",
"integrity": "sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -4360,15 +4387,13 @@
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -4557,8 +4582,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -4822,7 +4846,6 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4982,7 +5005,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -5463,7 +5485,6 @@
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+85 -30
View File
@@ -1,5 +1,6 @@
import * as React from "react";
import { createPortal } from "react-dom";
import { Search, X, Check, ChevronDown } from "lucide-react";
import { cn } from "../../lib/utils";
interface Option {
@@ -18,6 +19,7 @@ interface SelectProps {
placeholder?: string;
children?: React.ReactNode;
disabled?: boolean;
searchable?: boolean;
}
export function Select({
@@ -31,11 +33,14 @@ export function Select({
placeholder = "Select...",
children,
disabled,
searchable,
}: SelectProps) {
const [isOpen, setIsOpen] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState("");
const [rect, setRect] = React.useState<DOMRect | null>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const dropdownRef = React.useRef<HTMLDivElement>(null);
const searchInputRef = React.useRef<HTMLInputElement>(null);
const options = React.useMemo(() => {
const opts: Option[] = [];
@@ -53,6 +58,19 @@ export function Select({
const selectedOption = options.find((opt) => opt.value === value);
// By default, enable search if there are more than 5 options or if explicitly requested
const isSearchEnabled = searchable !== undefined ? searchable : options.length > 5;
const filteredOptions = React.useMemo(() => {
if (!isSearchEnabled || !searchQuery.trim()) {
return options;
}
const q = searchQuery.toLowerCase().trim();
return options.filter((opt) =>
opt.label.toLowerCase().includes(q) || opt.value.toLowerCase().includes(q)
);
}, [options, isSearchEnabled, searchQuery]);
// Recalculate position on scroll/resize while open
React.useEffect(() => {
if (!isOpen) return;
@@ -68,6 +86,17 @@ export function Select({
};
}, [isOpen]);
// Auto-focus search input when opened
React.useEffect(() => {
if (isOpen && isSearchEnabled) {
setTimeout(() => {
searchInputRef.current?.focus();
}, 50);
} else if (!isOpen) {
setSearchQuery("");
}
}, [isOpen, isSearchEnabled]);
// Close on outside click
React.useEffect(() => {
if (!isOpen) return;
@@ -77,6 +106,7 @@ export function Select({
dropdownRef.current?.contains(e.target as Node)
) return;
setIsOpen(false);
setSearchQuery("");
onBlur?.({ target: { name } } as any);
};
document.addEventListener("mousedown", handleClickOutside);
@@ -94,10 +124,11 @@ export function Select({
const handleSelect = (val: string) => {
onChange?.({ target: { name, value: val } });
setIsOpen(false);
setSearchQuery("");
};
return (
<div className="relative w-full">
<div className="relative w-full font-sans">
<button
ref={buttonRef}
id={id}
@@ -115,18 +146,12 @@ export function Select({
className
)}
>
<span className={cn(!selectedOption && "text-muted-foreground")}>
<span className={cn(!selectedOption && "text-muted-foreground", "truncate pr-2")}>
{selectedOption ? selectedOption.label : placeholder}
</span>
<svg
className={cn("h-4 w-4 text-muted-foreground transition-transform", isOpen && "rotate-180")}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="m19 9-7 7-7-7" />
</svg>
<ChevronDown
className={cn("h-4 w-4 text-muted-foreground transition-transform shrink-0", isOpen && "rotate-180")}
/>
</button>
{isOpen && rect && createPortal(
@@ -136,30 +161,60 @@ export function Select({
position: "fixed",
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
width: Math.max(rect.width, 220),
zIndex: 9999,
}}
className="bg-surface text-foreground border border-border shadow-lg rounded-xl p-1 max-h-60 overflow-y-auto"
className="bg-surface text-foreground border border-border shadow-xl rounded-xl overflow-hidden flex flex-col max-h-64 font-sans animate-in fade-in zoom-in-95 duration-100"
>
{options.map((opt) => (
<div
key={opt.value}
onMouseDown={(e) => { e.preventDefault(); handleSelect(opt.value); }}
className={cn(
"cursor-pointer rounded-lg px-3 py-2 text-sm select-none transition-colors flex items-center justify-between",
opt.value === value
? "bg-primary/10 text-primary font-medium"
: "hover:bg-primary/5 hover:text-primary text-foreground"
)}
>
{opt.label}
{opt.value === value && (
<svg className="h-4 w-4 fill-current text-primary shrink-0" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
{isSearchEnabled && (
<div className="p-2 border-b border-border bg-surface-muted flex items-center gap-2 shrink-0">
<Search className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
className="w-full px-2 py-1 text-xs border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary bg-background text-foreground font-sans"
onKeyDown={(e) => e.stopPropagation()}
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="p-0.5 text-muted-foreground hover:text-foreground shrink-0 cursor-pointer"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
))}
)}
<div className="p-1 overflow-y-auto flex-1 space-y-0.5">
{filteredOptions.length > 0 ? (
filteredOptions.map((opt) => (
<div
key={opt.value}
onMouseDown={(e) => { e.preventDefault(); handleSelect(opt.value); }}
className={cn(
"cursor-pointer rounded-lg px-3 py-2 text-xs select-none transition-colors flex items-center justify-between",
opt.value === value
? "bg-primary/10 text-primary font-medium"
: "hover:bg-background text-foreground"
)}
>
<span className="truncate pr-2">{opt.label}</span>
{opt.value === value && (
<Check className="h-3.5 w-3.5 text-primary shrink-0" />
)}
</div>
))
) : (
<div className="p-4 text-center text-xs text-muted-foreground">
No options found.
</div>
)}
</div>
</div>,
document.body
)}
+9 -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 } from "lucide-react";
import { Bell, ChevronDown, Building2, Globe, LogOut, Shield, User, Settings, CheckCircle2, ChevronRight, Menu, ShieldAlert, Home } from "lucide-react";
import { useLanguage, type Language } from "../../contexts/LanguageContext";
import { useHeader } from "../../contexts/HeaderContext";
import { useSidebar } from "../../contexts/SidebarContext";
@@ -173,6 +173,14 @@ export function Header() {
{/* Right Side Controls */}
<div className="flex items-center gap-2 sm:gap-4">
<button
onClick={() => window.location.assign(`${import.meta.env.VITE_SAAS_BASE_URL || 'http://localhost:3000'}/dashboard`)}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-semibold text-primary bg-primary/10 hover:bg-primary/15 transition-colors"
title="Return to SaaS home"
>
<Home className="w-4 h-4" />
<span className="hidden lg:inline">SaaS Home</span>
</button>
{/* Standard Non-Platform Tenant Badge */}
{!isPlatformUser && (
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium border border-border bg-surface-muted text-foreground">
@@ -0,0 +1,605 @@
import { useState, useEffect } from "react";
import { useFormik } from "formik";
import {
X, Image as ImageIcon, Video, FileText, Award, Megaphone,
HelpCircle, Plus, Check, Save
} from "lucide-react";
import { Input } from "../../../components/customs/Input";
import { TextArea } from "../../../components/customs/TextArea";
import { Select } from "../../../components/customs/Select";
import { Button } from "../../../components/customs/Button";
import { useAssetType } from "../hook/useAssetType";
import { assetTypeSchema } from "../validation/asset-types.schema";
import { notify } from "../../../services/toast";
import type { AssetType, AssetTypeCreateRequest } from "../types/asset-types.types";
const CATEGORIES = [
{
id: 'image',
label: 'Image',
icon: ImageIcon,
desc: 'jpg, jpeg, png...',
color: 'text-blue-600',
bg: 'bg-blue-50',
bgSelected: 'bg-blue-600',
borderSelected: 'border-blue-500',
ringSelected: 'ring-blue-500',
bgSelectedCard: 'bg-blue-50/70',
hoverBorder: 'hover:border-blue-300',
hoverBg: 'hover:bg-blue-50/50'
},
{
id: 'video',
label: 'Video',
icon: Video,
desc: 'mp4, mov, avi...',
color: 'text-orange-600',
bg: 'bg-orange-50',
bgSelected: 'bg-orange-600',
borderSelected: 'border-orange-500',
ringSelected: 'ring-orange-500',
bgSelectedCard: 'bg-orange-50/70',
hoverBorder: 'hover:border-orange-300',
hoverBg: 'hover:bg-orange-50/50'
},
{
id: 'document',
label: 'Document',
icon: FileText,
desc: 'pdf, docx, doc...',
color: 'text-red-600',
bg: 'bg-red-50',
bgSelected: 'bg-red-600',
borderSelected: 'border-red-500',
ringSelected: 'ring-red-500',
bgSelectedCard: 'bg-red-50/70',
hoverBorder: 'hover:border-red-300',
hoverBg: 'hover:bg-red-50/50'
},
{
id: 'certificate',
label: 'Certificate',
icon: Award,
desc: 'pdf, jpg, png',
color: 'text-emerald-600',
bg: 'bg-emerald-50',
bgSelected: 'bg-emerald-600',
borderSelected: 'border-emerald-500',
ringSelected: 'ring-emerald-500',
bgSelectedCard: 'bg-emerald-50/70',
hoverBorder: 'hover:border-emerald-300',
hoverBg: 'hover:bg-emerald-50/50'
},
{
id: 'marketing',
label: 'Marketing',
icon: Megaphone,
desc: 'jpg, png, svg...',
color: 'text-purple-600',
bg: 'bg-purple-50',
bgSelected: 'bg-purple-600',
borderSelected: 'border-purple-500',
ringSelected: 'ring-purple-500',
bgSelectedCard: 'bg-purple-50/70',
hoverBorder: 'hover:border-purple-300',
hoverBg: 'hover:bg-purple-50/50'
},
{
id: 'other',
label: 'Other',
icon: HelpCircle,
desc: 'pdf, zip, csv...',
color: 'text-muted-foreground',
bg: 'bg-background',
bgSelected: 'bg-surface-active',
borderSelected: 'border-border',
ringSelected: 'ring-ring',
bgSelectedCard: 'bg-background',
hoverBorder: 'hover:border-border',
hoverBg: 'hover:bg-background/50'
},
] 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 Info', step: 1 },
{ id: 'category', label: 'Asset Category', step: 2 },
{ id: 'validation', label: 'Validation Rules', step: 3 },
{ id: 'preview', label: 'Preview', step: 4 },
];
const labelClass = 'block text-xs font-semibold text-foreground mb-1.5';
const errorClass = 'text-xs text-red-500 mt-1';
interface CreateAssetTypeModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: (created: AssetType) => void;
}
export function CreateAssetTypeModal({ isOpen, onClose, onSuccess }: CreateAssetTypeModalProps) {
const { createItem, loading } = useAssetType();
const [newFileType, setNewFileType] = useState('');
const [activeStep, setActiveStep] = useState('basic');
const formik = useFormik({
initialValues: {
name: "",
code: "",
description: "",
status: "active" as "active" | "inactive",
isRequired: false,
category: "" as typeof CATEGORIES[number]['id'] | "",
validation: {
allowedFileTypes: [] as string[],
maxFileSize: 10,
minUploadCount: 0,
maxUploadCount: 1,
}
},
validationSchema: assetTypeSchema,
onSubmit: async (values, { setSubmitting }) => {
try {
const created = await createItem(values as AssetTypeCreateRequest);
if (created) {
onSuccess(created);
onClose();
}
} catch (err: any) {
// Handled in hook notify
} finally {
setSubmitting(false);
}
},
});
// 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);
}
}
}, [formik.submitCount, formik.isSubmitting, formik.errors]);
if (!isOpen) return null;
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
formik.handleChange(e);
if (!formik.touched.code) {
const generatedCode = e.target.value.toLowerCase().replace(/[^a-z0-9]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
formik.setFieldValue('code', generatedCode);
}
};
const handleAddFileType = () => {
const trimmed = newFileType.trim().toLowerCase().replace(/^\./, '');
if (trimmed && !formik.values.validation.allowedFileTypes.includes(trimmed)) {
formik.setFieldValue('validation.allowedFileTypes', [...formik.values.validation.allowedFileTypes, trimmed]);
setNewFileType('');
}
};
const removeFileType = (type: string) => {
formik.setFieldValue('validation.allowedFileTypes', formik.values.validation.allowedFileTypes.filter(t => t !== type));
};
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-xs animate-in fade-in duration-150 p-4 font-sans">
<div className="bg-surface rounded-2xl border border-border shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200">
{/* Modal Header */}
<div className="px-6 py-4 border-b border-border flex items-center justify-between bg-surface-muted/30">
<div>
<h3 className="font-bold text-foreground text-base">Create New Asset Type</h3>
<p className="text-xs text-muted-foreground mt-0.5">Configure media classifications and validation rules</p>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 hover:bg-background rounded-lg text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Step Indicator Bar */}
<div className="px-6 py-3 border-b border-border bg-surface flex items-center justify-between gap-2 overflow-x-auto">
{STEPS.map((s, idx) => {
const isActive = activeStep === s.id;
const isDone = idx < activeIndex;
return (
<button
key={s.id}
type="button"
onClick={() => setActiveStep(s.id)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all shrink-0 cursor-pointer ${
isActive
? 'bg-primary/10 text-primary font-bold border border-primary/20'
: isDone
? 'text-foreground hover:bg-surface-muted'
: 'text-muted-foreground hover:bg-surface-muted/50'
}`}
>
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold ${
isActive
? 'bg-primary text-white'
: isDone
? 'bg-emerald-500 text-white'
: 'bg-surface-muted text-muted-foreground border border-border'
}`}>
{isDone ? <Check className="w-3 h-3" /> : s.step}
</span>
<span>{s.label}</span>
</button>
);
})}
</div>
{/* Modal Body */}
<form id="inline-asset-type-form" onSubmit={formik.handleSubmit} className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Step 1 — Basic Information */}
{activeStep === 'basic' && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Asset Type Name <span className="text-red-500">*</span></label>
<Input
name="name"
value={formik.values.name}
onChange={handleNameChange}
onBlur={formik.handleBlur}
placeholder="e.g. Primary Image"
aria-invalid={formik.touched.name && Boolean(formik.errors.name)}
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div>
<label className={labelClass}>Asset Code <span className="text-red-500">*</span></label>
<Input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. primary_image"
aria-invalid={formik.touched.code && Boolean(formik.errors.code)}
/>
<p className="text-[10px] text-muted-foreground mt-1">Unique identifier (slug)</p>
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
</div>
</div>
<div>
<label className={labelClass}>Description</label>
<TextArea
name="description"
value={formik.values.description}
onChange={formik.handleChange}
rows={2}
placeholder="Describe the purpose and usage guidelines for this asset type..."
className="resize-none text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Status</label>
<Select
name="status"
value={formik.values.status}
onChange={formik.handleChange}
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</Select>
</div>
<div>
<label className={labelClass}>Required Asset</label>
<label className="flex items-center gap-3 p-2 border border-border rounded-lg cursor-pointer hover:bg-background transition-colors h-9">
<input
type="checkbox"
name="isRequired"
checked={formik.values.isRequired}
onChange={formik.handleChange}
className="w-4 h-4 text-primary rounded border-border focus:ring-primary"
/>
<div className="text-xs font-medium text-foreground">Mark as required by default</div>
</label>
</div>
</div>
</div>
)}
{/* Step 2 — Asset Category */}
{activeStep === 'category' && (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">Select the media category. This determines default validation rules and file type presets.</p>
<div className="grid grid-cols-3 gap-3">
{CATEGORIES.map(cat => (
<div
key={cat.id}
onClick={() => formik.setFieldValue('category', cat.id)}
className={`
cursor-pointer p-3.5 rounded-xl border transition-all flex flex-col items-center justify-center gap-1.5 text-center
${formik.values.category === cat.id
? `${cat.bgSelectedCard} ${cat.borderSelected} shadow-xs ring-1 ${cat.ringSelected}`
: `bg-surface border-border ${cat.hoverBorder} ${cat.hoverBg}`
}
`}
>
<div className={`w-9 h-9 rounded-lg flex items-center justify-center transition-all ${formik.values.category === cat.id ? `${cat.bgSelected} text-white` : `${cat.bg} ${cat.color}`}`}>
<cat.icon className="w-4 h-4" />
</div>
<div>
<div className={`font-semibold text-xs ${formik.values.category === cat.id ? 'text-foreground font-bold' : 'text-foreground'}`}>{cat.label}</div>
<div className="text-[10px] text-muted-foreground mt-0.5">{cat.desc}</div>
</div>
</div>
))}
</div>
{formik.touched.category && formik.errors.category && (
<p className={errorClass}>{formik.errors.category}</p>
)}
</div>
)}
{/* Step 3 — Validation Rules */}
{activeStep === 'validation' && (
<div className="space-y-4">
{/* Selected formats badges list */}
<div>
<label className={labelClass}>Allowed File Types</label>
<div className="min-h-[38px] p-2.5 border border-border rounded-lg flex flex-wrap gap-1.5 bg-background">
{formik.values.validation.allowedFileTypes.length === 0 ? (
<span className="text-xs text-muted-foreground py-0.5 px-1">No file types selected yet. Check boxes below or add custom formats.</span>
) : (
formik.values.validation.allowedFileTypes.map(type => (
<span key={type} className="inline-flex items-center gap-1 px-2 py-0.5 bg-surface border border-border rounded-md text-xs font-semibold text-foreground shadow-2xs">
.{type}
<button type="button" onClick={() => removeFileType(type)} className="text-muted-foreground hover:text-red-500 ml-1 transition-colors cursor-pointer"><X className="w-3 h-3" /></button>
</span>
))
)}
</div>
</div>
{/* Format selection */}
<div>
<label className={labelClass}>Select Formats</label>
<div className="bg-background border border-border rounded-xl p-3 space-y-3 max-h-48 overflow-y-auto">
{['image', 'video', 'document', 'other'].map(group => {
const exts = POPULAR_EXTENSIONS.filter(e => e.category === group);
const groupLabel = group === 'image' ? 'Image' : group === 'video' ? 'Video' : group === 'document' ? 'Document' : 'Data & Other';
return (
<div key={group} className="space-y-1.5">
<div className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider">{groupLabel}</div>
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2">
{exts.map(item => {
const isChecked = formik.values.validation.allowedFileTypes.includes(item.ext);
return (
<label
key={item.ext}
className={`
flex items-center gap-1.5 px-2 py-1 border rounded-md cursor-pointer transition-all select-none text-xs
${isChecked
? 'bg-primary/10 border-primary text-primary font-bold shadow-2xs'
: 'bg-surface border-border text-foreground hover:border-primary/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 h-3 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
/>
<span className="text-[11px]">.{item.ext}</span>
</label>
);
})}
</div>
</div>
);
})}
</div>
</div>
{/* Custom Extension Input */}
<div>
<label className="block text-[11px] font-semibold text-muted-foreground mb-1">Add Custom Extension</label>
<div className="flex gap-2 max-w-xs">
<Input
value={newFileType}
onChange={(e) => setNewFileType(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddFileType(); } }}
placeholder="e.g. psd"
className="text-xs h-8"
/>
<button
type="button"
onClick={handleAddFileType}
className="px-3 py-1 bg-surface hover:bg-background border border-border rounded-md text-xs font-semibold text-foreground flex items-center justify-center transition-colors cursor-pointer"
>
<Plus className="w-3.5 h-3.5 mr-1" />
Add
</button>
</div>
</div>
{/* Constraints */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className={labelClass}>Max Size (MB)</label>
<Input
type="number"
name="validation.maxFileSize"
value={formik.values.validation.maxFileSize}
onChange={formik.handleChange}
className="h-8 text-xs"
/>
</div>
<div>
<label className={labelClass}>Min Uploads</label>
<Input
type="number"
name="validation.minUploadCount"
value={formik.values.validation.minUploadCount}
onChange={formik.handleChange}
className="h-8 text-xs"
/>
</div>
<div>
<label className={labelClass}>Max Uploads</label>
<Input
type="number"
name="validation.maxUploadCount"
value={formik.values.validation.maxUploadCount}
onChange={formik.handleChange}
className="h-8 text-xs"
/>
</div>
</div>
</div>
)}
{/* Step 4 — Preview */}
{activeStep === 'preview' && (
<div className="space-y-4">
<div className="border border-border rounded-xl overflow-hidden shadow-xs">
<div className="bg-primary p-3.5 flex items-center justify-between text-white">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-surface/20 rounded-lg flex items-center justify-center backdrop-blur-sm">
<ImageIcon className="w-4 h-4" />
</div>
<div>
<div className="font-bold text-sm">{formik.values.name || 'Asset Type Name'}</div>
<div className="text-[11px] text-primary-light font-mono">{formik.values.code || 'asset_code'}</div>
</div>
</div>
<div className="px-2 py-0.5 bg-surface/20 rounded-full text-xs font-medium flex items-center gap-1.5 backdrop-blur-sm">
<div className={`w-1.5 h-1.5 rounded-full ${formik.values.status === 'active' ? 'bg-green-400' : 'bg-muted-foreground'}`} />
{formik.values.status === 'active' ? 'Active' : 'Inactive'}
</div>
</div>
<div className="bg-background p-4 grid grid-cols-2 gap-4 text-xs">
<div>
<div className="font-semibold text-muted-foreground uppercase mb-1.5 text-[10px]">Category & Formats</div>
<div className="mb-2 font-medium capitalize text-foreground">{formik.values.category || 'Not specified'}</div>
<div className="flex flex-wrap gap-1">
{formik.values.validation.allowedFileTypes.length === 0 ? (
<span className="text-muted-foreground text-[11px]">All file types permitted</span>
) : (
formik.values.validation.allowedFileTypes.map(type => (
<span key={type} className="px-1.5 py-0.5 bg-surface-muted text-foreground rounded text-[10px] font-mono">.{type}</span>
))
)}
</div>
</div>
<div>
<div className="font-semibold text-muted-foreground uppercase mb-1.5 text-[10px]">Constraints</div>
<div className="space-y-1 text-[11px] text-muted-foreground">
<div className="flex justify-between"><span>Max Size:</span> <span className="font-medium text-foreground">{formik.values.validation.maxFileSize} MB</span></div>
<div className="flex justify-between"><span>Uploads:</span> <span className="font-medium text-foreground">Min {formik.values.validation.minUploadCount}, Max {formik.values.validation.maxUploadCount}</span></div>
<div className="flex justify-between"><span>Required:</span> <span className="font-medium text-foreground">{formik.values.isRequired ? 'Yes' : 'No (Optional)'}</span></div>
</div>
</div>
</div>
</div>
</div>
)}
</form>
{/* Modal Footer */}
<div className="px-6 py-3.5 border-t border-border bg-surface-muted/30 flex items-center justify-between">
<Button variant="outline" size="sm" type="button" onClick={onClose} disabled={formik.isSubmitting}>
Cancel
</Button>
<div className="flex items-center gap-2">
{activeIndex > 0 && (
<Button variant="outline" size="sm" type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)}>
Back
</Button>
)}
{activeIndex < STEPS.length - 1 ? (
<Button variant="primary" size="sm" type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)}>
Next
</Button>
) : (
<Button
variant="primary"
size="sm"
type="submit"
form="inline-asset-type-form"
icon={<Save className="w-3.5 h-3.5" />}
loading={formik.isSubmitting || loading}
>
Create Asset Type
</Button>
)}
</div>
</div>
</div>
</div>
);
}
+2
View File
@@ -2,3 +2,5 @@ export * from './types/asset-types.types';
export * from './services/asset-types.service';
export * from './hook/useAssetType';
export * from './routes/asset-types.routes';
export * from './components/CreateAssetTypeModal';
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import { useFormik } from "formik";
import { Plus, Save, Info, Check } from "lucide-react";
@@ -139,6 +139,38 @@ export default function NewAttributeSet() {
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
useEffect(() => {
if (isEdit || isView) {
setHighestVisitedStep(STEPS.length);
}
}, [isEdit, isView]);
const isBasicValid = Boolean(formik.values.name?.trim() && formik.values.code?.trim() && !formik.errors.name && !formik.errors.code);
const isStepAccessible = useCallback((stepNum: number) => {
if (isEdit || isView) return true;
if (stepNum === 1) return true;
if (!isBasicValid) return false;
return stepNum <= highestVisitedStep + 1;
}, [isEdit, isView, isBasicValid, highestVisitedStep]);
const handleNextStep = useCallback(() => {
if (activeStep === 'basic') {
if (!isBasicValid) {
formik.setFieldTouched('name', true);
formik.setFieldTouched('code', true);
return;
}
}
if (activeIndex < STEPS.length - 1) {
const nextStepObj = STEPS[activeIndex + 1];
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
setActiveStep(nextStepObj.id);
}
}, [activeStep, isBasicValid, activeIndex, formik]);
return (
<ProtectedRoute node="products.attributes">
<PageWrapper>
@@ -181,17 +213,23 @@ export default function NewAttributeSet() {
const isActive = activeStep === s.id;
const isDone = idx < activeIndex;
const isLast = idx === STEPS.length - 1;
const accessible = isStepAccessible(s.step);
return (
<div key={s.id} className="flex gap-3">
<div className="flex flex-col items-center" style={{ width: 24 }}>
<button
type="button"
onClick={() => setActiveStep(s.id)}
disabled={!accessible}
onClick={() => {
if (accessible) {
setActiveStep(s.id);
}
}}
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
isActive ? "bg-primary ring-2 ring-primary/20" :
isDone ? "bg-emerald-500" :
"bg-surface border-2 border-border hover:border-primary/30"
}`}
} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
>
{isDone
? <Check className="w-3 h-3 text-white" />
@@ -204,8 +242,13 @@ export default function NewAttributeSet() {
</div>
<button
type="button"
onClick={() => setActiveStep(s.id)}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""}`}
disabled={!accessible}
onClick={() => {
if (accessible) {
setActiveStep(s.id);
}
}}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
>
<span className={`text-xs font-medium leading-tight block ${
isActive ? "text-primary-dark" : isDone ? "text-muted-foreground" : "text-muted-foreground hover:text-muted-foreground"
@@ -452,7 +495,7 @@ export default function NewAttributeSet() {
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm font-medium text-muted-foreground hover:bg-background transition-colors">Back</button>
)}
{activeIndex < STEPS.length - 1 && (
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
<button type="button" onClick={handleNextStep} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
)}
</div>
</div>
+50 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import { useFormik } from "formik";
import { Check, ChevronLeft, ChevronRight } from "lucide-react";
@@ -193,6 +193,39 @@ export default function NewAttribute() {
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
useEffect(() => {
if (isEdit || isView) {
setHighestVisitedStep(STEPS.length);
}
}, [isEdit, isView]);
const isGeneralValid = Boolean(formik.values.name?.trim() && formik.values.code?.trim() && formik.values.dataType && !formik.errors.name && !formik.errors.code && !formik.errors.dataType);
const isStepAccessible = useCallback((stepNum: number) => {
if (isEdit || isView) return true;
if (stepNum === 1) return true;
if (!isGeneralValid) return false;
return stepNum <= highestVisitedStep + 1;
}, [isEdit, isView, isGeneralValid, highestVisitedStep]);
const handleNextStep = useCallback(() => {
if (activeStep === 'general') {
if (!isGeneralValid) {
formik.setFieldTouched('name', true);
formik.setFieldTouched('code', true);
formik.setFieldTouched('type', true);
return;
}
}
if (activeIndex < STEPS.length - 1) {
const nextStepObj = STEPS[activeIndex + 1];
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
setActiveStep(nextStepObj.id);
}
}, [activeStep, isGeneralValid, activeIndex, formik]);
return (
<ProtectedRoute node="products.attributes">
<div className="h-screen flex flex-col overflow-hidden bg-background/50">
@@ -247,16 +280,22 @@ export default function NewAttribute() {
const isActive = activeStep === s.id;
const isDone = s.step < activeIndex + 1;
const isLast = idx === STEPS.length - 1;
const accessible = isStepAccessible(s.step);
return (
<div key={s.id} className="flex gap-3">
<div className="flex flex-col items-center" style={{ width: 24 }}>
<button
type="button"
onClick={() => setActiveStep(s.id)}
disabled={!accessible}
onClick={() => {
if (accessible) {
setActiveStep(s.id);
}
}}
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${isActive ? "bg-primary ring-2 ring-primary/20" :
isDone ? "bg-emerald-500" :
"bg-surface border-2 border-border hover:border-primary/30"
}`}
} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
>
{isDone
? <Check className="w-3 h-3 text-white" />
@@ -269,8 +308,13 @@ export default function NewAttribute() {
</div>
<button
type="button"
onClick={() => setActiveStep(s.id)}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""}`}
disabled={!accessible}
onClick={() => {
if (accessible) {
setActiveStep(s.id);
}
}}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
>
<span className={`text-xs font-medium leading-tight block ${isActive ? "text-primary-dark" : isDone ? "text-muted-foreground" : "text-muted-foreground hover:text-muted-foreground"
}`}>{s.label}</span>
@@ -578,7 +622,7 @@ export default function NewAttribute() {
<Button variant="outline" type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} icon={<ChevronLeft className="w-4 h-4" />}>Back</Button>
)}
{activeIndex < STEPS.length - 1 && (
<Button variant="primary" type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)}>Next <ChevronRight className="w-4 h-4" /></Button>
<Button variant="primary" type="button" onClick={handleNextStep}>Next <ChevronRight className="w-4 h-4" /></Button>
)}
</div>
</form>
+50 -6
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import { useFormik } from "formik";
import * as Yup from "yup";
@@ -125,6 +125,39 @@ export default function NewChannel() {
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
useEffect(() => {
if (isEdit || isView) {
setHighestVisitedStep(STEPS.length);
}
}, [isEdit, isView]);
const isBasicValid = Boolean(formik.values.name?.trim() && formik.values.code?.trim() && formik.values.channelType && !formik.errors.name && !formik.errors.code && !formik.errors.channelType);
const isStepAccessible = useCallback((stepNum: number) => {
if (isEdit || isView) return true;
if (stepNum === 1) return true;
if (!isBasicValid) return false;
return stepNum <= highestVisitedStep + 1;
}, [isEdit, isView, isBasicValid, highestVisitedStep]);
const handleNextStep = useCallback(() => {
if (activeStep === 'basic') {
if (!isBasicValid) {
formik.setFieldTouched('name', true);
formik.setFieldTouched('code', true);
formik.setFieldTouched('channelType', true);
return;
}
}
if (activeIndex < STEPS.length - 1) {
const nextStepObj = STEPS[activeIndex + 1];
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
setActiveStep(nextStepObj.id);
}
}, [activeStep, isBasicValid, activeIndex, formik]);
return (
<ProtectedRoute node="settings.integrations">
<PageWrapper>
@@ -169,17 +202,23 @@ export default function NewChannel() {
const isActive = activeStep === s.id;
const isDone = idx < activeIndex;
const isLast = idx === STEPS.length - 1;
const accessible = isStepAccessible(s.step);
return (
<div key={s.id} className="flex gap-3">
<div className="flex flex-col items-center" style={{ width: 24 }}>
<button
type="button"
onClick={() => setActiveStep(s.id)}
disabled={!accessible}
onClick={() => {
if (accessible) {
setActiveStep(s.id);
}
}}
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
isActive ? "bg-primary ring-2 ring-primary/20" :
isDone ? "bg-success" :
"bg-surface border-2 border-border hover:border-primary/30"
}`}
} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
>
{isDone
? <Check className="w-3 h-3 text-white" />
@@ -192,8 +231,13 @@ export default function NewChannel() {
</div>
<button
type="button"
onClick={() => setActiveStep(s.id)}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""}`}
disabled={!accessible}
onClick={() => {
if (accessible) {
setActiveStep(s.id);
}
}}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
>
<span className={`text-xs font-medium leading-tight block ${
isActive ? "text-primary" : isDone ? "text-muted-foreground" : "text-muted-foreground hover:text-muted-foreground"
@@ -426,7 +470,7 @@ export default function NewChannel() {
<Button variant="outline" type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)}>Back</Button>
)}
{activeIndex < STEPS.length - 1 && (
<Button variant="primary" type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)}>Next</Button>
<Button variant="primary" type="button" onClick={handleNextStep}>Next</Button>
)}
</div>
</div>
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,7 @@ export interface Family {
description?: string;
category?: string;
categoryId?: string | null;
productType?: 'simple' | 'variant' | string;
attributes: string[]; // List of attribute codes
attributeGroups?: number;
variantAxes: string[]; // List of attribute codes used as variant axes
@@ -17,6 +18,7 @@ export interface Family {
createdBy: string;
channels?: string[];
assetRequirements?: string[];
directAssetTypes?: string[];
completenessRules?: Record<string, number>;
workflowCode?: string;
attributeSetId?: string;
@@ -20,6 +20,8 @@ export const familySchema = Yup.object().shape({
})
),
status: Yup.string().oneOf(['active', 'inactive', 'draft']),
productType: Yup.string().oneOf(['simple', 'variant']).optional(),
allowedBrands: Yup.array().of(Yup.string()).optional(),
allowedUnits: Yup.array().of(Yup.string()).optional(),
directAssetTypes: Yup.array().of(Yup.string()).optional(),
});
@@ -1,95 +1,283 @@
import { useEffect, useState } from "react";
import { Copy, KeyRound, Plus, ShieldCheck, Trash2 } from "lucide-react";
import apiClient from "../../../api/axiosInstance";
import { useAppSelector } from "../../../store";
import { useEffect, useState } from 'react';
import { Copy, Check, KeyRound, Plus, ShieldCheck, Trash2, AlertTriangle, Key } from 'lucide-react';
import apiClient from '../../../api/axiosInstance';
import { useAppSelector } from '../../../store';
type ApiKeyRecord = {
id: string; name: string; prefix: string; scopes: string[]; status: string;
expiresAt: string; lastUsedAt?: string | null; createdAt: string; apiKey?: string;
id: string;
name: string;
prefix: string;
scopes: string[];
status: string;
expiresAt: string;
lastUsedAt?: string | null;
createdAt: string;
apiKey?: string;
};
type ApiResponse<T> = { success: boolean; data: T };
export default function ApiAccessTab() {
const user = useAppSelector((state) => state.auth.user);
const isPlatformUser = user?.type === "platform" || user?.user_type === "platform";
const isSupportMode = isPlatformUser && Boolean(localStorage.getItem("impersonatedTenantId"));
const isPlatformUser = user?.type === 'platform' || user?.user_type === 'platform';
const isSupportMode = isPlatformUser && Boolean(localStorage.getItem('impersonatedTenantId'));
const [keys, setKeys] = useState<ApiKeyRecord[]>([]);
const [name, setName] = useState("");
const [name, setName] = useState('');
const [expiresInDays, setExpiresInDays] = useState(90);
const [createdKey, setCreatedKey] = useState<ApiKeyRecord | null>(null);
const [copied, setCopied] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [error, setError] = useState<string>('');
const load = async () => {
const response = await apiClient.get<ApiResponse<ApiKeyRecord[]>>("/api/v1/api-keys");
setKeys(response.data || []);
try {
const response: any = await apiClient.get('/api/v1/api-keys');
const rawList = Array.isArray(response) ? response : (Array.isArray(response?.data) ? response.data : []);
setKeys(rawList);
} catch (cause: any) {
const msg = typeof cause?.response?.data?.message === 'string'
? cause.response.data.message
: (typeof cause?.message === 'string' ? cause.message : 'Could not load API keys.');
setError(msg);
}
};
useEffect(() => {
if (!isSupportMode) load().catch((cause: any) => setError(cause?.response?.data?.message || "Could not load API keys."));
if (!isSupportMode) {
load();
}
}, [isSupportMode]);
if (isSupportMode) {
return (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-5 text-sm text-amber-950">
<div className="flex items-center gap-2 font-semibold"><ShieldCheck className="h-4 w-4" />API-key management is protected in Support Mode</div>
<p className="mt-2">Platform support can inspect tenant data for troubleshooting, but cannot create, view, or revoke credentials on the tenant's behalf.</p>
<p className="mt-2">A real tenant administrator must sign in to this workspace and open <strong>Integration Hub API Access</strong> to manage product API keys.</p>
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-5 text-sm text-amber-200">
<div className="flex items-center gap-2 font-semibold"><ShieldCheck className="h-5 w-5 text-amber-400" />API-key management is protected in Support Mode</div>
<p className="mt-2 text-muted-foreground">Platform support can inspect tenant data for troubleshooting, but cannot create, view, or revoke credentials on the tenant's behalf.</p>
<p className="mt-2 text-muted-foreground">A real tenant administrator must sign in to this workspace and open <strong>Integration Hub → API Access</strong> to manage product API keys.</p>
</div>
);
}
const createKey = async () => {
setBusy(true); setError("");
setBusy(true);
setError('');
try {
const response = await apiClient.post<ApiResponse<ApiKeyRecord>>("/api/v1/api-keys", { name, expiresInDays });
setCreatedKey(response.data); setName(""); await load();
const response: any = await apiClient.post('/api/v1/api-keys', { name, expiresInDays });
const record = response?.data || response;
if (record && typeof record === 'object' && !Array.isArray(record)) {
setCreatedKey(record);
}
setName('');
setCopied(false);
await load();
} catch (cause: any) {
setError(cause?.response?.data?.message || "Could not create API key.");
} finally { setBusy(false); }
const msg = typeof cause?.response?.data?.message === 'string'
? cause.response.data.message
: (typeof cause?.message === 'string' ? cause.message : 'Could not create API key.');
setError(msg);
} finally {
setBusy(false);
}
};
const handleCopy = (text: string) => {
if (!text) return;
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 3000);
};
const revoke = async (id: string) => {
if (!window.confirm("Revoke this API key? Applications using it will immediately lose access.")) return;
await apiClient.delete(`/api/v1/api-keys/${id}`); await load();
if (!window.confirm('Revoke this API key? Applications using it will immediately lose access.')) return;
try {
await apiClient.delete('/api/v1/api-keys/' + id);
await load();
} catch (cause: any) {
const msg = typeof cause?.response?.data?.message === 'string'
? cause.response.data.message
: (typeof cause?.message === 'string' ? cause.message : 'Could not revoke API key.');
setError(msg);
}
};
return (
<div className="space-y-6">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-900">
<div className="flex items-center gap-2 font-semibold"><ShieldCheck className="h-4 w-4" />What this key can do</div>
<p className="mt-2">It can only read products belonging to your tenant through <code>GET /api/v1/external/products</code>. It cannot create, edit, delete, publish, or access another tenant.</p>
</div>
<div className="rounded-lg border border-border p-4">
<h3 className="font-semibold text-foreground">Create a read-only product key</h3>
<div className="mt-3 flex flex-wrap gap-3">
<input aria-label="API key name" value={name} onChange={(event) => setName(event.target.value)} placeholder="Example: Website product reader" maxLength={120} className="min-w-64 rounded-lg border border-border bg-surface px-3 py-2 text-sm" />
<select aria-label="API key expiry" value={expiresInDays} onChange={(event) => setExpiresInDays(Number(event.target.value))} className="rounded-lg border border-border bg-surface px-3 py-2 text-sm">
<option value={30}>30 days</option><option value={90}>90 days</option><option value={180}>180 days</option><option value={365}>365 days</option>
</select>
<button disabled={busy || !name.trim()} onClick={createKey} className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50"><Plus className="h-4 w-4" />Create key</button>
{/* Information Banner */}
<div className="rounded-xl border border-blue-500/20 bg-blue-500/10 p-4 text-sm text-blue-300">
<div className="flex items-center gap-2 font-semibold text-blue-200">
<ShieldCheck className="h-4 w-4 text-blue-400" />
What this key can do
</div>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
<p className="mt-1 text-xs text-blue-300/80 leading-relaxed">
Keys generated here grant read-only access to your tenant catalog via <code>GET /api/v1/external/products</code>. They are strictly isolated to your tenant data.
</p>
</div>
{createdKey?.apiKey && (
<div className="rounded-lg border-2 border-amber-300 bg-amber-50 p-4">
<p className="font-semibold text-amber-950">Copy this key now it will never be shown again.</p>
<div className="mt-3 flex gap-2"><code className="min-w-0 flex-1 overflow-x-auto rounded bg-white p-3 text-sm">{createdKey.apiKey}</code><button aria-label="Copy API key" onClick={() => navigator.clipboard.writeText(createdKey.apiKey!)} className="rounded-lg border border-amber-300 bg-white px-3"><Copy className="h-4 w-4" /></button></div>
<button onClick={() => setCreatedKey(null)} className="mt-3 text-sm font-medium text-amber-900 underline">I have saved it securely</button>
{/* Creation Form */}
<div className="rounded-xl border border-border/60 bg-card p-5 shadow-sm">
<h3 className="font-semibold text-foreground text-sm flex items-center gap-2">
<Key className="h-4 w-4 text-primary" />
Generate New API Key
</h3>
<p className="text-xs text-muted-foreground mt-0.5">Create secure tokens for external integrations like Inventory, Mobile Apps, or Storefronts.</p>
<div className="mt-4 flex flex-wrap items-center gap-3">
<input
aria-label="API key name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="e.g., Inventory Sync Integration"
maxLength={120}
className="min-w-[280px] flex-1 rounded-lg border border-border/80 bg-background px-3.5 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
<select
aria-label="API key expiry"
value={expiresInDays}
onChange={(event) => setExpiresInDays(Number(event.target.value))}
className="rounded-lg border border-border/80 bg-background px-3.5 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"
>
<option value={30}>Expires in 30 days</option>
<option value={90}>Expires in 90 days</option>
<option value={180}>Expires in 180 days</option>
<option value={365}>Expires in 1 year</option>
</select>
<button
disabled={busy || !name.trim()}
onClick={createKey}
className="inline-flex items-center gap-2 rounded-lg bg-primary hover:bg-primary/90 px-4 py-2 text-sm font-medium text-white transition-all disabled:opacity-50 shadow-sm"
>
<Plus className="h-4 w-4" />
Generate Key
</button>
</div>
{error ? <p className="mt-2 text-xs font-medium text-red-400">{String(error)}</p> : null}
</div>
{/* Pop-up / Reveal Card when key is created */}
{createdKey && typeof createdKey.apiKey === 'string' && (
<div className="rounded-xl border border-amber-500/40 bg-amber-950/30 p-5 backdrop-blur-sm shadow-md animate-in fade-in duration-200">
<div className="flex items-start gap-2.5">
<AlertTriangle className="h-5 w-5 text-amber-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h4 className="text-sm font-semibold text-amber-200">Save this API key immediately</h4>
<p className="text-xs text-amber-300/80 mt-0.5">For security reasons, this secret key will <strong>never be shown again</strong>. Copy and store it securely.</p>
<div className="mt-3 flex items-center gap-2">
<div className="flex-1 min-w-0 overflow-x-auto rounded-lg border border-amber-500/30 bg-black/40 px-3.5 py-2.5 text-xs font-mono text-amber-100 selection:bg-amber-500 selection:text-black">
{createdKey.apiKey}
</div>
<button
type="button"
onClick={() => handleCopy(createdKey.apiKey!)}
className={copied ? 'inline-flex items-center gap-1.5 rounded-lg px-3.5 py-2.5 text-xs font-medium transition-all shadow-sm shrink-0 bg-emerald-600 text-white' : 'inline-flex items-center gap-1.5 rounded-lg px-3.5 py-2.5 text-xs font-medium transition-all shadow-sm shrink-0 bg-amber-500 hover:bg-amber-400 text-black'}
>
{copied ? (
<>
<Check className="h-4 w-4" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy Key
</>
)}
</button>
</div>
<div className="mt-3 flex justify-end">
<button
onClick={() => setCreatedKey(null)}
className="text-xs font-medium text-amber-400 hover:text-amber-300 underline underline-offset-2 transition-colors"
>
I have saved it securely (Dismiss)
</button>
</div>
</div>
</div>
</div>
)}
<div className="overflow-hidden rounded-lg border border-border">
<table className="w-full text-left text-sm"><thead className="bg-surface-muted"><tr><th className="p-3">Name</th><th className="p-3">Prefix</th><th className="p-3">Scope</th><th className="p-3">Expires</th><th className="p-3">Last used</th><th className="p-3">Status</th><th className="p-3"></th></tr></thead>
<tbody>{keys.map((key) => <tr key={key.id} className="border-t border-border"><td className="p-3 font-medium"><span className="inline-flex items-center gap-2"><KeyRound className="h-4 w-4" />{key.name}</span></td><td className="p-3 font-mono text-xs">{key.prefix}</td><td className="p-3">products:read</td><td className="p-3">{new Date(key.expiresAt).toLocaleDateString()}</td><td className="p-3">{key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleString() : "Never"}</td><td className="p-3 capitalize">{key.status}</td><td className="p-3 text-right">{key.status === "active" && <button aria-label={`Revoke ${key.name}`} onClick={() => revoke(key.id)} className="text-red-600"><Trash2 className="h-4 w-4" /></button>}</td></tr>)}</tbody>
</table>
{keys.length === 0 && <p className="p-6 text-center text-muted-foreground">No API keys yet.</p>}
{/* Active Keys Table */}
<div className="overflow-hidden rounded-xl border border-border/60 bg-card shadow-sm">
<div className="px-5 py-3.5 border-b border-border/60 bg-muted/30 flex items-center justify-between">
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Active API Keys</h4>
<span className="text-xs text-muted-foreground">{keys.length} key{keys.length === 1 ? '' : 's'}</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-muted/40 text-xs font-medium text-muted-foreground border-b border-border/60">
<tr>
<th className="py-3 px-5">Key Name</th>
<th className="py-3 px-5">Key Prefix</th>
<th className="py-3 px-5">Scope</th>
<th className="py-3 px-5">Expires</th>
<th className="py-3 px-5">Last Used</th>
<th className="py-3 px-5">Status</th>
<th className="py-3 px-5 text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{keys.map((key) => (
<tr key={key.id} className="hover:bg-muted/20 transition-colors">
<td className="py-3 px-5 font-medium text-foreground">
<span className="inline-flex items-center gap-2">
<KeyRound className="h-3.5 w-3.5 text-primary" />
{String(key.name || '')}
</span>
</td>
<td className="py-3 px-5 font-mono text-xs text-muted-foreground">
<span className="bg-muted/50 px-2 py-0.5 rounded border border-border/40">
{String(key.prefix || '')}…
</span>
</td>
<td className="py-3 px-5 text-xs text-muted-foreground">
<span className="bg-primary/10 text-primary px-2 py-0.5 rounded-full font-medium">
products:read
</span>
</td>
<td className="py-3 px-5 text-xs text-muted-foreground">
{key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : ''}
</td>
<td className="py-3 px-5 text-xs text-muted-foreground">
{key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleString() : 'Never'}
</td>
<td className="py-3 px-5 text-xs">
<span className="inline-flex items-center gap-1.5 font-medium text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full capitalize">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
{String(key.status || 'active')}
</span>
</td>
<td className="py-3 px-5 text-right">
{key.status === 'active' && (
<button
title="Revoke key"
aria-label={'Revoke ' + String(key.name || '')}
onClick={() => revoke(key.id)}
className="inline-flex items-center justify-center h-8 w-8 rounded-lg text-muted-foreground hover:text-red-400 hover:bg-red-500/10 transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{keys.length === 0 && (
<div className="p-8 text-center">
<KeyRound className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">No API keys generated yet.</p>
</div>
)}
</div>
<div className="rounded-lg border border-border p-4 text-sm"><h3 className="font-semibold">Example request</h3><pre className="mt-2 overflow-x-auto rounded bg-slate-950 p-3 text-slate-100">{`curl https://YOUR-PIM/api/v1/external/products \\\n -H "X-API-Key: pim_live_your_key"`}</pre></div>
{/* Code Snippet */}
<div className="rounded-xl border border-border/60 bg-card p-5 text-sm shadow-sm">
<h4 className="font-semibold text-foreground text-xs uppercase tracking-wider text-muted-foreground mb-2">Example Usage</h4>
<pre className="overflow-x-auto rounded-lg bg-black/60 border border-border/40 p-3.5 text-xs font-mono text-emerald-400">
curl http://localhost:5002/api/v1/external/products -H &quot;X-API-Key: pim_live_your_key_here&quot;
</pre>
</div>
</div>
);
}
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Layers, RefreshCw, Save, Check, Plus, ArrowRight } from 'lucide-react';
import { useState } from 'react';
import { Layers, RefreshCw, Save, Plus, ArrowRight } from 'lucide-react';
import { notify } from '../../../services/toast/index';
interface MappingRow {
@@ -77,7 +77,7 @@ export default function FieldMappingsTab() {
<tr className="bg-background border-b border-border text-muted-foreground font-semibold">
<th className="py-3 px-4">Canonical Source Path (PIM)</th>
<th className="py-3 px-2 text-center">Transform</th>
<th className="py-3 px-4">Channel Target Path (Shopify)</th>
<th className="py-3 px-4">Destination Field / JSON Path</th>
<th className="py-3 px-4">Transformation Type</th>
<th className="py-3 px-4">Default Value</th>
<th className="py-3 px-4 text-center">Required</th>
@@ -100,7 +100,7 @@ export const IntegrationTemplateGallery: React.FC<IntegrationTemplateGalleryProp
</button>
</div>
{/* Custom API Card */}
{/* Outbound Custom API Card */}
<div className="bg-surface border border-border hover:border-primary/50 rounded-xl p-4 transition-all shadow-2xs group flex flex-col justify-between">
<div>
<div className="flex items-center justify-between mb-3">
@@ -111,9 +111,9 @@ export const IntegrationTemplateGallery: React.FC<IntegrationTemplateGalleryProp
Custom Wizard
</span>
</div>
<h3 className="font-bold text-foreground text-sm group-hover:text-primary transition-colors">Custom API / Webhook</h3>
<h3 className="font-bold text-foreground text-sm group-hover:text-primary transition-colors">External API / Webhook</h3>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
Configure multi-step generic REST/GraphQL endpoints with custom headers and transformations.
Send PIM products outward to a website or application endpoint using its credentials.
</p>
</div>
<button
@@ -121,7 +121,7 @@ export const IntegrationTemplateGallery: React.FC<IntegrationTemplateGalleryProp
onClick={onSelectCustomApi}
className="mt-4 w-full py-2 px-3 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-2xs flex items-center justify-center gap-1.5 cursor-pointer transition-colors"
>
Custom Wizard <ArrowRight className="w-3.5 h-3.5 text-muted-foreground" />
Configure Outbound API <ArrowRight className="w-3.5 h-3.5 text-muted-foreground" />
</button>
</div>
</div>
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { X, ShoppingCart, Zap, Key, Globe, Loader2, CheckCircle2, AlertCircle, Eye, EyeOff, ExternalLink, ArrowLeft } from 'lucide-react';
import { X, ShoppingCart, Zap, Globe, Loader2, CheckCircle2, AlertCircle, Eye, EyeOff, ExternalLink, ArrowLeft } from 'lucide-react';
import { useIntegration } from '../hook/useIntegration';
import { integrationsService } from '../services/integrations.service';
import { useSearchParams } from 'react-router-dom';
@@ -77,7 +77,7 @@ export const ShopifyTemplateModal: React.FC<ShopifyTemplateModalProps> = ({
const created = await createItem({
name,
channel: 'shopify',
integration_type: 'ecommerce',
integrationType: 'shopify',
sync_mode: syncMode,
sync_frequency: syncMode === 'auto' ? 'realtime' : 'manual',
status: 'pending'
@@ -1,27 +1,10 @@
import { useEffect, useState } from "react";
import { RefreshCw, Download, Layers } from "lucide-react";
import { RefreshCw } from "lucide-react";
import { DataTable } from "../../../components/customs/DataTable";
import { StatusBadge, type BadgeVariant } from "../../../components/customs/StatusBadge";
import { Button } from "../../../components/customs/Button";
import { useIntegration } from "../hook/useIntegration";
import { SyncJobStatusModal } from "./SyncJobStatusModal";
import type { SyncJob } from "../types/integrations.types";
const MOCK_JOBS = [
{
id: "JOB-2892",
integration: "Shopify Main Store",
type: "Realtime Sync",
records: "128",
success: "128",
failed: "0",
status: "Completed",
started: "2026-08-30 04:30",
completed: "2026-08-30 04:31",
duration: "1m 02s",
triggered: "Outbox Trigger"
}
];
export default function SyncJobsList() {
const [jobs, setJobs] = useState<any[]>([]);
@@ -27,7 +27,6 @@ import { useChannel } from "../../channels/hook/useChannel";
import type { Integration } from "../types/integrations.types";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { StatsCard } from "../../../components/customs/StatsCard";
import { channelsApi } from "../../channels/api/channels.api";
// Import Custom Feature Components & Pre-Built Templates
import { IntegrationHealthBadge } from "../components/IntegrationHealthBadge";
@@ -57,7 +56,7 @@ const INTEGRATION_META: Record<string, { label: string; icon: any; color: string
export default function IntegrationList() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState<"Connections" | "Field Mappings" | "Publishing Rules" | "Sync Jobs" | "Error Center" | "Audit & Logs">("Connections");
const [activeTab, setActiveTab] = useState<"Connections" | "Field Mappings" | "Publishing Rules" | "Sync Jobs" | "Error Center" | "Audit & Logs" | "API Access">("Connections");
const [statusFilter, setStatusFilter] = useState("All Status");
const [typeFilter, setTypeFilter] = useState("All Types");
@@ -78,9 +77,6 @@ export default function IntegrationList() {
useEffect(() => {
fetchItems();
fetchChannels();
Promise.all([channelsApi.getAllJobs(), channelsApi.getErrors()])
.then(([jobs, errors]) => setOperations({ jobs, errors }))
.catch(() => setOperations({ jobs: [], errors: [] }));
}, [fetchItems, fetchChannels]);
// Automatically open Shopify modal into Step 3 when returning from OAuth redirect
@@ -132,8 +128,8 @@ export default function IntegrationList() {
label: "Integration Name",
sortable: true,
render: (_: any, row: Integration) => {
const metaType = row.integrationType || 'ecommerce';
const meta = INTEGRATION_META[metaType] || INTEGRATION_META.ecommerce;
const metaType = row.integrationType || 'custom_api';
const meta = INTEGRATION_META[metaType] || INTEGRATION_META.custom_api;
const Icon = meta.icon;
return (
<div className="flex items-center gap-3">
@@ -153,7 +149,7 @@ export default function IntegrationList() {
label: "Channel",
render: (val: string) => {
const chan = channels.find(c => c.code === val || c.id === val);
return <span className="font-semibold text-xs uppercase px-2 py-0.5 rounded bg-surface border border-border">{chan ? chan.name : val || 'Shopify'}</span>;
return <span className="font-semibold text-xs uppercase px-2 py-0.5 rounded bg-surface border border-border">{chan ? chan.name : val || 'Not assigned'}</span>;
}
},
{
@@ -207,8 +203,10 @@ export default function IntegrationList() {
</button>
<button
type="button"
onClick={() => setCredentialModal({ isOpen: true, integrationId: row.id, name: row.name })}
title="Configure Credentials"
onClick={() => row.integrationType === 'shopify'
? setCredentialModal({ isOpen: true, integrationId: row.id, name: row.name })
: navigate(`${row.id}/edit`)}
title={row.integrationType === 'shopify' ? "Configure Shopify Credentials" : "Configure API Endpoint & Credentials"}
className="p-1.5 rounded-lg border border-border bg-surface hover:bg-background text-foreground cursor-pointer"
>
<Key className="w-3.5 h-3.5 text-muted-foreground" />
@@ -249,6 +247,9 @@ export default function IntegrationList() {
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button variant="outline" onClick={() => setActiveTab("API Access")}>
<Key className="w-4 h-4 mr-2" />Generate PIM API Key
</Button>
<Button onClick={() => setShopifyModalOpen(true)} className="bg-emerald-600 hover:bg-emerald-700 text-white">
<Plus className="w-4 h-4 mr-2" />Setup Shopify
</Button>
@@ -257,9 +258,9 @@ export default function IntegrationList() {
/>
{/* Quick Pre-Built Template Gallery */}
<IntegrationTemplateGallery
onSelectShopify={() => setShopifyModalOpen(true)}
onSelectCustomApi={() => navigate("new")}
<IntegrationTemplateGallery
onSelectShopify={() => setShopifyModalOpen(true)}
onSelectCustomApi={() => navigate("new?type=custom_api")}
/>
{/* Stats Cards */}
@@ -303,7 +304,8 @@ export default function IntegrationList() {
{ id: "Publishing Rules", count: 1 },
{ id: "Sync Jobs", count: null },
{ id: "Error Center", count: 0 },
{ id: "Audit & Logs", count: null }
{ id: "Audit & Logs", count: null },
{ id: "API Access", count: null }
].map(tab => (
<button
key={tab.id}
@@ -351,6 +353,15 @@ export default function IntegrationList() {
<option>Pending</option>
<option>Disconnected</option>
</select>
<select
className="h-9 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-foreground bg-surface"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
<option>All Types</option>
<option value="custom_api">Custom API</option>
<option value="shopify">Shopify</option>
</select>
</div>
}
/>
@@ -361,6 +372,7 @@ export default function IntegrationList() {
{activeTab === "Sync Jobs" && <SyncJobsList />}
{activeTab === "Error Center" && <ErrorCenterList />}
{activeTab === "Audit & Logs" && <AuditLogsList />}
{activeTab === "API Access" && <ApiAccessTab />}
</div>
</div>
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import { useState, useEffect, useCallback } from "react";
import { useNavigate, useParams, useLocation, useSearchParams } from "react-router-dom";
import { useFormik } from "formik";
import * as Yup from "yup";
import {
@@ -90,8 +90,8 @@ const integrationSchema = Yup.object({
storeUrl: Yup.string().when('integrationType', { is: 'shopify', then: schema => schema.required('Permanent .myshopify.com domain is required').matches(/^https:\/\/[a-z0-9][a-z0-9-]*\.myshopify\.com\/?$/i, 'Use https://your-store.myshopify.com') }),
clientId: Yup.string().when('integrationType', { is: 'shopify', then: schema => schema.required('Shopify Client ID is required') }),
clientSecret: Yup.string(),
endpoint: Yup.string().when('integrationType', { is: 'custom_api', then: schema => schema.required('Delivery endpoint is required').url('Enter a complete HTTPS URL').matches(/^https:\/\//i, 'External endpoints must use HTTPS') }),
testEndpoint: Yup.string().url('Enter a complete URL').matches(/^(https:\/\/|$)/i, 'External endpoints must use HTTPS'),
endpoint: Yup.string().when('integrationType', { is: 'custom_api', then: schema => schema.required('Delivery endpoint is required').url('Enter a valid URL').matches(/^(https:\/\/|http:\/\/(localhost|127\.0\.0\.1))/i, 'Endpoints must use HTTPS (or http://localhost for local testing)') }),
testEndpoint: Yup.string().url('Enter a valid URL').matches(/^(https:\/\/|http:\/\/(localhost|127\.0\.0\.1)|$)/i, 'Endpoints must use HTTPS (or http://localhost for local testing)'),
customApiHeaderName: Yup.string().matches(/^[A-Za-z0-9-]*$/, 'Header names may contain letters, numbers and hyphens only'),
});
@@ -99,8 +99,10 @@ export default function NewIntegration() {
const navigate = useNavigate();
const { id } = useParams<{ id?: string }>();
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const isView = pathname.includes('/view');
const isEdit = Boolean(id) && !isView;
const requestedType = searchParams.get('type') === 'shopify' ? 'shopify' : 'custom_api';
const [activeStep, setActiveStep] = useState('general');
const [showToken, setShowToken] = useState(false);
const [loadingItem, setLoadingItem] = useState(false);
@@ -118,9 +120,9 @@ export default function NewIntegration() {
initialValues: {
name: "",
channel: "",
integrationType: "custom_api",
integrationType: requestedType,
environment: "production",
status: "Connected",
status: "Pending",
description: "",
storeUrl: "",
accessToken: "",
@@ -205,8 +207,8 @@ export default function NewIntegration() {
formik.setValues({
name: item.name,
channel: item.channel,
integrationType: item.integrationType,
environment: item.environment,
integrationType: item.integrationType || "custom_api",
environment: item.environment || "production",
status: item.status,
description: item.description || "",
storeUrl: item.storeUrl || "",
@@ -214,10 +216,10 @@ export default function NewIntegration() {
apiVersion: item.apiVersion || "2026-07",
webhookSecret: item.webhookSecret || "",
shopIdentifier: item.shopIdentifier || "",
syncDirection: item.syncDirection,
syncFrequency: item.syncFrequency,
autoRetry: item.autoRetry,
retryAttempts: item.retryAttempts,
syncDirection: item.syncDirection || "pim_to_channel",
syncFrequency: item.syncFrequency || "manual",
autoRetry: item.autoRetry ?? true,
retryAttempts: item.retryAttempts ?? 3,
// Other credentials
sellerId: item.sellerId || "",
@@ -260,7 +262,7 @@ export default function NewIntegration() {
setConnectionMessage("");
try {
const result = await integrationsService.testConnection(id);
setConnectionMessage(result.message);
setConnectionMessage(result.message || (result.connected ? "Connection successful" : "Connection failed"));
} catch (error: any) {
setConnectionMessage(error?.response?.data?.message || "Connection test failed");
} finally {
@@ -285,6 +287,39 @@ export default function NewIntegration() {
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
useEffect(() => {
if (isEdit || isView) {
setHighestVisitedStep(STEPS.length);
}
}, [isEdit, isView]);
const isGeneralValid = Boolean(formik.values.name?.trim() && formik.values.channel && formik.values.integrationType && !formik.errors.name && !formik.errors.channel && !formik.errors.integrationType);
const isStepAccessible = useCallback((stepNum: number) => {
if (isEdit || isView) return true;
if (stepNum === 1) return true;
if (!isGeneralValid) return false;
return stepNum <= highestVisitedStep + 1;
}, [isEdit, isView, isGeneralValid, highestVisitedStep]);
const handleNextStep = useCallback(() => {
if (activeStep === 'general') {
if (!isGeneralValid) {
formik.setFieldTouched('name', true);
formik.setFieldTouched('channel', true);
formik.setFieldTouched('integrationType', true);
return;
}
}
if (activeIndex < STEPS.length - 1) {
const nextStepObj = STEPS[activeIndex + 1];
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
setActiveStep(nextStepObj.id);
}
}, [activeStep, isGeneralValid, activeIndex, formik]);
const selectedType = INTEGRATION_TYPES.find((t) => t.id === formik.values.integrationType);
const selectedChanName = channels.find(c => c.code === formik.values.channel || c.id === formik.values.channel)?.name ?? '';
@@ -347,15 +382,34 @@ export default function NewIntegration() {
const isActive = activeStep === s.id;
const isDone = idx < activeIndex;
const isLast = idx === STEPS.length - 1;
const accessible = isStepAccessible(s.step);
return (
<div key={s.id} className="flex gap-3">
<div className="flex flex-col items-center" style={{ width: 24 }}>
<button type="button" onClick={() => setActiveStep(s.id)} className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${isActive ? 'bg-primary ring-2 ring-primary/20' : isDone ? 'bg-success' : 'bg-surface border-2 border-border hover:border-primary/30'}`}>
<button
type="button"
disabled={!accessible}
onClick={() => {
if (accessible) {
setActiveStep(s.id);
}
}}
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${isActive ? 'bg-primary ring-2 ring-primary/20' : isDone ? 'bg-success' : 'bg-surface border-2 border-border hover:border-primary/30'} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
>
{isDone ? <Check className="w-3 h-3 text-white" /> : <span className={`text-[9px] font-bold ${isActive ? 'text-white' : 'text-muted-foreground'}`}>{s.step}</span>}
</button>
{!isLast && <div className={`w-px flex-1 my-0.5 ${isDone ? 'bg-success/50' : 'bg-surface-muted'}`} style={{ minHeight: 14 }} />}
</div>
<button type="button" onClick={() => setActiveStep(s.id)} className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''}`}>
<button
type="button"
disabled={!accessible}
onClick={() => {
if (accessible) {
setActiveStep(s.id);
}
}}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
>
<span className={`text-xs font-medium leading-tight block ${isActive ? 'text-primary' : isDone ? 'text-muted-foreground' : 'text-muted-foreground hover:text-muted-foreground'}`}>{s.label}</span>
</button>
</div>
@@ -909,7 +963,7 @@ export default function NewIntegration() {
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm font-medium text-muted-foreground hover:bg-background transition-colors">Back</button>
)}
{activeIndex < STEPS.length - 1 && (
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
<button type="button" onClick={handleNextStep} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
)}
</div>
</div>
@@ -9,83 +9,87 @@ type ApiResponse<T> = {
export const integrationService = {
getAll: async (params?: Record<string, any>): Promise<Integration[]> => {
const res = await apiClient.get<ApiResponse<Integration[]>>('/api/v1/integrations', { params });
const raw: any = res.data;
const res = await apiClient.get<ApiResponse<Integration[]>>('/api/v1/integrations', params);
const raw: any = res;
return (Array.isArray(raw) ? raw : raw?.data || []) as Integration[];
},
getById: async (id: string): Promise<Integration> => {
const res = await apiClient.get<ApiResponse<Integration>>();
const raw: any = res.data;
const res = await apiClient.get<ApiResponse<Integration>>('/api/v1/integrations/' + id);
const raw: any = res;
return (raw?.data || raw) as Integration;
},
create: async (data: IntegrationCreateRequest): Promise<Integration> => {
const res = await apiClient.post<ApiResponse<Integration>>('/api/v1/integrations', data);
const raw: any = res.data;
const raw: any = res;
return (raw?.data || raw) as Integration;
},
update: async (id: string, data: IntegrationUpdateRequest): Promise<Integration> => {
const res = await apiClient.put<ApiResponse<Integration>>(, data);
const raw: any = res.data;
const res = await apiClient.put<ApiResponse<Integration>>('/api/v1/integrations/' + id, data);
const raw: any = res;
return (raw?.data || raw) as Integration;
},
delete: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>();
return res.success || true;
const res = await apiClient.delete<ApiResponse<any>>('/api/v1/integrations/' + id);
const raw: any = res;
return raw?.success !== undefined ? raw.success : true;
},
getCredentials: async (id: string): Promise<Record<string, string>> => {
const res = await apiClient.get<ApiResponse<Record<string, string>>>();
const raw: any = res.data;
const res = await apiClient.get<ApiResponse<Record<string, string>>>('/api/v1/integrations/' + id + '/credentials');
const raw: any = res;
return (raw?.data || raw) as Record<string, string>;
},
setCredentials: async (id: string, credentialType: string, secretValue: string, expiresAt?: string): Promise<any> => {
const res = await apiClient.post<ApiResponse<any>>(, {
const res = await apiClient.post<ApiResponse<any>>('/api/v1/integrations/' + id + '/credentials', {
credential_type: credentialType,
secret_value: secretValue,
expires_at: expiresAt
});
const raw: any = res.data;
const raw: any = res;
return raw?.data || raw;
},
testConnection: async (id: string): Promise<TestConnectionResult> => {
const res = await apiClient.post<ApiResponse<TestConnectionResult>>();
const raw: any = res.data;
const res = await apiClient.post<ApiResponse<TestConnectionResult>>('/api/v1/integrations/' + id + '/test-connection');
const raw: any = res;
return (raw?.data || raw) as TestConnectionResult;
},
triggerSync: async (id: string, options?: { productId?: string; productIds?: string[] }): Promise<any> => {
const res = await apiClient.post<ApiResponse<any>>(, options || {});
const raw: any = res.data;
const res = await apiClient.post<ApiResponse<any>>('/api/v1/integrations/' + id + '/sync', options || {});
const raw: any = res;
return raw?.data || raw;
},
getSyncJobs: async (id: string): Promise<SyncJob[]> => {
const res = await apiClient.get<ApiResponse<SyncJob[]>>();
const raw: any = res.data;
const res = await apiClient.get<ApiResponse<SyncJob[]>>('/api/v1/integrations/' + id + '/jobs');
const raw: any = res;
return (Array.isArray(raw) ? raw : raw?.data || []) as SyncJob[];
},
getAllSyncJobs: async (): Promise<SyncJob[]> => {
const res = await apiClient.get<ApiResponse<SyncJob[]>>();
const raw: any = res.data;
const res = await apiClient.get<ApiResponse<SyncJob[]>>('/api/v1/integrations/jobs/all');
const raw: any = res;
return (Array.isArray(raw) ? raw : raw?.data || []) as SyncJob[];
},
getSyncItems: async (jobId: string): Promise<SyncItem[]> => {
const res = await apiClient.get<ApiResponse<SyncItem[]>>();
const raw: any = res.data;
const res = await apiClient.get<ApiResponse<SyncItem[]>>('/api/v1/integrations/jobs/' + jobId + '/items');
const raw: any = res;
return (Array.isArray(raw) ? raw : raw?.data || []) as SyncItem[];
},
startShopifyOAuth: async (id: string): Promise<{ authorizationUrl: string; state: string }> => {
const res = await apiClient.post<ApiResponse<any>>();
const raw: any = res.data;
const res = await apiClient.post<ApiResponse<any>>('/api/v1/integrations/' + id + '/shopify/oauth/start');
const raw: any = res;
return raw?.data || raw;
}
};
export const integrationsService = integrationService;
export default integrationService;
@@ -5,7 +5,8 @@ import { useAssetType } from '../../asset-types/hook/useAssetType';
import { assetFamiliesService } from '../../asset-families/services/asset-families.service';
import {
Upload, Image as ImageIcon, Video, FileText, Trash2,
Check, Loader2, Search, Info, Shield, ArrowUp, ArrowDown, Plus, Box
Check, Loader2, Search, Info, Shield, ArrowUp, ArrowDown, Plus, Box,
X, ChevronDown
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Loader } from '../../../components/customs/Loader';
@@ -18,21 +19,43 @@ interface ProductAssetsTabProps {
family: any;
readOnly?: boolean;
refreshProductData?: () => void;
variants?: any[];
initialAssets?: AssetMapping[];
initialVariantAssets?: any[];
onAssetsChange?: (assets: AssetMapping[], variantAssets: any[]) => void;
}
export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
productId,
family,
readOnly,
refreshProductData
refreshProductData,
variants: variantsProp = [],
initialAssets = [],
initialVariantAssets = [],
onAssetsChange
}) => {
const [assignedAssets, setAssignedAssets] = useState<AssetMapping[]>([]);
const [assignedVariantAssets, setAssignedVariantAssets] = useState<any[]>([]);
const [variants, setVariants] = useState<any[]>([]);
const [assignedAssets, setAssignedAssets] = useState<AssetMapping[]>(initialAssets);
const [assignedVariantAssets, setAssignedVariantAssets] = useState<any[]>(initialVariantAssets);
const [variants, setVariants] = useState<any[]>(variantsProp);
const [selectedVariantId, setSelectedVariantId] = useState<string>('global');
const [isBulkMode, setIsBulkMode] = useState(false);
const [selectedVariantIds, setSelectedVariantIds] = useState<string[]>([]);
// Sync variants from prop
useEffect(() => {
if (variantsProp && variantsProp.length > 0) {
setVariants(variantsProp);
}
}, [variantsProp]);
// Sync assets to parent
useEffect(() => {
if (onAssetsChange) {
onAssetsChange(assignedAssets, assignedVariantAssets);
}
}, [assignedAssets, assignedVariantAssets, onAssetsChange]);
// Strict product isolation & configured variants filtering
const configuredVariants = useMemo(() => {
const productSpecific = variants.filter((v: any) => {
@@ -100,7 +123,23 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
const { items: allAssetTypes, fetchItems: fetchAssetTypes } = useAssetType();
const [selectedAssetTypeId, setSelectedAssetTypeId] = useState<string>('');
const [allAssetFamilies, setAllAssetFamilies] = useState<any[]>([]);
const [selectedAssetFamilyId, setSelectedAssetFamilyId] = useState<string>('');
const [selectedAssetFamilyIds, setSelectedAssetFamilyIds] = useState<string[]>([]);
const [isAssetFamilyDropdownOpen, setIsAssetFamilyDropdownOpen] = useState(false);
const [assetFamilySearchQuery, setAssetFamilySearchQuery] = useState('');
const assetFamilyDropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown on click outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (assetFamilyDropdownRef.current && !assetFamilyDropdownRef.current.contains(event.target as Node)) {
setIsAssetFamilyDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
// Fetch asset types and families
useEffect(() => {
@@ -116,25 +155,42 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
fetchFamilies();
}, [fetchAssetTypes]);
// Filter asset types based on selected asset family
const filteredAssetTypes = useMemo(() => {
if (!selectedAssetFamilyId) {
return allAssetTypes;
}
const matchedFamily = allAssetFamilies.find(af => af.id === selectedAssetFamilyId);
if (!matchedFamily) {
return allAssetTypes;
}
const allowedTypeIds =
Array.isArray(matchedFamily.assetTypeIds) && matchedFamily.assetTypeIds.length > 0
? matchedFamily.assetTypeIds
: (matchedFamily.assetTypes || [])
.map((at: any) => at.id || at.assetTypeId)
.filter(Boolean);
// Filter asset families based on search query
const filteredAssetFamilies = useMemo(() => {
const activeFamilies = allAssetFamilies.filter(af => af && af.status === 'active');
if (!assetFamilySearchQuery.trim()) return activeFamilies;
const q = assetFamilySearchQuery.toLowerCase();
return activeFamilies.filter(af =>
af.name?.toLowerCase().includes(q) || (af.code && String(af.code).toLowerCase().includes(q))
);
}, [allAssetFamilies, assetFamilySearchQuery]);
const filtered = allAssetTypes.filter(at => allowedTypeIds.includes(at.id));
// Filter asset types based on selected asset families (union of requirements)
const filteredAssetTypes = useMemo(() => {
if (selectedAssetFamilyIds.length === 0) {
return allAssetTypes;
}
const allowedTypeIds = new Set<string>();
selectedAssetFamilyIds.forEach(afId => {
const matchedFamily = allAssetFamilies.find(af => String(af.id) === String(afId));
if (matchedFamily) {
const typeIds =
Array.isArray(matchedFamily.assetTypeIds) && matchedFamily.assetTypeIds.length > 0
? matchedFamily.assetTypeIds
: (matchedFamily.assetTypes || [])
.map((at: any) => at.id || at.assetTypeId)
.filter(Boolean);
typeIds.forEach((tId: string) => allowedTypeIds.add(String(tId)));
}
});
if (allowedTypeIds.size === 0) {
return allAssetTypes;
}
const filtered = allAssetTypes.filter(at => allowedTypeIds.has(String(at.id)));
return filtered.length > 0 ? filtered : allAssetTypes;
}, [allAssetTypes, allAssetFamilies, selectedAssetFamilyId]);
}, [allAssetTypes, allAssetFamilies, selectedAssetFamilyIds]);
// Auto-select initial asset type if none selected or if selected is no longer allowed
useEffect(() => {
@@ -168,8 +224,8 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
Array.isArray(matchedFamily.assetTypeIds) && matchedFamily.assetTypeIds.length > 0
? matchedFamily.assetTypeIds
: (matchedFamily.assetTypes || [])
.map((at: any) => at.id || at.assetTypeId)
.filter(Boolean);
.map((at: any) => at.id || at.assetTypeId)
.filter(Boolean);
ids.push(...allowedTypeIds);
}
});
@@ -232,9 +288,6 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
// Load product assets
const loadProductAssets = async () => {
if (!productId || productId === 'new' || productId === 'null' || productId === 'undefined') {
setAssignedAssets([]);
setVariants([]);
setAssignedVariantAssets([]);
return;
}
setLoading(true);
@@ -267,7 +320,9 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
setSelectedVariantIds([]);
setIsBulkMode(false);
setScopeFilter('all');
loadProductAssets();
if (productId) {
loadProductAssets();
}
}, [productId]);
// Load general assets library for selection modal
@@ -289,18 +344,6 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
}
}, [showPicker]);
if (!productId) {
return (
<div className="p-8 text-center bg-surface rounded-xl border border-border shadow-sm">
<Info className="w-10 h-10 text-primary mx-auto mb-3 animate-bounce" />
<h3 className="font-semibold text-foreground mb-1">Save Product to Upload Assets</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
Please fill in the required fields in the General step and click **Save** first to create the product, then you can assign assets.
</p>
</div>
);
}
// Handle multiple files upload sequentially
const handleMultipleFilesUpload = async (files: FileList | File[]) => {
if (!selectedAssetType) {
@@ -368,26 +411,69 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
// 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
});
if (productId) {
await assetsService.bulkAssignVariantAsset(productId, {
asset_id: newAsset.id,
role,
variant_ids: selectedVariantIds,
is_primary: false
});
} else {
const newVarMappings = selectedVariantIds.map((vId, idx) => ({
id: `temp-vmap-${Date.now()}-${idx}-${Math.random().toString(36).substr(2, 9)}`,
variantId: vId,
variant_id: vId,
asset_id: newAsset.id,
role,
is_primary: false,
display_order: assignedVariantAssets.filter(m => m.variantId === vId).length,
asset: newAsset,
variant: configuredVariants.find(v => v.id === vId)
}));
setAssignedVariantAssets(prev => [...prev, ...newVarMappings]);
}
} 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
});
if (productId && !selectedVariantId.startsWith('temp-')) {
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 {
const newVarMapping = {
id: `temp-vmap-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
variantId: selectedVariantId,
variant_id: 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,
asset: newAsset,
variant: configuredVariants.find(v => v.id === selectedVariantId)
};
setAssignedVariantAssets(prev => [...prev, newVarMapping]);
}
} else {
await assetsService.assignProductAsset(productId, {
asset_id: newAsset.id,
role,
is_primary: assignedAssets.length === 0 && successCount === 0,
display_order: assignedAssets.length + successCount
});
if (productId) {
await assetsService.assignProductAsset(productId, {
asset_id: newAsset.id,
role,
is_primary: assignedAssets.length === 0 && successCount === 0,
display_order: assignedAssets.length + successCount
});
} else {
const newMapping: AssetMapping = {
id: `temp-map-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
product_id: '',
asset_id: newAsset.id,
role,
is_primary: assignedAssets.length === 0 && successCount === 0,
display_order: assignedAssets.length + successCount,
asset: newAsset
};
setAssignedAssets(prev => [...prev, newMapping]);
}
}
successCount++;
@@ -399,7 +485,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
if (successCount > 0) {
toast.success(`Successfully uploaded and assigned ${successCount} assets.`);
loadProductAssets();
if (productId) loadProductAssets();
refreshProductData?.();
}
setUploading(false);
@@ -475,30 +561,73 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
}
if (isBulkMode && selectedVariantIds.length > 0) {
await assetsService.bulkAssignVariantAsset(productId, {
asset_id: asset.id,
role,
variant_ids: selectedVariantIds,
is_primary: false
});
if (productId) {
await assetsService.bulkAssignVariantAsset(productId, {
asset_id: asset.id,
role,
variant_ids: selectedVariantIds,
is_primary: false
});
} else {
const newVarMappings = selectedVariantIds.map((vId, idx) => ({
id: `temp-vmap-${Date.now()}-${idx}-${Math.random().toString(36).substr(2, 9)}`,
variantId: vId,
variant_id: vId,
asset_id: asset.id,
role,
is_primary: false,
display_order: assignedVariantAssets.filter(m => m.variantId === vId).length,
asset,
variant: configuredVariants.find(v => v.id === vId)
}));
setAssignedVariantAssets(prev => [...prev, ...newVarMappings]);
}
} 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
});
if (productId && !selectedVariantId.startsWith('temp-')) {
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 {
const newVarMapping = {
id: `temp-vmap-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
variantId: selectedVariantId,
variant_id: 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,
asset,
variant: configuredVariants.find(v => v.id === selectedVariantId)
};
setAssignedVariantAssets(prev => [...prev, newVarMapping]);
}
} else {
await assetsService.assignProductAsset(productId, {
asset_id: asset.id,
role,
is_primary: assignedAssets.length === 0,
display_order: assignedAssets.length
});
if (productId) {
await assetsService.assignProductAsset(productId, {
asset_id: asset.id,
role,
is_primary: assignedAssets.length === 0,
display_order: assignedAssets.length
});
} else {
const newMapping: AssetMapping = {
id: `temp-map-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
product_id: '',
asset_id: asset.id,
role,
is_primary: assignedAssets.length === 0,
display_order: assignedAssets.length,
asset
};
setAssignedAssets(prev => [...prev, newMapping]);
}
}
toast.success('Asset assigned from library');
loadProductAssets();
if (productId) loadProductAssets();
refreshProductData?.();
} catch (err: any) {
toast.error(err?.message || 'Failed to assign asset');
@@ -510,30 +639,40 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
if (!window.confirm('Are you sure you want to unassign this asset?')) return;
try {
if (variantId) {
await assetsService.unassignVariantAsset(variantId, assetId);
if (productId && !variantId.startsWith('temp-') && !assetId.startsWith('temp-')) {
await assetsService.unassignVariantAsset(variantId, assetId);
}
setAssignedVariantAssets(prev => prev.filter(m => !((m.variantId === variantId || m.variant_id === variantId) && (m.asset_id === assetId || m.id === assetId))));
} else {
await assetsService.unassignProductAsset(productId, assetId);
if (productId && !assetId.startsWith('temp-')) {
await assetsService.unassignProductAsset(productId, assetId);
}
setAssignedAssets(prev => prev.filter(m => m.asset_id !== assetId && m.id !== assetId));
}
toast.success('Asset unassigned');
loadProductAssets();
if (productId) loadProductAssets();
refreshProductData?.();
} catch (err: any) {
toast.error(err?.message || 'Failed to unassign asset');
}
};
// Set selected asset as the primary display image
const handleSetPrimary = async (assetId: string, variantId?: string) => {
try {
if (variantId) {
await assetsService.updateVariantAsset(variantId, assetId, { is_primary: true });
if (productId && !variantId.startsWith('temp-') && !assetId.startsWith('temp-')) {
await assetsService.updateVariantAsset(variantId, assetId, { is_primary: true });
}
setAssignedVariantAssets(prev => prev.map(m => (m.variantId === variantId || m.variant_id === variantId) ? { ...m, is_primary: m.asset_id === assetId || m.id === assetId } : m));
} else {
await assetsService.updateProductAsset(productId, assetId, { is_primary: true });
if (productId && !assetId.startsWith('temp-')) {
await assetsService.updateProductAsset(productId, assetId, { is_primary: true });
}
setAssignedAssets(prev => prev.map(m => ({ ...m, is_primary: m.asset_id === assetId || m.id === assetId })));
}
toast.success('Primary image updated');
loadProductAssets();
if (productId) loadProductAssets();
refreshProductData?.();
} catch (err: any) {
toast.error(err?.message || 'Failed to set primary image');
@@ -551,15 +690,17 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
items[index] = items[targetIndex];
items[targetIndex] = temp;
// Bulk save display order updates
try {
await Promise.all([
assetsService.updateProductAsset(productId, items[index].asset_id, { display_order: index }),
assetsService.updateProductAsset(productId, items[targetIndex].asset_id, { display_order: targetIndex })
]);
setAssignedAssets(items);
} catch (err: any) {
toast.error(err?.message || 'Failed to reorder assets');
setAssignedAssets(items);
if (productId && !items[index].asset_id?.startsWith('temp-') && !items[targetIndex].asset_id?.startsWith('temp-')) {
try {
await Promise.all([
assetsService.updateProductAsset(productId, items[index].asset_id, { display_order: index }),
assetsService.updateProductAsset(productId, items[targetIndex].asset_id, { display_order: targetIndex })
]);
} catch (err: any) {
toast.error(err?.message || 'Failed to reorder assets');
}
}
};
@@ -591,8 +732,8 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
<span
key={idx}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold border ${isSatisfied
? 'bg-success/10 text-success border-success/20'
: 'bg-warning/10 text-warning border-warning/20'
? 'bg-success/10 text-success border-success/20'
: 'bg-warning/10 text-warning border-warning/20'
}`}
>
{isSatisfied ? <Check className="w-3 h-3 text-success" /> : <span className="w-1.5 h-1.5 rounded-full bg-warning" />}
@@ -608,23 +749,119 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
{!readOnly && (
<div className="bg-surface border border-border rounded-xl p-5 shadow-2xs space-y-4">
<div className="flex gap-4 flex-wrap">
{/* Asset Family Selector */}
{/* Asset Family Multi-Selector */}
<div className="flex-1 min-w-[240px]">
<label className="text-xs font-bold text-foreground block mb-2">Asset Family</label>
<div className="relative">
<Select
id="asset-family-select"
value={selectedAssetFamilyId}
onChange={(e) => setSelectedAssetFamilyId(e.target.value)}
placeholder="Select Asset Family..."
className="w-full text-xs font-semibold"
<div ref={assetFamilyDropdownRef} className="relative">
<div
onClick={() => {
if (!readOnly) {
setIsAssetFamilyDropdownOpen(!isAssetFamilyDropdownOpen);
}
}}
className={`w-full border rounded-lg px-3 py-2 text-xs font-semibold flex items-center justify-between bg-surface ${
isAssetFamilyDropdownOpen ? 'border-primary ring-2 ring-primary/20' : 'border-border'
} ${readOnly ? 'cursor-not-allowed opacity-75' : 'cursor-pointer hover:border-primary/30'}`}
>
{allAssetFamilies.filter(af => af && af.status === 'active').map((af) => (
<option key={af.id} value={af.id}>
{af.name}
</option>
))}
</Select>
{(() => {
const selectedObjs = allAssetFamilies.filter(af => selectedAssetFamilyIds.includes(String(af.id)));
if (selectedObjs.length === 0) {
return <span className="text-muted-foreground font-normal">Select Asset Family...</span>;
}
if (selectedObjs.length === 1) {
return (
<span className="text-foreground font-semibold truncate">
{selectedObjs[0].name} {selectedObjs[0].code ? `(${selectedObjs[0].code})` : ''}
</span>
);
}
return (
<span className="text-foreground font-semibold truncate">
{selectedObjs.length} Asset Families selected ({selectedObjs.map(af => af.name).join(', ')})
</span>
);
})()}
<div className="flex items-center gap-1.5 shrink-0 ml-2">
{selectedAssetFamilyIds.length > 0 && !readOnly && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setSelectedAssetFamilyIds([]);
}}
className="p-0.5 hover:bg-background rounded-full text-muted-foreground hover:text-foreground cursor-pointer"
title="Clear all selected asset families"
>
<X className="w-3.5 h-3.5" />
</button>
)}
<ChevronDown className={`w-4 h-4 text-muted-foreground transition-transform duration-200 ${isAssetFamilyDropdownOpen ? 'rotate-180 text-primary' : ''}`} />
</div>
</div>
{isAssetFamilyDropdownOpen && (
<div className="absolute z-50 mt-1.5 w-full bg-surface border border-border rounded-xl shadow-xl overflow-hidden flex flex-col max-h-60 animate-in fade-in zoom-in-95 duration-100 font-sans">
<div className="p-2 border-b border-border bg-surface-muted flex items-center gap-2">
<Search className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<input
type="text"
value={assetFamilySearchQuery}
onChange={(e) => setAssetFamilySearchQuery(e.target.value)}
onClick={(e) => e.stopPropagation()}
placeholder="Search by name or code..."
autoFocus
className="w-full px-2 py-1 text-xs border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary bg-background text-foreground font-sans"
/>
{assetFamilySearchQuery && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setAssetFamilySearchQuery('');
}}
className="p-0.5 text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<div className="p-1 overflow-y-auto flex-1 space-y-0.5">
{filteredAssetFamilies.length > 0 ? (
filteredAssetFamilies.map((af: any) => {
const afId = String(af.id);
const isSelected = selectedAssetFamilyIds.includes(afId);
return (
<div
key={af.id}
onClick={(e) => {
e.stopPropagation();
if (isSelected) {
setSelectedAssetFamilyIds(prev => prev.filter(id => id !== afId));
} else {
setSelectedAssetFamilyIds(prev => [...prev, afId]);
}
}}
className={`px-3 py-2 rounded-lg text-xs cursor-pointer transition-colors flex items-center justify-between select-none ${
isSelected ? 'bg-primary/10 text-primary font-bold' : 'hover:bg-background text-foreground'
}`}
>
<div className="min-w-0 flex-1 mr-2">
<div className="font-semibold truncate">{af.name}</div>
{af.code && <div className="text-[10px] text-muted-foreground font-mono">{af.code}</div>}
</div>
{isSelected && <Check className="w-4 h-4 text-primary shrink-0" />}
</div>
);
})
) : (
<div className="p-3 text-center text-xs text-muted-foreground">
No asset families found matching "{assetFamilySearchQuery}".
</div>
)}
</div>
</div>
)}
</div>
</div>
@@ -636,9 +873,9 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
id="asset-type-select"
value={selectedAssetTypeId}
onChange={(e) => setSelectedAssetTypeId(e.target.value)}
placeholder={selectedAssetFamilyId && filteredAssetTypes.length === 0 ? "No asset types available for this family" : "Select Asset Type classification..."}
placeholder={selectedAssetFamilyIds.length > 0 && filteredAssetTypes.length === 0 ? "No asset types available for selected families" : "Select Asset Type classification..."}
className="w-full text-xs font-semibold"
disabled={!!(selectedAssetFamilyId && filteredAssetTypes.length === 0)}
disabled={!!(selectedAssetFamilyIds.length > 0 && filteredAssetTypes.length === 0)}
>
{filteredAssetTypes.filter(at => at && at.status === 'active').map((at) => {
const isRequired = isAssetTypeRequiredByFamily(at.code);
@@ -734,7 +971,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
return false;
})
.map(v => v.id);
const isAllSelected = matchingIds.every(id => selectedVariantIds.includes(id));
return (
@@ -748,11 +985,10 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
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
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>
@@ -815,7 +1051,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
<div>
<span className="font-bold text-foreground">Max File Size: </span>
<span className="font-semibold text-muted-foreground">
{maxFileSize
{maxFileSize
? `${Math.round(maxFileSize / (1024 * 1024))} MB`
: '10 MB'}
</span>
@@ -845,9 +1081,8 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
}
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'
} ${isBulkMode && selectedVariantIds.length === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
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"
@@ -957,22 +1192,20 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
<button
type="button"
onClick={() => setScopeFilter('all')}
className={`px-2.5 py-1 rounded-full font-semibold transition-colors shrink-0 ${
scopeFilter === 'all'
className={`px-2.5 py-1 rounded-full font-semibold transition-colors shrink-0 ${scopeFilter === 'all'
? 'bg-primary text-white'
: 'bg-surface hover:bg-background border border-border text-muted-foreground'
}`}
}`}
>
All Assets ({combinedAssets.length})
</button>
<button
type="button"
onClick={() => setScopeFilter('global')}
className={`px-2.5 py-1 rounded-full font-semibold transition-colors shrink-0 ${
scopeFilter === 'global'
className={`px-2.5 py-1 rounded-full font-semibold transition-colors shrink-0 ${scopeFilter === 'global'
? 'bg-primary text-white'
: 'bg-surface hover:bg-background border border-border text-muted-foreground'
}`}
}`}
>
Global ({assignedAssets.length})
</button>
@@ -985,11 +1218,10 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
key={v.id}
type="button"
onClick={() => setScopeFilter(v.id)}
className={`px-2.5 py-1 rounded-full font-semibold transition-colors shrink-0 ${
isSelected
className={`px-2.5 py-1 rounded-full font-semibold transition-colors shrink-0 ${isSelected
? 'bg-primary text-white'
: 'bg-surface hover:bg-background border border-border text-muted-foreground'
}`}
}`}
>
Variant: {v.name.split(' - ')[1] || v.name} ({count})
</button>
@@ -1081,11 +1313,10 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
<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'
<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>
@@ -1208,8 +1439,8 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
disabled={isAssigned}
onClick={() => handleAssignFromLibrary(asset)}
className={`text-xs font-semibold px-3 py-1.5 rounded-lg border transition-all ${isAssigned
? 'bg-background text-muted-foreground border-border cursor-not-allowed'
: 'bg-primary hover:bg-primary-hover text-white border-transparent'
? 'bg-background text-muted-foreground border-border cursor-not-allowed'
: 'bg-primary hover:bg-primary-hover text-white border-transparent'
}`}
>
{isAssigned ? 'Assigned' : 'Assign'}
@@ -5,7 +5,7 @@ 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, X } from 'lucide-react';
import { LayoutGrid, List, Plus, RefreshCw, Layers, X } from 'lucide-react';
import { Loader } from '../../../../components/customs/Loader';
import { notify } from '../../../../services/toast';
@@ -17,6 +17,8 @@ interface VariantsTabProps {
readOnly?: boolean;
productAttributes?: Record<string, any>;
availableAttributes?: any[];
onVariantsChange?: (variants: Variant[]) => void;
initialVariants?: Variant[];
}
export const VariantsTab: React.FC<VariantsTabProps> = ({
@@ -26,10 +28,13 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
family,
readOnly,
productAttributes = {},
availableAttributes = []
availableAttributes = [],
onVariantsChange,
initialVariants = []
}) => {
const {
variants,
setVariants,
loading,
generating,
fetchByProduct,
@@ -38,7 +43,14 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
archiveVariant,
generateBatch,
bulkUpdate
} = useVariant();
} = useVariant(initialVariants);
// Sync to parent when variants change
useEffect(() => {
if (onVariantsChange) {
onVariantsChange(variants);
}
}, [variants, onVariantsChange]);
const [viewLayout, setViewLayout] = useState<'list' | 'matrix'>('matrix');
const [showGenerator, setShowGenerator] = useState(false);
@@ -49,7 +61,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
// Filter attributes that are marked as variant eligible OR are of eligible types (including type 'color')
const selectableAttributes = useMemo(() => {
return availableAttributes.filter(attr =>
return availableAttributes.filter(attr =>
attr.is_variant_eligible === true ||
attr.isVariantEligible === true ||
['select', 'enumeration', 'swatch', 'multiselect', 'color'].includes(attr.type || '')
@@ -136,12 +148,12 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
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,
@@ -150,7 +162,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
type: attr.type || 'select',
optionsList: attr.optionsList || []
};
setLocalAxes(prev => [...prev, newAxis]);
setSelectedAttrId('');
notify.success(`Added axis: ${attr.name}`);
@@ -159,7 +171,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
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;
@@ -167,12 +179,12 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
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,
@@ -180,7 +192,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
type: 'select',
optionsList: []
};
setLocalAxes(prev => [...prev, newAxis]);
setCustomAxisName('');
setCustomAxisCode('');
@@ -195,7 +207,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
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 !== '') {
@@ -211,13 +223,35 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
return map;
}, [localAxes, productAttributes]);
// Filter out unconfigured simple master variants (0 attributes) and ensure strict parent productId matching
// Helper: cartesian product of axes
const cartesian = (axes: { code: string; name: string; values: string[] }[]): Array<Array<{ code: string; value: string }>> => {
if (!axes || axes.length === 0) return [];
return axes.reduce<Array<Array<{ code: string; value: string }>>>((acc, axis) => {
if (!axis.values || axis.values.length === 0) return acc;
if (acc.length === 0) return axis.values.map(v => [{ code: axis.code, value: v }]);
return acc.flatMap(combo => axis.values.map(v => [...combo, { code: axis.code, value: v }]));
}, []);
};
// Helper: build SKU from template
const buildSku = (template: string | undefined, pSku: string, combo: Array<{ code: string; value: string }>): string => {
let sku = template || '{PARENT_SKU}-{COMBO}';
sku = sku.replace('{PARENT_SKU}', pSku || 'SKU');
const comboStr = combo.map(c => c.value.replace(/\s+/g, '')).join('-');
sku = sku.replace('{COMBO}', comboStr);
for (const { code, value } of combo) {
sku = sku.replace(new RegExp(`\\{${code}\\}`, 'gi'), value.replace(/\s+/g, ''));
}
return sku.toUpperCase();
};
// Filter out unconfigured simple master variants (0 attributes) and ensure strict parent productId matching when productId is present
const configuredVariants = useMemo(() => {
if (!Array.isArray(variants) || !productId) return [];
if (!Array.isArray(variants)) return [];
return variants.filter(v => {
if (!v) return false;
const vParentId = v.parentProductId || (v as any).product_id || (v as any).productId;
if (vParentId && vParentId !== productId) return false;
if (productId && vParentId && vParentId !== productId) return false;
return v.attributes && typeof v.attributes === 'object' && Object.keys(v.attributes).length > 0;
});
}, [variants, productId]);
@@ -235,7 +269,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
}
return (localAxes || []).map(a => a.code);
}, [configuredVariants, localAxes]);
const axesNames = useMemo(() => {
const map: Record<string, string> = {};
(localAxes || []).forEach(a => {
@@ -257,19 +291,6 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
);
}
// If parent product has not been created/saved yet
if (!productId) {
return (
<div className="p-8 text-center bg-surface rounded-xl border border-border shadow-sm">
<Info className="w-10 h-10 text-primary mx-auto mb-3 animate-bounce" />
<h3 className="font-semibold text-foreground mb-1">Save Product to Configure Variants</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
You must create and save the basic product information first before you can configure and generate variants. Please fill in the required fields in the General step and click **Create Draft** on the header bar.
</p>
</div>
);
}
const handleGenerate = async (selected: Record<string, string[]>, skuTemplate: string) => {
const formattedAxes = Object.entries(selected).map(([code, values]) => {
const axisInfo = localAxes.find(a => a.code === code);
@@ -280,15 +301,82 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
};
});
try {
await generateBatch({
productId,
axes: formattedAxes,
skuTemplate
});
setShowGenerator(false);
} catch (err) {
// handled in hook
if (productId) {
try {
await generateBatch({
productId,
axes: formattedAxes,
skuTemplate
});
setShowGenerator(false);
} catch (err) {
// handled in hook
}
} else {
// Local client-side generation before product is persisted
const combinations = cartesian(formattedAxes);
if (combinations.length === 0) {
notify.error('No combinations could be generated from the selected values');
return;
}
// Detect duplicates
const existingSignatures = new Set(
(variants || []).map(v => {
return JSON.stringify(Object.fromEntries(Object.entries(v.attributes || {}).sort()));
})
);
const parentSkuVal = parentSku || 'SKU';
const newVariants: Variant[] = [];
let createdCount = 0;
let skippedCount = 0;
for (const combo of combinations) {
const attrMap: Record<string, string> = {};
for (const { code, value } of combo) {
attrMap[code] = value;
}
const sig = JSON.stringify(Object.fromEntries(Object.entries(attrMap).sort()));
if (existingSignatures.has(sig)) {
skippedCount++;
continue;
}
const generatedSku = buildSku(skuTemplate, parentSkuVal, combo);
const variantName = combo.map(c => c.value).join(' / ');
const newVariant: Variant = {
id: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
sku: generatedSku,
name: variantName,
parentProductId: '',
attributes: attrMap,
status: 'draft',
price: 0,
costPrice: 0,
currency: 'USD',
stock: 0,
availableStock: 0,
reservedStock: 0,
safetyStock: 0,
images: []
};
existingSignatures.add(sig);
newVariants.push(newVariant);
createdCount++;
}
if (createdCount > 0) {
setVariants(prev => [...prev, ...newVariants]);
notify.success(`Generated ${createdCount} variant(s)${skippedCount > 0 ? `, skipped ${skippedCount} duplicates` : ''}`);
setShowGenerator(false);
} else {
notify.info('All combinations already exist — no new variants created');
setShowGenerator(false);
}
}
};
@@ -319,9 +407,9 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
ids,
updates
});
fetchByProduct(productId);
if (productId) fetchByProduct(productId);
setSelectedIds(new Set());
} catch (err) {}
} catch (err) { }
};
const handleBulkDelete = async () => {
@@ -329,7 +417,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
try {
await Promise.all(Array.from(selectedIds).map(id => deleteVariant(id)));
setSelectedIds(new Set());
} catch (err) {}
} catch (err) { }
};
const handleBulkArchive = async () => {
@@ -337,7 +425,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
try {
await Promise.all(Array.from(selectedIds).map(id => archiveVariant(id)));
setSelectedIds(new Set());
} catch (err) {}
} catch (err) { }
};
const handleSingleUpdate = async (id: string, updates: Partial<Variant>) => {
@@ -528,7 +616,9 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
)}
<button
type="button"
onClick={() => fetchByProduct(productId)}
onClick={() => {
if (productId) fetchByProduct(productId);
}}
className="p-1.5 border border-border hover:bg-background text-muted-foreground rounded-lg"
title="Refresh variants list"
>
@@ -541,11 +631,10 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
<button
type="button"
onClick={() => setViewLayout('matrix')}
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium transition-all ${
viewLayout === 'matrix'
? 'bg-surface text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground'
}`}
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium transition-all ${viewLayout === 'matrix'
? 'bg-surface text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<LayoutGrid className="w-3.5 h-3.5" />
Matrix Grid
@@ -553,11 +642,10 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
<button
type="button"
onClick={() => setViewLayout('list')}
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium transition-all ${
viewLayout === 'list'
? 'bg-surface text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground'
}`}
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium transition-all ${viewLayout === 'list'
? 'bg-surface text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<List className="w-3.5 h-3.5" />
List Table
@@ -34,13 +34,15 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
let setObj = blueprint.attributeSet || blueprint.attribute_set;
const setId = blueprint.attribute_set_id || blueprint.attributeSetId || (setObj ? setObj.id : null);
if ((!setObj || !setObj.groups) && setId) {
if ((!setObj || !setObj.groups || setObj.groups.length === 0) && setId) {
const fetchedSet = await attributeSetsService.getById(setId).catch(() => null);
if (fetchedSet) setObj = fetchedSet;
}
setAttributeSet(setObj || null);
const groupsData = blueprint.groups || blueprint.attributeGroups || [];
const groupsData = (setObj && Array.isArray(setObj.groups) && setObj.groups.length > 0)
? setObj.groups
: (blueprint.groups || blueprint.attributeGroups || []);
let flatAttrs: any[] = [];
if (Array.isArray(groupsData) && groupsData.length > 0) {
setGroups(groupsData);
@@ -92,8 +94,20 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
})
: [];
const resolvedProductType =
blueprint.productType ||
blueprint.product_type ||
(blueprint.completenessRules ? blueprint.completenessRules.productType : null) ||
null;
const normalizedBlueprint = {
...blueprint,
attributeSet: setObj || null,
attributeSetId: setId || null,
attribute_set_id: setId || null,
groups: groupsData,
attributes: flatAttrs,
productType: resolvedProductType,
variantAxes: resolvedVariantAxes,
variantEnabled: resolvedVariantAxes.length > 0 || Boolean(blueprint.variantEnabled)
};
+39 -15
View File
@@ -8,14 +8,13 @@ import type {
} from '../types/variant.types';
import { toast } from 'react-toastify';
export const useVariant = () => {
const [variants, setVariants] = useState<Variant[]>([]);
export const useVariant = (initialVariants: Variant[] = []) => {
const [variants, setVariants] = useState<Variant[]>(initialVariants);
const [loading, setLoading] = useState(false);
const [generating, setGenerating] = useState(false);
const fetchByProduct = useCallback(async (productId: string) => {
if (!productId || productId === 'new' || productId === 'null' || productId === 'undefined') {
setVariants([]);
return;
}
setLoading(true);
@@ -30,6 +29,10 @@ export const useVariant = () => {
}, []);
const updateVariant = useCallback(async (id: string, updates: VariantUpdateRequest) => {
if (id.startsWith('temp-')) {
setVariants(prev => prev.map(v => v.id === id ? { ...v, ...updates } : v));
return { id, ...updates } as Variant;
}
try {
const updated = await variantService.update(id, updates);
setVariants(prev => prev.map(v => v.id === id ? { ...v, ...updated } : v));
@@ -41,6 +44,11 @@ export const useVariant = () => {
}, []);
const deleteVariant = useCallback(async (id: string) => {
if (id.startsWith('temp-')) {
setVariants(prev => prev.filter(v => v.id !== id));
toast.success('Variant deleted');
return;
}
try {
await variantService.delete(id);
setVariants(prev => prev.filter(v => v.id !== id));
@@ -52,6 +60,11 @@ export const useVariant = () => {
}, []);
const archiveVariant = useCallback(async (id: string) => {
if (id.startsWith('temp-')) {
setVariants(prev => prev.map(v => v.id === id ? { ...v, status: 'archived' } : v));
toast.success('Variant archived');
return;
}
try {
await variantService.archive(id);
setVariants(prev => prev.map(v => v.id === id ? { ...v, status: 'archived' } : v));
@@ -82,18 +95,29 @@ export const useVariant = () => {
}, []);
const bulkUpdate = useCallback(async (req: BulkUpdateRequest) => {
setLoading(true);
try {
const results = await variantService.bulkUpdate(req);
const successCount = results.filter(r => r.success).length;
// Refresh variants after bulk
toast.success(`Updated ${successCount}/${req.ids.length} variants`);
return results;
} catch (err: any) {
toast.error(err?.message || 'Bulk update failed');
throw err;
} finally {
setLoading(false);
const tempIds = req.ids.filter(id => id.startsWith('temp-'));
const realIds = req.ids.filter(id => !id.startsWith('temp-'));
if (tempIds.length > 0) {
setVariants(prev => prev.map(v => tempIds.includes(v.id) ? { ...v, ...req.updates } : v));
}
if (realIds.length > 0) {
setLoading(true);
try {
const results = await variantService.bulkUpdate({ ...req, ids: realIds });
const successCount = results.filter(r => r.success).length;
toast.success(`Updated ${successCount + tempIds.length}/${req.ids.length} variants`);
return results;
} catch (err: any) {
toast.error(err?.message || 'Bulk update failed');
throw err;
} finally {
setLoading(false);
}
} else {
toast.success(`Updated ${tempIds.length}/${req.ids.length} variants`);
return tempIds.map(id => ({ id, success: true }));
}
}, []);
+377 -56
View File
@@ -19,6 +19,7 @@ import { useAttribute } from '../../attributes/hook/useAttribute';
import { useAttributeGroup } from '../../attribute-groups/hook/useAttributeGroup';
import { attributeSetsService } from '../../attribute-sets/services/attribute-sets.service';
import { attributeGroupsService } from '../../attribute-groups/services/attribute-groups.service';
import { channelsApi } from '../../channels/api/channels.api';
import { Box, LayoutGrid, Tags, Globe, Eye, Save, Send, Image as ImageIcon, FolderTree, Check, Plus, CheckCircle2, Loader2, AlertCircle, Search, Pencil, X } from 'lucide-react';
import { PageWrapper } from '../../../components/layouts/PageWrapper';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
@@ -121,7 +122,12 @@ export default function NewProduct() {
channels: f.channelCount ?? (Array.isArray(f.channels) ? f.channels.length : 0),
workflow: f.workflowCode || 'Standard Approval',
allowedBrands: f.allowedBrands || [],
allowedUnits: f.allowedUnits || []
allowedUnits: f.allowedUnits || [],
category: f.category,
categoryId: f.category_id || f.categoryId || (typeof f.category === 'object' ? f.category?.id : f.category) || null,
productType: f.productType || f.product_type || (f.completenessRules as any)?.productType || null,
attributeSetId: f.attributeSetId || f.attribute_set_id || (f.attributeSet ? f.attributeSet.id : null) || null,
attributeSet: f.attributeSet || null
}));
}, [families]);
@@ -239,11 +245,16 @@ export default function NewProduct() {
// Sync Attribute Set ID from Product Family configuration
useEffect(() => {
if (attributeSet) {
setSelectedAttributeSetId(attributeSet.id);
setSelectedAttributeSetObj(attributeSet);
if (!isEdit && selectedFamily && creationMethod !== 'clone') {
if (attributeSet) {
setSelectedAttributeSetId(attributeSet.id);
setSelectedAttributeSetObj(attributeSet);
} else if (family && !family.attributeSet && !family.attributeSetId && !family.attribute_set_id) {
setSelectedAttributeSetId(null);
setSelectedAttributeSetObj(null);
}
}
}, [attributeSet]);
}, [attributeSet, family, selectedFamily, isEdit, creationMethod]);
const handleAttributeSetChange = async (setId: string) => {
setSelectedAttributeSetId(setId || null);
@@ -638,7 +649,7 @@ export default function NewProduct() {
await productService.update(targetId, submissionValues as any);
await hydrateProductEditor(targetId);
if (submissionValues.status === 'active') {
notify.success("Product published successfully with Active status!");
notify.success("Product updated successfully with Active status!");
} else {
notify.success("Changes saved successfully!");
}
@@ -646,7 +657,7 @@ export default function NewProduct() {
const createdRaw: any = await productService.create({ ...submissionValues, family_id: selectedFamily } as any);
const created = createdRaw?.data?.id ? createdRaw.data : (createdRaw?.id ? createdRaw : createdRaw?.data || createdRaw);
if (submissionValues.status === 'active') {
notify.success("Product published successfully with Active status!");
notify.success("Product created successfully with Active status!");
} else {
notify.success("Product draft created successfully!");
}
@@ -654,7 +665,9 @@ export default function NewProduct() {
setIsEditMode(true);
navigate(`/products/${created.id}/edit`, { replace: true });
await hydrateProductEditor(created.id);
setActiveTab('attributes');
if (activeTab === 'general') {
setActiveTab('attributes');
}
}
} catch (err: any) {
const msg = err?.response?.data?.message || err?.message || 'Unable to create product.';
@@ -709,6 +722,20 @@ export default function NewProduct() {
}
}, [activeTab, currentTabs]);
const [maxUnlockedStep, setMaxUnlockedStep] = useState(1);
useEffect(() => {
if (isEdit || isReadOnlyView) {
setMaxUnlockedStep(currentTabs.length);
}
}, [isEdit, isReadOnlyView, currentTabs.length]);
const isStepAccessible = useCallback((stepNum: number) => {
if (isEdit || isReadOnlyView) return true;
if (stepNum === 1) return true;
return stepNum <= maxUnlockedStep;
}, [isEdit, isReadOnlyView, maxUnlockedStep]);
const isNextDisabled = useMemo(() => {
// 1. Core General fields check (always required to proceed)
const isGeneralInvalid = !formik.values.name || !formik.values.brand || !formik.values.unit || !formik.values.category;
@@ -793,6 +820,47 @@ export default function NewProduct() {
formik.handleSubmit();
};
const [isPublishing, setIsPublishing] = useState(false);
const handlePublishToChannels = async () => {
const isCurrentActive = product?.status === 'active' || formik.values.status === 'active';
if (!isCurrentActive) {
notify.error('A product must be created as ACTIVE before it can be published.');
return;
}
const inheritedCodes = (family?.channels || []).map((fc: any) => fc.channel_code);
const optionalCodes = formik.values.metadata?.channels || [];
const allSelectedCodes = Array.from(new Set([...inheritedCodes, ...optionalCodes]));
if (allSelectedCodes.length === 0) {
notify.error('Please select at least one channel in the Channels step before publishing.');
return;
}
setIsPublishing(true);
try {
const targetChannels = (allChannels || []).filter((ch: any) => allSelectedCodes.includes(ch.code));
for (const ch of targetChannels) {
if (ch.id) {
try {
await channelsApi.triggerSyndication(ch.id);
} catch (syndErr) {
console.warn(`Syndication trigger warning for channel ${ch.name || ch.code}:`, syndErr);
}
}
}
notify.success(`Product successfully published to ${allSelectedCodes.length} selected channel(s)!`);
} catch (err: any) {
const msg = err?.response?.data?.message || err?.message || 'Failed to publish product to channels.';
notify.error(msg);
} finally {
setIsPublishing(false);
}
};
const setValuesRef = useRef(formik.setValues);
useEffect(() => {
setValuesRef.current = formik.setValues;
@@ -1001,12 +1069,49 @@ export default function NewProduct() {
}
}, [id, productId, hydrateProductEditor]);
// Set default category when product family loads (Category Inheritance)
// Set category, product type, and attribute set when product family is selected/changed (Inheritance in Create mode)
const prevSelectedFamilyRef = useRef<string | null>(null);
useEffect(() => {
if (familyCategory && !formik.values.category) {
formik.setFieldValue('category', familyCategory.id);
if (!isEdit && selectedFamily) {
if (prevSelectedFamilyRef.current !== selectedFamily) {
prevSelectedFamilyRef.current = selectedFamily;
const resolvedCategoryId =
(familyCategory && typeof familyCategory === 'object' ? familyCategory.id : familyCategory) ||
(family?.category && typeof family.category === 'object' ? family.category.id : family?.category) ||
family?.category_id ||
family?.categoryId ||
'';
formik.setFieldValue('category', resolvedCategoryId || '', true);
const resolvedProductType =
family?.productType ||
selectedFamilyObj?.productType ||
null;
if (resolvedProductType) {
formik.setFieldValue('type', resolvedProductType, true);
}
const resolvedSet = attributeSet || family?.attributeSet || selectedFamilyObj?.attributeSet || null;
const resolvedSetId = resolvedSet?.id || family?.attributeSetId || family?.attribute_set_id || selectedFamilyObj?.attributeSetId || null;
if (resolvedSet) {
setSelectedAttributeSetId(resolvedSet.id);
setSelectedAttributeSetObj(resolvedSet);
} else if (resolvedSetId) {
setSelectedAttributeSetId(resolvedSetId);
attributeSetsService.getById(resolvedSetId).then(res => {
if (res) setSelectedAttributeSetObj(res);
}).catch(() => { });
} else if (family && !family.attributeSet && !family.attributeSetId && !family.attribute_set_id) {
setSelectedAttributeSetId(null);
setSelectedAttributeSetObj(null);
}
}
} else if (!selectedFamily && !isEdit) {
prevSelectedFamilyRef.current = null;
setSelectedAttributeSetId(null);
setSelectedAttributeSetObj(null);
}
}, [familyCategory]);
}, [selectedFamily, familyCategory, family, attributeSet, selectedFamilyObj, isEdit]);
// Generate dynamic product code if empty and name is provided
useEffect(() => {
@@ -1090,14 +1195,47 @@ export default function NewProduct() {
return;
}
// Reset previous custom attributes and excluded groups
setExcludedGroupIds(new Set());
setCustomAddedAttributes([]);
// Load configuration of the product family
const familyId = productData.family_id || productData.familyId || (productData.family ? (typeof productData.family === 'object' ? productData.family.id : productData.family) : null);
let blueprint: any = null;
if (familyId) {
setSelectedFamily(familyId);
await loadConfiguration(familyId);
blueprint = await loadConfiguration(familyId);
} else {
setSelectedFamily(null);
}
// Extract saved attributes
// Resolve Attribute Set from blueprint, productData, family, or metadata
let resolvedSet: any = blueprint?.attributeSet || productData.family?.attributeSet || productData.attributeSet || null;
let resolvedSetId: string | null = blueprint?.attributeSetId || blueprint?.attribute_set_id || productData.family?.attributeSetId || productData.family?.attribute_set_id || productData.metadata?.attributeSetId || productData.metadata?.attribute_set_id || productData.attributeSetId || productData.attribute_set_id || (resolvedSet ? resolvedSet.id : null);
if ((!resolvedSet || !resolvedSet.groups || resolvedSet.groups.length === 0) && resolvedSetId) {
try {
const setDetails = await attributeSetsService.getById(resolvedSetId);
if (setDetails) {
resolvedSet = setDetails;
}
} catch (err) {
console.error("Failed to load attribute set details during clone:", err);
}
}
if (resolvedSet && resolvedSet.id) {
setSelectedAttributeSetId(resolvedSet.id);
setSelectedAttributeSetObj(resolvedSet);
} else if (resolvedSetId) {
setSelectedAttributeSetId(resolvedSetId);
setSelectedAttributeSetObj(resolvedSet || null);
} else {
setSelectedAttributeSetId(null);
setSelectedAttributeSetObj(null);
}
// Extract saved attributes from productData
const savedAttrs: Record<string, any> = {
...(productData.metadata?.attributes || {}),
...(productData.attributes || {})
@@ -1111,6 +1249,73 @@ export default function NewProduct() {
});
}
// Initialize blueprint attributes for all defined fields in the attribute set
const blueprintAttrs: Record<string, any> = {};
if (resolvedSet?.groups) {
resolvedSet.groups.forEach((g: any) => {
if (Array.isArray(g.attributes)) {
g.attributes.forEach((attr: any) => {
if (attr && attr.code) {
blueprintAttrs[attr.code] = '';
}
});
}
});
} else if (blueprint && Array.isArray(blueprint.attributes)) {
blueprint.attributes.forEach((attr: any) => {
if (attr && attr.code) {
blueprintAttrs[attr.code] = '';
}
});
}
const mergedAttributes = {
...blueprintAttrs,
...savedAttrs
};
// Collect codes in the resolved Attribute Set
const setAttrCodes = new Set<string>();
if (resolvedSet?.groups) {
resolvedSet.groups.forEach((g: any) => {
if (Array.isArray(g.attributes)) {
g.attributes.forEach((a: any) => {
if (a.code) setAttrCodes.add(a.code.toLowerCase());
});
}
});
}
// Collect any custom attributes not in the resolved attribute set
const customAdded: any[] = [];
if (Array.isArray(productData.attributeValues)) {
productData.attributeValues.forEach((av: any) => {
const code = (av.attribute?.code || av.attribute_code || '').toLowerCase();
if (code && !setAttrCodes.has(code) && !EXCLUDED_ATTRIBUTE_CODES.includes(code)) {
const attrObj = av.attribute || allRegistryAttributes.find((a: any) => (a.code || '').toLowerCase() === code);
if (attrObj && !customAdded.some((x: any) => x.id === attrObj.id || (x.code || '').toLowerCase() === code)) {
customAdded.push(attrObj);
}
}
});
}
Object.keys(savedAttrs).forEach((code) => {
const codeLower = code.toLowerCase();
if (!setAttrCodes.has(codeLower) && !EXCLUDED_ATTRIBUTE_CODES.includes(codeLower)) {
let attrObj = allRegistryAttributes.find((a: any) => (a.code || '').toLowerCase() === codeLower);
if (!attrObj && Array.isArray(productData.attributeValues)) {
const matchAv = productData.attributeValues.find((av: any) => (av.attribute?.code || av.attribute_code || '').toLowerCase() === codeLower);
if (matchAv?.attribute) attrObj = matchAv.attribute;
}
if (attrObj && !customAdded.some((x: any) => x.id === attrObj.id || (x.code || '').toLowerCase() === codeLower)) {
customAdded.push(attrObj);
}
}
});
setCustomAddedAttributes(customAdded);
const brandId = productData.brand_id || productData.brandId || (productData.brand && typeof productData.brand === 'object' ? (productData.brand as any).id : productData.brand) || '';
const categoryId = productData.category_id || productData.categoryId || (productData.category && typeof productData.category === 'object' ? (productData.category as any).id : productData.category) || '';
const unitId = productData.unit_id || productData.unitId || (productData.unit && typeof productData.unit === 'object' ? (productData.unit as any).id : productData.unit) || '';
@@ -1140,7 +1345,7 @@ export default function NewProduct() {
...(productData.metadata || {}),
currentStage: 'draft',
},
attributes: savedAttrs
attributes: mergedAttributes
});
notify.success(`Template preloaded from product: ${productData.name}`);
} catch (err) {
@@ -1175,6 +1380,70 @@ export default function NewProduct() {
>
<Pencil className="w-4 h-4" /> Edit Product
</button>
) : activeTab === 'review' ? (
<>
<button
type="button"
disabled={formik.isSubmitting || isPublishing}
onClick={() => handleSubmitWithValidation('draft')}
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors cursor-pointer disabled:opacity-50 flex items-center gap-2 shadow-xs"
>
{formik.isSubmitting && formik.values.status === 'draft' ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Saving Draft...
</>
) : (
<>
<Save className="w-4 h-4" />
Create Draft
</>
)}
</button>
<button
type="button"
disabled={formik.isSubmitting || isPublishing}
onClick={() => handleSubmitWithValidation('active')}
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors cursor-pointer disabled:opacity-50 flex items-center gap-2 shadow-xs"
>
{formik.isSubmitting && formik.values.status === 'active' ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Creating Product...
</>
) : (
<>
<CheckCircle2 className="w-4 h-4" />
Create Product
</>
)}
</button>
<button
type="button"
disabled={
formik.isSubmitting ||
isPublishing ||
(product?.status !== 'active' && formik.values.status !== 'active')
}
onClick={handlePublishToChannels}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 shadow-xs ${(product?.status === 'active' || formik.values.status === 'active')
? 'bg-primary hover:bg-primary-hover text-white cursor-pointer'
: 'bg-surface-muted text-muted-foreground cursor-not-allowed border border-border opacity-60'
}`}
>
{isPublishing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Publishing...
</>
) : (
<>
<Send className="w-4 h-4" />
Publish
</>
)}
</button>
</>
) : !isEdit ? (
<button
type="button"
@@ -1307,21 +1576,22 @@ export default function NewProduct() {
const isActive = activeTab === tab.id;
const isDone = tab.step < activeIndex + 1;
const isLast = idx === currentTabs.length - 1;
const accessible = isStepAccessible(tab.step);
return (
<div key={tab.id} className="flex gap-3">
<div className="flex flex-col items-center" style={{ width: 24 }}>
<button
type="button"
onClick={() => {
if (isEdit || tab.id === 'general') {
if (accessible) {
setActiveTab(tab.id);
}
}}
disabled={!isEdit && tab.id !== 'general'}
disabled={!accessible}
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${isActive ? 'bg-primary ring-2 ring-primary/20' :
isDone ? 'bg-success' :
'bg-surface border-2 border-border hover:border-primary/30'
} ${(!isEdit && tab.id !== 'general') ? 'opacity-40 cursor-not-allowed' : ''}`}
} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
>
{isDone
? <Check className="w-3 h-3 text-white" />
@@ -1335,12 +1605,12 @@ export default function NewProduct() {
<button
type="button"
onClick={() => {
if (isEdit || tab.id === 'general') {
if (accessible) {
setActiveTab(tab.id);
}
}}
disabled={!isEdit && tab.id !== 'general'}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''} ${(!isEdit && tab.id !== 'general') ? 'opacity-40 cursor-not-allowed' : ''}`}
disabled={!accessible}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
>
<span className={`text-xs font-medium leading-tight block ${isActive ? 'text-primary-dark' : isDone ? 'text-muted-foreground' : 'text-muted-foreground hover:text-muted-foreground'
}`}>{tab.label}</span>
@@ -1421,6 +1691,10 @@ export default function NewProduct() {
setCreationMethod(val as any);
setSelectedFamily(null);
setClonedProductId(null);
setSelectedAttributeSetId(null);
setSelectedAttributeSetObj(null);
setCustomAddedAttributes([]);
setExcludedGroupIds(new Set());
formik.resetForm();
}}
>
@@ -1461,7 +1735,47 @@ export default function NewProduct() {
key={fam.id}
onClick={async () => {
setSelectedFamily(fam.id);
await loadConfiguration(fam.id);
const cfg = await loadConfiguration(fam.id);
const resolvedCatId =
(cfg?.category && typeof cfg.category === 'object' ? cfg.category.id : cfg?.category) ||
cfg?.category_id ||
cfg?.categoryId ||
(fam.category && typeof fam.category === 'object' ? fam.category.id : fam.category) ||
fam.categoryId ||
fam.category_id ||
'';
formik.setFieldValue('category', resolvedCatId || '', true);
formik.setFieldTouched('category', true, false);
const resolvedProdType =
cfg?.productType ||
fam.productType ||
(fam.completenessRules as any)?.productType ||
null;
if (resolvedProdType) {
formik.setFieldValue('type', resolvedProdType, true);
formik.setFieldTouched('type', true, false);
}
const resolvedSet = cfg?.attributeSet || fam.attributeSet || null;
const resolvedSetId = resolvedSet?.id || cfg?.attributeSetId || cfg?.attribute_set_id || fam.attributeSetId || fam.attribute_set_id || null;
if (resolvedSet) {
setSelectedAttributeSetId(resolvedSet.id);
setSelectedAttributeSetObj(resolvedSet);
} else if (resolvedSetId) {
setSelectedAttributeSetId(resolvedSetId);
const fetched = await attributeSetsService.getById(resolvedSetId).catch(() => null);
if (fetched) {
setSelectedAttributeSetObj(fetched);
} else {
setSelectedAttributeSetId(null);
setSelectedAttributeSetObj(null);
}
} else {
setSelectedAttributeSetId(null);
setSelectedAttributeSetObj(null);
}
setIsFamilyDropdownOpen(false);
setFamilySearchQuery('');
}}
@@ -2013,40 +2327,38 @@ export default function NewProduct() {
</div>
)}
{activeTab === 'variants' && (
!isEdit ? (
<div className="bg-surface border border-border rounded-xl p-8 text-center flex flex-col items-center justify-center min-h-[300px]">
<Tags className="w-10 h-10 text-muted-foreground mb-3 animate-pulse" />
<h3 className="font-semibold text-foreground mb-1">Please save the product before managing variants.</h3>
<p className="text-xs text-muted-foreground">Variants matrix generation and overrides require a persistent product entity.</p>
</div>
) : (
<VariantsTab
productId={id || productId}
productType={formik.values.type}
parentSku={formik.values.sku}
family={family}
readOnly={isReadOnlyView}
productAttributes={formik.values.attributes}
availableAttributes={activeAttributesList}
/>
)
<VariantsTab
productId={id || productId}
productType={formik.values.type}
parentSku={formik.values.sku}
family={family}
readOnly={isReadOnlyView}
productAttributes={formik.values.attributes}
availableAttributes={activeAttributesList}
initialVariants={product?.variants || []}
onVariantsChange={(vars) => {
setProduct((prev: any) => ({ ...(prev || {}), variants: vars }));
}}
/>
)}
{activeTab === 'assets' && (
!isEdit ? (
<div className="bg-surface border border-border rounded-xl p-8 text-center flex flex-col items-center justify-center min-h-[300px]">
<ImageIcon className="w-10 h-10 text-muted-foreground mb-3 animate-pulse" />
<h3 className="font-semibold text-foreground mb-1">Save product before uploading assets.</h3>
<p className="text-xs text-muted-foreground">Digital Asset Management maps files directly to database product IDs.</p>
</div>
) : (
<ProductAssetsTab
productId={id || productId}
family={family}
readOnly={isReadOnlyView}
refreshProductData={refreshProductData}
/>
)
<ProductAssetsTab
productId={id || productId}
family={family}
readOnly={isReadOnlyView}
refreshProductData={refreshProductData}
variants={product?.variants || []}
initialAssets={product?.productAssets || []}
initialVariantAssets={product?.variantAssets || []}
onAssetsChange={(assets, variantAssets) => {
setProduct((prev: any) => ({
...(prev || {}),
productAssets: assets,
variantAssets: variantAssets
}));
}}
/>
)}
{activeTab === 'channels' && (
@@ -2539,12 +2851,21 @@ export default function NewProduct() {
disabled={isNextDisabled}
onClick={async () => {
const errors: any = await formik.validateForm();
const currentIdx = currentTabs.findIndex(t => t.id === activeTab);
const nextTabObj = currentTabs[currentIdx + 1];
const advanceToNext = () => {
if (nextTabObj) {
setMaxUnlockedStep(prev => Math.max(prev, nextTabObj.step));
setActiveTab(nextTabObj.id);
}
};
if (activeTab === 'general') {
const requiredFields = ['name', 'brand', 'unit', 'category'];
const hasRequiredErrors = Object.keys(errors).some(k => requiredFields.includes(k));
if (!hasRequiredErrors) {
setActiveTab(currentTabs[currentTabs.findIndex(t => t.id === activeTab) + 1].id);
advanceToNext();
} else {
const touchedObj: any = { ...formik.touched };
requiredFields.forEach(k => {
@@ -2570,7 +2891,7 @@ export default function NewProduct() {
}
if (!hasRequiredErrors && !hasAttributeErrors && isAttributesComplete) {
setActiveTab(currentTabs[currentTabs.findIndex(t => t.id === activeTab) + 1].id);
advanceToNext();
} else {
const touchedObj: any = {
...formik.touched,
@@ -2604,7 +2925,7 @@ export default function NewProduct() {
formik.setTouched(touchedObj);
}
} else {
setActiveTab(currentTabs[currentTabs.findIndex(t => t.id === activeTab) + 1].id);
advanceToNext();
}
}}
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+50 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { useFormik } from 'formik';
import { PageWrapper } from '../../../components/layouts/PageWrapper';
@@ -181,6 +181,39 @@ export default function NewVariant() {
const activeProducts = products.filter(p => p.status === 'active');
const isLoading = productsLoading || variantLoading || parentLoading;
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
useEffect(() => {
if (isEdit || isView) {
setHighestVisitedStep(STEPS.length);
}
}, [isEdit, isView]);
const isBasicValid = Boolean(formik.values.sku?.trim() && formik.values.name?.trim() && formik.values.parentProductId && !formik.errors.sku && !formik.errors.name && !formik.errors.parentProductId);
const isStepAccessible = useCallback((stepNum: number) => {
if (isEdit || isView) return true;
if (stepNum === 1) return true;
if (!isBasicValid) return false;
return stepNum <= highestVisitedStep + 1;
}, [isEdit, isView, isBasicValid, highestVisitedStep]);
const handleNextStep = useCallback(() => {
if (activeStep === 'basic') {
if (!isBasicValid) {
formik.setFieldTouched('sku', true);
formik.setFieldTouched('name', true);
formik.setFieldTouched('parentProductId', true);
return;
}
}
if (activeIndex < STEPS.length - 1) {
const nextStepObj = STEPS[activeIndex + 1];
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
setActiveStep(nextStepObj.id);
}
}, [activeStep, isBasicValid, activeIndex, formik]);
return (
<ProtectedRoute node="products.variants">
<PageWrapper>
@@ -235,17 +268,23 @@ export default function NewVariant() {
const isActive = activeStep === s.id;
const isDone = idx < activeIndex;
const isLast = idx === STEPS.length - 1;
const accessible = isStepAccessible(s.step);
return (
<div key={s.id} className="flex gap-3">
<div className="flex flex-col items-center" style={{ width: 24 }}>
<button
type="button"
onClick={() => setActiveStep(s.id)}
disabled={!accessible}
onClick={() => {
if (accessible) {
setActiveStep(s.id);
}
}}
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
isActive ? 'bg-primary ring-2 ring-primary/20' :
isDone ? 'bg-success' :
'bg-surface border-2 border-border hover:border-primary/30'
}`}
} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
>
{isDone
? <Check className="w-3 h-3 text-white" />
@@ -258,8 +297,13 @@ export default function NewVariant() {
</div>
<button
type="button"
onClick={() => setActiveStep(s.id)}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''}`}
disabled={!accessible}
onClick={() => {
if (accessible) {
setActiveStep(s.id);
}
}}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
>
<span className={`text-xs font-medium leading-tight block ${
isActive ? 'text-primary' : isDone ? 'text-muted-foreground' : 'text-muted-foreground hover:text-muted-foreground'
@@ -586,7 +630,7 @@ export default function NewVariant() {
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm font-medium text-muted-foreground hover:bg-background transition-colors">Back</button>
)}
{activeIndex < STEPS.length - 1 && (
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
<button type="button" onClick={handleNextStep} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
)}
</div>
</div>