Add product editor
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
198
inventory/package-lock.json
generated
198
inventory/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ 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">
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
85
inventory/src/components/product-editor/ComboboxField.tsx
Normal file
85
inventory/src/components/product-editor/ComboboxField.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
403
inventory/src/components/product-editor/ImageManager.tsx
Normal file
403
inventory/src/components/product-editor/ImageManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
528
inventory/src/components/product-editor/ProductEditForm.tsx
Normal file
528
inventory/src/components/product-editor/ProductEditForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
inventory/src/components/product-editor/ProductSearch.tsx
Normal file
114
inventory/src/components/product-editor/ProductSearch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
inventory/src/components/product-editor/types.ts
Normal file
95
inventory/src/components/product-editor/types.ts
Normal 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;
|
||||||
|
}
|
||||||
260
inventory/src/components/ui/carousel.tsx
Normal file
260
inventory/src/components/ui/carousel.tsx
Normal 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,
|
||||||
|
}
|
||||||
55
inventory/src/pages/ProductEditor.tsx
Normal file
55
inventory/src/pages/ProductEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
inventory/src/services/productEditor.ts
Normal file
98
inventory/src/services/productEditor.ts
Normal 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
Reference in New Issue
Block a user