Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8a3f26d0b | |||
| 297d47bd19 | |||
| a2c0789625 | |||
| 23aa78d3da | |||
| 1b07359415 | |||
| 6f4affcd66 | |||
| 211d8c5c01 | |||
| d16cd1fbf5 | |||
| fecf56ff09 | |||
| 85b0590d85 | |||
| 725a7508b0 | |||
| 4df074f02d | |||
| 06cda68929 | |||
| c19510d413 | |||
| 2de5c6670d | |||
| 6a08fccb7a | |||
| da52080e3e | |||
| f0b6ddf145 | |||
| d1e78e91e4 | |||
| 071f126244 | |||
| 415b7db643 | |||
| 8ede847ba0 | |||
| 193edc80aa | |||
| 1f8db29b15 | |||
| 4b9db5bebf | |||
| a77f44b800 | |||
| 0b26a61f90 | |||
| 2efe339576 | |||
| c4edcb568c | |||
| e3246d022e | |||
| 9ab8b3ef60 | |||
| 7da453606e | |||
| 8d591bb769 | |||
| bb596c7e32 | |||
| 1ed8c055d7 | |||
| 40d537efb6 | |||
| 015833150a | |||
| 04833b490c | |||
| 072110e655 | |||
| 421c275d48 | |||
| ed091711c8 | |||
| 3aa770321c | |||
| 2e9df3659f | |||
| 1e48ca142f | |||
| c89ab1c818 | |||
| 4263b8afd0 |
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
Generated
+799
-5
@@ -8,17 +8,21 @@
|
|||||||
"name": "gestion-md",
|
"name": "gestion-md",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@imgly/background-removal": "^1.7.0",
|
||||||
"@prisma/adapter-better-sqlite3": "^7.8.0",
|
"@prisma/adapter-better-sqlite3": "^7.8.0",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.10.0",
|
"better-sqlite3": "^12.10.0",
|
||||||
"googleapis": "^173.0.0",
|
"googleapis": "^173.0.0",
|
||||||
|
"heic-convert": "^2.1.0",
|
||||||
"heic2any": "^0.0.4",
|
"heic2any": "^0.0.4",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"next": "14.2.35",
|
"next": "14.2.35",
|
||||||
"next-auth": "^4.24.14",
|
"next-auth": "^4.24.14",
|
||||||
|
"onnxruntime-web": "^1.21.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18"
|
"react-dom": "^18",
|
||||||
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
@@ -28,6 +32,7 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
"@types/sharp": "^0.31.1",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.2.35",
|
"eslint-config-next": "14.2.35",
|
||||||
"jest": "^30.4.2",
|
"jest": "^30.4.2",
|
||||||
@@ -812,7 +817,6 @@
|
|||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -928,6 +932,533 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/@img/colour": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-riscv64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-wasm32": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/runtime": "^1.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@imgly/background-removal": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@imgly/background-removal/-/background-removal-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-/1ZryrMYg2ckIvJKoTu5Np50JfYMVffDMlVmppw/BdbN3pBTN7e6stI5/7E/LVh9DDzz6J588s7sWqul3fy5wA==",
|
||||||
|
"license": "SEE LICENSE IN LICENSE.md",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"ndarray": "~1.0.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"onnxruntime-web": "1.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@isaacs/cliui": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
@@ -2246,6 +2777,69 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@protobufjs/aspromise": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@protobufjs/base64": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@protobufjs/codegen": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@protobufjs/eventemitter": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@protobufjs/fetch": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@protobufjs/aspromise": "^1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@protobufjs/float": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@protobufjs/inquire": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@protobufjs/path": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@protobufjs/pool": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@protobufjs/utf8": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/primitive": {
|
"node_modules/@radix-ui/primitive": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
@@ -2709,7 +3303,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.42",
|
"version": "20.19.42",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
@@ -2737,6 +3330,16 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/sharp": {
|
||||||
|
"version": "0.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/sharp/-/sharp-0.31.1.tgz",
|
||||||
|
"integrity": "sha512-5nWwamN9ZFHXaYEincMSuza8nNfOof8nmO+mcI+Agx1uMUk4/pQnNIcix+9rLPXzKrm1pS34+6WRDbDV0Jn7ag==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/stack-utils": {
|
"node_modules/@types/stack-utils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
||||||
@@ -6214,6 +6817,12 @@
|
|||||||
"node": "^10.12.0 || >=12.0.0"
|
"node": "^10.12.0 || >=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/flatbuffers": {
|
||||||
|
"version": "25.9.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
|
||||||
|
"integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.4.2",
|
"version": "3.4.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -6718,6 +7327,12 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/guid-typescript": {
|
||||||
|
"version": "1.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
|
||||||
|
"integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/handlebars": {
|
"node_modules/handlebars": {
|
||||||
"version": "4.7.9",
|
"version": "4.7.9",
|
||||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
|
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
|
||||||
@@ -6818,6 +7433,32 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/heic-convert": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/heic-convert/-/heic-convert-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-1qDuRvEHifTVAj3pFIgkqGgJIr0M3X7cxEPjEp0oG4mo8GFjq99DpCo8Eg3kg17Cy0MTjxpFdoBHOatj7ZVKtg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"heic-decode": "^2.0.0",
|
||||||
|
"jpeg-js": "^0.4.4",
|
||||||
|
"pngjs": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/heic-decode": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"libheif-js": "^1.19.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/heic2any": {
|
"node_modules/heic2any": {
|
||||||
"version": "0.0.4",
|
"version": "0.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz",
|
||||||
@@ -7034,6 +7675,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/iota-array": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-array-buffer": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -7115,6 +7762,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-buffer": {
|
||||||
|
"version": "1.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
|
||||||
|
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-bun-module": {
|
"node_modules/is-bun-module": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -8641,6 +9294,12 @@
|
|||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jpeg-js": {
|
||||||
|
"version": "0.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
|
||||||
|
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -8890,6 +9549,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/libheif-js": {
|
||||||
|
"version": "1.19.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.19.8.tgz",
|
||||||
|
"integrity": "sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ==",
|
||||||
|
"license": "LGPL-3.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lie": {
|
"node_modules/lie": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
@@ -8929,6 +9597,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash-es": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.memoize": {
|
"node_modules/lodash.memoize": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
||||||
@@ -8945,7 +9619,6 @@
|
|||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
@@ -9208,6 +9881,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ndarray": {
|
||||||
|
"version": "1.0.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/ndarray/-/ndarray-1.0.19.tgz",
|
||||||
|
"integrity": "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iota-array": "^1.0.0",
|
||||||
|
"is-buffer": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/neo-async": {
|
"node_modules/neo-async": {
|
||||||
"version": "2.6.2",
|
"version": "2.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||||
@@ -9740,6 +10423,26 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/onnxruntime-common": {
|
||||||
|
"version": "1.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz",
|
||||||
|
"integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/onnxruntime-web": {
|
||||||
|
"version": "1.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.21.0.tgz",
|
||||||
|
"integrity": "sha512-adzOe+7uI7lKz6pQNbAsLMQd2Fq5Jhmoxd8LZjJr8m3KvbFyiYyRxRiC57/XXD+jb18voppjeGAjoZmskXG+7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"flatbuffers": "^25.1.24",
|
||||||
|
"guid-typescript": "^1.0.9",
|
||||||
|
"long": "^5.2.3",
|
||||||
|
"onnxruntime-common": "1.21.0",
|
||||||
|
"platform": "^1.3.6",
|
||||||
|
"protobufjs": "^7.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/openid-client": {
|
"node_modules/openid-client": {
|
||||||
"version": "5.7.1",
|
"version": "5.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||||
@@ -10070,6 +10773,21 @@
|
|||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/platform": {
|
||||||
|
"version": "1.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
|
||||||
|
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -10393,6 +11111,30 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/protobufjs": {
|
||||||
|
"version": "7.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.2.tgz",
|
||||||
|
"integrity": "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@protobufjs/aspromise": "^1.1.2",
|
||||||
|
"@protobufjs/base64": "^1.1.2",
|
||||||
|
"@protobufjs/codegen": "^2.0.5",
|
||||||
|
"@protobufjs/eventemitter": "^1.1.1",
|
||||||
|
"@protobufjs/fetch": "^1.1.1",
|
||||||
|
"@protobufjs/float": "^1.0.2",
|
||||||
|
"@protobufjs/inquire": "^1.1.2",
|
||||||
|
"@protobufjs/path": "^1.1.2",
|
||||||
|
"@protobufjs/pool": "^1.1.0",
|
||||||
|
"@protobufjs/utf8": "^1.1.1",
|
||||||
|
"@types/node": ">=13.7.0",
|
||||||
|
"long": "^5.3.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pump": {
|
"node_modules/pump": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||||
@@ -11028,6 +11770,50 @@
|
|||||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sharp": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@img/colour": "^1.0.0",
|
||||||
|
"detect-libc": "^2.1.2",
|
||||||
|
"semver": "^7.7.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-darwin-arm64": "0.34.5",
|
||||||
|
"@img/sharp-darwin-x64": "0.34.5",
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||||
|
"@img/sharp-linux-arm": "0.34.5",
|
||||||
|
"@img/sharp-linux-arm64": "0.34.5",
|
||||||
|
"@img/sharp-linux-ppc64": "0.34.5",
|
||||||
|
"@img/sharp-linux-riscv64": "0.34.5",
|
||||||
|
"@img/sharp-linux-s390x": "0.34.5",
|
||||||
|
"@img/sharp-linux-x64": "0.34.5",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||||
|
"@img/sharp-wasm32": "0.34.5",
|
||||||
|
"@img/sharp-win32-arm64": "0.34.5",
|
||||||
|
"@img/sharp-win32-ia32": "0.34.5",
|
||||||
|
"@img/sharp-win32-x64": "0.34.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -12449,7 +13235,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unrs-resolver": {
|
"node_modules/unrs-resolver": {
|
||||||
@@ -13021,6 +13806,15 @@
|
|||||||
"grammex": "^3.1.11",
|
"grammex": "^3.1.11",
|
||||||
"graphmatch": "^1.1.0"
|
"graphmatch": "^1.1.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "3.25.76",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -13,17 +13,21 @@
|
|||||||
"seed": "ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts"
|
"seed": "ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@imgly/background-removal": "^1.7.0",
|
||||||
"@prisma/adapter-better-sqlite3": "^7.8.0",
|
"@prisma/adapter-better-sqlite3": "^7.8.0",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.10.0",
|
"better-sqlite3": "^12.10.0",
|
||||||
"googleapis": "^173.0.0",
|
"googleapis": "^173.0.0",
|
||||||
|
"heic-convert": "^2.1.0",
|
||||||
"heic2any": "^0.0.4",
|
"heic2any": "^0.0.4",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"next": "14.2.35",
|
"next": "14.2.35",
|
||||||
"next-auth": "^4.24.14",
|
"next-auth": "^4.24.14",
|
||||||
|
"onnxruntime-web": "^1.21.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18"
|
"react-dom": "^18",
|
||||||
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
@@ -33,6 +37,7 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
"@types/sharp": "^0.31.1",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.2.35",
|
"eslint-config-next": "14.2.35",
|
||||||
"jest": "^30.4.2",
|
"jest": "^30.4.2",
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ProductCreation" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"sheetTab" TEXT NOT NULL,
|
||||||
|
"sheetRow" INTEGER NOT NULL,
|
||||||
|
"shopifyId" TEXT NOT NULL,
|
||||||
|
"sku" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'success',
|
||||||
|
"errorMessage" TEXT NOT NULL DEFAULT '',
|
||||||
|
CONSTRAINT "ProductCreation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
@@ -14,6 +14,7 @@ model User {
|
|||||||
role String @default("gestionnaire")
|
role String @default("gestionnaire")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
syncRuns SyncRun[]
|
syncRuns SyncRun[]
|
||||||
|
productCreations ProductCreation[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model SyncRun {
|
model SyncRun {
|
||||||
@@ -28,3 +29,17 @@ model SyncRun {
|
|||||||
errors Int @default(0)
|
errors Int @default(0)
|
||||||
log String @default("[]")
|
log String @default("[]")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ProductCreation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
sheetTab String
|
||||||
|
sheetRow Int
|
||||||
|
shopifyId String
|
||||||
|
sku String
|
||||||
|
title String
|
||||||
|
status String @default("success")
|
||||||
|
errorMessage String @default("")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { Header } from '@/components/layout/Header'
|
||||||
|
import { PendingRowsTable } from '@/components/creation/PendingRowsTable'
|
||||||
|
import { MetafieldResolver } from '@/components/creation/MetafieldResolver'
|
||||||
|
import { CreationProgress } from '@/components/creation/CreationProgress'
|
||||||
|
import { FamilySelector } from '@/components/common/FamilySelector'
|
||||||
|
import type { PendingProduct, MetafieldResolution, CreationStreamEvent } from '@/types/creation'
|
||||||
|
|
||||||
|
type Step = 'famille' | 'analyse' | 'metafields' | 'confirmation' | 'execution'
|
||||||
|
|
||||||
|
export default function CreationPage() {
|
||||||
|
const [step, setStep] = useState<Step>('famille')
|
||||||
|
const [selectedTab, setSelectedTab] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [byTab, setByTab] = useState<Record<string, PendingProduct[]>>({})
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [specificColumns, setSpecificColumns] = useState<string[]>([])
|
||||||
|
const [resolutions, setResolutions] = useState<MetafieldResolution[]>([])
|
||||||
|
const [events, setEvents] = useState<CreationStreamEvent[]>([])
|
||||||
|
const [done, setDone] = useState(false)
|
||||||
|
|
||||||
|
const handleSelectTab = (tab: string) => {
|
||||||
|
setSelectedTab(tab)
|
||||||
|
setStep('analyse')
|
||||||
|
}
|
||||||
|
|
||||||
|
const analyse = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const url = selectedTab
|
||||||
|
? `/api/creation/preview?tab=${encodeURIComponent(selectedTab)}`
|
||||||
|
: '/api/creation/preview'
|
||||||
|
const res = await fetch(url)
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.error) throw new Error(data.error)
|
||||||
|
setByTab(data.byTab)
|
||||||
|
setTotal(data.total)
|
||||||
|
setSpecificColumns(data.specificColumns)
|
||||||
|
setStep('metafields')
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Erreur')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResolved = useCallback((r: MetafieldResolution[]) => { setResolutions(r) }, [])
|
||||||
|
|
||||||
|
const execute = async () => {
|
||||||
|
setStep('execution')
|
||||||
|
setEvents([])
|
||||||
|
setDone(false)
|
||||||
|
const res = await fetch('/api/creation/execute', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ resolutions }),
|
||||||
|
})
|
||||||
|
if (!res.body) return
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
while (true) {
|
||||||
|
const { done: readerDone, value } = await reader.read()
|
||||||
|
if (readerDone) break
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() ?? ''
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(line) as CreationStreamEvent
|
||||||
|
setEvents(prev => [...prev, event])
|
||||||
|
if (event.type === 'done') setDone(true)
|
||||||
|
} catch { /* ligne incomplète */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDone(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEPS: { id: Step; label: string }[] = [
|
||||||
|
{ id: 'analyse', label: 'Analyse' },
|
||||||
|
{ id: 'metafields', label: 'Métachamps' },
|
||||||
|
{ id: 'confirmation', label: 'Confirmation' },
|
||||||
|
{ id: 'execution', label: 'Exécution' },
|
||||||
|
]
|
||||||
|
const currentIdx = STEPS.findIndex(s => s.id === step)
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setStep('famille')
|
||||||
|
setSelectedTab(null)
|
||||||
|
setEvents([])
|
||||||
|
setDone(false)
|
||||||
|
setByTab({})
|
||||||
|
setTotal(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header title="Création produits" />
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{STEPS.map((s, i) => (
|
||||||
|
<div key={s.id} className="flex items-center gap-2">
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs font-medium ${step === s.id ? 'bg-indigo-600 text-white' : i < currentIdx ? 'bg-green-700 text-white' : 'bg-slate-700 text-gray-400'}`}>
|
||||||
|
{i + 1}. {s.label}
|
||||||
|
</span>
|
||||||
|
{i < STEPS.length - 1 && <span className="text-gray-600">→</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Étape 0 — Sélection famille */}
|
||||||
|
{step === 'famille' && (
|
||||||
|
<div className="bg-slate-800 rounded-xl p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-white mb-4">Choisir une famille</h2>
|
||||||
|
<FamilySelector onSelect={handleSelectTab} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-slate-800 rounded-xl p-6" style={{ display: step === 'famille' ? 'none' : undefined }}>
|
||||||
|
{error && <div className="mb-4 p-3 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">{error}</div>}
|
||||||
|
|
||||||
|
{step === 'analyse' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 bg-indigo-950/50 border border-indigo-800/50 rounded-lg px-3 py-2 w-fit">
|
||||||
|
<span className="text-indigo-400 text-sm">📂 Famille :</span>
|
||||||
|
<span className="text-white text-sm font-semibold">{selectedTab}</span>
|
||||||
|
<button onClick={() => { setStep('famille'); setSelectedTab(null) }} className="ml-2 text-xs text-slate-400 hover:text-slate-200">changer</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-300 text-sm">
|
||||||
|
Détecte les lignes de <strong className="text-white">{selectedTab}</strong> où <code className="text-indigo-300">Publié=1</code> et <code className="text-indigo-300">ID vide</code>.
|
||||||
|
</p>
|
||||||
|
<button onClick={analyse} disabled={loading}
|
||||||
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
|
{loading ? 'Analyse en cours…' : 'Analyser le Sheets'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'metafields' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PendingRowsTable byTab={byTab} total={total} />
|
||||||
|
{total > 0 && (
|
||||||
|
<>
|
||||||
|
<hr className="border-slate-700" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-white mb-3">Résolution des métachamps</h2>
|
||||||
|
<MetafieldResolver specificColumns={specificColumns} onResolved={handleResolved} />
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setStep('confirmation')}
|
||||||
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
|
Continuer →
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'confirmation' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<p className="text-gray-300"><span className="text-white font-medium">{total} produit{total > 1 ? 's' : ''}</span> à créer dans Shopify</p>
|
||||||
|
<p className="text-gray-300"><span className="text-white font-medium">{resolutions.filter(r => r.action !== 'skip').length}</span> type{resolutions.filter(r => r.action !== 'skip').length > 1 ? 's' : ''} de métachamps à assigner</p>
|
||||||
|
<p className="text-gray-300"><span className="text-white font-medium">{resolutions.filter(r => r.action === 'create').length}</span> nouvelle{resolutions.filter(r => r.action === 'create').length > 1 ? 's' : ''} définition{resolutions.filter(r => r.action === 'create').length > 1 ? 's' : ''} à créer dans Shopify</p>
|
||||||
|
<p className="text-yellow-300 text-xs mt-2">⚠ Les descriptions seront générées par OpenAI GPT-4o (~0.01$ par produit)</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => setStep('metafields')}
|
||||||
|
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-gray-300 rounded-lg text-sm font-medium transition-colors">
|
||||||
|
← Retour
|
||||||
|
</button>
|
||||||
|
<button onClick={execute}
|
||||||
|
className="px-4 py-2 bg-green-700 hover:bg-green-600 text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
|
Lancer la création ({total} produits)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'execution' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<CreationProgress events={events} done={done} />
|
||||||
|
{done && (
|
||||||
|
<button onClick={reset}
|
||||||
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
|
Nouvelle session
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,11 +5,12 @@ import { DropZone } from '@/components/images/DropZone'
|
|||||||
import { ImageCard } from '@/components/images/ImageCard'
|
import { ImageCard } from '@/components/images/ImageCard'
|
||||||
import { Lightbox } from '@/components/images/Lightbox'
|
import { Lightbox } from '@/components/images/Lightbox'
|
||||||
import { GlobalActions } from '@/components/images/GlobalActions'
|
import { GlobalActions } from '@/components/images/GlobalActions'
|
||||||
|
import { ErrorBoundary } from '@/components/common/ErrorBoundary'
|
||||||
import { useImages } from '@/hooks/useImages'
|
import { useImages } from '@/hooks/useImages'
|
||||||
import type { ImageItem } from '@/types/images'
|
import type { ImageItem } from '@/types/images'
|
||||||
|
|
||||||
export default function ImagesPage() {
|
function ImagesPageInner() {
|
||||||
const { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem } = useImages()
|
const { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem, assignProduct } = useImages()
|
||||||
const [lightboxItem, setLightboxItem] = useState<ImageItem | null>(null)
|
const [lightboxItem, setLightboxItem] = useState<ImageItem | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -41,6 +42,7 @@ export default function ImagesPage() {
|
|||||||
onUpload={uploadOne}
|
onUpload={uploadOne}
|
||||||
onRemove={removeItem}
|
onRemove={removeItem}
|
||||||
onOpenLightbox={setLightboxItem}
|
onOpenLightbox={setLightboxItem}
|
||||||
|
onAssignProduct={assignProduct}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -59,3 +61,11 @@ export default function ImagesPage() {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function ImagesPage() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ImagesPageInner />
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,291 @@
|
|||||||
|
'use client'
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
import { Header } from '@/components/layout/Header'
|
import { Header } from '@/components/layout/Header'
|
||||||
|
|
||||||
|
interface ProductStats {
|
||||||
|
active: number
|
||||||
|
draft: number
|
||||||
|
total: number
|
||||||
|
withoutImages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LastSync {
|
||||||
|
createdAt: string
|
||||||
|
userName: string
|
||||||
|
status: string
|
||||||
|
created: number
|
||||||
|
updated: number
|
||||||
|
deactivated: number
|
||||||
|
errors: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardStats {
|
||||||
|
products: ProductStats
|
||||||
|
lastSync: LastSync | null
|
||||||
|
sheetDrift: boolean
|
||||||
|
sheetModifiedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
href,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
icon: string
|
||||||
|
label: string
|
||||||
|
value: string | number | null
|
||||||
|
sub?: string
|
||||||
|
href?: string
|
||||||
|
loading?: boolean
|
||||||
|
error?: boolean
|
||||||
|
accent?: 'indigo' | 'amber' | 'green' | 'red'
|
||||||
|
}) {
|
||||||
|
const accentClass = {
|
||||||
|
indigo: 'border-indigo-500/30 bg-indigo-500/5',
|
||||||
|
amber: 'border-amber-500/30 bg-amber-500/5',
|
||||||
|
green: 'border-green-500/30 bg-green-500/5',
|
||||||
|
red: 'border-red-500/30 bg-red-500/5',
|
||||||
|
}[accent ?? 'indigo']
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className={`rounded-xl border p-5 flex flex-col gap-1 ${accentClass} ${href ? 'hover:brightness-110 transition-all cursor-pointer' : ''}`}>
|
||||||
|
<span className="text-xl">{icon}</span>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{label}</p>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-2xl font-bold text-slate-600 animate-pulse">—</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="text-sm text-red-400">Erreur</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-2xl font-bold text-white">{value ?? '—'}</p>
|
||||||
|
)}
|
||||||
|
{sub && <p className="text-xs text-slate-500">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return href ? <Link href={href}>{content}</Link> : content
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionCard({ href, icon, label, description }: { href: string; icon: string; label: string; description: string }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="flex items-start gap-3 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl p-4 transition-colors group"
|
||||||
|
>
|
||||||
|
<span className="text-2xl mt-0.5">{icon}</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-white group-hover:text-indigo-300 transition-colors">{label}</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">{description}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
day: '2-digit', month: 'short', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
}).format(new Date(iso))
|
||||||
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||||
|
const [statsError, setStatsError] = useState(false)
|
||||||
|
const [statsLoading, setStatsLoading] = useState(true)
|
||||||
|
|
||||||
|
const [pendingCount, setPendingCount] = useState<number | null>(null)
|
||||||
|
const [pendingLoading, setPendingLoading] = useState(false)
|
||||||
|
const [pendingError, setPendingError] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/dashboard/stats')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.error) throw new Error(data.error)
|
||||||
|
setStats(data)
|
||||||
|
})
|
||||||
|
.catch(() => setStatsError(true))
|
||||||
|
.finally(() => setStatsLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadPending = useCallback(async () => {
|
||||||
|
setPendingLoading(true)
|
||||||
|
setPendingError(false)
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/dashboard/pending')
|
||||||
|
const data = await r.json()
|
||||||
|
if (data.error) throw new Error(data.error)
|
||||||
|
setPendingCount(data.count)
|
||||||
|
} catch {
|
||||||
|
setPendingError(true)
|
||||||
|
} finally {
|
||||||
|
setPendingLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const lastSync = stats?.lastSync
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="📊 Dashboard" />
|
<Header title="📊 Dashboard" />
|
||||||
<div className="p-6"><p className="text-gray-400 text-sm">Dashboard — à implémenter (Plan 4)</p></div>
|
|
||||||
|
<div className="p-6 flex flex-col gap-8 flex-1 overflow-y-auto">
|
||||||
|
|
||||||
|
{/* ── Alerte drift Sheets ───────────────────────────────── */}
|
||||||
|
{!statsLoading && stats?.sheetDrift && (
|
||||||
|
<Link href="/sync" className="flex items-center gap-3 bg-amber-950/40 border border-amber-600/50 rounded-xl px-4 py-3 hover:bg-amber-950/60 transition-colors group">
|
||||||
|
<span className="text-xl">⚠️</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-amber-300">Google Sheets modifié depuis la dernière sync</p>
|
||||||
|
<p className="text-xs text-amber-500 mt-0.5">
|
||||||
|
{stats.sheetModifiedAt
|
||||||
|
? `Dernière modification : ${formatDate(stats.sheetModifiedAt)}`
|
||||||
|
: 'Des changements sont peut-être en attente de synchronisation'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-amber-400 group-hover:text-amber-300 whitespace-nowrap">Lancer la sync →</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── KPIs ─────────────────────────────────────────────── */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Vue d'ensemble</h2>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<StatCard
|
||||||
|
icon="📦"
|
||||||
|
label="Produits actifs"
|
||||||
|
value={stats?.products.active ?? null}
|
||||||
|
sub={stats ? `${stats.products.draft} en brouillon` : undefined}
|
||||||
|
loading={statsLoading}
|
||||||
|
error={statsError}
|
||||||
|
accent="green"
|
||||||
|
href="/produits"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon="🖼️"
|
||||||
|
label="Sans image"
|
||||||
|
value={stats?.products.withoutImages ?? null}
|
||||||
|
sub={stats ? `sur ${stats.products.total} produits` : undefined}
|
||||||
|
loading={statsLoading}
|
||||||
|
error={statsError}
|
||||||
|
accent={stats && stats.products.withoutImages > 0 ? 'amber' : 'green'}
|
||||||
|
href="/images"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon="🔄"
|
||||||
|
label="Dernière sync"
|
||||||
|
value={lastSync ? formatDate(lastSync.createdAt) : 'Aucune'}
|
||||||
|
sub={lastSync ? `par ${lastSync.userName}` : undefined}
|
||||||
|
loading={statsLoading}
|
||||||
|
error={statsError}
|
||||||
|
accent="indigo"
|
||||||
|
href="/sync"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pending — chargement lazy */}
|
||||||
|
<div
|
||||||
|
className="rounded-xl border border-indigo-500/30 bg-indigo-500/5 p-5 flex flex-col gap-1 cursor-pointer hover:brightness-110 transition-all"
|
||||||
|
onClick={pendingCount === null && !pendingLoading ? loadPending : undefined}
|
||||||
|
>
|
||||||
|
<span className="text-xl">✨</span>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">En attente de création</p>
|
||||||
|
{pendingLoading ? (
|
||||||
|
<p className="text-2xl font-bold text-slate-600 animate-pulse">—</p>
|
||||||
|
) : pendingError ? (
|
||||||
|
<p className="text-sm text-red-400">Erreur — réessayer</p>
|
||||||
|
) : pendingCount !== null ? (
|
||||||
|
<p className="text-2xl font-bold text-white">{pendingCount}</p>
|
||||||
|
) : (
|
||||||
|
<button className="text-xs text-indigo-400 hover:text-indigo-300 text-left mt-1">
|
||||||
|
Calculer →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{pendingCount !== null && (
|
||||||
|
<p className="text-xs text-slate-500">depuis Google Sheets</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Dernière sync détail ──────────────────────────────── */}
|
||||||
|
{!statsLoading && lastSync && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Dernière synchronisation</h2>
|
||||||
|
<div className="bg-slate-800 border border-slate-700 rounded-xl p-5 flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-white">{formatDate(lastSync.createdAt)}</p>
|
||||||
|
<p className="text-xs text-slate-400">par {lastSync.userName}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-medium px-2 py-1 rounded-full border ${
|
||||||
|
lastSync.status === 'success'
|
||||||
|
? 'bg-green-900/30 border-green-700 text-green-400'
|
||||||
|
: 'bg-red-900/30 border-red-700 text-red-400'
|
||||||
|
}`}>
|
||||||
|
{lastSync.status === 'success' ? '✅ Succès' : '❌ Erreur'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-3 pt-2 border-t border-slate-700">
|
||||||
|
{[
|
||||||
|
{ label: 'Créations', value: lastSync.created, color: 'text-green-400' },
|
||||||
|
{ label: 'Mises à jour', value: lastSync.updated, color: 'text-blue-400' },
|
||||||
|
{ label: 'Désactivés', value: lastSync.deactivated, color: 'text-amber-400' },
|
||||||
|
{ label: 'Erreurs', value: lastSync.errors, color: 'text-red-400' },
|
||||||
|
].map(({ label, value, color }) => (
|
||||||
|
<div key={label} className="text-center">
|
||||||
|
<p className={`text-xl font-bold ${color}`}>{value}</p>
|
||||||
|
<p className="text-xs text-slate-500">{label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Link href="/sync" className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors">
|
||||||
|
Nouvelle sync →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Actions rapides ───────────────────────────────────── */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Actions rapides</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<ActionCard
|
||||||
|
href="/images"
|
||||||
|
icon="🖼️"
|
||||||
|
label="Uploader des images"
|
||||||
|
description="Convertir HEIC, détourer et envoyer sur Shopify"
|
||||||
|
/>
|
||||||
|
<ActionCard
|
||||||
|
href="/sync"
|
||||||
|
icon="🔄"
|
||||||
|
label="Synchroniser le catalogue"
|
||||||
|
description="Comparer Google Sheets avec Shopify et appliquer les changements"
|
||||||
|
/>
|
||||||
|
<ActionCard
|
||||||
|
href="/creation"
|
||||||
|
icon="✨"
|
||||||
|
label="Créer des produits"
|
||||||
|
description="Ajouter les produits en attente depuis Google Sheets"
|
||||||
|
/>
|
||||||
|
<ActionCard
|
||||||
|
href="/produits"
|
||||||
|
icon="📦"
|
||||||
|
label="Voir le catalogue"
|
||||||
|
description="Parcourir et filtrer tous les produits Shopify"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
'use client'
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Header } from '@/components/layout/Header'
|
||||||
|
import type { DuplicateGroup } from '@/app/api/products/duplicates/route'
|
||||||
|
|
||||||
|
type DeleteState = 'idle' | 'confirm' | 'deleting' | 'done'
|
||||||
|
|
||||||
|
interface GroupState {
|
||||||
|
group: DuplicateGroup
|
||||||
|
keepId: string
|
||||||
|
deleteState: DeleteState
|
||||||
|
deleted: string[] // IDs supprimés
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DoublonsPage() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [groupStates, setGroupStates] = useState<GroupState[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/products/duplicates')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.error) throw new Error(data.error)
|
||||||
|
setGroupStates(
|
||||||
|
(data.groups as DuplicateGroup[]).map((group) => ({
|
||||||
|
group,
|
||||||
|
keepId: group.products[0].shopifyId, // garder le plus ancien par défaut
|
||||||
|
deleteState: 'idle',
|
||||||
|
deleted: [],
|
||||||
|
error: null,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch((e: Error) => setError(e.message))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setKeep = useCallback((groupIdx: number, id: string) => {
|
||||||
|
setGroupStates((prev) =>
|
||||||
|
prev.map((gs, i) => i === groupIdx ? { ...gs, keepId: id } : gs)
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const askConfirm = useCallback((groupIdx: number) => {
|
||||||
|
setGroupStates((prev) =>
|
||||||
|
prev.map((gs, i) => i === groupIdx ? { ...gs, deleteState: 'confirm', error: null } : gs)
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const cancelConfirm = useCallback((groupIdx: number) => {
|
||||||
|
setGroupStates((prev) =>
|
||||||
|
prev.map((gs, i) => i === groupIdx ? { ...gs, deleteState: 'idle' } : gs)
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const deleteGroup = useCallback(async (groupIdx: number) => {
|
||||||
|
const gs = groupStates[groupIdx]
|
||||||
|
const toDelete = gs.group.products
|
||||||
|
.map((p) => p.shopifyId)
|
||||||
|
.filter((id) => id !== gs.keepId && !gs.deleted.includes(id))
|
||||||
|
|
||||||
|
setGroupStates((prev) =>
|
||||||
|
prev.map((s, i) => i === groupIdx ? { ...s, deleteState: 'deleting', error: null } : s)
|
||||||
|
)
|
||||||
|
|
||||||
|
const newDeleted = [...gs.deleted]
|
||||||
|
let lastError: string | null = null
|
||||||
|
|
||||||
|
for (const id of toDelete) {
|
||||||
|
const res = await fetch(`/api/products/${id}`, { method: 'DELETE' })
|
||||||
|
if (res.ok) {
|
||||||
|
newDeleted.push(id)
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
lastError = data.error ?? `Erreur HTTP ${res.status}`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setGroupStates((prev) =>
|
||||||
|
prev.map((s, i) =>
|
||||||
|
i === groupIdx
|
||||||
|
? { ...s, deleted: newDeleted, deleteState: lastError ? 'idle' : 'done', error: lastError }
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}, [groupStates])
|
||||||
|
|
||||||
|
const remaining = groupStates.filter((gs) => gs.deleteState !== 'done').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header
|
||||||
|
title="🔍 Doublons produits"
|
||||||
|
actions={
|
||||||
|
<Link href="/produits" className="text-xs text-slate-400 hover:text-slate-200">
|
||||||
|
← Retour au catalogue
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="p-6 flex flex-col gap-6 flex-1 overflow-y-auto">
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center gap-2 text-slate-400 text-sm py-12 justify-center">
|
||||||
|
<span className="animate-spin">⏳</span> Analyse du catalogue Shopify…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && groupStates.length === 0 && (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<p className="text-4xl mb-3">✅</p>
|
||||||
|
<p className="text-white font-semibold">Aucun doublon détecté</p>
|
||||||
|
<p className="text-slate-400 text-sm mt-1">Tous les produits ont une référence unique.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && groupStates.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
<span className="text-white font-semibold">{groupStates.length}</span> groupe{groupStates.length > 1 ? 's' : ''} de doublons détectés
|
||||||
|
{remaining < groupStates.length && (
|
||||||
|
<span className="ml-2 text-green-400">· {groupStates.length - remaining} résolu{groupStates.length - remaining > 1 ? 's' : ''}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{groupStates.map((gs, groupIdx) => {
|
||||||
|
const { group, keepId, deleteState, deleted, error: groupError } = gs
|
||||||
|
const isDone = deleteState === 'done'
|
||||||
|
const activeProducts = group.products.filter((p) => !deleted.includes(p.shopifyId))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.ref}
|
||||||
|
className={`border rounded-xl overflow-hidden transition-all ${
|
||||||
|
isDone
|
||||||
|
? 'border-green-700/50 bg-green-950/20 opacity-60'
|
||||||
|
: 'border-slate-700 bg-slate-800/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* En-tête groupe */}
|
||||||
|
<div className="px-4 py-3 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="font-mono text-sm text-indigo-300 font-semibold">{group.ref}</span>
|
||||||
|
<span className="ml-3 text-xs text-slate-500">
|
||||||
|
{isDone ? '✅ Résolu' : `${activeProducts.length} produits`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isDone && deleteState === 'idle' && (
|
||||||
|
<button
|
||||||
|
onClick={() => askConfirm(groupIdx)}
|
||||||
|
className="text-xs px-3 py-1.5 bg-red-900/40 hover:bg-red-800/60 border border-red-700/50 text-red-300 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Supprimer les doublons
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste des produits */}
|
||||||
|
{!isDone && (
|
||||||
|
<div className="divide-y divide-slate-700/50">
|
||||||
|
{activeProducts.map((product) => {
|
||||||
|
const isKeep = product.shopifyId === keepId
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={product.shopifyId}
|
||||||
|
className={`px-4 py-3 flex items-center gap-3 ${isKeep ? 'bg-green-950/20' : ''}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`keep-${groupIdx}`}
|
||||||
|
checked={isKeep}
|
||||||
|
onChange={() => setKeep(groupIdx, product.shopifyId)}
|
||||||
|
className="accent-green-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-white truncate">{product.title}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">
|
||||||
|
ID {product.shopifyId} ·{' '}
|
||||||
|
<span className={product.status === 'active' ? 'text-green-400' : 'text-slate-400'}>
|
||||||
|
{product.status}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{isKeep && (
|
||||||
|
<span className="text-xs text-green-400 font-medium">À garder</span>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={product.shopifyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-xs text-slate-500 hover:text-slate-300"
|
||||||
|
>
|
||||||
|
Shopify ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Zone de confirmation */}
|
||||||
|
{deleteState === 'confirm' && (
|
||||||
|
<div className="px-4 py-3 bg-red-950/30 border-t border-red-800/40 flex items-center justify-between gap-4">
|
||||||
|
<p className="text-sm text-red-300">
|
||||||
|
Supprimer définitivement{' '}
|
||||||
|
<strong>{activeProducts.length - 1} produit{activeProducts.length - 1 > 1 ? 's' : ''}</strong>{' '}
|
||||||
|
de Shopify. Cette action est irréversible.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => cancelConfirm(groupIdx)}
|
||||||
|
className="text-xs px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded-lg"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteGroup(groupIdx)}
|
||||||
|
className="text-xs px-3 py-1.5 bg-red-700 hover:bg-red-600 text-white font-semibold rounded-lg"
|
||||||
|
>
|
||||||
|
Confirmer la suppression
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleteState === 'deleting' && (
|
||||||
|
<div className="px-4 py-3 border-t border-slate-700 text-sm text-slate-400 flex items-center gap-2">
|
||||||
|
<span className="animate-spin">⏳</span> Suppression en cours…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groupError && (
|
||||||
|
<div className="px-4 py-2 border-t border-red-800/40 text-xs text-red-400">
|
||||||
|
Erreur : {groupError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
import { Header } from '@/components/layout/Header'
|
import { Header } from '@/components/layout/Header'
|
||||||
import { ProductFiltersBar } from '@/components/products/ProductFiltersBar'
|
import { ProductFiltersBar } from '@/components/products/ProductFiltersBar'
|
||||||
import { ProductTable } from '@/components/products/ProductTable'
|
import { ProductTable } from '@/components/products/ProductTable'
|
||||||
@@ -35,7 +36,14 @@ export default function ProduitsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="Produits" />
|
<Header
|
||||||
|
title="Produits"
|
||||||
|
actions={
|
||||||
|
<Link href="/produits/doublons" className="text-xs px-3 py-1.5 bg-slate-700 hover:bg-slate-600 border border-slate-600 text-slate-300 rounded-lg transition-colors">
|
||||||
|
🔍 Vérifier les doublons
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="bg-slate-800 rounded-xl p-6">
|
<div className="bg-slate-800 rounded-xl p-6">
|
||||||
<ProductFiltersBar
|
<ProductFiltersBar
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import { SyncStepper } from '@/components/sync/SyncStepper'
|
|||||||
import { DiffTable } from '@/components/sync/DiffTable'
|
import { DiffTable } from '@/components/sync/DiffTable'
|
||||||
import { SyncProgress } from '@/components/sync/SyncProgress'
|
import { SyncProgress } from '@/components/sync/SyncProgress'
|
||||||
import { SyncHistory } from '@/components/sync/SyncHistory'
|
import { SyncHistory } from '@/components/sync/SyncHistory'
|
||||||
|
import { FamilySelector } from '@/components/common/FamilySelector'
|
||||||
import type { DiffResult, SyncChange, SyncHistoryEntry } from '@/types/sync'
|
import type { DiffResult, SyncChange, SyncHistoryEntry } from '@/types/sync'
|
||||||
|
|
||||||
type Step = 1 | 2 | 3 | 4
|
type Step = 0 | 1 | 2 | 3 | 4
|
||||||
|
|
||||||
export default function SyncPage() {
|
export default function SyncPage() {
|
||||||
const [step, setStep] = useState<Step>(1)
|
const [step, setStep] = useState<Step>(0)
|
||||||
|
const [selectedTab, setSelectedTab] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [diff, setDiff] = useState<DiffResult | null>(null)
|
const [diff, setDiff] = useState<DiffResult | null>(null)
|
||||||
@@ -24,12 +26,19 @@ export default function SyncPage() {
|
|||||||
created: number; updated: number; deactivated: number; errors: number; status: string
|
created: number; updated: number; deactivated: number; errors: number; status: string
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
|
// Étape 0 → 1 : sélection famille
|
||||||
|
const handleSelectTab = useCallback((tab: string) => {
|
||||||
|
setSelectedTab(tab)
|
||||||
|
setStep(1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Étape 1 → 2 : lancer le preview
|
// Étape 1 → 2 : lancer le preview
|
||||||
const handleAnalyze = useCallback(async () => {
|
const handleAnalyze = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/sync/preview')
|
const url = selectedTab ? `/api/sync/preview?tab=${encodeURIComponent(selectedTab)}` : '/api/sync/preview'
|
||||||
|
const res = await fetch(url)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||||
setDiff(data)
|
setDiff(data)
|
||||||
@@ -39,7 +48,7 @@ export default function SyncPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [selectedTab])
|
||||||
|
|
||||||
// Étape 3 → 4 : lancer la sync en streaming
|
// Étape 3 → 4 : lancer la sync en streaming
|
||||||
const handleExecute = useCallback(async () => {
|
const handleExecute = useCallback(async () => {
|
||||||
@@ -114,13 +123,26 @@ export default function SyncPage() {
|
|||||||
|
|
||||||
<div className="p-6 flex flex-col gap-6 flex-1 overflow-y-auto">
|
<div className="p-6 flex flex-col gap-6 flex-1 overflow-y-auto">
|
||||||
{/* Stepper */}
|
{/* Stepper */}
|
||||||
<SyncStepper currentStep={step} />
|
<SyncStepper currentStep={step > 0 ? step : 1} />
|
||||||
|
|
||||||
|
{/* Étape 0 — Sélection famille */}
|
||||||
|
{step === 0 && (
|
||||||
|
<div className="bg-slate-800/50 border border-slate-700 rounded-xl p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-white mb-4">Choisir une famille</h2>
|
||||||
|
<FamilySelector onSelect={handleSelectTab} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Étape 1 — Analyser */}
|
{/* Étape 1 — Analyser */}
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<div className="flex flex-col items-center gap-4 py-8">
|
<div className="flex flex-col items-center gap-4 py-8">
|
||||||
|
<div className="flex items-center gap-2 bg-indigo-950/50 border border-indigo-800/50 rounded-lg px-4 py-2">
|
||||||
|
<span className="text-indigo-400 text-sm">📂 Famille :</span>
|
||||||
|
<span className="text-white text-sm font-semibold">{selectedTab}</span>
|
||||||
|
<button onClick={() => { setStep(0); setSelectedTab(null) }} className="ml-2 text-xs text-slate-400 hover:text-slate-200">changer</button>
|
||||||
|
</div>
|
||||||
<p className="text-slate-400 text-sm text-center max-w-md">
|
<p className="text-slate-400 text-sm text-center max-w-md">
|
||||||
Lit Google Sheets et compare avec le catalogue Shopify actuel pour générer l'aperçu des changements.
|
Compare la famille <strong className="text-white">{selectedTab}</strong> avec le catalogue Shopify.
|
||||||
</p>
|
</p>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-950 border border-red-800 text-red-300 text-sm rounded-lg px-4 py-2 max-w-md">
|
<div className="bg-red-950 border border-red-800 text-red-300 text-sm rounded-lg px-4 py-2 max-w-md">
|
||||||
@@ -196,7 +218,7 @@ export default function SyncPage() {
|
|||||||
{summary && (
|
{summary && (
|
||||||
<div className="flex gap-3 justify-end">
|
<div className="flex gap-3 justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setStep(1); setDiff(null); setSummary(null); setLog([]) }}
|
onClick={() => { setStep(0); setSelectedTab(null); setDiff(null); setSummary(null); setLog([]) }}
|
||||||
className="px-4 py-2 text-sm bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded-lg"
|
className="px-4 py-2 text-sm bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded-lg"
|
||||||
>
|
>
|
||||||
↺ Nouvelle sync
|
↺ Nouvelle sync
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { fetchPendingProducts } from '@/lib/creationSheets'
|
||||||
|
import { generateDescriptions } from '@/lib/openaiClient'
|
||||||
|
import { ensureMetafieldDefinition, setProductMetafield } from '@/lib/shopifyMetafields'
|
||||||
|
import { addProductToCollection } from '@/lib/shopifyCollections'
|
||||||
|
import { createShopifyProduct, fetchAllShopifyProducts } from '@/lib/shopifySync'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import type { MetafieldResolution, CreationStreamEvent } from '@/types/creation'
|
||||||
|
|
||||||
|
function encode(event: CreationStreamEvent): string {
|
||||||
|
return JSON.stringify(event) + '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Non authentifié' }), { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = (session.user as { id: string }).id
|
||||||
|
const body = await req.json().catch(() => ({})) as { resolutions?: MetafieldResolution[] }
|
||||||
|
const resolutions: MetafieldResolution[] = body.resolutions ?? []
|
||||||
|
const resolutionMap = new Map<string, MetafieldResolution>(resolutions.map(r => [r.columnHeader, r]))
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
const send = (event: CreationStreamEvent) => controller.enqueue(encode(event))
|
||||||
|
try {
|
||||||
|
const [existingRows, shopifyProducts] = await Promise.all([
|
||||||
|
prisma.productCreation.findMany({ select: { sheetTab: true, sheetRow: true } }),
|
||||||
|
fetchAllShopifyProducts(),
|
||||||
|
])
|
||||||
|
const alreadyCreated = new Set(existingRows.map(e => `${e.sheetTab}:${e.sheetRow}`))
|
||||||
|
const shopifyRefs = new Set(shopifyProducts.map(p => p.ref.trim().toLowerCase()))
|
||||||
|
const pending = await fetchPendingProducts(alreadyCreated)
|
||||||
|
const total = pending.length
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
send({ type: 'done', summary: { created: 0, skipped: 0, errors: 0, status: 'success' } })
|
||||||
|
controller.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crée les définitions manquantes en amont
|
||||||
|
for (const resolution of resolutions) {
|
||||||
|
if (resolution.action === 'create') await ensureMetafieldDefinition(resolution)
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = 0
|
||||||
|
let skipped = 0
|
||||||
|
let errors = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < pending.length; i++) {
|
||||||
|
const product = pending[i]
|
||||||
|
|
||||||
|
// Vérifier si le produit existe déjà dans Shopify par SKU
|
||||||
|
if (shopifyRefs.has(product.sku.trim().toLowerCase())) {
|
||||||
|
send({ type: 'skipped', title: product.title, sku: product.sku })
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Génération descriptions…' })
|
||||||
|
try {
|
||||||
|
const { longDesc } = await generateDescriptions({
|
||||||
|
title: product.title,
|
||||||
|
rawDescription: '',
|
||||||
|
comment: product.comment,
|
||||||
|
vendor: product.vendor,
|
||||||
|
famille: product.famille,
|
||||||
|
})
|
||||||
|
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Création produit Shopify…' })
|
||||||
|
const shopifyId = await createShopifyProduct({
|
||||||
|
ref: product.sku,
|
||||||
|
handle: product.sku.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||||
|
title: product.title,
|
||||||
|
description: longDesc,
|
||||||
|
price: product.priceActual || '0.00',
|
||||||
|
stock: product.stock,
|
||||||
|
status: 'active',
|
||||||
|
})
|
||||||
|
if (product.collectionId) await addProductToCollection(shopifyId, product.collectionId)
|
||||||
|
if (product.subCollectionId) await addProductToCollection(shopifyId, product.subCollectionId)
|
||||||
|
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Métachamps…' })
|
||||||
|
for (const [colHeader, value] of Object.entries(product.specificFields)) {
|
||||||
|
const resolution = resolutionMap.get(colHeader)
|
||||||
|
if (!resolution || resolution.action === 'skip') continue
|
||||||
|
await setProductMetafield(shopifyId, resolution, value)
|
||||||
|
}
|
||||||
|
await prisma.productCreation.create({
|
||||||
|
data: { userId, sheetTab: product.tab, sheetRow: product.rowNumber, shopifyId, sku: product.sku, title: product.title, status: 'success' },
|
||||||
|
})
|
||||||
|
created++
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
send({ type: 'error', title: product.title, message })
|
||||||
|
await prisma.productCreation.create({
|
||||||
|
data: { userId, sheetTab: product.tab, sheetRow: product.rowNumber, shopifyId: '', sku: product.sku, title: product.title, status: 'error', errorMessage: message },
|
||||||
|
})
|
||||||
|
errors++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const status = errors === 0 ? 'success' : created === 0 ? 'error' : 'partial'
|
||||||
|
send({ type: 'done', summary: { created, skipped, errors, status } })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
send({ type: 'done', summary: { created: 0, skipped: 0, errors: 1, status: 'error' } })
|
||||||
|
console.error('[creation/execute]', message)
|
||||||
|
} finally {
|
||||||
|
controller.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: { 'Content-Type': 'application/x-ndjson', 'Transfer-Encoding': 'chunked' },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { GET } from './route'
|
||||||
|
import * as shopifyMetafields from '@/lib/shopifyMetafields'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
|
||||||
|
jest.mock('next-auth')
|
||||||
|
jest.mock('@/lib/shopifyMetafields')
|
||||||
|
|
||||||
|
const mockSession = getServerSession as jest.MockedFunction<typeof getServerSession>
|
||||||
|
const mockFetchDefs = shopifyMetafields.fetchMetafieldDefinitions as jest.MockedFunction<typeof shopifyMetafields.fetchMetafieldDefinitions>
|
||||||
|
const mockMatch = shopifyMetafields.matchMetafields as jest.MockedFunction<typeof shopifyMetafields.matchMetafields>
|
||||||
|
|
||||||
|
describe('GET /api/creation/metafields', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSession.mockResolvedValue({ user: { id: 'u1', role: 'admin' } } as never)
|
||||||
|
mockFetchDefs.mockResolvedValue([
|
||||||
|
{ id: '1', namespace: 'custom', key: 'puissance', name: 'Puissance', type: 'single_line_text_field' },
|
||||||
|
])
|
||||||
|
mockMatch.mockReturnValue({
|
||||||
|
matched: { 'Puissance (AN)': { columnHeader: 'Puissance (AN)', action: 'map', namespace: 'custom', key: 'puissance', existingDefinitionId: '1' } },
|
||||||
|
unmatched: ['Batterie (AP)'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne 401 sans session', async () => {
|
||||||
|
mockSession.mockResolvedValue(null)
|
||||||
|
const res = await GET(new Request('http://localhost/api/creation/metafields?columns=Puissance') as never)
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne définitions + matching', async () => {
|
||||||
|
const res = await GET(new Request('http://localhost/api/creation/metafields?columns=Puissance+%28AN%29%2CBatterie+%28AP%29') as never)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.definitions).toHaveLength(1)
|
||||||
|
expect(body.matched['Puissance (AN)'].key).toBe('puissance')
|
||||||
|
expect(body.unmatched).toContain('Batterie (AP)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { fetchMetafieldDefinitions, matchMetafields } from '@/lib/shopifyMetafields'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const columnsParam = searchParams.get('columns') ?? ''
|
||||||
|
const columns = columnsParam ? columnsParam.split(',').map(c => c.trim()).filter(Boolean) : []
|
||||||
|
const definitions = await fetchMetafieldDefinitions()
|
||||||
|
const { matched, unmatched } = matchMetafields(columns, definitions)
|
||||||
|
return NextResponse.json({ definitions, matched, unmatched })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { GET } from './route'
|
||||||
|
import * as creationSheets from '@/lib/creationSheets'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
|
||||||
|
jest.mock('next-auth')
|
||||||
|
jest.mock('@/lib/creationSheets')
|
||||||
|
jest.mock('@/lib/prisma', () => ({
|
||||||
|
prisma: { productCreation: { findMany: jest.fn().mockResolvedValue([]) } },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockSession = getServerSession as jest.MockedFunction<typeof getServerSession>
|
||||||
|
const mockFetch = creationSheets.fetchPendingProducts as jest.MockedFunction<typeof creationSheets.fetchPendingProducts>
|
||||||
|
|
||||||
|
const makePending = (tab: string, row: number, specific: Record<string, string> = {}) => ({
|
||||||
|
tab, rowNumber: row, sku: `SKU-${row}`, title: `Produit ${row}`,
|
||||||
|
priceMarket: '99', priceActual: '79', stock: 5, weightKg: 2,
|
||||||
|
vendor: 'BOSCH', famille: tab, sousFamille: '', comment: '',
|
||||||
|
collectionId: '10', subCollectionId: '', hasPhoto: false,
|
||||||
|
specificFields: specific,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GET /api/creation/preview', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSession.mockResolvedValue({ user: { id: 'u1', role: 'admin' } } as never)
|
||||||
|
mockFetch.mockResolvedValue([
|
||||||
|
makePending('OUTILLAGE', 2, { 'Puissance (AN)': '1500W' }),
|
||||||
|
makePending('CARRELAGE', 3),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne 401 sans session', async () => {
|
||||||
|
mockSession.mockResolvedValue(null)
|
||||||
|
const res = await GET(new Request('http://localhost') as never)
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne les produits groupés par onglet', async () => {
|
||||||
|
const res = await GET(new Request('http://localhost') as never)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.byTab['OUTILLAGE']).toHaveLength(1)
|
||||||
|
expect(body.byTab['CARRELAGE']).toHaveLength(1)
|
||||||
|
expect(body.total).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liste les colonnes spécifiques uniques', async () => {
|
||||||
|
const res = await GET(new Request('http://localhost') as never)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.specificColumns).toContain('Puissance (AN)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { fetchPendingProducts } from '@/lib/creationSheets'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import type { PendingProduct } from '@/types/creation'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
const tab = new URL(req.url).searchParams.get('tab') ?? undefined
|
||||||
|
try {
|
||||||
|
const existing = await prisma.productCreation.findMany({ select: { sheetTab: true, sheetRow: true } })
|
||||||
|
const alreadyCreated = new Set(existing.map(e => `${e.sheetTab}:${e.sheetRow}`))
|
||||||
|
const pending = await fetchPendingProducts(alreadyCreated, tab)
|
||||||
|
const byTab: Record<string, PendingProduct[]> = {}
|
||||||
|
const specificColumns = new Set<string>()
|
||||||
|
for (const p of pending) {
|
||||||
|
if (!byTab[p.tab]) byTab[p.tab] = []
|
||||||
|
byTab[p.tab].push(p)
|
||||||
|
Object.keys(p.specificFields).forEach(k => specificColumns.add(k))
|
||||||
|
}
|
||||||
|
return NextResponse.json({ byTab, total: pending.length, specificColumns: Array.from(specificColumns).sort() })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { fetchPendingProducts } from '@/lib/creationSheets'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await prisma.productCreation.findMany({ select: { sheetTab: true, sheetRow: true } })
|
||||||
|
const alreadyCreated = new Set(existing.map((e) => `${e.sheetTab}:${e.sheetRow}`))
|
||||||
|
const pending = await fetchPendingProducts(alreadyCreated)
|
||||||
|
return NextResponse.json({ count: pending.length })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
const API_VERSION = '2024-01'
|
||||||
|
|
||||||
|
function getShopifyConfig() {
|
||||||
|
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||||
|
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||||
|
if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
|
||||||
|
return {
|
||||||
|
baseUrl: `https://${domain}/admin/api/${API_VERSION}`,
|
||||||
|
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNextUrl(linkHeader: string | null): string | null {
|
||||||
|
if (!linkHeader) return null
|
||||||
|
const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/)
|
||||||
|
return match ? match[1] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LightProduct {
|
||||||
|
id: number
|
||||||
|
status: string
|
||||||
|
images: Array<{ id: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProductsLight(status: 'active' | 'draft'): Promise<LightProduct[]> {
|
||||||
|
const { baseUrl, headers } = getShopifyConfig()
|
||||||
|
const all: LightProduct[] = []
|
||||||
|
let url: string | null =
|
||||||
|
`${baseUrl}/products.json?limit=250&fields=id,status,images&status=${status}`
|
||||||
|
|
||||||
|
while (url) {
|
||||||
|
const res = await fetch(url, { headers, cache: 'no-store' })
|
||||||
|
if (!res.ok) throw new Error(`Shopify fetch error (${status}): ${res.status}`)
|
||||||
|
const data = await res.json()
|
||||||
|
all.push(...(data.products as LightProduct[]))
|
||||||
|
url = extractNextUrl(res.headers.get('Link'))
|
||||||
|
}
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSheetModifiedTime(): Promise<string | null> {
|
||||||
|
const apiKey = process.env.GOOGLE_API_KEY
|
||||||
|
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||||
|
if (!apiKey || !sheetId) return null
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://www.googleapis.com/drive/v3/files/${sheetId}?fields=modifiedTime&key=${apiKey}`,
|
||||||
|
{ cache: 'no-store' },
|
||||||
|
)
|
||||||
|
if (!res.ok) return null
|
||||||
|
const data = await res.json()
|
||||||
|
return (data.modifiedTime as string) ?? null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [activeProducts, draftProducts, lastSyncRun, sheetModifiedTime] = await Promise.all([
|
||||||
|
fetchProductsLight('active'),
|
||||||
|
fetchProductsLight('draft'),
|
||||||
|
prisma.syncRun.findFirst({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { user: { select: { name: true } } },
|
||||||
|
}),
|
||||||
|
fetchSheetModifiedTime(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const allProducts = [...activeProducts, ...draftProducts]
|
||||||
|
const withoutImages = allProducts.filter((p) => p.images.length === 0).length
|
||||||
|
|
||||||
|
const lastSync = lastSyncRun
|
||||||
|
? {
|
||||||
|
createdAt: lastSyncRun.createdAt.toISOString(),
|
||||||
|
userName: lastSyncRun.user.name,
|
||||||
|
status: lastSyncRun.status,
|
||||||
|
created: lastSyncRun.created,
|
||||||
|
updated: lastSyncRun.updated,
|
||||||
|
deactivated: lastSyncRun.deactivated,
|
||||||
|
errors: lastSyncRun.errors,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Drift : Sheets modifié après la dernière sync réussie
|
||||||
|
const sheetDrift =
|
||||||
|
sheetModifiedTime !== null &&
|
||||||
|
lastSyncRun !== null &&
|
||||||
|
new Date(sheetModifiedTime) > lastSyncRun.createdAt
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
products: {
|
||||||
|
active: activeProducts.length,
|
||||||
|
draft: draftProducts.length,
|
||||||
|
total: allProducts.length,
|
||||||
|
withoutImages,
|
||||||
|
},
|
||||||
|
lastSync,
|
||||||
|
sheetDrift,
|
||||||
|
sheetModifiedAt: sheetModifiedTime,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import convert from 'heic-convert'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = await req.formData()
|
||||||
|
const file = formData.get('file') as File | null
|
||||||
|
if (!file) return NextResponse.json({ error: 'Fichier manquant' }, { status: 400 })
|
||||||
|
|
||||||
|
const inputBuffer = Buffer.from(await file.arrayBuffer())
|
||||||
|
// heic-convert utilise libheif WASM — supporte HEIC/HEIF sur tous les environnements
|
||||||
|
const outputBuffer = await convert({
|
||||||
|
buffer: inputBuffer,
|
||||||
|
format: 'JPEG',
|
||||||
|
quality: 0.92,
|
||||||
|
})
|
||||||
|
|
||||||
|
const jpeg = Buffer.from(outputBuffer)
|
||||||
|
return new NextResponse(jpeg.buffer as ArrayBuffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'image/jpeg',
|
||||||
|
'Content-Length': String(jpeg.length),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur conversion'
|
||||||
|
console.error('[convert-heic API]', message)
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { deleteShopifyProduct } from '@/lib/shopifySync'
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: { id: string } },
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteShopifyProduct(params.id)
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
|
||||||
|
import type { ShopifyProductFull } from '@/types/sync'
|
||||||
|
|
||||||
|
export interface DuplicateGroup {
|
||||||
|
ref: string
|
||||||
|
products: Array<{
|
||||||
|
shopifyId: string
|
||||||
|
title: string
|
||||||
|
status: string
|
||||||
|
handle: string
|
||||||
|
shopifyUrl: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const domain = process.env.SHOPIFY_STORE_DOMAIN ?? ''
|
||||||
|
const all = await fetchAllShopifyProducts()
|
||||||
|
|
||||||
|
// Grouper par ref (SKU)
|
||||||
|
const byRef = new Map<string, ShopifyProductFull[]>()
|
||||||
|
for (const p of all) {
|
||||||
|
const key = p.ref.trim().toLowerCase()
|
||||||
|
if (!byRef.has(key)) byRef.set(key, [])
|
||||||
|
byRef.get(key)!.push(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: DuplicateGroup[] = []
|
||||||
|
for (const products of Array.from(byRef.values())) {
|
||||||
|
if (products.length < 2) continue
|
||||||
|
// Trier : le plus ancien (ID numérique le plus petit) en premier
|
||||||
|
products.sort((a: ShopifyProductFull, b: ShopifyProductFull) => Number(a.shopifyId) - Number(b.shopifyId))
|
||||||
|
groups.push({
|
||||||
|
ref: products[0].ref,
|
||||||
|
products: products.map((p: ShopifyProductFull) => ({
|
||||||
|
shopifyId: p.shopifyId,
|
||||||
|
title: p.title,
|
||||||
|
status: p.status,
|
||||||
|
handle: p.handle,
|
||||||
|
shopifyUrl: `https://${domain}/admin/products/${p.shopifyId}`,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trier les groupes par ref
|
||||||
|
groups.sort((a, b) => a.ref.localeCompare(b.ref))
|
||||||
|
|
||||||
|
return NextResponse.json({ groups, total: groups.length })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cherche un produit dans Google Sheets par sa référence (UGS, col C).
|
||||||
|
* Retourne le Shopify ID (col A) et le titre (col D) si trouvé.
|
||||||
|
* Parcourt tous les onglets.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ found: false }, { status: 401 })
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const ref = searchParams.get('ref')?.trim()
|
||||||
|
if (!ref) return NextResponse.json({ found: false }, { status: 400 })
|
||||||
|
|
||||||
|
const apiKey = process.env.GOOGLE_API_KEY
|
||||||
|
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||||
|
if (!apiKey || !sheetId) {
|
||||||
|
return NextResponse.json({ found: false, error: 'GOOGLE_API_KEY et GOOGLE_SHEETS_ID requis' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Récupère la liste des onglets
|
||||||
|
const metaRes = await fetch(
|
||||||
|
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`,
|
||||||
|
)
|
||||||
|
if (!metaRes.ok) throw new Error(`Sheets metadata error: ${metaRes.status}`)
|
||||||
|
const meta = await metaRes.json()
|
||||||
|
const tabs: string[] = (meta.sheets as Array<{ properties: { title: string } }>).map(
|
||||||
|
s => s.properties.title,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const range = encodeURIComponent(`${tab}!A2:D`)
|
||||||
|
const res = await fetch(
|
||||||
|
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`,
|
||||||
|
)
|
||||||
|
if (!res.ok) continue
|
||||||
|
const data = await res.json()
|
||||||
|
const rows = (data.values ?? []) as string[][]
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const shopifyId = (row[0] ?? '').trim() // col A = ID Shopify
|
||||||
|
// col C = UGS (index 2 car on commence à A)
|
||||||
|
const ugs = (row[2] ?? '').trim()
|
||||||
|
|
||||||
|
// L'UGS doit apparaître comme mot entier dans le nom de fichier
|
||||||
|
// (entouré de séparateurs ou en début/fin) — ex: "dessus-30827-vue" ✓, "308270" ✗
|
||||||
|
const refLower = ref.toLowerCase()
|
||||||
|
const ugsLower = ugs.toLowerCase()
|
||||||
|
const wordBoundary = new RegExp(`(^|[^a-z0-9])${ugsLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`)
|
||||||
|
const isMatch = ugs && shopifyId && wordBoundary.test(refLower)
|
||||||
|
|
||||||
|
if (isMatch) {
|
||||||
|
const title = (row[3] ?? '').trim() // col D = Nom
|
||||||
|
return NextResponse.json({ found: true, shopifyId, title })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ found: false })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur'
|
||||||
|
return NextResponse.json({ found: false, error: message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
|
||||||
|
const apiKey = process.env.GOOGLE_API_KEY
|
||||||
|
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||||
|
if (!apiKey || !sheetId) {
|
||||||
|
return NextResponse.json({ error: 'GOOGLE_API_KEY et GOOGLE_SHEETS_ID requis' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`
|
||||||
|
const res = await fetch(url, { cache: 'no-store' })
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text()
|
||||||
|
throw new Error(`Google Sheets API error: ${res.status} — ${err}`)
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
const tabs: string[] = (data.sheets as Array<{ properties: { title: string } }>).map(
|
||||||
|
(s) => s.properties.title,
|
||||||
|
)
|
||||||
|
return NextResponse.json({ tabs })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,15 +3,15 @@ import { readSheetProducts } from '@/lib/googleSheets'
|
|||||||
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
|
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
|
||||||
import { computeDiff } from '@/lib/syncDiff'
|
import { computeDiff } from '@/lib/syncDiff'
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
export async function GET(req: NextRequest) {
|
||||||
export async function GET(_req: NextRequest) {
|
const tab = new URL(req.url).searchParams.get('tab') ?? undefined
|
||||||
try {
|
try {
|
||||||
const [sheetProducts, shopifyProducts] = await Promise.all([
|
const [sheetProducts, shopifyProducts] = await Promise.all([
|
||||||
readSheetProducts(),
|
readSheetProducts(tab),
|
||||||
fetchAllShopifyProducts(),
|
fetchAllShopifyProducts(),
|
||||||
])
|
])
|
||||||
|
|
||||||
const diff = computeDiff(sheetProducts, shopifyProducts)
|
const diff = computeDiff(sheetProducts, shopifyProducts, tab)
|
||||||
return NextResponse.json(diff)
|
return NextResponse.json(diff)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
|||||||
@@ -13,6 +13,24 @@ export const metadata: Metadata = {
|
|||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
{/* Import map requis par @imgly/background-removal chargé depuis CDN.
|
||||||
|
Doit être placé avant tout script module — Next.js le respecte car
|
||||||
|
le layout est rendu côté serveur avant injection des chunks. */}
|
||||||
|
<script
|
||||||
|
type="importmap"
|
||||||
|
// eslint-disable-next-line react/no-danger
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: JSON.stringify({
|
||||||
|
imports: {
|
||||||
|
'onnxruntime-web': 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.21.0/dist/ort.all.min.mjs',
|
||||||
|
'onnxruntime-web/webgpu': 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.21.0/dist/ort.webgpu.min.mjs',
|
||||||
|
'onnxruntime-web/wasm': 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.21.0/dist/ort.wasm.min.mjs',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
<body className={inter.className}>
|
<body className={inter.className}>
|
||||||
<SessionProvider>{children}</SessionProvider>
|
<SessionProvider>{children}</SessionProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
'use client'
|
||||||
|
import { Component, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
fallback?: (error: Error, reset: () => void) => ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
error: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = { error: null }
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { error }
|
||||||
|
}
|
||||||
|
|
||||||
|
reset = () => this.setState({ error: null })
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.error) {
|
||||||
|
if (this.props.fallback) return this.props.fallback(this.state.error, this.reset)
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="bg-red-900/30 border border-red-700 rounded-xl p-6 text-center space-y-3">
|
||||||
|
<p className="text-red-300 font-semibold">Une erreur est survenue</p>
|
||||||
|
<p className="text-red-400 text-sm font-mono">{this.state.error.message}</p>
|
||||||
|
<button
|
||||||
|
onClick={this.reset}
|
||||||
|
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
'use client'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSelect: (tab: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FamilySelector({ onSelect }: Props) {
|
||||||
|
const [tabs, setTabs] = useState<string[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/sheets/tabs')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.error) throw new Error(data.error)
|
||||||
|
setTabs(data.tabs)
|
||||||
|
})
|
||||||
|
.catch((e: Error) => setError(e.message))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-slate-400 text-sm py-8 justify-center">
|
||||||
|
<span className="animate-spin">⏳</span> Chargement des familles…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">
|
||||||
|
Impossible de charger les familles : {error}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-slate-300 text-sm">
|
||||||
|
Choisissez une famille (onglet Google Sheets) pour travailler uniquement sur celle-ci.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => onSelect(tab)}
|
||||||
|
className="flex items-center gap-2 px-4 py-3 bg-slate-700 hover:bg-indigo-700 border border-slate-600 hover:border-indigo-500 rounded-xl text-sm text-slate-200 hover:text-white font-medium transition-all text-left group"
|
||||||
|
>
|
||||||
|
<span className="text-base group-hover:scale-110 transition-transform">📂</span>
|
||||||
|
<span className="truncate">{tab}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
'use client'
|
||||||
|
import type { CreationStreamEvent } from '@/types/creation'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
events: CreationStreamEvent[]
|
||||||
|
done: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreationProgress({ events, done }: Props) {
|
||||||
|
const lastProgress = [...events].reverse().find(e => e.type === 'progress') as Extract<CreationStreamEvent, { type: 'progress' }> | undefined
|
||||||
|
const doneEvent = events.find(e => e.type === 'done') as Extract<CreationStreamEvent, { type: 'done' }> | undefined
|
||||||
|
const errors = events.filter(e => e.type === 'error') as Array<Extract<CreationStreamEvent, { type: 'error' }>>
|
||||||
|
const skipped = events.filter(e => e.type === 'skipped') as Array<Extract<CreationStreamEvent, { type: 'skipped' }>>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{lastProgress && !done && (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-sm text-gray-300 mb-1">
|
||||||
|
<span>{lastProgress.step}</span>
|
||||||
|
<span>{lastProgress.current}/{lastProgress.total}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-slate-700 rounded-full h-2">
|
||||||
|
<div className="bg-indigo-600 h-2 rounded-full transition-all"
|
||||||
|
style={{ width: `${(lastProgress.current / lastProgress.total) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-1 truncate">{lastProgress.tab} — {lastProgress.title}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{skipped.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{skipped.map((e, i) => (
|
||||||
|
<div key={i} className="flex gap-2 p-2 bg-amber-900/20 border border-amber-800/40 rounded text-xs text-amber-300">
|
||||||
|
<span>⚠️</span>
|
||||||
|
<span className="font-medium">{e.title}</span>
|
||||||
|
<span className="text-amber-500">— SKU {e.sku} déjà présent dans Shopify, ignoré</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{errors.map((e, i) => (
|
||||||
|
<div key={i} className="flex gap-2 p-2 bg-red-900/30 rounded text-xs text-red-300">
|
||||||
|
<span className="font-medium">{e.title}</span>
|
||||||
|
<span className="text-red-400">— {e.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{doneEvent && (
|
||||||
|
<div className={`p-4 rounded-lg ${doneEvent.summary.status === 'success' ? 'bg-green-900/30 border border-green-700' : doneEvent.summary.status === 'partial' ? 'bg-yellow-900/30 border border-yellow-700' : 'bg-red-900/30 border border-red-700'}`}>
|
||||||
|
<p className={`font-semibold ${doneEvent.summary.status === 'success' ? 'text-green-300' : doneEvent.summary.status === 'partial' ? 'text-yellow-300' : 'text-red-300'}`}>
|
||||||
|
{doneEvent.summary.status === 'success' ? '✓ Terminé' : doneEvent.summary.status === 'partial' ? '⚠ Partiel' : '✗ Erreur'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-300 mt-1">
|
||||||
|
{doneEvent.summary.created} créé{doneEvent.summary.created > 1 ? 's' : ''}
|
||||||
|
{doneEvent.summary.skipped > 0 && ` — ${doneEvent.summary.skipped} déjà existant${doneEvent.summary.skipped > 1 ? 's' : ''} (ignorés)`}
|
||||||
|
{doneEvent.summary.errors > 0 && ` — ${doneEvent.summary.errors} erreur${doneEvent.summary.errors > 1 ? 's' : ''}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!done && !lastProgress && <p className="text-gray-400 text-sm">Démarrage…</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import type { MetafieldDefinition, MetafieldResolution } from '@/types/creation'
|
||||||
|
import { normalizeMetafieldKey } from '@/lib/shopifyMetafields'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
specificColumns: string[]
|
||||||
|
onResolved: (resolutions: MetafieldResolution[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetafieldResolver({ specificColumns, onResolved }: Props) {
|
||||||
|
const [definitions, setDefinitions] = useState<MetafieldDefinition[]>([])
|
||||||
|
const [matched, setMatched] = useState<Record<string, MetafieldResolution>>({})
|
||||||
|
const [unmatched, setUnmatched] = useState<string[]>([])
|
||||||
|
const [resolutions, setResolutions] = useState<Record<string, MetafieldResolution>>({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (specificColumns.length === 0) { setLoading(false); onResolved([]); return }
|
||||||
|
const params = specificColumns.map(c => encodeURIComponent(c)).join(',')
|
||||||
|
fetch(`/api/creation/metafields?columns=${params}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) throw new Error(data.error)
|
||||||
|
setDefinitions(data.definitions)
|
||||||
|
setMatched(data.matched)
|
||||||
|
setUnmatched(data.unmatched)
|
||||||
|
const init: Record<string, MetafieldResolution> = { ...data.matched }
|
||||||
|
// Défaut : 'skip' — l'utilisateur choisit explicitement ce qu'il veut créer
|
||||||
|
data.unmatched.forEach((col: string) => {
|
||||||
|
init[col] = { columnHeader: col, action: 'skip', namespace: 'custom', key: normalizeMetafieldKey(col) }
|
||||||
|
})
|
||||||
|
setResolutions(init)
|
||||||
|
})
|
||||||
|
.catch(e => setError(e.message))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [specificColumns.join(',')])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading) onResolved(Object.values(resolutions))
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [resolutions, loading])
|
||||||
|
|
||||||
|
const updateResolution = (col: string, partial: Partial<MetafieldResolution>) => {
|
||||||
|
setResolutions(prev => ({ ...prev, [col]: { ...prev[col], ...partial } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <p className="text-gray-400 text-sm">Analyse des métachamps Shopify…</p>
|
||||||
|
if (error) return <p className="text-red-400 text-sm">{error}</p>
|
||||||
|
if (specificColumns.length === 0) return <p className="text-gray-400 text-sm">Aucune colonne spécifique détectée.</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Object.entries(matched).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-green-400 mb-2">
|
||||||
|
✓ {Object.entries(matched).length} colonne{Object.entries(matched).length > 1 ? 's' : ''} avec correspondance automatique
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{Object.entries(matched).map(([col, res]) => (
|
||||||
|
<div key={col} className="flex items-center gap-3 px-3 py-2 bg-slate-700/50 rounded text-sm">
|
||||||
|
<span className="text-gray-300 flex-1">{col}</span>
|
||||||
|
<span className="text-green-400 text-xs">→ {res.namespace}.{res.key}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{unmatched.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 p-3 bg-slate-700/50 rounded-lg border border-slate-600">
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Ces colonnes n'ont pas de métachamp correspondant dans Shopify.
|
||||||
|
Choisissez pour chacune : <span className="text-indigo-300 font-medium">Créer nouveau</span> (nouveau métachamp Shopify),
|
||||||
|
<span className="text-blue-300 font-medium"> Associer</span> (utiliser un existant), ou
|
||||||
|
<span className="text-gray-300 font-medium"> Ignorer</span> (ne pas synchroniser).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{unmatched.map(col => {
|
||||||
|
const res = resolutions[col]
|
||||||
|
if (!res) return null
|
||||||
|
return (
|
||||||
|
<div key={col} className={`p-3 rounded-lg space-y-2 border ${res.action === 'skip' ? 'bg-slate-700/40 border-slate-700' : res.action === 'create' ? 'bg-indigo-900/20 border-indigo-700/50' : 'bg-blue-900/20 border-blue-700/50'}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-white font-medium">{col}</p>
|
||||||
|
{res.action === 'create' && <span className="text-xs text-indigo-300">✓ Sera créé dans Shopify</span>}
|
||||||
|
{res.action === 'map' && <span className="text-xs text-blue-300">✓ Associé à un existant</span>}
|
||||||
|
{res.action === 'skip' && <span className="text-xs text-gray-500">— Ignoré</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['create', 'map', 'skip'] as const).map(action => (
|
||||||
|
<button
|
||||||
|
key={action}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateResolution(col, { action })}
|
||||||
|
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||||
|
res.action === action
|
||||||
|
? action === 'create' ? 'bg-indigo-600 text-white'
|
||||||
|
: action === 'map' ? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-slate-500 text-white'
|
||||||
|
: 'bg-slate-600 text-gray-400 hover:bg-slate-500 hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{action === 'create' ? '+ Créer nouveau' : action === 'map' ? '↔ Associer existant' : '✕ Ignorer'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{res.action === 'create' && (
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Clé Shopify</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={res.key}
|
||||||
|
onChange={e => updateResolution(col, { key: e.target.value })}
|
||||||
|
placeholder="ex: puissance"
|
||||||
|
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-28">
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Namespace</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={res.namespace}
|
||||||
|
onChange={e => updateResolution(col, { namespace: e.target.value })}
|
||||||
|
placeholder="custom"
|
||||||
|
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{res.action === 'map' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Métachamp existant</label>
|
||||||
|
<select
|
||||||
|
value={res.existingDefinitionId ?? ''}
|
||||||
|
onChange={e => { const def = definitions.find(d => d.id === e.target.value); if (def) updateResolution(col, { existingDefinitionId: def.id, key: def.key, namespace: def.namespace }) }}
|
||||||
|
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">-- Choisir un métachamp existant --</option>
|
||||||
|
{definitions.map(d => <option key={d.id} value={d.id}>{d.name} ({d.namespace}.{d.key})</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* Résumé des actions */}
|
||||||
|
<div className="mt-3 flex gap-4 text-xs text-gray-400">
|
||||||
|
<span className="text-indigo-300 font-medium">{Object.values(resolutions).filter(r => r.action === 'create').length} à créer</span>
|
||||||
|
<span className="text-blue-300 font-medium">{Object.values(resolutions).filter(r => r.action === 'map').length} à associer</span>
|
||||||
|
<span>{Object.values(resolutions).filter(r => r.action === 'skip').length} ignorés</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
'use client'
|
||||||
|
import type { PendingProduct } from '@/types/creation'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
byTab: Record<string, PendingProduct[]>
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PendingRowsTable({ byTab, total }: Props) {
|
||||||
|
if (total === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 text-gray-400 text-sm">
|
||||||
|
Aucun produit en attente (Publié=1 et ID vide).
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{Object.entries(byTab).map(([tab, products]) => (
|
||||||
|
<div key={tab}>
|
||||||
|
<h3 className="text-sm font-semibold text-indigo-300 mb-2 uppercase tracking-wide">
|
||||||
|
{tab} — {products.length} produit{products.length > 1 ? 's' : ''}
|
||||||
|
</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-400 border-b border-slate-700">
|
||||||
|
<th className="pb-2 pr-4 font-medium">SKU</th>
|
||||||
|
<th className="pb-2 pr-4 font-medium">Titre</th>
|
||||||
|
<th className="pb-2 pr-4 font-medium">Prix</th>
|
||||||
|
<th className="pb-2 pr-4 font-medium">Stock</th>
|
||||||
|
<th className="pb-2 font-medium">Métachamps</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{products.map((p) => (
|
||||||
|
<tr key={`${p.tab}-${p.rowNumber}`} className="border-b border-slate-700/50">
|
||||||
|
<td className="py-2 pr-4 font-mono text-indigo-300 text-xs">{p.sku}</td>
|
||||||
|
<td className="py-2 pr-4 text-white">{p.title}</td>
|
||||||
|
<td className="py-2 pr-4 text-gray-300">{p.priceActual} €</td>
|
||||||
|
<td className="py-2 pr-4 text-gray-300">{p.stock}</td>
|
||||||
|
<td className="py-2 text-gray-400 text-xs">
|
||||||
|
{Object.keys(p.specificFields).length} champ{Object.keys(p.specificFields).length > 1 ? 's' : ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ interface ImageCardProps {
|
|||||||
onUpload: (id: string) => void
|
onUpload: (id: string) => void
|
||||||
onRemove: (id: string) => void
|
onRemove: (id: string) => void
|
||||||
onOpenLightbox: (item: ImageItem) => void
|
onOpenLightbox: (item: ImageItem) => void
|
||||||
|
onAssignProduct: (id: string, shopifyProductId: string, shopifyProductTitle: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
@@ -19,6 +20,7 @@ const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
|||||||
ready: { label: '✓ Prêt', color: 'text-green-400' },
|
ready: { label: '✓ Prêt', color: 'text-green-400' },
|
||||||
uploading: { label: '⬆️ Upload…', color: 'text-blue-400' },
|
uploading: { label: '⬆️ Upload…', color: 'text-blue-400' },
|
||||||
uploaded: { label: '✅ Uploadé', color: 'text-green-500' },
|
uploaded: { label: '✅ Uploadé', color: 'text-green-500' },
|
||||||
|
skipped: { label: '⏭️ Déjà sur Shopify', color: 'text-slate-400' },
|
||||||
not_found: { label: '❌ Réf. introuvable', color: 'text-red-400' },
|
not_found: { label: '❌ Réf. introuvable', color: 'text-red-400' },
|
||||||
error: { label: '❌ Erreur', color: 'text-red-400' },
|
error: { label: '❌ Erreur', color: 'text-red-400' },
|
||||||
}
|
}
|
||||||
@@ -36,6 +38,59 @@ function BlobThumbnail({ blob, alt }: { blob: Blob; alt: string }) {
|
|||||||
return <img src={url} alt={alt} className="w-full h-full object-contain" />
|
return <img src={url} alt={alt} className="w-full h-full object-contain" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProductSearch({ itemId, onAssign }: { itemId: string; onAssign: (id: string, shopifyProductId: string, title: string) => void }) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState<{ shopifyId: string; title: string }[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
if (!query.trim()) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/products/search?ref=${encodeURIComponent(query.trim())}`)
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.found) setResults([{ shopifyId: data.shopifyId, title: data.title }])
|
||||||
|
else setResults([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 mt-1">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && search()}
|
||||||
|
placeholder="Rechercher un produit…"
|
||||||
|
className="flex-1 px-2 py-1 text-xs bg-slate-800 border border-slate-600 rounded text-slate-200 placeholder-slate-500 focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={search}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-2 py-1 text-xs bg-slate-700 hover:bg-slate-600 rounded text-slate-300 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '…' : '🔍'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{results.length === 0 && !loading && query && (
|
||||||
|
<p className="text-xs text-slate-500">Aucun résultat</p>
|
||||||
|
)}
|
||||||
|
{results.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.shopifyId}
|
||||||
|
onClick={() => onAssign(itemId, r.shopifyId, r.title)}
|
||||||
|
className="text-left px-2 py-1.5 text-xs bg-slate-800 hover:bg-indigo-900 border border-slate-600 hover:border-indigo-500 rounded text-slate-200 truncate"
|
||||||
|
>
|
||||||
|
{r.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function ImageCard({
|
export function ImageCard({
|
||||||
item,
|
item,
|
||||||
onFeatherChange,
|
onFeatherChange,
|
||||||
@@ -43,6 +98,7 @@ export function ImageCard({
|
|||||||
onUpload,
|
onUpload,
|
||||||
onRemove,
|
onRemove,
|
||||||
onOpenLightbox,
|
onOpenLightbox,
|
||||||
|
onAssignProduct,
|
||||||
}: ImageCardProps) {
|
}: ImageCardProps) {
|
||||||
const statusInfo = STATUS_LABELS[item.status] ?? { label: item.status, color: 'text-slate-400' }
|
const statusInfo = STATUS_LABELS[item.status] ?? { label: item.status, color: 'text-slate-400' }
|
||||||
const isProcessing = ['pending', 'converting', 'processing', 'searching', 'uploading'].includes(item.status)
|
const isProcessing = ['pending', 'converting', 'processing', 'searching', 'uploading'].includes(item.status)
|
||||||
@@ -55,11 +111,11 @@ export function ImageCard({
|
|||||||
className="relative h-40 bg-slate-800 cursor-pointer"
|
className="relative h-40 bg-slate-800 cursor-pointer"
|
||||||
onClick={() => onOpenLightbox(item)}
|
onClick={() => onOpenLightbox(item)}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0">
|
<div className={`absolute top-0 bottom-0 left-0 overflow-hidden ${item.processedBlob ? 'w-1/2' : 'w-full'}`}>
|
||||||
<BlobThumbnail blob={item.originalBlob} alt="Original" />
|
<BlobThumbnail blob={item.originalBlob} alt="Original" />
|
||||||
</div>
|
</div>
|
||||||
{item.processedBlob && (
|
{item.processedBlob && (
|
||||||
<div className="absolute inset-0 border-l-2 border-indigo-500 left-1/2 w-1/2">
|
<div className="absolute top-0 bottom-0 right-0 w-1/2 overflow-hidden border-l-2 border-indigo-500">
|
||||||
<BlobThumbnail blob={item.processedBlob} alt="Traité" />
|
<BlobThumbnail blob={item.processedBlob} alt="Traité" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -88,6 +144,10 @@ export function ImageCard({
|
|||||||
{item.error && <p className="text-xs text-red-400 mt-0.5 truncate" title={item.error}>{item.error}</p>}
|
{item.error && <p className="text-xs text-red-400 mt-0.5 truncate" title={item.error}>{item.error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{item.status === 'not_found' && (
|
||||||
|
<ProductSearch itemId={item.id} onAssign={onAssignProduct} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Contrôles feather + rotation */}
|
{/* Contrôles feather + rotation */}
|
||||||
<div className={canAdjust ? '' : 'opacity-40 pointer-events-none'}>
|
<div className={canAdjust ? '' : 'opacity-40 pointer-events-none'}>
|
||||||
<label className="block text-xs text-slate-500 mb-1">
|
<label className="block text-xs text-slate-500 mb-1">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
{ href: '/produits', label: 'Produits', icon: '📦' },
|
{ href: '/produits', label: 'Produits', icon: '📦' },
|
||||||
{ href: '/images', label: 'Images', icon: '🖼️' },
|
{ href: '/images', label: 'Images', icon: '🖼️' },
|
||||||
{ href: '/sync', label: 'Sync Sheets', icon: '🔄' },
|
{ href: '/sync', label: 'Sync Sheets', icon: '🔄' },
|
||||||
|
{ href: '/creation', label: 'Création', icon: '✨' },
|
||||||
{ href: '/parametres', label: 'Paramètres', icon: '⚙️', adminOnly: true },
|
{ href: '/parametres', label: 'Paramètres', icon: '⚙️', adminOnly: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -56,28 +56,33 @@ export function DiffTable({ diff }: DiffTableProps) {
|
|||||||
{paginated.length === 0 ? (
|
{paginated.length === 0 ? (
|
||||||
<p className="text-slate-600 text-sm py-4 text-center">Aucun changement dans cette catégorie.</p>
|
<p className="text-slate-600 text-sm py-4 text-center">Aucun changement dans cette catégorie.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="border border-slate-700 rounded-xl overflow-hidden">
|
<div className="border border-slate-700 rounded-xl overflow-x-auto">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs table-fixed">
|
||||||
|
<colgroup>
|
||||||
|
<col className="w-36" />
|
||||||
|
<col className="w-32" />
|
||||||
|
<col />
|
||||||
|
</colgroup>
|
||||||
<thead className="bg-slate-800 text-slate-400">
|
<thead className="bg-slate-800 text-slate-400">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 text-left">Ref</th>
|
<th className="px-3 py-2 text-left">Ref</th>
|
||||||
<th className="px-3 py-2 text-left">Type</th>
|
<th className="px-3 py-2 text-left">Type</th>
|
||||||
<th className="px-3 py-2 text-left">Champs modifiés</th>
|
<th className="px-3 py-2 text-left">Détail</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{paginated.map((change) => (
|
{paginated.map((change) => (
|
||||||
<tr key={change.ref} className={`border-t border-slate-800 ${TYPE_CONFIG[change.type].bg}`}>
|
<tr key={change.ref} className={`border-t border-slate-800 ${TYPE_CONFIG[change.type].bg}`}>
|
||||||
<td className="px-3 py-2 font-mono text-slate-300">{change.ref}</td>
|
<td className="px-3 py-2 font-mono text-slate-300 truncate max-w-0" title={change.ref}>{change.ref}</td>
|
||||||
<td className={`px-3 py-2 font-semibold ${TYPE_CONFIG[change.type].color}`}>
|
<td className={`px-3 py-2 font-semibold whitespace-nowrap ${TYPE_CONFIG[change.type].color}`}>
|
||||||
{TYPE_CONFIG[change.type].label}
|
{TYPE_CONFIG[change.type].label}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-slate-400">
|
<td className="px-3 py-2 text-slate-400">
|
||||||
{change.type === 'update' && change.changedFields?.map((f) => (
|
{change.type === 'update' && change.changedFields?.map((f) => (
|
||||||
<span key={f.field} className="inline-block mr-2">
|
<span key={f.field} className="inline-block mr-3">
|
||||||
<span className="text-slate-500">{f.field} : </span>
|
<span className="text-slate-500">{f.field} : </span>
|
||||||
<span className="line-through text-red-400">{f.from}</span>
|
<span className="line-through text-red-400 mr-1">{f.from}</span>
|
||||||
<span className="text-slate-400"> → </span>
|
<span className="text-slate-500 mr-1">→</span>
|
||||||
<span className="text-green-400">{f.to}</span>
|
<span className="text-green-400">{f.to}</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+31
-4
@@ -57,6 +57,19 @@ export function useImages() {
|
|||||||
async (id: string, ref: string) => {
|
async (id: string, ref: string) => {
|
||||||
update(id, { status: 'searching' })
|
update(id, { status: 'searching' })
|
||||||
try {
|
try {
|
||||||
|
// 1. Cherche d'abord dans Google Sheets (col C = UGS → col A = Shopify ID)
|
||||||
|
const sheetsRes = await fetch(`/api/products/search-sheets?ref=${encodeURIComponent(ref)}`)
|
||||||
|
const sheetsData = await sheetsRes.json()
|
||||||
|
if (sheetsData.found) {
|
||||||
|
update(id, {
|
||||||
|
status: 'ready',
|
||||||
|
shopifyProductId: sheetsData.shopifyId,
|
||||||
|
shopifyProductTitle: sheetsData.title,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback : recherche directe dans Shopify par handle/titre
|
||||||
const res = await fetch(`/api/products/search?ref=${encodeURIComponent(ref)}`)
|
const res = await fetch(`/api/products/search?ref=${encodeURIComponent(ref)}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.found) {
|
if (data.found) {
|
||||||
@@ -88,6 +101,8 @@ export function useImages() {
|
|||||||
try {
|
try {
|
||||||
update(id, { status: 'converting' })
|
update(id, { status: 'converting' })
|
||||||
jpegBlob = await convertHeicToJpeg(blob)
|
jpegBlob = await convertHeicToJpeg(blob)
|
||||||
|
// Met à jour originalBlob avec le JPEG converti (Chrome ne peut pas afficher HEIC)
|
||||||
|
update(id, { originalBlob: jpegBlob })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
update(id, { status: 'error', error: `Conversion HEIC : ${(err as Error).message}` })
|
update(id, { status: 'error', error: `Conversion HEIC : ${(err as Error).message}` })
|
||||||
return
|
return
|
||||||
@@ -226,7 +241,7 @@ export function useImages() {
|
|||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||||
update(id, { status: 'uploaded', uploadedUrl: data.url })
|
update(id, { status: data.skipped ? 'skipped' : 'uploaded', uploadedUrl: data.url })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
update(id, { status: 'error', error: `Upload : ${(err as Error).message}` })
|
update(id, { status: 'error', error: `Upload : ${(err as Error).message}` })
|
||||||
}
|
}
|
||||||
@@ -234,13 +249,25 @@ export function useImages() {
|
|||||||
[items, update],
|
[items, update],
|
||||||
)
|
)
|
||||||
|
|
||||||
const uploadAll = useCallback(() => {
|
const uploadAll = useCallback(async () => {
|
||||||
items.filter((i) => i.status === 'ready').forEach((i) => uploadOne(i.id))
|
const ready = items.filter((i) => i.status === 'ready')
|
||||||
|
for (const item of ready) {
|
||||||
|
await uploadOne(item.id)
|
||||||
|
// Pause de 500ms entre chaque upload pour éviter le rate limit Shopify
|
||||||
|
await new Promise(r => setTimeout(r, 500))
|
||||||
|
}
|
||||||
}, [items, uploadOne])
|
}, [items, uploadOne])
|
||||||
|
|
||||||
const removeItem = useCallback((id: string) => {
|
const removeItem = useCallback((id: string) => {
|
||||||
dispatch({ type: 'REMOVE', id })
|
dispatch({ type: 'REMOVE', id })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem }
|
const assignProduct = useCallback(
|
||||||
|
(id: string, shopifyProductId: string, shopifyProductTitle: string) => {
|
||||||
|
update(id, { shopifyProductId, shopifyProductTitle, status: 'ready' })
|
||||||
|
},
|
||||||
|
[update],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem, assignProduct }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const CDN_VERSION = '1.7.0'
|
const CDN_URL = 'https://cdn.jsdelivr.net/npm/@imgly/background-removal@1.7.0/dist/index.mjs'
|
||||||
const CDN_URL = `https://cdn.jsdelivr.net/npm/@imgly/background-removal@${CDN_VERSION}/dist/index.mjs`
|
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let _removeBg: ((blob: Blob, opts: Record<string, unknown>) => Promise<Blob>) | null = null
|
let _removeBg: ((blob: Blob, opts: Record<string, unknown>) => Promise<Blob>) | null = null
|
||||||
|
|
||||||
async function loadLib() {
|
async function loadLib() {
|
||||||
@@ -24,7 +24,6 @@ export async function removeBackgroundRaw(
|
|||||||
onProgress?: (e: ProgressEvent) => void,
|
onProgress?: (e: ProgressEvent) => void,
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
const removeBg = await loadLib()
|
const removeBg = await loadLib()
|
||||||
|
|
||||||
return removeBg(blob, {
|
return removeBg(blob, {
|
||||||
output: { format: 'image/png', type: 'foreground', quality: 1 },
|
output: { format: 'image/png', type: 'foreground', quality: 1 },
|
||||||
progress: (key: string, current: number, total: number) => {
|
progress: (key: string, current: number, total: number) => {
|
||||||
@@ -64,13 +63,23 @@ export function compositeOnWhite(pngBlob: Blob, feather: number): Promise<Blob>
|
|||||||
fgCtx.globalCompositeOperation = 'source-over'
|
fgCtx.globalCompositeOperation = 'source-over'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Canvas de sortie 1000×1000 blanc, sujet centré avec padding de 5%
|
||||||
|
const SIZE = 1000
|
||||||
|
const PADDING = 0.05
|
||||||
|
const available = Math.round(SIZE * (1 - PADDING * 2))
|
||||||
|
const scale = Math.min(available / width, available / height)
|
||||||
|
const dw = Math.round(width * scale)
|
||||||
|
const dh = Math.round(height * scale)
|
||||||
|
const dx = Math.round((SIZE - dw) / 2)
|
||||||
|
const dy = Math.round((SIZE - dh) / 2)
|
||||||
|
|
||||||
const out = document.createElement('canvas')
|
const out = document.createElement('canvas')
|
||||||
out.width = width
|
out.width = SIZE
|
||||||
out.height = height
|
out.height = SIZE
|
||||||
const ctx = out.getContext('2d')!
|
const ctx = out.getContext('2d')!
|
||||||
ctx.fillStyle = '#ffffff'
|
ctx.fillStyle = '#ffffff'
|
||||||
ctx.fillRect(0, 0, width, height)
|
ctx.fillRect(0, 0, SIZE, SIZE)
|
||||||
ctx.drawImage(fgCanvas, 0, 0)
|
ctx.drawImage(fgCanvas, dx, dy, dw, dh)
|
||||||
|
|
||||||
URL.revokeObjectURL(objectUrl)
|
URL.revokeObjectURL(objectUrl)
|
||||||
out.toBlob(
|
out.toBlob(
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Convertit un Blob HEIC/HEIF en Blob JPEG via heic2any.
|
* Convertit un Blob HEIC/HEIF en Blob JPEG.
|
||||||
|
* Tente d'abord la conversion native du navigateur (instantané sur Safari/macOS),
|
||||||
|
* puis repasse sur heic2any si le natif échoue (Chrome, Firefox).
|
||||||
* À utiliser uniquement côté client (navigateur).
|
* À utiliser uniquement côté client (navigateur).
|
||||||
*/
|
*/
|
||||||
export async function convertHeicToJpeg(blob: Blob): Promise<Blob> {
|
export async function convertHeicToJpeg(blob: Blob): Promise<Blob> {
|
||||||
if (!(blob instanceof Blob)) {
|
if (!(blob instanceof Blob)) {
|
||||||
throw new TypeError('L\'entrée doit être un Blob')
|
throw new TypeError("L'entrée doit être un Blob")
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = blob.type.toLowerCase()
|
const type = blob.type.toLowerCase()
|
||||||
@@ -12,14 +14,35 @@ export async function convertHeicToJpeg(blob: Blob): Promise<Blob> {
|
|||||||
throw new TypeError(`Format attendu : HEIC/HEIF. Reçu : ${blob.type || '(vide)'}`)
|
throw new TypeError(`Format attendu : HEIC/HEIF. Reçu : ${blob.type || '(vide)'}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Méthode 1 : conversion native (Safari 14+, macOS Chrome 105+)
|
||||||
|
try {
|
||||||
|
const result = await convertNative(blob)
|
||||||
|
console.log('[heicConverter] ✅ Conversion native réussie')
|
||||||
|
return result
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[heicConverter] Conversion native échouée (${(e as Error).message}), repasse sur heic2any…`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Méthode 2 : conversion serveur via /api/images/convert-heic (sharp, fiable sur tous les navigateurs)
|
||||||
|
try {
|
||||||
|
const result = await convertViaServer(blob)
|
||||||
|
console.log('[heicConverter] ✅ Conversion serveur réussie')
|
||||||
|
return result
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[heicConverter] Conversion serveur échouée (${(e as Error).message}), repasse sur heic2any…`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Méthode 3 : heic2any fallback (plus lent, ~10-30s)
|
||||||
const heic2any = (await import('heic2any')).default
|
const heic2any = (await import('heic2any')).default
|
||||||
|
|
||||||
const timeout = new Promise<never>((_, reject) =>
|
const timeout = new Promise<never>((_, reject) =>
|
||||||
setTimeout(() => reject(new Error('Timeout conversion HEIC (120s)')), 120_000),
|
setTimeout(
|
||||||
|
() => reject(new Error('Timeout conversion HEIC (30s). Essayez de convertir le fichier en JPG avant de le déposer.')),
|
||||||
|
30_000,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const conversion = heic2any({ blob, toType: 'image/jpeg', quality: 0.92 })
|
const conversion = heic2any({ blob, toType: 'image/jpeg', quality: 0.92 })
|
||||||
|
|
||||||
const result = await Promise.race([conversion, timeout])
|
const result = await Promise.race([conversion, timeout])
|
||||||
const output = Array.isArray(result) ? result[0] : result
|
const output = Array.isArray(result) ? result[0] : result
|
||||||
|
|
||||||
@@ -27,5 +50,64 @@ export async function convertHeicToJpeg(blob: Blob): Promise<Blob> {
|
|||||||
throw new Error("heic2any n'a pas retourné un Blob")
|
throw new Error("heic2any n'a pas retourné un Blob")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[heicConverter] ✅ Conversion heic2any réussie')
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function convertNative(blob: Blob): Promise<Blob> {
|
||||||
|
const typed = blob.type ? blob : new Blob([blob], { type: 'image/heic' })
|
||||||
|
|
||||||
|
// Tentative 1 : createImageBitmap (Safari 14+)
|
||||||
|
try {
|
||||||
|
const bitmap = await createImageBitmap(typed)
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = bitmap.width
|
||||||
|
canvas.height = bitmap.height
|
||||||
|
canvas.getContext('2d')!.drawImage(bitmap, 0, 0)
|
||||||
|
bitmap.close()
|
||||||
|
return await canvasToBlob(canvas)
|
||||||
|
} catch {
|
||||||
|
// Pas supporté — essai suivant
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentative 2 : <img> + canvas (Chrome sur macOS — utilise le décodeur HEIC natif de macOS)
|
||||||
|
return new Promise<Blob>((resolve, reject) => {
|
||||||
|
const url = URL.createObjectURL(typed)
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = img.naturalWidth
|
||||||
|
canvas.height = img.naturalHeight
|
||||||
|
canvas.getContext('2d')!.drawImage(img, 0, 0)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
canvasToBlob(canvas).then(resolve).catch(reject)
|
||||||
|
}
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
reject(new Error('Décodage HEIC non supporté par ce navigateur'))
|
||||||
|
}
|
||||||
|
img.src = url
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertViaServer(blob: Blob): Promise<Blob> {
|
||||||
|
const formData = new FormData()
|
||||||
|
const file = blob instanceof File ? blob : new File([blob], 'image.heic', { type: 'image/heic' })
|
||||||
|
formData.append('file', file)
|
||||||
|
const res = await fetch('/api/images/convert-heic', { method: 'POST', body: formData })
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
||||||
|
throw new Error(err.error ?? `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
return res.blob()
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
b => (b ? resolve(b) : reject(new Error('canvas.toBlob a retourné null'))),
|
||||||
|
'image/jpeg',
|
||||||
|
0.92,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { parseTabRows, findColIndex, extractSpecificFields } from './creationSheets'
|
||||||
|
|
||||||
|
const HEADERS = [
|
||||||
|
'ID', 'Type', 'UGS', 'Nom', 'Publié', 'Mis en avant ?',
|
||||||
|
'Description courte', 'Description longue',
|
||||||
|
'Date de début de promo', 'Date de fin de promo',
|
||||||
|
'État de la TVA', 'Classe de TVA', 'En stock ?',
|
||||||
|
'Stock (en pièce)', 'Vendre individuellement ?',
|
||||||
|
'Prix marché', 'Prix remisé', 'Code ecopart', 'Ecopart montant',
|
||||||
|
'Famille', 'Sous famille',
|
||||||
|
"Classe d'expédition (en retrait, livraison)",
|
||||||
|
'Conditionnement', 'Surface (m2)', 'Poids (kg) fonction unité',
|
||||||
|
'Images', 'Unité', 'Marque', 'Neuf ?',
|
||||||
|
"Commentaire\nexemple : Modèle d'expo (lègères rayures)",
|
||||||
|
'Longueur (cm)', 'Largeur (cm)', 'Hauteur (cm)', 'Épaisseur (cm)',
|
||||||
|
'Poids par carton', 'Stock conditionné (en carton)',
|
||||||
|
'Fabrication', 'Garantie', 'Coloris',
|
||||||
|
'Puissance (AN)', 'Batterie (AP)', 'Couple (AQ)',
|
||||||
|
'PHOTO ?', 'ID FAMILLE', 'ID SOUS FAMILLE',
|
||||||
|
]
|
||||||
|
|
||||||
|
const makeRow = (overrides: Record<number, string> = {}): string[] => {
|
||||||
|
const base: Record<number, string> = {
|
||||||
|
0: '', // ID vide = à créer
|
||||||
|
2: 'SKU-001', // UGS
|
||||||
|
3: 'Perceuse Test',
|
||||||
|
4: '1', // Publié
|
||||||
|
13: '5', // Stock
|
||||||
|
15: '99.99', // Prix marché
|
||||||
|
16: '79.99', // Prix remisé
|
||||||
|
19: 'OUTILLAGE',
|
||||||
|
24: '2.5', // Poids
|
||||||
|
27: 'BOSCH', // Marque
|
||||||
|
39: '1500W', // Puissance
|
||||||
|
40: 'Li-Ion', // Batterie
|
||||||
|
// Couple (index 41) laissé vide
|
||||||
|
[HEADERS.length - 2]: '10', // ID FAMILLE
|
||||||
|
[HEADERS.length - 1]: '20', // ID SOUS FAMILLE
|
||||||
|
}
|
||||||
|
return HEADERS.map((_, i) => overrides[i] ?? base[i] ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('findColIndex', () => {
|
||||||
|
it('trouve un en-tête exact', () => {
|
||||||
|
expect(findColIndex(HEADERS, 'UGS')).toBe(2)
|
||||||
|
})
|
||||||
|
it('trouve un en-tête commençant par Commentaire', () => {
|
||||||
|
const idx = findColIndex(HEADERS, 'Commentaire')
|
||||||
|
expect(idx).toBeGreaterThan(-1)
|
||||||
|
})
|
||||||
|
it('retourne -1 si non trouvé', () => {
|
||||||
|
expect(findColIndex(HEADERS, 'Inexistant')).toBe(-1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('extractSpecificFields', () => {
|
||||||
|
it('exclut les colonnes communes', () => {
|
||||||
|
const row = makeRow()
|
||||||
|
const specific = extractSpecificFields(HEADERS, row)
|
||||||
|
expect(Object.keys(specific)).not.toContain('Nom')
|
||||||
|
expect(Object.keys(specific)).not.toContain('UGS')
|
||||||
|
})
|
||||||
|
it('inclut les colonnes spécifiques non vides', () => {
|
||||||
|
const row = makeRow()
|
||||||
|
const specific = extractSpecificFields(HEADERS, row)
|
||||||
|
expect(specific['Puissance (AN)']).toBe('1500W')
|
||||||
|
expect(specific['Batterie (AP)']).toBe('Li-Ion')
|
||||||
|
})
|
||||||
|
it('exclut les colonnes spécifiques vides', () => {
|
||||||
|
const row = makeRow()
|
||||||
|
const specific = extractSpecificFields(HEADERS, row)
|
||||||
|
expect(Object.keys(specific)).not.toContain('Couple (AQ)')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseTabRows', () => {
|
||||||
|
it('ignore les lignes avec ID déjà rempli', () => {
|
||||||
|
const row = makeRow({ 0: '12345' })
|
||||||
|
const result = parseTabRows('OUTILLAGE', HEADERS, [row])
|
||||||
|
expect(result).toHaveLength(0)
|
||||||
|
})
|
||||||
|
it('ignore les lignes avec Publié ≠ 1', () => {
|
||||||
|
const row = makeRow({ 4: '0' })
|
||||||
|
const result = parseTabRows('OUTILLAGE', HEADERS, [row])
|
||||||
|
expect(result).toHaveLength(0)
|
||||||
|
})
|
||||||
|
it('retourne un PendingProduct pour une ligne valide', () => {
|
||||||
|
const row = makeRow()
|
||||||
|
const result = parseTabRows('OUTILLAGE', HEADERS, [row])
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].sku).toBe('SKU-001')
|
||||||
|
expect(result[0].title).toBe('Perceuse Test')
|
||||||
|
expect(result[0].priceActual).toBe('79.99')
|
||||||
|
expect(result[0].collectionId).toBe('10')
|
||||||
|
expect(result[0].specificFields['Puissance (AN)']).toBe('1500W')
|
||||||
|
})
|
||||||
|
it('attribue le bon numéro de ligne (1-indexé depuis row 2)', () => {
|
||||||
|
const rows = [makeRow(), makeRow({ 3: 'Deuxième' })]
|
||||||
|
const result = parseTabRows('OUTILLAGE', HEADERS, rows)
|
||||||
|
expect(result[0].rowNumber).toBe(2)
|
||||||
|
expect(result[1].rowNumber).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type { PendingProduct } from '@/types/creation'
|
||||||
|
|
||||||
|
const COMMON_HEADERS = new Set([
|
||||||
|
'ID', 'Type', 'UGS', 'Nom', 'Publié', 'Mis en avant ?',
|
||||||
|
'Description courte', 'Description longue',
|
||||||
|
'Date de début de promo', 'Date de fin de promo',
|
||||||
|
'État de la TVA', 'Classe de TVA', 'En stock ?',
|
||||||
|
'Stock (en pièce)', 'Vendre individuellement ?',
|
||||||
|
'Prix marché', 'Prix remisé', 'Code ecopart', 'Ecopart montant',
|
||||||
|
'Famille', 'Sous famille',
|
||||||
|
"Classe d'expédition (en retrait, livraison)",
|
||||||
|
'Conditionnement', 'Surface (m2)', 'Poids (kg) fonction unité',
|
||||||
|
'Images', 'Unité', 'Marque', 'Neuf ?', 'PHOTO ?',
|
||||||
|
'ID FAMILLE', 'ID SOUS FAMILLE',
|
||||||
|
])
|
||||||
|
|
||||||
|
export function findColIndex(headers: string[], search: string): number {
|
||||||
|
const exact = headers.findIndex(h => h === search)
|
||||||
|
if (exact !== -1) return exact
|
||||||
|
return headers.findIndex(h => h.startsWith(search))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractSpecificFields(headers: string[], row: string[]): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {}
|
||||||
|
headers.forEach((header, i) => {
|
||||||
|
if (!header) return
|
||||||
|
const isCommon = COMMON_HEADERS.has(header) || header.startsWith('Commentaire')
|
||||||
|
if (isCommon) return
|
||||||
|
const val = (row[i] ?? '').trim()
|
||||||
|
if (val) result[header] = val
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTabRows(tab: string, headers: string[], rows: string[][], dataStartRow = 2): PendingProduct[] {
|
||||||
|
const idx = {
|
||||||
|
id: findColIndex(headers, 'ID'),
|
||||||
|
sku: findColIndex(headers, 'UGS'),
|
||||||
|
nom: findColIndex(headers, 'Nom'),
|
||||||
|
publie: findColIndex(headers, 'Publié'),
|
||||||
|
stock: findColIndex(headers, 'Stock (en pièce)'),
|
||||||
|
prixMarche: findColIndex(headers, 'Prix marché'),
|
||||||
|
prixRemise: findColIndex(headers, 'Prix remisé'),
|
||||||
|
poids: findColIndex(headers, 'Poids (kg) fonction unité'),
|
||||||
|
marque: findColIndex(headers, 'Marque'),
|
||||||
|
famille: findColIndex(headers, 'Famille'),
|
||||||
|
sousFamille: findColIndex(headers, 'Sous famille'),
|
||||||
|
commentaire: findColIndex(headers, 'Commentaire'),
|
||||||
|
photo: findColIndex(headers, 'PHOTO ?'),
|
||||||
|
collectionId: findColIndex(headers, 'ID FAMILLE'),
|
||||||
|
subCollectionId: findColIndex(headers, 'ID SOUS FAMILLE'),
|
||||||
|
}
|
||||||
|
|
||||||
|
const get = (row: string[], i: number) => (i >= 0 ? (row[i] ?? '').trim() : '')
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map((row, rowIdx): PendingProduct | null => {
|
||||||
|
const id = get(row, idx.id)
|
||||||
|
const publie = get(row, idx.publie)
|
||||||
|
if (id !== '' || publie !== '1') return null
|
||||||
|
return {
|
||||||
|
tab,
|
||||||
|
rowNumber: dataStartRow + rowIdx,
|
||||||
|
sku: get(row, idx.sku),
|
||||||
|
title: get(row, idx.nom),
|
||||||
|
priceMarket: get(row, idx.prixMarche).replace(',', '.'),
|
||||||
|
priceActual: get(row, idx.prixRemise).replace(',', '.'),
|
||||||
|
stock: parseInt(get(row, idx.stock) || '0', 10) || 0,
|
||||||
|
weightKg: parseFloat(get(row, idx.poids).replace(',', '.') || '0') || 0,
|
||||||
|
vendor: get(row, idx.marque),
|
||||||
|
famille: get(row, idx.famille),
|
||||||
|
sousFamille: get(row, idx.sousFamille),
|
||||||
|
comment: get(row, idx.commentaire),
|
||||||
|
collectionId: get(row, idx.collectionId),
|
||||||
|
subCollectionId: get(row, idx.subCollectionId),
|
||||||
|
hasPhoto: get(row, idx.photo) !== '',
|
||||||
|
specificFields: extractSpecificFields(headers, row),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((p): p is PendingProduct => p !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPendingProducts(
|
||||||
|
alreadyCreatedRows: Set<string>,
|
||||||
|
tabFilter?: string,
|
||||||
|
): Promise<PendingProduct[]> {
|
||||||
|
const apiKey = process.env.GOOGLE_API_KEY
|
||||||
|
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||||
|
if (!apiKey || !sheetId) throw new Error('GOOGLE_API_KEY et GOOGLE_SHEETS_ID requis')
|
||||||
|
|
||||||
|
let tabs: string[]
|
||||||
|
if (tabFilter) {
|
||||||
|
tabs = [tabFilter]
|
||||||
|
} else {
|
||||||
|
const metaRes = await fetch(
|
||||||
|
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`,
|
||||||
|
)
|
||||||
|
if (!metaRes.ok) throw new Error(`Sheets metadata error: ${metaRes.status}`)
|
||||||
|
const meta = await metaRes.json()
|
||||||
|
tabs = (meta.sheets as Array<{ properties: { title: string } }>).map(
|
||||||
|
s => s.properties.title,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const all: PendingProduct[] = []
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const range = encodeURIComponent(`${tab}!A1:BZ`)
|
||||||
|
const res = await fetch(
|
||||||
|
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`,
|
||||||
|
)
|
||||||
|
if (!res.ok) continue
|
||||||
|
const data = await res.json()
|
||||||
|
const rows = (data.values ?? []) as string[][]
|
||||||
|
if (rows.length < 2) continue
|
||||||
|
// La ligne d'en-têtes est la première ligne qui contient "ID" en col A
|
||||||
|
// (certains onglets ont une ligne de section en row 1, les vrais en-têtes sont en row 2)
|
||||||
|
const headerRowIdx = rows.findIndex(r => (r[0] ?? '').trim() === 'ID')
|
||||||
|
if (headerRowIdx === -1) continue // pas un onglet produit
|
||||||
|
const headers = rows[headerRowIdx]
|
||||||
|
const dataRows = rows.slice(headerRowIdx + 1)
|
||||||
|
// Ignorer les onglets sans colonne "Publié" (COLLECTIONS, LIVRAISONS, etc.)
|
||||||
|
if (findColIndex(headers, 'Publié') === -1) continue
|
||||||
|
const pending = parseTabRows(tab, headers, dataRows, headerRowIdx + 2).filter(
|
||||||
|
p => !alreadyCreatedRows.has(`${p.tab}:${p.rowNumber}`),
|
||||||
|
)
|
||||||
|
all.push(...pending)
|
||||||
|
}
|
||||||
|
return all
|
||||||
|
}
|
||||||
+43
-9
@@ -12,21 +12,33 @@ function normalizeHandle(ref: string): string {
|
|||||||
* Colonnes : A=Référence, B=Nom, C=Description, D=Prix, E=Stock, F=Statut
|
* Colonnes : A=Référence, B=Nom, C=Description, D=Prix, E=Stock, F=Statut
|
||||||
* Exporté pour les tests unitaires.
|
* Exporté pour les tests unitaires.
|
||||||
*/
|
*/
|
||||||
|
const HEADER_WORDS = new Set(['id', 'ref', 'sku', 'nom', 'titre', 'reference', 'prix', 'statut'])
|
||||||
|
|
||||||
|
function isValidRef(ref: string): boolean {
|
||||||
|
if (ref.length < 2) return false
|
||||||
|
if (/^[^a-zA-Z0-9]+$/.test(ref)) return false // que des caractères spéciaux
|
||||||
|
if (HEADER_WORDS.has(ref.toLowerCase())) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
export function parseSheetRows(rows: string[][]): SyncProduct[] {
|
export function parseSheetRows(rows: string[][]): SyncProduct[] {
|
||||||
return rows
|
return rows
|
||||||
.map((row): SyncProduct | null => {
|
.map((row): SyncProduct | null => {
|
||||||
const ref = row[0]?.trim()
|
const ref = row[0]?.trim()
|
||||||
if (!ref) return null
|
if (!ref || !isValidRef(ref)) return null
|
||||||
|
|
||||||
const rawPrice = (row[3] ?? '').replace(',', '.').trim()
|
const rawPrice = (row[3] ?? '').replace(',', '.').trim()
|
||||||
const price = parseFloat(rawPrice || '0')
|
const price = parseFloat(rawPrice || '0')
|
||||||
|
|
||||||
|
// Ligne sans prix valide = séparateur ou note, pas un produit
|
||||||
|
if (isNaN(price) || price <= 0) return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ref,
|
ref,
|
||||||
handle: normalizeHandle(ref),
|
handle: normalizeHandle(ref),
|
||||||
title: row[1]?.trim() || ref,
|
title: row[1]?.trim() || ref,
|
||||||
description: row[2]?.trim() || '',
|
description: row[2]?.trim() || '',
|
||||||
price: isNaN(price) ? '0.00' : price.toFixed(2),
|
price: price.toFixed(2),
|
||||||
stock: parseInt(row[4] ?? '0', 10) || 0,
|
stock: parseInt(row[4] ?? '0', 10) || 0,
|
||||||
status: (row[5] ?? '').toLowerCase().includes('inac') ? 'draft' : 'active',
|
status: (row[5] ?? '').toLowerCase().includes('inac') ? 'draft' : 'active',
|
||||||
}
|
}
|
||||||
@@ -36,27 +48,49 @@ export function parseSheetRows(rows: string[][]): SyncProduct[] {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Lit tous les produits depuis Google Sheets via l'API v4 (API Key publique).
|
* Lit tous les produits depuis Google Sheets via l'API v4 (API Key publique).
|
||||||
|
* Si GOOGLE_SHEETS_TAB est défini, lit uniquement cet onglet.
|
||||||
|
* Sinon, lit tous les onglets du fichier et combine les produits.
|
||||||
* Le fichier Sheets doit être partagé en lecture ("Toute personne avec le lien").
|
* Le fichier Sheets doit être partagé en lecture ("Toute personne avec le lien").
|
||||||
*/
|
*/
|
||||||
export async function readSheetProducts(): Promise<SyncProduct[]> {
|
export async function readSheetProducts(tab?: string): Promise<SyncProduct[]> {
|
||||||
const apiKey = process.env.GOOGLE_API_KEY
|
const apiKey = process.env.GOOGLE_API_KEY
|
||||||
const sheetId = process.env.GOOGLE_SHEETS_ID
|
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||||
const sheetName = process.env.GOOGLE_SHEETS_TAB ?? 'Produits'
|
const envTab = process.env.GOOGLE_SHEETS_TAB
|
||||||
|
|
||||||
if (!apiKey || !sheetId) {
|
if (!apiKey || !sheetId) {
|
||||||
throw new Error('Variables GOOGLE_API_KEY et GOOGLE_SHEETS_ID requises')
|
throw new Error('Variables GOOGLE_API_KEY et GOOGLE_SHEETS_ID requises')
|
||||||
}
|
}
|
||||||
|
|
||||||
const range = encodeURIComponent(`${sheetName}!A2:F`)
|
const sheetNames = tab ? [tab] : envTab ? [envTab] : await listSheetTabs(sheetId, apiKey)
|
||||||
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`
|
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
sheetNames.map((name) => readOneTab(sheetId, apiKey, name)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return results.flat()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listSheetTabs(sheetId: string, apiKey: string): Promise<string[]> {
|
||||||
|
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`
|
||||||
const res = await fetch(url)
|
const res = await fetch(url)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.text()
|
const err = await res.text()
|
||||||
throw new Error(`Google Sheets API error: ${res.status} — ${err}`)
|
throw new Error(`Google Sheets API error: ${res.status} — ${err}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
const rows = (data.values ?? []) as string[][]
|
return (data.sheets as Array<{ properties: { title: string } }>).map(
|
||||||
return parseSheetRows(rows)
|
(s) => s.properties.title,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readOneTab(sheetId: string, apiKey: string, sheetName: string): Promise<SyncProduct[]> {
|
||||||
|
const range = encodeURIComponent(`${sheetName}!A2:F`)
|
||||||
|
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text()
|
||||||
|
throw new Error(`Google Sheets API error (onglet "${sheetName}"): ${res.status} — ${err}`)
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
return parseSheetRows((data.values ?? []) as string[][])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { generateDescriptions } from './openaiClient'
|
||||||
|
|
||||||
|
const mockFetch = jest.fn()
|
||||||
|
global.fetch = mockFetch
|
||||||
|
|
||||||
|
const mockResponse = (content: string) => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ choices: [{ message: { content } }] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateDescriptions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.OPENAI_API_KEY = 'sk-test'
|
||||||
|
mockFetch.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne shortDesc et longDesc depuis OpenAI', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(mockResponse('<p>Description courte</p>'))
|
||||||
|
.mockResolvedValueOnce(mockResponse('<p>Description longue</p>'))
|
||||||
|
const result = await generateDescriptions({
|
||||||
|
title: 'Perceuse', rawDescription: 'Une perceuse', comment: '', vendor: 'BOSCH', famille: 'OUTILLAGE',
|
||||||
|
})
|
||||||
|
expect(result.shortDesc).toBe('<p>Description courte</p>')
|
||||||
|
expect(result.longDesc).toBe('<p>Description longue</p>')
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lance les deux requêtes en parallèle', async () => {
|
||||||
|
mockFetch.mockResolvedValue(mockResponse('<p>ok</p>'))
|
||||||
|
await generateDescriptions({ title: 'T', rawDescription: '', comment: '', vendor: '', famille: '' })
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lance une erreur si OPENAI_API_KEY manquant', async () => {
|
||||||
|
delete process.env.OPENAI_API_KEY
|
||||||
|
await expect(generateDescriptions({ title: 'T', rawDescription: '', comment: '', vendor: '', famille: '' }))
|
||||||
|
.rejects.toThrow('OPENAI_API_KEY')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lance une erreur si la réponse OpenAI est non-ok', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ ok: false, status: 429, text: async () => 'rate limited' })
|
||||||
|
await expect(generateDescriptions({ title: 'T', rawDescription: '', comment: '', vendor: '', famille: '' }))
|
||||||
|
.rejects.toThrow('429')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
interface DescriptionInput {
|
||||||
|
title: string
|
||||||
|
rawDescription: string
|
||||||
|
comment: string
|
||||||
|
vendor: string
|
||||||
|
famille: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Descriptions {
|
||||||
|
shortDesc: string
|
||||||
|
longDesc: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callOpenAI(prompt: string): Promise<string> {
|
||||||
|
const apiKey = process.env.OPENAI_API_KEY
|
||||||
|
if (!apiKey) throw new Error('OPENAI_API_KEY requis')
|
||||||
|
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||||
|
body: JSON.stringify({ model: 'gpt-4o', messages: [{ role: 'user', content: prompt }], temperature: 0.7 }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text()
|
||||||
|
throw new Error(`OpenAI API error: ${res.status} — ${err}`)
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
return (data.choices?.[0]?.message?.content ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrompt(input: DescriptionInput, length: number): string {
|
||||||
|
return `A partir des caractéristiques suivantes, crée un texte descriptif de ${length} caractères en faisant attention au SEO pour qu'il soit en rapport avec la catégorie ${input.famille} et le produit ${input.title} :
|
||||||
|
Nom = ${input.title}
|
||||||
|
Description : ${input.rawDescription}
|
||||||
|
Commentaire : ${input.comment}
|
||||||
|
Marque : ${input.vendor}
|
||||||
|
Formate ce texte en HTML.
|
||||||
|
Ta réponse doit commencer directement par une balise HTML (comme <p> ou <h1>), sans bloc \`\`\`html, sans aucun mot ou encadré autour.`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateDescriptions(input: DescriptionInput): Promise<Descriptions> {
|
||||||
|
const [shortDesc, longDesc] = await Promise.all([
|
||||||
|
callOpenAI(buildPrompt(input, 400)),
|
||||||
|
callOpenAI(buildPrompt(input, 1000)),
|
||||||
|
])
|
||||||
|
return { shortDesc, longDesc }
|
||||||
|
}
|
||||||
+94
-19
@@ -21,34 +21,62 @@ export type ShopifyProductResult =
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Recherche un produit Shopify par sa référence.
|
* Recherche un produit Shopify par sa référence.
|
||||||
* Tente d'abord par handle (ex: "ref-1042"), puis par titre exact.
|
* Stratégie : SKU exact → SKU sans suffixe → handle → titre.
|
||||||
|
* Les images peuvent être nommées "30827-dessus.jpg" alors que le SKU est "30827".
|
||||||
*/
|
*/
|
||||||
export async function searchProductByRef(ref: string): Promise<ShopifyProductResult> {
|
export async function searchProductByRef(ref: string): Promise<ShopifyProductResult> {
|
||||||
const { baseUrl, headers } = getConfig()
|
const { baseUrl, headers } = getConfig()
|
||||||
|
|
||||||
// Normalise la référence en handle Shopify (minuscules, tirets)
|
// Recherche par SKU via l'API variants
|
||||||
const handle = ref.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
const searchBySku = async (sku: string): Promise<ShopifyProductResult> => {
|
||||||
|
const res = await fetch(
|
||||||
const tryFetch = async (params: string) => {
|
`${baseUrl}/variants.json?sku=${encodeURIComponent(sku)}&fields=id,sku,product_id&limit=1`,
|
||||||
const res = await fetch(`${baseUrl}/products.json?${params}&fields=id,title,handle&limit=1`, { headers })
|
{ headers },
|
||||||
if (!res.ok) throw new Error(`Shopify API error: ${res.status}`)
|
)
|
||||||
|
if (!res.ok) return { found: false }
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
return (data.products ?? []) as Array<{ id: number; title: string; handle: string }>
|
const variants = (data.variants ?? []) as Array<{ id: number; sku: string; product_id: number }>
|
||||||
|
if (variants.length === 0) return { found: false }
|
||||||
|
|
||||||
|
// Récupère le titre du produit
|
||||||
|
const productRes = await fetch(
|
||||||
|
`${baseUrl}/products/${variants[0].product_id}.json?fields=id,title`,
|
||||||
|
{ headers },
|
||||||
|
)
|
||||||
|
if (!productRes.ok) return { found: false }
|
||||||
|
const productData = await productRes.json()
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
shopifyId: String(variants[0].product_id),
|
||||||
|
title: productData.product?.title ?? sku,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. SKU exact (ex: "30827")
|
||||||
|
let result = await searchBySku(ref)
|
||||||
|
if (result.found) return result
|
||||||
|
|
||||||
|
// 2. SKU sans suffixe : "30827-dessus" → "30827" (retire tout après le dernier séparateur)
|
||||||
|
const withoutSuffix = ref.replace(/[-_ ][^-_ ]*$/, '')
|
||||||
|
if (withoutSuffix !== ref) {
|
||||||
|
result = await searchBySku(withoutSuffix)
|
||||||
|
if (result.found) return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback handle Shopify
|
||||||
|
const handle = ref.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
||||||
|
const tryFetch = async (params: string) => {
|
||||||
|
const res = await fetch(`${baseUrl}/products.json?${params}&fields=id,title&limit=1`, { headers })
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return (data.products ?? []) as Array<{ id: number; title: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
let products = await tryFetch(`handle=${encodeURIComponent(handle)}`)
|
let products = await tryFetch(`handle=${encodeURIComponent(handle)}`)
|
||||||
|
if (products.length === 0) products = await tryFetch(`title=${encodeURIComponent(ref)}`)
|
||||||
if (products.length === 0) {
|
|
||||||
products = await tryFetch(`title=${encodeURIComponent(ref)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (products.length === 0) return { found: false }
|
if (products.length === 0) return { found: false }
|
||||||
|
|
||||||
return {
|
return { found: true, shopifyId: String(products[0].id), title: products[0].title }
|
||||||
found: true,
|
|
||||||
shopifyId: String(products[0].id),
|
|
||||||
title: products[0].title,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,19 +85,63 @@ export async function searchProductByRef(ref: string): Promise<ShopifyProductRes
|
|||||||
* @param imageBase64 — contenu JPEG encodé en base64 (sans le préfixe data:)
|
* @param imageBase64 — contenu JPEG encodé en base64 (sans le préfixe data:)
|
||||||
* @param filename — nom de fichier avec extension (ex: "REF-1042.jpg")
|
* @param filename — nom de fichier avec extension (ex: "REF-1042.jpg")
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Vérifie si une image avec ce nom de fichier existe déjà sur le produit.
|
||||||
|
* Compare le nom de base (sans extension) avec les src des images existantes.
|
||||||
|
*/
|
||||||
|
export async function productImageExists(
|
||||||
|
shopifyProductId: string,
|
||||||
|
filename: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const { baseUrl, headers } = getConfig()
|
||||||
|
const res = await fetch(`${baseUrl}/products/${shopifyProductId}/images.json?fields=id,src`, { headers })
|
||||||
|
if (!res.ok) return null
|
||||||
|
const data = await res.json()
|
||||||
|
const images = (data.images ?? []) as Array<{ id: number; src: string }>
|
||||||
|
// Compare le nom de base sans extension (ex: "30827" dans "30827.jpg")
|
||||||
|
const baseName = filename.replace(/\.[^.]+$/, '').toLowerCase()
|
||||||
|
const existing = images.find(img => {
|
||||||
|
const imgFile = img.src.split('/').pop()?.split('?')[0] ?? ''
|
||||||
|
return imgFile.replace(/\.[^.]+$/, '').toLowerCase() === baseName
|
||||||
|
})
|
||||||
|
return existing ? existing.src : null
|
||||||
|
}
|
||||||
|
|
||||||
export async function uploadProductImage(
|
export async function uploadProductImage(
|
||||||
shopifyProductId: string,
|
shopifyProductId: string,
|
||||||
imageBase64: string,
|
imageBase64: string,
|
||||||
filename: string,
|
filename: string,
|
||||||
): Promise<{ url: string }> {
|
): Promise<{ url: string; skipped?: boolean }> {
|
||||||
const { baseUrl, headers } = getConfig()
|
const { baseUrl, headers } = getConfig()
|
||||||
|
|
||||||
|
// Vérifie si l'image existe déjà
|
||||||
|
const existingUrl = await productImageExists(shopifyProductId, filename)
|
||||||
|
if (existingUrl) return { url: existingUrl, skipped: true }
|
||||||
|
|
||||||
|
// Retry avec backoff exponentiel (gère le 429 rate limit)
|
||||||
|
const MAX_RETRIES = 4
|
||||||
|
let delay = 1000
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||||
const res = await fetch(`${baseUrl}/products/${shopifyProductId}/images.json`, {
|
const res = await fetch(`${baseUrl}/products/${shopifyProductId}/images.json`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({ image: { attachment: imageBase64, filename } }),
|
body: JSON.stringify({ image: { attachment: imageBase64, filename } }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 409 Conflict = image déjà présente → traiter comme skipped
|
||||||
|
if (res.status === 409) {
|
||||||
|
return { url: `https://placeholder/already-exists/${filename}`, skipped: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 429 Too Many Requests → attendre et réessayer
|
||||||
|
if (res.status === 429 && attempt < MAX_RETRIES) {
|
||||||
|
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '0', 10)
|
||||||
|
await new Promise(r => setTimeout(r, retryAfter > 0 ? retryAfter * 1000 : delay))
|
||||||
|
delay *= 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.text()
|
const body = await res.text()
|
||||||
throw new Error(`Shopify upload error: ${res.status} — ${body}`)
|
throw new Error(`Shopify upload error: ${res.status} — ${body}`)
|
||||||
@@ -81,3 +153,6 @@ export async function uploadProductImage(
|
|||||||
}
|
}
|
||||||
return { url: data.image.src as string }
|
return { url: data.image.src as string }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw new Error('Shopify upload error: trop de tentatives (rate limit persistant)')
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const API_VERSION = '2024-01'
|
||||||
|
|
||||||
|
function getConfig() {
|
||||||
|
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||||
|
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||||
|
if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
|
||||||
|
return {
|
||||||
|
baseUrl: `https://${domain}/admin/api/${API_VERSION}`,
|
||||||
|
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addProductToCollection(shopifyProductId: string, collectionId: string): Promise<void> {
|
||||||
|
if (!collectionId?.trim()) return
|
||||||
|
const { baseUrl, headers } = getConfig()
|
||||||
|
const res = await fetch(`${baseUrl}/collects.json`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ collect: { product_id: shopifyProductId, collection_id: collectionId } }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text()
|
||||||
|
if (!err.includes('already') && !err.includes('taken')) {
|
||||||
|
throw new Error(`Shopify addToCollection error: ${res.status} — ${err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { normalizeMetafieldKey, matchMetafields } from './shopifyMetafields'
|
||||||
|
import type { MetafieldDefinition } from '@/types/creation'
|
||||||
|
|
||||||
|
describe('normalizeMetafieldKey', () => {
|
||||||
|
it('minuscules + accents retirés', () => {
|
||||||
|
expect(normalizeMetafieldKey('Éclairage LED')).toBe('eclairage_led')
|
||||||
|
})
|
||||||
|
it('parenthèses et espaces → underscore', () => {
|
||||||
|
expect(normalizeMetafieldKey('Puissance (AN)')).toBe('puissance_an')
|
||||||
|
})
|
||||||
|
it('limité à 30 caractères', () => {
|
||||||
|
const long = 'a'.repeat(40)
|
||||||
|
expect(normalizeMetafieldKey(long)).toHaveLength(30)
|
||||||
|
})
|
||||||
|
it('underscores multiples réduits à un', () => {
|
||||||
|
expect(normalizeMetafieldKey('Poids par carton')).toBe('poids_par_carton')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('matchMetafields', () => {
|
||||||
|
const definitions: MetafieldDefinition[] = [
|
||||||
|
{ id: '1', namespace: 'custom', key: 'puissance_an', name: 'Puissance (AN)', type: 'single_line_text_field' },
|
||||||
|
{ id: '2', namespace: 'custom', key: 'coloris', name: 'Coloris', type: 'single_line_text_field' },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('trouve une correspondance exacte sur la clé normalisée', () => {
|
||||||
|
const result = matchMetafields(['Puissance (AN)', 'Batterie (AP)'], definitions)
|
||||||
|
expect(result.matched['Puissance (AN)'].existingDefinitionId).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('met les colonnes sans correspondance dans unmatched', () => {
|
||||||
|
const result = matchMetafields(['Puissance (AN)', 'Batterie (AP)'], definitions)
|
||||||
|
expect(result.unmatched).toContain('Batterie (AP)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne met pas en unmatched les colonnes qui ont un match', () => {
|
||||||
|
const result = matchMetafields(['Puissance (AN)'], definitions)
|
||||||
|
expect(result.unmatched).not.toContain('Puissance (AN)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import type { MetafieldDefinition, MetafieldResolution } from '@/types/creation'
|
||||||
|
|
||||||
|
export function normalizeMetafieldKey(header: string): string {
|
||||||
|
return header
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[̀-ͯ]/g, '') // retire les accents
|
||||||
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '')
|
||||||
|
.replace(/_+/g, '_')
|
||||||
|
.substring(0, 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchResult {
|
||||||
|
matched: Record<string, MetafieldResolution>
|
||||||
|
unmatched: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchMetafields(columnHeaders: string[], definitions: MetafieldDefinition[]): MatchResult {
|
||||||
|
const matched: Record<string, MetafieldResolution> = {}
|
||||||
|
const unmatched: string[] = []
|
||||||
|
for (const header of columnHeaders) {
|
||||||
|
const key = normalizeMetafieldKey(header)
|
||||||
|
const def = definitions.find(d => d.key === key)
|
||||||
|
if (def) {
|
||||||
|
matched[header] = { columnHeader: header, action: 'map', namespace: def.namespace, key: def.key, type: def.type, existingDefinitionId: def.id }
|
||||||
|
} else {
|
||||||
|
unmatched.push(header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { matched, unmatched }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère toutes les définitions de métachamps produit via l'API GraphQL Admin.
|
||||||
|
* L'API REST ne dispose pas de cet endpoint — seul GraphQL l'expose.
|
||||||
|
*/
|
||||||
|
export async function fetchMetafieldDefinitions(): Promise<MetafieldDefinition[]> {
|
||||||
|
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||||
|
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||||
|
if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
|
||||||
|
|
||||||
|
const query = `{
|
||||||
|
metafieldDefinitions(ownerType: PRODUCT, first: 250) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
namespace
|
||||||
|
key
|
||||||
|
name
|
||||||
|
type { name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
const res = await fetch(`https://${domain}/admin/api/2024-01/graphql.json`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ query }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`Shopify GraphQL error: ${res.status}`)
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (data.errors) {
|
||||||
|
// Si les définitions ne sont pas accessibles, on retourne une liste vide
|
||||||
|
// plutôt que de bloquer — l'utilisateur verra toutes les colonnes comme "non matchées"
|
||||||
|
console.warn('[shopifyMetafields] GraphQL errors:', data.errors)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return (data.data?.metafieldDefinitions?.edges ?? []).map((edge: {
|
||||||
|
node: { id: string; namespace: string; key: string; name: string; type: { name: string } }
|
||||||
|
}) => ({
|
||||||
|
id: edge.node.id,
|
||||||
|
namespace: edge.node.namespace,
|
||||||
|
key: edge.node.key,
|
||||||
|
name: edge.node.name,
|
||||||
|
type: edge.node.type?.name ?? 'single_line_text_field',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une définition de métachamp produit via GraphQL si elle n'existe pas encore.
|
||||||
|
*/
|
||||||
|
export async function ensureMetafieldDefinition(resolution: MetafieldResolution): Promise<void> {
|
||||||
|
if (resolution.action !== 'create') return
|
||||||
|
|
||||||
|
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||||
|
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||||
|
if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
|
||||||
|
|
||||||
|
const mutation = `mutation CreateMetafieldDefinition($definition: MetafieldDefinitionInput!) {
|
||||||
|
metafieldDefinitionCreate(definition: $definition) {
|
||||||
|
createdDefinition { id key namespace }
|
||||||
|
userErrors { field message }
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
const res = await fetch(`https://${domain}/admin/api/2024-01/graphql.json`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: mutation,
|
||||||
|
variables: {
|
||||||
|
definition: {
|
||||||
|
namespace: resolution.namespace,
|
||||||
|
key: resolution.key,
|
||||||
|
name: resolution.columnHeader,
|
||||||
|
type: 'single_line_text_field',
|
||||||
|
ownerType: 'PRODUCT',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`Shopify GraphQL metafield create error: ${res.status}`)
|
||||||
|
const data = await res.json()
|
||||||
|
const errors = data.data?.metafieldDefinitionCreate?.userErrors ?? []
|
||||||
|
const isAlreadyTaken = errors.some((e: { message: string }) =>
|
||||||
|
e.message.toLowerCase().includes('taken') || e.message.toLowerCase().includes('already'),
|
||||||
|
)
|
||||||
|
if (errors.length > 0 && !isAlreadyTaken) {
|
||||||
|
throw new Error(`Shopify metafield definition error: ${errors.map((e: { message: string }) => e.message).join(', ')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setProductMetafield(
|
||||||
|
shopifyProductId: string,
|
||||||
|
resolution: MetafieldResolution,
|
||||||
|
value: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (resolution.action === 'skip' || !value.trim()) return
|
||||||
|
|
||||||
|
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||||
|
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||||
|
if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
|
||||||
|
|
||||||
|
const gid = `gid://shopify/Product/${shopifyProductId}`
|
||||||
|
const type = resolution.type ?? 'single_line_text_field'
|
||||||
|
|
||||||
|
const mutation = `mutation MetafieldsSet($metafields: [MetafieldsSetInput!]!) {
|
||||||
|
metafieldsSet(metafields: $metafields) {
|
||||||
|
metafields { key namespace value }
|
||||||
|
userErrors { field message }
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
const res = await fetch(`https://${domain}/admin/api/2024-01/graphql.json`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: mutation,
|
||||||
|
variables: {
|
||||||
|
metafields: [{
|
||||||
|
ownerId: gid,
|
||||||
|
namespace: resolution.namespace,
|
||||||
|
key: resolution.key,
|
||||||
|
value,
|
||||||
|
type,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text()
|
||||||
|
throw new Error(`Shopify metafieldsSet HTTP error: ${res.status} — ${err}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
const errors = data.data?.metafieldsSet?.userErrors ?? []
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(`Shopify metafieldsSet error: ${errors.map((e: { message: string }) => e.message).join(', ')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-3
@@ -54,18 +54,26 @@ function extractNextUrl(linkHeader: string | null): string | null {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Récupère tous les produits Shopify (actifs + drafts), en paginant.
|
* Récupère tous les produits Shopify (actifs + drafts), en paginant.
|
||||||
* Limite : 250 par page via cursor-based pagination.
|
* L'API REST ne supporte pas status=any — on fait deux passes (active + draft).
|
||||||
*/
|
*/
|
||||||
export async function fetchAllShopifyProducts(): Promise<ShopifyProductFull[]> {
|
export async function fetchAllShopifyProducts(): Promise<ShopifyProductFull[]> {
|
||||||
|
const [active, draft] = await Promise.all([
|
||||||
|
fetchByStatus('active'),
|
||||||
|
fetchByStatus('draft'),
|
||||||
|
])
|
||||||
|
return [...active, ...draft]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchByStatus(status: 'active' | 'draft'): Promise<ShopifyProductFull[]> {
|
||||||
const { baseUrl, headers } = getConfig()
|
const { baseUrl, headers } = getConfig()
|
||||||
const all: ShopifyProductFull[] = []
|
const all: ShopifyProductFull[] = []
|
||||||
|
|
||||||
let url: string | null =
|
let url: string | null =
|
||||||
`${baseUrl}/products.json?limit=250&fields=id,title,handle,status,body_html,variants&status=any`
|
`${baseUrl}/products.json?limit=250&fields=id,title,handle,status,body_html,variants&status=${status}`
|
||||||
|
|
||||||
while (url) {
|
while (url) {
|
||||||
const res = await fetch(url, { headers })
|
const res = await fetch(url, { headers })
|
||||||
if (!res.ok) throw new Error(`Shopify fetch error: ${res.status}`)
|
if (!res.ok) throw new Error(`Shopify fetch error (${status}): ${res.status}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
all.push(...(data.products as ShopifyAPIProduct[]).map(toSyncProduct))
|
all.push(...(data.products as ShopifyAPIProduct[]).map(toSyncProduct))
|
||||||
url = extractNextUrl(res.headers.get('Link'))
|
url = extractNextUrl(res.headers.get('Link'))
|
||||||
@@ -156,6 +164,21 @@ export async function deactivateShopifyProduct(shopifyId: string): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime définitivement un produit Shopify.
|
||||||
|
*/
|
||||||
|
export async function deleteShopifyProduct(shopifyId: string): Promise<void> {
|
||||||
|
const { baseUrl, headers } = getConfig()
|
||||||
|
const res = await fetch(`${baseUrl}/products/${shopifyId}.json`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text()
|
||||||
|
throw new Error(`Shopify deleteProduct error: ${res.status} — ${err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Récupère l'ID de la première variante d'un produit Shopify.
|
* Récupère l'ID de la première variante d'un produit Shopify.
|
||||||
* Nécessaire pour mettre à jour le prix (update variant).
|
* Nécessaire pour mettre à jour le prix (update variant).
|
||||||
|
|||||||
@@ -18,10 +18,13 @@ function getChangedFields(
|
|||||||
/**
|
/**
|
||||||
* Calcule le diff entre les produits Sheets et Shopify.
|
* Calcule le diff entre les produits Sheets et Shopify.
|
||||||
* Matching par handle (ref normalisée).
|
* Matching par handle (ref normalisée).
|
||||||
|
* Si tabFilter est fourni, les désactivations sont ignorées : on ne peut pas savoir
|
||||||
|
* si un produit Shopify absent du tab appartient à une autre famille ou a été supprimé.
|
||||||
*/
|
*/
|
||||||
export function computeDiff(
|
export function computeDiff(
|
||||||
sheetProducts: SyncProduct[],
|
sheetProducts: SyncProduct[],
|
||||||
shopifyProducts: ShopifyProductFull[],
|
shopifyProducts: ShopifyProductFull[],
|
||||||
|
tabFilter?: string,
|
||||||
): DiffResult {
|
): DiffResult {
|
||||||
const shopifyMap = new Map(shopifyProducts.map((p) => [p.handle, p]))
|
const shopifyMap = new Map(shopifyProducts.map((p) => [p.handle, p]))
|
||||||
const sheetHandles = new Set(sheetProducts.map((p) => p.handle))
|
const sheetHandles = new Set(sheetProducts.map((p) => p.handle))
|
||||||
@@ -48,11 +51,14 @@ export function computeDiff(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Désactivations uniquement en mode catalogue complet (sans filtre par famille)
|
||||||
|
if (!tabFilter) {
|
||||||
for (const shopify of shopifyProducts) {
|
for (const shopify of shopifyProducts) {
|
||||||
if (shopify.status === 'active' && !sheetHandles.has(shopify.handle)) {
|
if (shopify.status === 'active' && !sheetHandles.has(shopify.handle)) {
|
||||||
changes.push({ type: 'deactivate', ref: shopify.ref, shopifyProduct: shopify })
|
changes.push({ type: 'deactivate', ref: shopify.ref, shopifyProduct: shopify })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const summary = {
|
const summary = {
|
||||||
create: changes.filter((c) => c.type === 'create').length,
|
create: changes.filter((c) => c.type === 'create').length,
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/** Un produit en attente de création, extrait d'un onglet Sheets */
|
||||||
|
export interface PendingProduct {
|
||||||
|
tab: string // nom de l'onglet (famille)
|
||||||
|
rowNumber: number // numéro de ligne 1-indexé dans le sheet
|
||||||
|
sku: string // UGS
|
||||||
|
title: string // Nom
|
||||||
|
priceMarket: string // Prix marché (compare_at_price), ex: "29.99"
|
||||||
|
priceActual: string // Prix remisé (price), ex: "19.99"
|
||||||
|
stock: number
|
||||||
|
weightKg: number
|
||||||
|
vendor: string // Marque
|
||||||
|
famille: string // Famille (contexte IA)
|
||||||
|
sousFamille: string
|
||||||
|
comment: string // Commentaire
|
||||||
|
collectionId: string // ID FAMILLE → collection Shopify
|
||||||
|
subCollectionId: string // ID SOUS FAMILLE
|
||||||
|
hasPhoto: boolean
|
||||||
|
specificFields: Record<string, string> // header → valeur pour les métachamps
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Définition de métachamp existante dans Shopify */
|
||||||
|
export interface MetafieldDefinition {
|
||||||
|
id: string // ex: "123"
|
||||||
|
namespace: string // ex: "custom"
|
||||||
|
key: string // ex: "puissance"
|
||||||
|
name: string // ex: "Puissance"
|
||||||
|
type: string // ex: "single_line_text_field"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Résolution d'une colonne spécifique vers un métachamp */
|
||||||
|
export interface MetafieldResolution {
|
||||||
|
columnHeader: string // en-tête colonne Sheets, ex: "Puissance (AN)"
|
||||||
|
action: 'create' | 'map' | 'skip'
|
||||||
|
namespace: string // "custom" par défaut
|
||||||
|
key: string // clé normalisée
|
||||||
|
type?: string // type Shopify réel, ex: 'multi_line_text_field', 'number_decimal'
|
||||||
|
existingDefinitionId?: string // si action === 'map'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Événement streamé par /api/creation/execute */
|
||||||
|
export type CreationStreamEvent =
|
||||||
|
| { type: 'progress'; current: number; total: number; tab: string; title: string; step: string }
|
||||||
|
| { type: 'error'; title: string; message: string }
|
||||||
|
| { type: 'skipped'; title: string; sku: string }
|
||||||
|
| { type: 'done'; summary: { created: number; skipped: number; errors: number; status: 'success' | 'partial' | 'error' } }
|
||||||
|
|
||||||
|
/** Résumé d'une création (stocké en DB) */
|
||||||
|
export interface CreationRecord {
|
||||||
|
id: string
|
||||||
|
createdAt: string
|
||||||
|
userName: string
|
||||||
|
sheetTab: string
|
||||||
|
sheetRow: number
|
||||||
|
shopifyId: string
|
||||||
|
sku: string
|
||||||
|
title: string
|
||||||
|
status: 'success' | 'error'
|
||||||
|
errorMessage?: string
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
declare module 'heic-convert';
|
||||||
@@ -6,6 +6,7 @@ export type ImageStatus =
|
|||||||
| 'ready' // prêt à uploader
|
| 'ready' // prêt à uploader
|
||||||
| 'uploading' // upload Shopify en cours
|
| 'uploading' // upload Shopify en cours
|
||||||
| 'uploaded' // uploadé avec succès
|
| 'uploaded' // uploadé avec succès
|
||||||
|
| 'skipped' // image déjà présente sur le produit Shopify
|
||||||
| 'not_found' // référence produit introuvable dans Shopify
|
| 'not_found' // référence produit introuvable dans Shopify
|
||||||
| 'error' // erreur à n'importe quelle étape
|
| 'error' // erreur à n'importe quelle étape
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user