Add product editor

This commit is contained in:
2026-01-29 21:55:34 -05:00
parent f9e8c9265e
commit 01d4097030
15 changed files with 1898 additions and 9 deletions

View File

@@ -1 +1,2 @@
* Avoid using glob tool for search as it may not work properly on this codebase. Search using bash instead. * Avoid using glob tool for search as it may not work properly on this codebase. Search using bash instead.
* If you use the task tool to have an agent investigate something, make sure to let it know to avoid using glob

View File

@@ -1246,6 +1246,48 @@ router.get('/search-products', async (req, res) => {
} }
}); });
// Get product images for a given PID from production DB
router.get('/product-images/:pid', async (req, res) => {
const pid = parseInt(req.params.pid, 10);
if (!pid || pid <= 0) {
return res.status(400).json({ error: 'Valid PID is required' });
}
try {
const { connection } = await getDbConnection();
const [rows] = await connection.query(
'SELECT iid, type, width, height, `order`, hidden FROM product_images WHERE pid = ? ORDER BY `order` DESC, type',
[pid]
);
// Group by iid and build image URLs using the same logic as the PHP codebase
const typeMap = { 1: 'o', 2: 'l', 3: 't', 4: '100x100', 5: '175x175', 6: '300x300', 7: '600x600', 8: '500x500', 9: '150x150' };
const padded = String(pid).padStart(10, '0');
const pathPrefix = `${padded.substring(0, 4)}/${padded.substring(4, 7)}/`;
const imagesByIid = {};
for (const row of rows) {
const typeName = typeMap[row.type];
if (!typeName) continue;
if (!imagesByIid[row.iid]) {
imagesByIid[row.iid] = { iid: row.iid, order: row.order, hidden: !!row.hidden, sizes: {} };
}
imagesByIid[row.iid].sizes[typeName] = {
width: row.width,
height: row.height,
url: `https://sbing.com/i/products/${pathPrefix}${pid}-${typeName}-${row.iid}.jpg`,
};
}
const images = Object.values(imagesByIid).sort((a, b) => b.order - a.order);
res.json(images);
} catch (error) {
console.error('Error fetching product images:', error);
res.status(500).json({ error: 'Failed to fetch product images' });
}
});
const UPC_SUPPLIER_PREFIX_LEADING_DIGIT = '4'; const UPC_SUPPLIER_PREFIX_LEADING_DIGIT = '4';
const UPC_MAX_SEQUENCE = 99999; const UPC_MAX_SEQUENCE = 99999;
const UPC_RESERVATION_TTL = 5 * 60 * 1000; // 5 minutes const UPC_RESERVATION_TTL = 5 * 60 * 1000; // 5 minutes

View File

@@ -28,7 +28,7 @@
"@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.4", "@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-toast": "^1.2.6",
@@ -51,6 +51,7 @@
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"diff": "^7.0.0", "diff": "^7.0.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.4.4", "framer-motion": "^12.4.4",
"immer": "^11.1.3", "immer": "^11.1.3",
"input-otp": "^1.4.1", "input-otp": "^1.4.1",
@@ -1498,6 +1499,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": { "node_modules/@radix-ui/react-arrow": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz",
@@ -1633,6 +1652,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": { "node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
@@ -1699,6 +1736,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": { "node_modules/@radix-ui/react-direction": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
@@ -1891,6 +1946,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover": { "node_modules/@radix-ui/react-popover": {
"version": "1.1.6", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz",
@@ -1928,6 +2001,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": { "node_modules/@radix-ui/react-popper": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz",
@@ -2031,6 +2122,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-progress": { "node_modules/@radix-ui/react-progress": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.2.tgz",
@@ -2192,6 +2301,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": { "node_modules/@radix-ui/react-separator": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz",
@@ -2216,12 +2343,12 @@
} }
}, },
"node_modules/@radix-ui/react-slot": { "node_modules/@radix-ui/react-slot": {
"version": "1.1.2", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-compose-refs": "1.1.1" "@radix-ui/react-compose-refs": "1.1.2"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -2233,6 +2360,21 @@
} }
} }
}, },
"node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch": { "node_modules/@radix-ui/react-switch": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.3.tgz",
@@ -2414,6 +2556,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": { "node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
@@ -4794,6 +4954,34 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/embla-carousel": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
"license": "MIT"
},
"node_modules/embla-carousel-react": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz",
"integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==",
"license": "MIT",
"dependencies": {
"embla-carousel": "8.6.0",
"embla-carousel-reactive-utils": "8.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/embla-carousel-reactive-utils": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz",
"integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==",
"license": "MIT",
"peerDependencies": {
"embla-carousel": "8.6.0"
}
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",

View File

@@ -32,7 +32,7 @@
"@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.4", "@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-toast": "^1.2.6",
@@ -55,6 +55,7 @@
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"diff": "^7.0.0", "diff": "^7.0.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.4.4", "framer-motion": "^12.4.4",
"immer": "^11.1.3", "immer": "^11.1.3",
"input-otp": "^1.4.1", "input-otp": "^1.4.1",

View File

@@ -33,9 +33,12 @@ const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard'));
const Dashboard = lazy(() => import('./pages/Dashboard')); const Dashboard = lazy(() => import('./pages/Dashboard'));
const SmallDashboard = lazy(() => import('./pages/SmallDashboard')); const SmallDashboard = lazy(() => import('./pages/SmallDashboard'));
// 3. Product import - separate chunk // 3. Product import - separate chunk
const Import = lazy(() => import('./pages/Import').then(module => ({ default: module.Import }))); const Import = lazy(() => import('./pages/Import').then(module => ({ default: module.Import })));
// Product editor
const ProductEditor = lazy(() => import('./pages/ProductEditor'));
// 4. Chat archive - separate chunk // 4. Chat archive - separate chunk
const Chat = lazy(() => import('./pages/Chat').then(module => ({ default: module.Chat }))); const Chat = lazy(() => import('./pages/Chat').then(module => ({ default: module.Chat })));
@@ -185,6 +188,15 @@ function App() {
</Protected> </Protected>
} /> } />
{/* Product editor */}
<Route path="/product-editor" element={
<Protected page="product_editor">
<Suspense fallback={<PageLoading />}>
<ProductEditor />
</Suspense>
</Protected>
} />
{/* Product import - separate chunk */} {/* Product import - separate chunk */}
<Route path="/import" element={ <Route path="/import" element={
<Protected page="import"> <Protected page="import">

View File

@@ -13,6 +13,7 @@ import {
Percent, Percent,
FileSearch, FileSearch,
ShoppingCart, ShoppingCart,
FilePenLine,
} from "lucide-react"; } from "lucide-react";
import { IconCrystalBall } from "@tabler/icons-react"; import { IconCrystalBall } from "@tabler/icons-react";
import { import {
@@ -113,6 +114,12 @@ const toolsItems = [
icon: IconCrystalBall, icon: IconCrystalBall,
url: "/forecasting", url: "/forecasting",
permission: "access:forecasting" permission: "access:forecasting"
},
{
title: "Product Editor",
icon: FilePenLine,
url: "/product-editor",
permission: "access:product_editor"
} }
]; ];

View File

@@ -0,0 +1,85 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ChevronsUpDown, Check } from "lucide-react";
import { cn } from "@/lib/utils";
import type { FieldOption } from "./types";
export function ComboboxField({
options,
value,
onChange,
placeholder,
searchPlaceholder,
disabled,
}: {
options: FieldOption[];
value: string;
onChange: (val: string) => void;
placeholder: string;
searchPlaceholder?: string;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const selectedLabel = options.find((o) => o.value === value)?.label;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className="w-full justify-between font-normal"
>
<span className="truncate">{selectedLabel ?? placeholder}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-0"
align="start"
>
<Command>
<CommandInput placeholder={searchPlaceholder ?? "Search..."} />
<CommandList>
<CommandEmpty>No results.</CommandEmpty>
<CommandGroup>
{options.map((opt) => (
<CommandItem
key={opt.value}
value={opt.label}
onSelect={() => {
onChange(opt.value);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === opt.value ? "opacity-100" : "opacity-0"
)}
/>
{opt.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,403 @@
import { useState, useCallback, useRef } from "react";
import axios from "axios";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Loader2,
Eye,
EyeOff,
Trash2,
ZoomIn,
ImagePlus,
Link,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragOverlay,
type DragStartEvent,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
horizontalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { ProductImage } from "./types";
// ── Helper: get best image URL ─────────────────────────────────────────
export function getImageSrc(img: ProductImage): string | null {
if (img.imageUrl) return img.imageUrl;
return (
img.sizes["600x600"]?.url ??
img.sizes["500x500"]?.url ??
img.sizes["300x300"]?.url ??
img.sizes["l"]?.url ??
img.sizes["o"]?.url ??
null
);
}
// ── Sortable Image Card ────────────────────────────────────────────────
function SortableImageCard({
image,
onToggleHidden,
onDelete,
onZoom,
}: {
image: ProductImage;
onToggleHidden: (iid: number | string) => void;
onDelete: (iid: number | string) => void;
onZoom: (image: ProductImage) => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: image.iid });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
touchAction: "none" as const,
};
const src = getImageSrc(image);
if (!src) return null;
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={cn(
"relative group rounded-lg border bg-white shrink-0 cursor-grab active:cursor-grabbing",
"w-[140px] h-[140px]",
image.hidden && "opacity-50 ring-2 ring-yellow-400"
)}
>
{/* Action buttons */}
<div className="absolute top-1 right-1 z-10 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onZoom(image);
}}
className="p-1 rounded bg-black/40 text-white hover:bg-black/60"
title="View full size"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
<button
type="button"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onToggleHidden(image.iid);
}}
className="p-1 rounded bg-black/40 text-white hover:bg-black/60"
title={image.hidden ? "Show image" : "Hide image"}
>
{image.hidden ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onDelete(image.iid);
}}
className="p-1 rounded bg-red-500/80 text-white hover:bg-red-600"
title="Delete image"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
{/* Hidden badge */}
{image.hidden && (
<div className="absolute bottom-1 left-1 z-10">
<Badge variant="outline" className="text-[10px] bg-yellow-100 border-yellow-400 px-1 py-0">
Hidden
</Badge>
</div>
)}
<img
src={src}
alt={`Image ${image.iid}`}
className="w-full h-full object-contain rounded-lg pointer-events-none select-none"
draggable={false}
/>
</div>
);
}
// ── Image Manager Section ──────────────────────────────────────────────
let newImageCounter = 0;
export function ImageManager({
images,
setImages,
isLoading,
}: {
images: ProductImage[];
setImages: React.Dispatch<React.SetStateAction<ProductImage[]>>;
isLoading: boolean;
}) {
const [activeId, setActiveId] = useState<number | string | null>(null);
const [zoomImage, setZoomImage] = useState<ProductImage | null>(null);
const [urlInput, setUrlInput] = useState("");
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as number | string);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveId(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
setImages((prev) => {
const oldIndex = prev.findIndex((img) => img.iid === active.id);
const newIndex = prev.findIndex((img) => img.iid === over.id);
return arrayMove(prev, oldIndex, newIndex);
});
},
[setImages]
);
const toggleHidden = useCallback(
(iid: number | string) => {
setImages((prev) =>
prev.map((img) =>
img.iid === iid ? { ...img, hidden: !img.hidden } : img
)
);
},
[setImages]
);
const deleteImage = useCallback(
(iid: number | string) => {
setImages((prev) => prev.filter((img) => img.iid !== iid));
},
[setImages]
);
const addNewImage = useCallback(
(imageUrl: string) => {
const id = `new-${newImageCounter++}`;
const newImg: ProductImage = {
iid: id,
order: 0,
hidden: false,
sizes: {},
imageUrl,
isNew: true,
};
setImages((prev) => [...prev, newImg]);
},
[setImages]
);
const handleFileUpload = useCallback(
async (files: FileList | null) => {
if (!files || files.length === 0) return;
setIsUploading(true);
try {
for (const file of Array.from(files)) {
const formData = new FormData();
formData.append("image", file);
const res = await axios.post("/api/import/upload-image", formData);
if (res.data?.imageUrl) {
addNewImage(res.data.imageUrl);
}
}
} catch {
toast.error("Failed to upload image");
} finally {
setIsUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
},
[addNewImage]
);
const handleUrlAdd = useCallback(() => {
const url = urlInput.trim();
if (!url) return;
addNewImage(url);
setUrlInput("");
}, [urlInput, addNewImage]);
const activeImage = activeId
? images.find((img) => img.iid === activeId)
: null;
if (isLoading) {
return (
<div className="flex items-center justify-center h-40">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
const imageIds = images.map((img) => img.iid);
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
Images ({images.length})
</h3>
<p className="text-xs text-muted-foreground">
Drag to reorder. First visible image is the main image.
</p>
</div>
{/* Image grid with drag-and-drop */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={imageIds} strategy={horizontalListSortingStrategy}>
<div className="flex gap-3 flex-wrap">
{images.map((img) => (
<SortableImageCard
key={img.iid}
image={img}
onToggleHidden={toggleHidden}
onDelete={deleteImage}
onZoom={setZoomImage}
/>
))}
{/* Add image button */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="w-[140px] h-[140px] rounded-lg border-2 border-dashed border-muted-foreground/25 hover:border-muted-foreground/50 flex flex-col items-center justify-center gap-2 text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
{isUploading ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : (
<>
<ImagePlus className="h-6 w-6" />
<span className="text-xs">Add Image</span>
</>
)}
</button>
</div>
</SortableContext>
<DragOverlay>
{activeImage ? (
<div className="w-[140px] h-[140px] rounded-lg border bg-white shadow-lg">
<img
src={getImageSrc(activeImage) ?? ""}
alt="Dragging"
className="w-full h-full object-contain rounded-lg"
/>
</div>
) : null}
</DragOverlay>
</DndContext>
{/* Add by URL */}
<div className="flex gap-2 items-center">
<Link className="h-4 w-4 text-muted-foreground shrink-0" />
<Input
placeholder="Add image by URL..."
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleUrlAdd())}
className="text-sm h-8"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleUrlAdd}
disabled={!urlInput.trim()}
className="h-8 shrink-0"
>
Add
</Button>
</div>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => handleFileUpload(e.target.files)}
/>
{/* Full-size image overlay */}
<Dialog open={!!zoomImage} onOpenChange={(open) => !open && setZoomImage(null)}>
<DialogContent className="max-w-3xl p-2">
<DialogTitle className="sr-only">Product image preview</DialogTitle>
{zoomImage && (
<img
src={
zoomImage.imageUrl ??
zoomImage.sizes["o"]?.url ??
zoomImage.sizes["600x600"]?.url ??
getImageSrc(zoomImage) ??
""
}
alt={`Image ${zoomImage.iid}`}
className="w-full h-auto object-contain max-h-[80vh] rounded"
/>
)}
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,528 @@
import { useState, useEffect, useCallback, useRef } from "react";
import axios from "axios";
import { useForm, Controller } from "react-hook-form";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Loader2, X } from "lucide-react";
import { submitProductEdit, type ImageChanges } from "@/services/productEditor";
import { ComboboxField } from "./ComboboxField";
import { ImageManager } from "./ImageManager";
import type {
SearchProduct,
FieldOptions,
LineOption,
ProductImage,
ProductFormValues,
} from "./types";
export function ProductEditForm({
product,
fieldOptions,
onClose,
}: {
product: SearchProduct;
fieldOptions: FieldOptions;
onClose: () => void;
}) {
const [lineOptions, setLineOptions] = useState<LineOption[]>([]);
const [sublineOptions, setSublineOptions] = useState<LineOption[]>([]);
const [productImages, setProductImages] = useState<ProductImage[]>([]);
const [isLoadingImages, setIsLoadingImages] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const originalValuesRef = useRef<ProductFormValues | null>(null);
const originalImagesRef = useRef<ProductImage[]>([]);
const {
register,
control,
handleSubmit,
reset,
watch,
formState: { dirtyFields },
} = useForm<ProductFormValues>();
const watchCompany = watch("company");
const watchLine = watch("line");
// Populate form on mount
useEffect(() => {
const formValues: ProductFormValues = {
name: product.title ?? "",
company: String(product.brand_id ?? ""),
line: String(product.line_id ?? ""),
subline: String(product.subline_id ?? ""),
supplier: String(product.supplier ?? ""),
upc: product.barcode ?? "",
item_number: product.sku ?? "",
supplier_no: product.vendor_reference ?? "",
notions_no: product.notions_reference ?? "",
msrp: String(product.regular_price ?? ""),
cost_each: String(product.cost_price ?? ""),
qty_per_unit: String(product.moq ?? ""),
case_qty: String(product.case_qty ?? ""),
tax_cat: String(product.tax_code ?? ""),
artist: String(product.artist_id ?? ""),
weight: product.weight?.toString() ?? "",
length: product.length?.toString() ?? "",
width: product.width?.toString() ?? "",
height: product.height?.toString() ?? "",
ship_restrictions: product.shipping_restrictions ?? "",
coo: product.country_of_origin ?? "",
hts_code: product.harmonized_tariff_code ?? "",
size_cat: product.size_cat ?? "",
description: product.description ?? "",
priv_notes: "",
};
originalValuesRef.current = { ...formValues };
reset(formValues);
// Fetch images
setIsLoadingImages(true);
axios
.get(`/api/import/product-images/${product.pid}`)
.then((res) => {
setProductImages(res.data);
originalImagesRef.current = res.data;
})
.catch(() => toast.error("Failed to load product images"))
.finally(() => setIsLoadingImages(false));
}, [product, reset]);
// Load lines when company changes
useEffect(() => {
if (!watchCompany) {
setLineOptions([]);
return;
}
axios
.get(`/api/import/product-lines/${watchCompany}`)
.then((res) => setLineOptions(res.data))
.catch(() => setLineOptions([]));
}, [watchCompany]);
// Load sublines when line changes
useEffect(() => {
if (!watchLine) {
setSublineOptions([]);
return;
}
axios
.get(`/api/import/sublines/${watchLine}`)
.then((res) => setSublineOptions(res.data))
.catch(() => setSublineOptions([]));
}, [watchLine]);
const computeImageChanges = useCallback((): ImageChanges | null => {
const original = originalImagesRef.current;
const current = productImages;
const originalIds = original.map((img) => img.iid);
const currentIds = current.map((img) => img.iid);
const deleted = originalIds.filter((id) => !currentIds.includes(id)) as number[];
const hidden = current.filter((img) => img.hidden).map((img) => img.iid).filter((id): id is number => typeof id === "number");
const added: Record<string, string> = {};
for (const img of current) {
if (img.isNew && img.imageUrl) {
added[String(img.iid)] = img.imageUrl;
}
}
const order = current.map((img) => img.iid);
const originalHidden = original.filter((img) => img.hidden).map((img) => img.iid);
const orderChanged = JSON.stringify(order.filter((id) => typeof id === "number")) !== JSON.stringify(originalIds);
const hiddenChanged = JSON.stringify([...hidden].sort()) !== JSON.stringify([...(originalHidden as number[])].sort());
const hasDeleted = deleted.length > 0;
const hasAdded = Object.keys(added).length > 0;
if (!orderChanged && !hiddenChanged && !hasDeleted && !hasAdded) {
return null;
}
return { order, hidden, deleted, added };
}, [productImages]);
const onSubmit = useCallback(
async (data: ProductFormValues) => {
if (!originalValuesRef.current) return;
const original = originalValuesRef.current;
const changes: Record<string, unknown> = {};
for (const key of Object.keys(data) as (keyof ProductFormValues)[]) {
if (data[key] !== original[key]) {
changes[key] = data[key];
}
}
const imageChanges = computeImageChanges();
if (Object.keys(changes).length === 0 && !imageChanges) {
toast.info("No changes to submit");
return;
}
setIsSubmitting(true);
try {
const result = await submitProductEdit({
pid: product.pid,
changes,
environment: "prod",
imageChanges: imageChanges ?? undefined,
});
if (result.success) {
toast.success("Product updated successfully");
originalValuesRef.current = { ...data };
originalImagesRef.current = [...productImages];
reset(data);
} else {
toast.error(result.message ?? "Failed to update product");
if (result.error) {
console.error("Edit error details:", result.error);
}
}
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to update product"
);
} finally {
setIsSubmitting(false);
}
},
[product.pid, reset, computeImageChanges, productImages]
);
const hasImageChanges = computeImageChanges() !== null;
const changedCount = Object.keys(dirtyFields).length;
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">
Editing: {product.title}
</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
PID: {product.pid} | SKU: {product.sku}
</p>
</div>
<div className="flex items-center gap-2">
{(changedCount > 0 || hasImageChanges) && (
<Badge variant="secondary">
{changedCount > 0 ? `${changedCount} field${changedCount !== 1 ? "s" : ""}` : ""}
{changedCount > 0 && hasImageChanges ? " + " : ""}
{hasImageChanges ? "images" : ""}
{" changed"}
</Badge>
)}
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{/* Product Images */}
<div className="mb-6">
<ImageManager
images={productImages}
setImages={setProductImages}
isLoading={isLoadingImages}
/>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Basic Info */}
<div className="space-y-4">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
Basic Info
</h3>
<div className="grid grid-cols-1 gap-4">
<div>
<Label>Name</Label>
<Input {...register("name")} />
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<Label>UPC</Label>
<Input {...register("upc")} />
</div>
<div>
<Label>Item Number</Label>
<Input {...register("item_number")} />
</div>
<div>
<Label>Supplier #</Label>
<Input {...register("supplier_no")} />
</div>
</div>
<div>
<Label>Notions #</Label>
<Input
{...register("notions_no")}
className="max-w-[200px]"
/>
</div>
</div>
{/* Taxonomy */}
<div className="space-y-4">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
Taxonomy
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Supplier</Label>
<Controller
name="supplier"
control={control}
render={({ field }) => (
<ComboboxField
options={fieldOptions.suppliers ?? []}
value={field.value}
onChange={field.onChange}
placeholder="Select supplier"
searchPlaceholder="Search suppliers..."
/>
)}
/>
</div>
<div>
<Label>Company / Brand</Label>
<Controller
name="company"
control={control}
render={({ field }) => (
<ComboboxField
options={fieldOptions.companies ?? []}
value={field.value}
onChange={field.onChange}
placeholder="Select company"
searchPlaceholder="Search companies..."
/>
)}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<Label>Line</Label>
<Controller
name="line"
control={control}
render={({ field }) => (
<ComboboxField
options={lineOptions}
value={field.value}
onChange={field.onChange}
placeholder="Select line"
searchPlaceholder="Search lines..."
disabled={!watchCompany}
/>
)}
/>
</div>
<div>
<Label>Sub Line</Label>
<Controller
name="subline"
control={control}
render={({ field }) => (
<ComboboxField
options={sublineOptions}
value={field.value}
onChange={field.onChange}
placeholder="Select subline"
searchPlaceholder="Search sublines..."
disabled={!watchLine}
/>
)}
/>
</div>
<div>
<Label>Artist</Label>
<Controller
name="artist"
control={control}
render={({ field }) => (
<ComboboxField
options={fieldOptions.artists ?? []}
value={field.value}
onChange={field.onChange}
placeholder="Select artist"
searchPlaceholder="Search artists..."
/>
)}
/>
</div>
</div>
</div>
{/* Pricing */}
<div className="space-y-4">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
Pricing & Quantities
</h3>
<div className="grid grid-cols-4 gap-4">
<div>
<Label>MSRP</Label>
<Input {...register("msrp")} />
</div>
<div>
<Label>Cost Each</Label>
<Input {...register("cost_each")} />
</div>
<div>
<Label>Min Qty</Label>
<Input {...register("qty_per_unit")} />
</div>
<div>
<Label>Case Pack</Label>
<Input {...register("case_qty")} />
</div>
</div>
</div>
{/* Dimensions & Shipping */}
<div className="space-y-4">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
Dimensions & Shipping
</h3>
<div className="grid grid-cols-4 gap-4">
<div>
<Label>Weight (lbs)</Label>
<Input {...register("weight")} />
</div>
<div>
<Label>Length (in)</Label>
<Input {...register("length")} />
</div>
<div>
<Label>Width (in)</Label>
<Input {...register("width")} />
</div>
<div>
<Label>Height (in)</Label>
<Input {...register("height")} />
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<Label>Tax Category</Label>
<Controller
name="tax_cat"
control={control}
render={({ field }) => (
<ComboboxField
options={fieldOptions.taxCategories ?? []}
value={field.value}
onChange={field.onChange}
placeholder="Select tax category"
/>
)}
/>
</div>
<div>
<Label>Shipping Restrictions</Label>
<Controller
name="ship_restrictions"
control={control}
render={({ field }) => (
<ComboboxField
options={fieldOptions.shippingRestrictions ?? []}
value={field.value}
onChange={field.onChange}
placeholder="Select restriction"
/>
)}
/>
</div>
<div>
<Label>Size Category</Label>
<Controller
name="size_cat"
control={control}
render={({ field }) => (
<ComboboxField
options={fieldOptions.sizes ?? []}
value={field.value}
onChange={field.onChange}
placeholder="Select size"
/>
)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Country of Origin</Label>
<Input
{...register("coo")}
placeholder="2-letter code"
maxLength={2}
/>
</div>
<div>
<Label>HTS Code</Label>
<Input {...register("hts_code")} />
</div>
</div>
</div>
{/* Description */}
<div className="space-y-4">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
Description
</h3>
<div>
<Label>Description</Label>
<Textarea {...register("description")} rows={4} />
</div>
<div>
<Label>Private Notes</Label>
<Textarea {...register("priv_notes")} rows={2} />
</div>
</div>
{/* Submit */}
<div className="flex items-center justify-end gap-3 pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={() => {
if (originalValuesRef.current) {
reset(originalValuesRef.current);
}
}}
>
Reset Changes
</Button>
<Button
type="submit"
disabled={isSubmitting || (changedCount === 0 && !hasImageChanges)}
>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Submit{" "}
{changedCount > 0 || hasImageChanges
? `(${changedCount} field${changedCount !== 1 ? "s" : ""}${hasImageChanges ? " + images" : ""})`
: ""}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,114 @@
import { useState, useCallback } from "react";
import axios from "axios";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Loader2, Search } from "lucide-react";
import type { SearchProduct } from "./types";
export function ProductSearch({
onSelect,
}: {
onSelect: (product: SearchProduct) => void;
}) {
const [searchTerm, setSearchTerm] = useState("");
const [searchResults, setSearchResults] = useState<SearchProduct[]>([]);
const [isSearching, setIsSearching] = useState(false);
const handleSearch = useCallback(async () => {
if (!searchTerm.trim()) return;
setIsSearching(true);
try {
const res = await axios.get("/api/import/search-products", {
params: { q: searchTerm },
});
setSearchResults(res.data);
} catch {
toast.error("Search failed");
} finally {
setIsSearching(false);
}
}, [searchTerm]);
const handleSelect = useCallback(
(product: SearchProduct) => {
onSelect(product);
setSearchResults([]);
},
[onSelect]
);
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Search Products</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Input
placeholder="Search by name, SKU, UPC, brand..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<Button onClick={handleSearch} disabled={isSearching}>
{isSearching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
</Button>
</div>
{searchResults.length > 0 && (
<div className="mt-4 border rounded-md">
<ScrollArea className="max-h-80">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>SKU</TableHead>
<TableHead>Brand</TableHead>
<TableHead>Line</TableHead>
<TableHead className="text-right">Price</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{searchResults.map((product) => (
<TableRow
key={product.pid}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSelect(product)}
>
<TableCell className="max-w-[300px] truncate">
{product.title}
</TableCell>
<TableCell>{product.sku}</TableCell>
<TableCell>{product.brand}</TableCell>
<TableCell>{product.line}</TableCell>
<TableCell className="text-right">
$
{Number(product.regular_price)?.toFixed(2) ??
product.regular_price}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,95 @@
export interface SearchProduct {
pid: number;
title: string;
description: string;
sku: string;
barcode: string;
harmonized_tariff_code: string;
price: number;
regular_price: number;
cost_price: number;
vendor: string;
vendor_reference: string;
notions_reference: string;
brand: string;
brand_id: string;
line: string;
line_id: string;
subline: string;
subline_id: string;
artist: string;
artist_id: string;
moq: number;
weight: number;
length: number;
width: number;
height: number;
country_of_origin: string;
total_sold: number;
first_received: string | null;
date_last_sold: string | null;
supplier?: string;
case_qty?: number;
tax_code?: string;
size_cat?: string;
shipping_restrictions?: string;
}
export interface FieldOption {
label: string;
value: string;
}
export interface FieldOptions {
companies: FieldOption[];
artists: FieldOption[];
sizes: FieldOption[];
themes: FieldOption[];
categories: FieldOption[];
colors: FieldOption[];
suppliers: FieldOption[];
taxCategories: FieldOption[];
shippingRestrictions: FieldOption[];
}
export interface LineOption {
label: string;
value: string;
}
export interface ProductImage {
iid: number | string; // number for existing, string like "new-0" for added
order: number;
hidden: boolean;
sizes: Record<string, { width: number; height: number; url: string }>;
imageUrl?: string; // for newly added images (the uploaded URL)
isNew?: boolean;
}
export interface ProductFormValues {
name: string;
company: string;
line: string;
subline: string;
supplier: string;
upc: string;
item_number: string;
supplier_no: string;
notions_no: string;
msrp: string;
cost_each: string;
qty_per_unit: string;
case_qty: string;
tax_cat: string;
artist: string;
weight: string;
length: string;
width: string;
height: string;
ship_restrictions: string;
coo: string;
hts_code: string;
size_cat: string;
description: string;
priv_notes: string;
}

View File

@@ -0,0 +1,260 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@@ -0,0 +1,55 @@
import { useState, useEffect } from "react";
import axios from "axios";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { ProductSearch } from "@/components/product-editor/ProductSearch";
import { ProductEditForm } from "@/components/product-editor/ProductEditForm";
import type { SearchProduct, FieldOptions } from "@/components/product-editor/types";
export default function ProductEditor() {
const [selectedProduct, setSelectedProduct] = useState<SearchProduct | null>(null);
const [fieldOptions, setFieldOptions] = useState<FieldOptions | null>(null);
const [isLoadingOptions, setIsLoadingOptions] = useState(true);
useEffect(() => {
axios
.get("/api/import/field-options")
.then((res) => setFieldOptions(res.data))
.catch((err) => {
console.error("Failed to load field options:", err);
toast.error("Failed to load field options");
})
.finally(() => setIsLoadingOptions(false));
}, []);
if (isLoadingOptions) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="container mx-auto py-6 max-w-4xl space-y-6">
<div>
<h1 className="text-2xl font-bold">Product Editor</h1>
<p className="text-muted-foreground">
Search for a product and edit its fields. Only changed fields will be
submitted.
</p>
</div>
<ProductSearch onSelect={setSelectedProduct} />
{selectedProduct && fieldOptions && (
<ProductEditForm
key={selectedProduct.pid}
product={selectedProduct}
fieldOptions={fieldOptions}
onClose={() => setSelectedProduct(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,98 @@
export interface ImageChanges {
order: (number | string)[];
hidden: number[];
deleted: number[];
added: Record<string, string>; // e.g. { "new-0": "https://..." }
}
export interface SubmitProductEditArgs {
pid: number;
changes: Record<string, unknown>;
environment: "dev" | "prod";
imageChanges?: ImageChanges;
}
export interface SubmitProductEditResponse {
success: boolean;
message?: string;
data?: unknown;
error?: unknown;
}
const DEV_ENDPOINT = "/apiv2-test/product/edit";
const PROD_ENDPOINT = "/apiv2/product/edit";
const isHtmlResponse = (payload: string) => {
const trimmed = payload.trim();
return trimmed.startsWith("<!DOCTYPE html") || trimmed.startsWith("<html");
};
export async function submitProductEdit({
pid,
changes,
environment,
imageChanges,
}: SubmitProductEditArgs): Promise<SubmitProductEditResponse> {
const targetUrl = environment === "dev" ? DEV_ENDPOINT : PROD_ENDPOINT;
const product: Record<string, unknown> = { pid, ...changes };
if (imageChanges) {
product.image_changes = imageChanges;
}
const payload = new URLSearchParams();
payload.append("products", JSON.stringify([product]));
const fetchOptions: RequestInit = {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
},
body: payload,
};
if (environment === "dev") {
const authToken = import.meta.env.VITE_APIV2_AUTH_TOKEN;
if (authToken) {
payload.append("auth", authToken);
fetchOptions.body = payload;
}
} else {
fetchOptions.credentials = "include";
}
let response: Response;
try {
response = await fetch(targetUrl, fetchOptions);
} catch (networkError) {
throw new Error(
networkError instanceof Error ? networkError.message : "Network request failed"
);
}
const rawBody = await response.text();
if (isHtmlResponse(rawBody)) {
throw new Error(
"Backend authentication required. Please ensure you are logged into the backend system."
);
}
let parsed: unknown;
try {
parsed = JSON.parse(rawBody);
} catch {
throw new Error(`Unexpected response from backend (${response.status}).`);
}
if (!parsed || typeof parsed !== "object") {
throw new Error("Empty response from backend");
}
const parsedResponse = parsed as Record<string, unknown>;
return {
success: Boolean(parsedResponse.success),
message: typeof parsedResponse.message === "string" ? parsedResponse.message : undefined,
data: parsedResponse.data,
error: parsedResponse.error ?? parsedResponse.errors ?? parsedResponse.error_msg,
};
}

File diff suppressed because one or more lines are too long