Product editor tweaks
This commit is contained in:
Generated
+363
-18
@@ -32,8 +32,8 @@
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.6",
|
||||
"@radix-ui/react-toggle": "^1.1.1",
|
||||
"@radix-ui/react-toggle-group": "^1.1.1",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"@tabler/icons-react": "^3.28.1",
|
||||
@@ -2469,14 +2469,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.2.tgz",
|
||||
"integrity": "sha512-lntKchNWx3aCHuWKiDY+8WudiegQvBpDRAYL8dKLRvKEH8VOpl0XX6SSU/bUBqIRJbcTy4+MW06Wv8vgp10rzQ==",
|
||||
"version": "1.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
|
||||
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.0.2",
|
||||
"@radix-ui/react-use-controllable-state": "1.1.0"
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -2494,18 +2494,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle-group": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.2.tgz",
|
||||
"integrity": "sha512-JBm6s6aVG/nwuY5eadhU2zDi/IwYS0sDM5ZWb4nymv/hn3hZdkw+gENn0LP4iY1yCd7+bgJaCwueMYJIU3vk4A==",
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz",
|
||||
"integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-direction": "1.1.0",
|
||||
"@radix-ui/react-primitive": "2.0.2",
|
||||
"@radix-ui/react-roving-focus": "1.1.2",
|
||||
"@radix-ui/react-toggle": "1.1.2",
|
||||
"@radix-ui/react-use-controllable-state": "1.1.0"
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-roving-focus": "1.1.11",
|
||||
"@radix-ui/react-toggle": "1.1.10",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -2522,6 +2522,318 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle-group/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-toggle-group/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"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-toggle-group/node_modules/@radix-ui/react-direction": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
|
||||
"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-toggle-group/node_modules/@radix-ui/react-id": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "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-toggle-group/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-roving-focus": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
|
||||
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"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-toggle-group/node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
|
||||
"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-toggle-group/node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||
"@radix-ui/react-use-layout-effect": "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-toggle-group/node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||
"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-toggle/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle/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-toggle/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"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-toggle/node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||
"@radix-ui/react-use-layout-effect": "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-toggle/node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||
"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-tooltip": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz",
|
||||
@@ -2607,6 +2919,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-effect-event": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
|
||||
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "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-effect-event/node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||
"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-use-escape-keydown": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz",
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.6",
|
||||
"@radix-ui/react-toggle": "^1.1.1",
|
||||
"@radix-ui/react-toggle-group": "^1.1.1",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"@tabler/icons-react": "^3.28.1",
|
||||
|
||||
@@ -43,13 +43,13 @@ export function EditableComboboxField({
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex h-auto w-full items-center gap-1.5 rounded-md border-b border-muted-foreground/50 bg-transparent px-3 py-1 text-sm text-left",
|
||||
"flex h-auto w-full items-center gap-1.5 rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm text-left",
|
||||
"hover:border-input hover:bg-muted/50 transition-colors",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{label && (
|
||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||
<span className="text-muted-foreground text-xs shrink-0">{label}</span>
|
||||
)}
|
||||
<span className={cn("truncate flex-1", !selectedLabel && "text-muted-foreground")}>
|
||||
{selectedLabel ?? placeholder}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Copy } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function EditableInput({
|
||||
@@ -12,6 +14,9 @@ export function EditableInput({
|
||||
className,
|
||||
inputClassName,
|
||||
maxLength,
|
||||
copyable,
|
||||
alwaysShowCopy,
|
||||
formatDisplay,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
@@ -22,6 +27,9 @@ export function EditableInput({
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
maxLength?: number;
|
||||
copyable?: boolean;
|
||||
alwaysShowCopy?: boolean;
|
||||
formatDisplay?: (val: string) => string;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -36,7 +44,7 @@ export function EditableInput({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-auto w-full items-center gap-1.5 rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm",
|
||||
"flex h-auto items-center gap-1.5 rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -59,28 +67,45 @@ export function EditableInput({
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
className={cn("border-0 p-0 h-auto shadow-none focus-visible:ring-0", inputClassName)}
|
||||
size={Math.max((value?.length || placeholder?.length || 1) + 1, 3)}
|
||||
className={cn("border-0 p-0 h-auto shadow-none focus-visible:ring-0 w-auto min-w-0", inputClassName)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-auto w-full items-center gap-1.5 rounded-md border-b border-muted-foreground/50 bg-transparent px-3 py-1 text-sm text-left",
|
||||
"hover:border-input hover:bg-muted/50 transition-colors",
|
||||
"flex h-auto items-center gap-1.5 rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm text-left",
|
||||
"hover:border-input hover:bg-muted/50 transition-colors group",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label && (
|
||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
className="flex items-center gap-1.5 flex-1 min-w-0"
|
||||
>
|
||||
{label && (
|
||||
<span className="text-muted-foreground text-xs shrink-0">{label}</span>
|
||||
)}
|
||||
<span className={cn("truncate", !value && "text-muted-foreground")}>
|
||||
{value ? (formatDisplay ? formatDisplay(value) : value) : placeholder}
|
||||
</span>
|
||||
</button>
|
||||
{copyable && value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(value);
|
||||
toast.success(`Copied ${label ?? "value"}`);
|
||||
}}
|
||||
className={cn("shrink-0 transition-opacity", alwaysShowCopy ? "opacity-50 hover:opacity-100" : "opacity-0 group-hover:opacity-50 hover:!opacity-100")}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<span className={cn("truncate", !value && "text-muted-foreground")}>
|
||||
{value || placeholder}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,50 +84,37 @@ export function EditableMultiSelect({
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className={cn(
|
||||
"flex h-auto w-full items-center gap-1.5 rounded-md border-b border-muted-foreground/50 bg-transparent px-3 py-1 text-sm text-left",
|
||||
"flex flex-col h-auto w-full rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm text-left",
|
||||
"hover:border-input hover:bg-muted/50 transition-colors",
|
||||
)}
|
||||
>
|
||||
{label && (
|
||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||
<span className="text-muted-foreground text-xs mb-0.5">{label}</span>
|
||||
)}
|
||||
{selectedLabels.length === 0 ? (
|
||||
<span className="text-muted-foreground truncate">
|
||||
{placeholder ?? "—"}
|
||||
</span>
|
||||
) : showColors ? (
|
||||
<span className="flex items-center gap-1 truncate">
|
||||
{selectedLabels.slice(0, 8).map((s) =>
|
||||
s.hex ? (
|
||||
<span
|
||||
key={s.value}
|
||||
className={cn(
|
||||
"inline-block h-3 w-3 rounded-full shrink-0",
|
||||
isWhite(s.hex) && "border border-black"
|
||||
)}
|
||||
style={{ backgroundColor: s.hex }}
|
||||
title={s.label}
|
||||
/>
|
||||
) : (
|
||||
<Badge key={s.value} variant="secondary" className="text-xs py-0">
|
||||
{s.label}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
{selectedLabels.length > 8 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
+{selectedLabels.length - 8}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 truncate">
|
||||
<Badge variant="secondary" className="text-xs py-0 shrink-0">
|
||||
{selectedLabels.length}
|
||||
</Badge>
|
||||
<span className="truncate">
|
||||
{selectedLabels.map((s) => s.label).join(", ")}
|
||||
</span>
|
||||
<span className="flex flex-wrap gap-1 w-full">
|
||||
{selectedLabels.map((s) => (
|
||||
<Badge
|
||||
key={s.value}
|
||||
variant="secondary"
|
||||
className="text-[11px] py-0 px-1.5 gap-1 shrink-0 font-normal"
|
||||
>
|
||||
{s.hex && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2.5 w-2.5 rounded-full shrink-0",
|
||||
isWhite(s.hex) && "border border-black"
|
||||
)}
|
||||
style={{ backgroundColor: s.hex }}
|
||||
/>
|
||||
)}
|
||||
{s.label}
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -4,13 +4,18 @@ import { toast } from "sonner";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Loader2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Trash2,
|
||||
ZoomIn,
|
||||
ImagePlus,
|
||||
Plus,
|
||||
Upload,
|
||||
Link,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -29,15 +34,15 @@ import {
|
||||
DragOverlay,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
type DragOverEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
horizontalListSortingStrategy,
|
||||
rectSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { ProductImage } from "./types";
|
||||
|
||||
// ── Helper: get best image URL ─────────────────────────────────────────
|
||||
@@ -54,15 +59,21 @@ export function getImageSrc(img: ProductImage): string | null {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sortable Image Card ────────────────────────────────────────────────
|
||||
// ── Sortable Image Cell ─────────────────────────────────────────────────
|
||||
|
||||
function SortableImageCard({
|
||||
function SortableImageCell({
|
||||
image,
|
||||
index,
|
||||
isMain,
|
||||
showOrder,
|
||||
onToggleHidden,
|
||||
onDelete,
|
||||
onZoom,
|
||||
}: {
|
||||
image: ProductImage;
|
||||
index: number;
|
||||
isMain: boolean;
|
||||
showOrder: boolean;
|
||||
onToggleHidden: (iid: number | string) => void;
|
||||
onDelete: (iid: number | string) => void;
|
||||
onZoom: (image: ProductImage) => void;
|
||||
@@ -71,90 +82,100 @@ function SortableImageCard({
|
||||
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 didDragRef = useRef(false);
|
||||
const pointerStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const src = getImageSrc(image);
|
||||
if (!src) return null;
|
||||
|
||||
const iconSize = isMain ? "h-4 w-4" : "h-4 w-4";
|
||||
const btnPad = isMain ? "p-1" : "p-0.5";
|
||||
const btnGap = isMain ? "gap-1" : "gap-0.5";
|
||||
const orderSize = "w-7 h-7 text-base";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onPointerDown={(e) => {
|
||||
didDragRef.current = false;
|
||||
pointerStartRef.current = { x: e.clientX, y: e.clientY };
|
||||
listeners?.onPointerDown?.(e);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (pointerStartRef.current) {
|
||||
const dx = e.clientX - pointerStartRef.current.x;
|
||||
const dy = e.clientY - pointerStartRef.current.y;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
|
||||
didDragRef.current = true;
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!didDragRef.current) {
|
||||
onZoom(image);
|
||||
}
|
||||
}}
|
||||
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"
|
||||
"relative group border bg-white cursor-grab active:cursor-grabbing transition-opacity",
|
||||
isMain ? "rounded-lg col-span-2 row-span-2" : "rounded-md",
|
||||
image.hidden && "opacity-50 ring-2 ring-yellow-400",
|
||||
isDragging && "opacity-30",
|
||||
)}
|
||||
style={{ touchAction: "none" }}
|
||||
>
|
||||
{/* Action buttons */}
|
||||
<div className="absolute top-1 right-1 z-10 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className={cn("absolute top-0.5 right-0.5 z-10 flex opacity-0 group-hover:opacity-100 transition-opacity", btnGap)}>
|
||||
<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"
|
||||
onClick={(e) => { e.stopPropagation(); onToggleHidden(image.iid); }}
|
||||
className={cn(btnPad, "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" />
|
||||
)}
|
||||
{image.hidden ? <EyeOff className={iconSize} /> : <Eye className={iconSize} />}
|
||||
</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"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(image.iid); }}
|
||||
className={cn(btnPad, "rounded bg-red-500/80 text-white hover:bg-red-600")}
|
||||
title="Delete image"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<Trash2 className={iconSize} />
|
||||
</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">
|
||||
<div className="absolute bottom-0.5 right-0.5 z-10">
|
||||
<Badge variant="outline" className={cn(
|
||||
"bg-yellow-100 border-yellow-400 px-1 py-0 leading-tight",
|
||||
isMain ? "text-sm" : "text-xs"
|
||||
)}>
|
||||
Hidden
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showOrder && (
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10">
|
||||
<span className={cn("flex items-center justify-center rounded-full bg-black/30 text-white font-medium", orderSize)}>
|
||||
{index}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<img
|
||||
src={src}
|
||||
alt={`Image ${image.iid}`}
|
||||
className="w-full h-full object-contain rounded-lg pointer-events-none select-none"
|
||||
className={cn(
|
||||
"w-full h-full object-cover pointer-events-none select-none",
|
||||
isMain ? "rounded-lg" : "rounded-md"
|
||||
)}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
@@ -169,15 +190,19 @@ export function ImageManager({
|
||||
images,
|
||||
setImages,
|
||||
isLoading,
|
||||
maxRows = 3.2,
|
||||
}: {
|
||||
images: ProductImage[];
|
||||
setImages: React.Dispatch<React.SetStateAction<ProductImage[]>>;
|
||||
isLoading: boolean;
|
||||
maxRows?: number;
|
||||
}) {
|
||||
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 [addOpen, setAddOpen] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
@@ -193,21 +218,29 @@ export function ImageManager({
|
||||
setActiveId(event.active.id as number | string);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
// Live reorder during drag so the grid layout updates immediately
|
||||
const handleDragOver = useCallback(
|
||||
(event: DragOverEvent) => {
|
||||
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);
|
||||
if (oldIndex === newIndex) return prev;
|
||||
return arrayMove(prev, oldIndex, newIndex);
|
||||
});
|
||||
},
|
||||
[setImages]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(_event: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const toggleHidden = useCallback(
|
||||
(iid: number | string) => {
|
||||
setImages((prev) =>
|
||||
@@ -246,6 +279,7 @@ export function ImageManager({
|
||||
async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
setIsUploading(true);
|
||||
setAddOpen(false);
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
const formData = new FormData();
|
||||
@@ -270,6 +304,7 @@ export function ImageManager({
|
||||
if (!url) return;
|
||||
addNewImage(url);
|
||||
setUrlInput("");
|
||||
setAddOpen(false);
|
||||
}, [urlInput, addNewImage]);
|
||||
|
||||
const activeImage = activeId
|
||||
@@ -286,80 +321,119 @@ export function ImageManager({
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
const CELL = 100;
|
||||
const GAP = 6;
|
||||
const gridSize = CELL * 3 + GAP * 2;
|
||||
|
||||
{/* Image grid with drag-and-drop */}
|
||||
const maxHeight = CELL * maxRows + GAP * (maxRows - 1);
|
||||
|
||||
return (
|
||||
<div style={{ width: gridSize }}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={imageIds} strategy={horizontalListSortingStrategy}>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{images.map((img) => (
|
||||
<SortableImageCard
|
||||
<SortableContext items={imageIds} strategy={rectSortingStrategy}>
|
||||
<div
|
||||
className="grid overflow-y-auto"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(3, ${CELL}px)`,
|
||||
gridAutoRows: CELL,
|
||||
gap: GAP,
|
||||
maxHeight,
|
||||
}}
|
||||
>
|
||||
{images.map((img, i) => (
|
||||
<SortableImageCell
|
||||
key={img.iid}
|
||||
image={img}
|
||||
index={i + 1}
|
||||
isMain={i === 0}
|
||||
showOrder={!!activeId}
|
||||
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">Upload Image</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{/* Add button with file drop */}
|
||||
<Popover open={addOpen} onOpenChange={setAddOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isUploading}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
handleFileUpload(e.dataTransfer.files);
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-md border-2 border-dashed flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors",
|
||||
isDragOver
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-muted-foreground/25 hover:border-muted-foreground/50"
|
||||
)}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-3 space-y-2" align="start">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => {
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload Image
|
||||
</Button>
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<Link className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
placeholder="Image URL..."
|
||||
value={urlInput}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleUrlAdd();
|
||||
}
|
||||
}}
|
||||
className="text-sm h-7"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleUrlAdd}
|
||||
disabled={!urlInput.trim()}
|
||||
className="h-7 px-2 shrink-0"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{activeImage ? (
|
||||
<div className="w-[140px] h-[140px] rounded-lg border bg-white shadow-lg">
|
||||
<div className="rounded-md border bg-white shadow-lg" style={{ width: CELL, height: CELL }}>
|
||||
<img
|
||||
src={getImageSrc(activeImage) ?? ""}
|
||||
alt="Dragging"
|
||||
className="w-full h-full object-contain rounded-lg"
|
||||
className="w-full h-full object-cover rounded-md"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -3,11 +3,12 @@ import axios from "axios";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Loader2, X, Copy } from "lucide-react";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Loader2, X, Copy, Maximize2, Minus, Store, Terminal, ExternalLink } from "lucide-react";
|
||||
import { submitProductEdit, type ImageChanges } from "@/services/productEditor";
|
||||
import { EditableComboboxField } from "./EditableComboboxField";
|
||||
import { EditableInput } from "./EditableInput";
|
||||
@@ -34,12 +35,15 @@ interface FieldConfig {
|
||||
searchPlaceholder?: string;
|
||||
maxLength?: number;
|
||||
rows?: number;
|
||||
copyable?: boolean;
|
||||
/** For combobox: key into fieldOptions, or "lines"/"sublines" for dynamic */
|
||||
optionsKey?: keyof FieldOptions | "lines" | "sublines";
|
||||
/** For combobox: disable when this other field is empty */
|
||||
disabledUnless?: keyof ProductFormValues;
|
||||
/** For multiselect colors field */
|
||||
showColors?: boolean;
|
||||
/** Format value for display (editing shows raw value) */
|
||||
formatDisplay?: (val: string) => string;
|
||||
}
|
||||
|
||||
interface FieldGroup {
|
||||
@@ -48,83 +52,104 @@ interface FieldGroup {
|
||||
fields: FieldConfig[];
|
||||
}
|
||||
|
||||
// --- Default field layout ---
|
||||
// --- Layout modes ---
|
||||
|
||||
const DEFAULT_LAYOUT: FieldGroup[] = [
|
||||
{
|
||||
label: "Taxonomy",
|
||||
cols: 3,
|
||||
fields: [
|
||||
{ key: "company", label: "Company", type: "combobox", optionsKey: "companies", searchPlaceholder: "Search companies..." },
|
||||
{ key: "line", label: "Line", type: "combobox", optionsKey: "lines", searchPlaceholder: "Search lines...", disabledUnless: "company" },
|
||||
{ key: "subline", label: "Sub Line", type: "combobox", optionsKey: "sublines", searchPlaceholder: "Search sublines...", disabledUnless: "line" },
|
||||
],
|
||||
},
|
||||
{
|
||||
cols: 3,
|
||||
fields: [
|
||||
{ key: "artist", label: "Artist", type: "combobox", optionsKey: "artists", searchPlaceholder: "Search artists..." },
|
||||
{ key: "tax_cat", label: "Tax Category", type: "combobox", optionsKey: "taxCategories" },
|
||||
{ key: "ship_restrictions", label: "Shipping", type: "combobox", optionsKey: "shippingRestrictions" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Identifiers",
|
||||
cols: 4,
|
||||
fields: [
|
||||
{ key: "upc", label: "UPC", type: "input" },
|
||||
{ key: "item_number", label: "Item #", type: "input" },
|
||||
{ key: "supplier_no", label: "Supplier #", type: "input" },
|
||||
{ key: "notions_no", label: "Notions #", type: "input" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Pricing",
|
||||
cols: 4,
|
||||
fields: [
|
||||
{ key: "msrp", label: "MSRP", type: "input" },
|
||||
{ key: "cost_each", label: "Cost", type: "input" },
|
||||
{ key: "qty_per_unit", label: "Min Qty", type: "input" },
|
||||
{ key: "case_qty", label: "Case Pack", type: "input" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Dimensions",
|
||||
cols: 4,
|
||||
fields: [
|
||||
{ key: "weight", label: "Weight (oz)", type: "input" },
|
||||
{ key: "length", label: "Length (in)", type: "input" },
|
||||
{ key: "width", label: "Width (in)", type: "input" },
|
||||
{ key: "height", label: "Height (in)", type: "input" },
|
||||
],
|
||||
},
|
||||
{
|
||||
cols: 3,
|
||||
fields: [
|
||||
{ key: "size_cat", label: "Size Category", type: "combobox", optionsKey: "sizes" },
|
||||
{ key: "coo", label: "Country of Origin", type: "input", placeholder: "2-letter code", maxLength: 2 },
|
||||
{ key: "hts_code", label: "HTS Code", type: "input" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Classification",
|
||||
cols: 3,
|
||||
fields: [
|
||||
{ key: "categories", label: "Categories", type: "multiselect", optionsKey: "categories", searchPlaceholder: "Search categories..." },
|
||||
{ key: "themes", label: "Themes", type: "multiselect", optionsKey: "themes", searchPlaceholder: "Search themes..." },
|
||||
{ key: "colors", label: "Colors", type: "multiselect", optionsKey: "colors", searchPlaceholder: "Search colors...", showColors: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Description",
|
||||
cols: 1,
|
||||
fields: [
|
||||
{ key: "description", label: "Description", type: "textarea", rows: 4 },
|
||||
{ key: "priv_notes", label: "Private Notes", type: "textarea", rows: 2 },
|
||||
],
|
||||
},
|
||||
type LayoutMode = "full" | "minimal" | "shop" | "backend";
|
||||
|
||||
const LAYOUT_ICONS: { mode: LayoutMode; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
|
||||
{ mode: "full", label: "Full", icon: Maximize2 },
|
||||
{ mode: "minimal", label: "Minimal", icon: Minus },
|
||||
{ mode: "shop", label: "Shop", icon: Store },
|
||||
{ mode: "backend", label: "Backend", icon: Terminal },
|
||||
];
|
||||
|
||||
// --- Field registry (all available fields) ---
|
||||
|
||||
const F: Record<string, FieldConfig> = {
|
||||
company: { key: "company", label: "Company", type: "combobox", optionsKey: "companies", searchPlaceholder: "Search companies..." },
|
||||
line: { key: "line", label: "Line", type: "combobox", optionsKey: "lines", searchPlaceholder: "Search lines...", disabledUnless: "company" },
|
||||
subline: { key: "subline", label: "Subline", type: "combobox", optionsKey: "sublines", searchPlaceholder: "Search sublines...", disabledUnless: "line" },
|
||||
artist: { key: "artist", label: "Artist", type: "combobox", optionsKey: "artists", searchPlaceholder: "Search artists..." },
|
||||
tax_cat: { key: "tax_cat", label: "Tax Cat", type: "combobox", optionsKey: "taxCategories" },
|
||||
ship: { key: "ship_restrictions", label: "Shipping", type: "combobox", optionsKey: "shippingRestrictions" },
|
||||
msrp: { key: "msrp", label: "MSRP", type: "input" },
|
||||
cost: { key: "cost_each", label: "Cost", type: "input", formatDisplay: (v) => { const n = parseFloat(v); return isNaN(n) ? v : n.toFixed(2); } },
|
||||
min_qty: { key: "qty_per_unit", label: "Min Qty", type: "input" },
|
||||
case_qty: { key: "case_qty", label: "Case Pack", type: "input" },
|
||||
weight: { key: "weight", label: "Weight (oz)", type: "input" },
|
||||
length: { key: "length", label: "Length (in)", type: "input" },
|
||||
width: { key: "width", label: "Width (in)", type: "input" },
|
||||
height: { key: "height", label: "Height (in)", type: "input" },
|
||||
size_cat: { key: "size_cat", label: "Size Cat", type: "combobox", optionsKey: "sizes" },
|
||||
coo: { key: "coo", label: "Country of Origin", type: "input", placeholder: "2-letter code", maxLength: 2 },
|
||||
hts_code: { key: "hts_code", label: "HTS Code", type: "input" },
|
||||
categories: { key: "categories", label: "Categories", type: "multiselect", optionsKey: "categories", searchPlaceholder: "Search categories..." },
|
||||
themes: { key: "themes", label: "Themes", type: "multiselect", optionsKey: "themes", searchPlaceholder: "Search themes..." },
|
||||
colors: { key: "colors", label: "Colors", type: "multiselect", optionsKey: "colors", searchPlaceholder: "Search colors...", showColors: true },
|
||||
supplier: { key: "supplier", label: "Supplier", type: "combobox", optionsKey: "suppliers", searchPlaceholder: "Search suppliers..." },
|
||||
description: { key: "description", label: "Description", type: "textarea", rows: 8 },
|
||||
priv_notes: { key: "priv_notes", label: "Private Notes", type: "textarea", rows: 2 },
|
||||
};
|
||||
|
||||
// --- Per-mode layouts with fields combined into sensible rows ---
|
||||
|
||||
interface ModeLayout {
|
||||
/** Number of leading groups to render beside images (max cols 2 in sidebar) */
|
||||
sidebarGroups: number;
|
||||
/** Max visible rows in the image grid */
|
||||
imageRows?: number;
|
||||
/** Override description textarea rows */
|
||||
descriptionRows?: number;
|
||||
groups: FieldGroup[];
|
||||
}
|
||||
|
||||
const MODE_LAYOUTS: Record<LayoutMode, ModeLayout> = {
|
||||
full: {
|
||||
sidebarGroups: 3,
|
||||
groups: [
|
||||
{ label: "Taxonomy", cols: 2, fields: [F.supplier,F.company, F.line, F.subline] },
|
||||
{ cols: 2, fields: [F.artist, F.size_cat] },
|
||||
{ label: "Description", cols: 1, fields: [F.description] },
|
||||
{ label: "Pricing", cols: 4, fields: [F.msrp, F.cost, F.min_qty, F.case_qty] },
|
||||
{ label: "Dimensions", cols: 4, fields: [F.weight, F.length, F.width, F.height] },
|
||||
{ cols: 4, fields: [ F.tax_cat, F.ship,F.coo, F.hts_code] },
|
||||
{ label: "Classification", cols: 3, fields: [F.categories, F.themes, F.colors] },
|
||||
{ label: "Notes", cols: 1, fields: [F.priv_notes] },
|
||||
],
|
||||
},
|
||||
shop: {
|
||||
sidebarGroups: 3,
|
||||
descriptionRows: 8,
|
||||
groups: [
|
||||
{ label: "Taxonomy", cols: 2, fields: [F.company, F.msrp, F.line, F.subline] },
|
||||
{ cols: 2, fields: [F.artist, F.size_cat] },
|
||||
{ label: "Description", cols: 1, fields: [F.description] },
|
||||
{ label: "Classification", cols: 3, fields: [F.categories, F.themes, F.colors] },
|
||||
|
||||
],
|
||||
},
|
||||
backend: {
|
||||
sidebarGroups: 5,
|
||||
groups: [
|
||||
{ label: "Pricing", cols: 2, fields: [F.supplier, F.min_qty, F.cost, F.msrp] },
|
||||
{ cols: 3, fields: [F.case_qty, F.size_cat, F.weight] },
|
||||
{ label: "Dimensions", cols: 3, fields: [ F.length, F.width, F.height] },
|
||||
{ cols: 2, fields: [F.tax_cat, F.ship, F.coo, F.hts_code] },
|
||||
{ label: "Notes", cols: 1, fields: [F.priv_notes] },
|
||||
|
||||
],
|
||||
},
|
||||
minimal: {
|
||||
sidebarGroups: 3,
|
||||
imageRows: 2.2,
|
||||
descriptionRows: 6,
|
||||
groups: [
|
||||
{ cols: 2, fields: [F.company, F.msrp, F.line, F.subline] },
|
||||
{ label: "Description", cols: 1, fields: [F.description] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function ProductEditForm({
|
||||
product,
|
||||
fieldOptions,
|
||||
@@ -134,6 +159,7 @@ export function ProductEditForm({
|
||||
fieldOptions: FieldOptions;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [layoutMode, setLayoutMode] = useState<LayoutMode>("full");
|
||||
const [lineOptions, setLineOptions] = useState<LineOption[]>([]);
|
||||
const [sublineOptions, setSublineOptions] = useState<LineOption[]>([]);
|
||||
const [productImages, setProductImages] = useState<ProductImage[]>([]);
|
||||
@@ -352,172 +378,213 @@ export function ProductEditForm({
|
||||
const hasImageChanges = computeImageChanges() !== null;
|
||||
const changedCount = Object.keys(dirtyFields).length;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
const renderFieldGroup = (group: FieldGroup, gi: number) => (
|
||||
<div
|
||||
key={gi}
|
||||
className="grid gap-x-2 gap-y-2 p-1 rounded-md"
|
||||
style={{ gridTemplateColumns: `repeat(${group.cols}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{group.fields.map((fc) => {
|
||||
if (fc.type === "input") {
|
||||
return (
|
||||
<Controller
|
||||
name="name"
|
||||
key={fc.key}
|
||||
name={fc.key}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<EditableInput
|
||||
value={field.value}
|
||||
label={fc.label}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="Product name"
|
||||
className="text-lg font-semibold border-none"
|
||||
inputClassName="text-lg font-semibold"
|
||||
placeholder={fc.placeholder ?? "—"}
|
||||
maxLength={fc.maxLength}
|
||||
copyable={fc.copyable}
|
||||
formatDisplay={fc.formatDisplay}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</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}>
|
||||
);
|
||||
}
|
||||
if (fc.type === "combobox") {
|
||||
return (
|
||||
<Controller
|
||||
key={fc.key}
|
||||
name={fc.key}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<EditableComboboxField
|
||||
label={fc.label}
|
||||
options={getOptions(fc.optionsKey)}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
placeholder="—"
|
||||
searchPlaceholder={fc.searchPlaceholder}
|
||||
disabled={fc.disabledUnless ? !watch(fc.disabledUnless) : false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (fc.type === "multiselect") {
|
||||
return (
|
||||
<Controller
|
||||
key={fc.key}
|
||||
name={fc.key}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<EditableMultiSelect
|
||||
label={fc.label}
|
||||
options={getOptions(fc.optionsKey)}
|
||||
value={(field.value as string[]) ?? []}
|
||||
onChange={field.onChange}
|
||||
placeholder="—"
|
||||
searchPlaceholder={fc.searchPlaceholder}
|
||||
showColors={fc.showColors}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (fc.type === "textarea") {
|
||||
return (
|
||||
<div key={fc.key} className="col-span-full flex flex-col gap-0.5 rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm hover:border-input hover:bg-muted/50 transition-colors">
|
||||
<span className="text-muted-foreground text-xs shrink-0">{fc.label}</span>
|
||||
<Textarea {...register(fc.key)} rows={(fc.key === "description" && MODE_LAYOUTS[layoutMode].descriptionRows) || fc.rows || 3} className="border-0 p-0 h-auto shadow-none focus-visible:ring-0 resize-y text-sm min-h-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
{/* Layout toggle */}
|
||||
<div className="flex items-center justify-end mb-3">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={layoutMode}
|
||||
onValueChange={(v) => { if (v) setLayoutMode(v as LayoutMode); }}
|
||||
>
|
||||
{LAYOUT_ICONS.map(({ mode, label, icon: Icon }) => (
|
||||
<Tooltip key={mode}>
|
||||
<TooltipTrigger asChild>
|
||||
<ToggleGroupItem value={mode} size="sm" variant="outline">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="text-xs">{label}</span>
|
||||
</ToggleGroupItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<Button variant="secondary" size="icon" onClick={onClose} className="ml-4">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<EditableInput
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="Product name"
|
||||
className="text-base font-semibold"
|
||||
inputClassName="text-base font-semibold"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors px-2"
|
||||
>
|
||||
<ExternalLink className="h-5 w-5" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">View in Backend</TooltipContent>
|
||||
</Tooltip>
|
||||
</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-1">
|
||||
{/* Supplier + badges row */}
|
||||
<div className="flex justify-between gap-1">
|
||||
<div className="p-1 rounded-md">
|
||||
<Controller name="supplier" control={control} render={({ field }) => (
|
||||
<EditableComboboxField label="Supplier" options={fieldOptions.suppliers ?? []} value={field.value} onChange={field.onChange} placeholder="—" searchPlaceholder="Search suppliers..." />
|
||||
)} />
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{[
|
||||
{ label: "PID", value: product.pid },
|
||||
{ label: "Item #", value: product.sku },
|
||||
{ label: "UPC", value: product.barcode },
|
||||
...(product.vendor_reference ? [{ label: "Supplier #", value: product.vendor_reference }] : []),
|
||||
...(product.notions_reference ? [{ label: "Notions #", value: product.notions_reference }] : []),
|
||||
].map((item) => (
|
||||
<Badge
|
||||
key={item.label}
|
||||
variant="outline"
|
||||
className="gap-1 cursor-pointer hover:bg-muted border-none border-muted-foreground/50"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(String(item.value));
|
||||
toast.success(`Copied ${item.label}`);
|
||||
}}
|
||||
>
|
||||
{item.label}: {item.value}
|
||||
<Copy className="h-3 w-3 opacity-50" />
|
||||
</Badge>
|
||||
<div className="flex flex-wrap gap-2 items-center justify-start">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1 cursor-pointer hover:bg-muted text-xs px-2.5 h-[22px] font-normal"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(String(product.pid));
|
||||
toast.success("Copied PID");
|
||||
}}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">PID</span> {product.pid}
|
||||
<Copy className="h-3 w-3 opacity-50" />
|
||||
</Badge>
|
||||
{(["item_number", "upc", "supplier_no", "notions_no"] as const).map((key) => (
|
||||
<Controller
|
||||
key={key}
|
||||
name={key}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<EditableInput
|
||||
label={{ item_number: "Item #", upc: "UPC", supplier_no: "Supplier #", notions_no: "Notions #" }[key]}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="—"
|
||||
copyable
|
||||
alwaysShowCopy
|
||||
className="w-auto border-none py-0 px-2.5 text-xs h-[22px] rounded-full bg-secondary hover:bg-muted"
|
||||
inputClassName="text-xs"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-1">
|
||||
{/* Images + sidebar fields */}
|
||||
<div className="flex gap-2">
|
||||
<div className="shrink-0">
|
||||
<ImageManager
|
||||
images={productImages}
|
||||
setImages={setProductImages}
|
||||
isLoading={isLoadingImages}
|
||||
maxRows={MODE_LAYOUTS[layoutMode].imageRows}
|
||||
/>
|
||||
</div>
|
||||
{MODE_LAYOUTS[layoutMode].sidebarGroups > 0 && (
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
{MODE_LAYOUTS[layoutMode].groups.slice(0, MODE_LAYOUTS[layoutMode].sidebarGroups).map((group, gi) =>
|
||||
renderFieldGroup(group, gi)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dynamic field groups */}
|
||||
{DEFAULT_LAYOUT.map((group, gi) => (
|
||||
<div
|
||||
key={gi}
|
||||
className={`grid gap-x-4 gap-y-1 p-1 rounded-md`}
|
||||
style={{ gridTemplateColumns: `repeat(${group.cols}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{group.fields.map((fc) => {
|
||||
if (fc.type === "input") {
|
||||
return (
|
||||
<Controller
|
||||
key={fc.key}
|
||||
name={fc.key}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<EditableInput
|
||||
label={fc.label}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder={fc.placeholder ?? "—"}
|
||||
maxLength={fc.maxLength}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (fc.type === "combobox") {
|
||||
return (
|
||||
<Controller
|
||||
key={fc.key}
|
||||
name={fc.key}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<EditableComboboxField
|
||||
label={fc.label}
|
||||
options={getOptions(fc.optionsKey)}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
placeholder="—"
|
||||
searchPlaceholder={fc.searchPlaceholder}
|
||||
disabled={fc.disabledUnless ? !watch(fc.disabledUnless) : false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (fc.type === "multiselect") {
|
||||
return (
|
||||
<Controller
|
||||
key={fc.key}
|
||||
name={fc.key}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<EditableMultiSelect
|
||||
label={fc.label}
|
||||
options={getOptions(fc.optionsKey)}
|
||||
value={(field.value as string[]) ?? []}
|
||||
onChange={field.onChange}
|
||||
placeholder="—"
|
||||
searchPlaceholder={fc.searchPlaceholder}
|
||||
showColors={fc.showColors}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (fc.type === "textarea") {
|
||||
return (
|
||||
<div key={fc.key} className="col-span-full">
|
||||
<Label>{fc.label}</Label>
|
||||
<Textarea {...register(fc.key)} rows={fc.rows ?? 3} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{/* Remaining field groups */}
|
||||
{MODE_LAYOUTS[layoutMode].groups.slice(MODE_LAYOUTS[layoutMode].sidebarGroups).map((group, gi) =>
|
||||
renderFieldGroup(group, gi + MODE_LAYOUTS[layoutMode].sidebarGroups)
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t">
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (originalValuesRef.current) {
|
||||
reset(originalValuesRef.current);
|
||||
@@ -529,6 +596,7 @@ export function ProductEditForm({
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || (changedCount === 0 && !hasImageChanges)}
|
||||
size="sm"
|
||||
>
|
||||
{isSubmitting && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -16,20 +16,32 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Loader2, Search } from "lucide-react";
|
||||
import type { SearchProduct } from "./types";
|
||||
|
||||
interface QuickSearchResult {
|
||||
pid: number;
|
||||
title: string;
|
||||
sku: string;
|
||||
barcode: string;
|
||||
brand: string;
|
||||
line: string;
|
||||
regular_price: number;
|
||||
image_175: string | null;
|
||||
}
|
||||
|
||||
export function ProductSearch({
|
||||
onSelect,
|
||||
}: {
|
||||
onSelect: (product: SearchProduct) => void;
|
||||
}) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<SearchProduct[]>([]);
|
||||
const [searchResults, setSearchResults] = useState<QuickSearchResult[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isLoadingProduct, setIsLoadingProduct] = useState<number | null>(null);
|
||||
|
||||
const handleSearch = useCallback(async () => {
|
||||
if (!searchTerm.trim()) return;
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const res = await axios.get("/api/import/search-products", {
|
||||
const res = await axios.get("/api/products/search", {
|
||||
params: { q: searchTerm },
|
||||
});
|
||||
setSearchResults(res.data);
|
||||
@@ -41,9 +53,24 @@ export function ProductSearch({
|
||||
}, [searchTerm]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(product: SearchProduct) => {
|
||||
onSelect(product);
|
||||
setSearchResults([]);
|
||||
async (product: QuickSearchResult) => {
|
||||
setIsLoadingProduct(product.pid);
|
||||
try {
|
||||
const res = await axios.get("/api/import/search-products", {
|
||||
params: { pid: product.pid },
|
||||
});
|
||||
const full = (res.data as SearchProduct[])[0];
|
||||
if (full) {
|
||||
onSelect(full);
|
||||
setSearchResults([]);
|
||||
} else {
|
||||
toast.error("Could not load full product details");
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to load product details");
|
||||
} finally {
|
||||
setIsLoadingProduct(null);
|
||||
}
|
||||
},
|
||||
[onSelect]
|
||||
);
|
||||
@@ -87,10 +114,13 @@ export function ProductSearch({
|
||||
{searchResults.map((product) => (
|
||||
<TableRow
|
||||
key={product.pid}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleSelect(product)}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${isLoadingProduct === product.pid ? "opacity-50" : ""}`}
|
||||
onClick={() => !isLoadingProduct && handleSelect(product)}
|
||||
>
|
||||
<TableCell className="max-w-[300px] truncate">
|
||||
{isLoadingProduct === product.pid && (
|
||||
<Loader2 className="h-3 w-3 animate-spin inline mr-2" />
|
||||
)}
|
||||
{product.title}
|
||||
</TableCell>
|
||||
<TableCell>{product.sku}</TableCell>
|
||||
|
||||
Reference in New Issue
Block a user