12 Commits

Author SHA1 Message Date
C_Ma_For 97f011a24b feat: module Sync Sheets complet — stepper 4 étapes, diff, streaming, historique
- Replace placeholder page.tsx with full implementation:
  - 4-step stepper (Analyser → Diff → Confirmer → Progression)
  - Step 1: Call /api/sync/preview to analyze Sheet vs Shopify
  - Step 2: Display DiffTable with changes summary
  - Step 3: Confirmation with actionable changes count
  - Step 4: Real-time progress streaming from /api/sync/execute
  - History section with loadable entries
- Fix type annotations in preview.test.ts (SyncProduct, ShopifyProductFull)
- All 40 tests passing, TypeScript strict mode clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:26:37 +02:00
C_Ma_For cdbddddaec feat: composant SyncHistory
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:07:10 +02:00
C_Ma_For 94993c3cbc feat: composant SyncProgress
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:07:04 +02:00
C_Ma_For 9c3ec2a953 feat: composant DiffTable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:06:53 +02:00
C_Ma_For e6a670c67b feat: composant SyncStepper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:06:46 +02:00
C_Ma_For e9d4f9dc20 feat: API route GET /api/sync/history (TDD) 2026-06-08 21:04:45 +02:00
C_Ma_For 16fece8ca9 feat: API route POST /api/sync/execute (NDJSON streaming) 2026-06-08 21:04:45 +02:00
C_Ma_For d4b54efa17 feat: API route GET /api/sync/preview (TDD) 2026-06-08 21:04:00 +02:00
C_Ma_For 2988ecab0b feat: logique diff Sheets/Shopify (TDD)
Implémentation de la fonction pure computeDiff pour calculer les changements
entre les produits Google Sheets et Shopify, avec distinction des opérations
(create, update, deactivate, unchanged) et traçabilité des champs modifiés.

- Matching par handle (ref normalisée)
- Comparaison des champs: title, description, price, status
- Draft products Shopify non synchronisés ne sont pas déactivés
- Tests TDD: 9 cas couverts (empty, create, unchanged, update, deactivate)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:51:21 +02:00
C_Ma_For f415138103 feat: client Shopify sync (fetch all products + CRUD) 2026-06-08 20:47:35 +02:00
C_Ma_For c4010e8c5e feat: client Google Sheets + parseSheetRows (TDD)
Implémente la lecture depuis Google Sheets avec la fonction de parsing en TDD.

- parseSheetRows(): parseur pur, testé avec 7 cas (tests unitaires)
- readSheetProducts(): client async qui utilise l'API v4 de Google Sheets
- Normalisation des handles (minuscules, tirets, trim)
- Gestion des prix français (virgule → point), status inactif → draft
- Valeurs par défaut pour colonnes manquantes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:44:26 +02:00
C_Ma_For 1daf9bda72 feat: types sync + dépendance googleapis 2026-06-08 20:42:00 +02:00
18 changed files with 1660 additions and 64 deletions
+318 -63
View File
@@ -12,6 +12,7 @@
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"googleapis": "^173.0.0",
"heic2any": "^0.0.4",
"jszip": "^3.10.1",
"next": "14.2.35",
@@ -929,7 +930,6 @@
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
@@ -945,7 +945,6 @@
},
"node_modules/@isaacs/cliui/node_modules/ansi-regex": {
"version": "6.2.2",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -956,7 +955,6 @@
},
"node_modules/@isaacs/cliui/node_modules/strip-ansi": {
"version": "7.2.0",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
@@ -1947,7 +1945,6 @@
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
@@ -3405,7 +3402,6 @@
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
@@ -3457,7 +3453,6 @@
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -3465,7 +3460,6 @@
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -4293,7 +4287,6 @@
},
"node_modules/balanced-match": {
"version": "1.0.2",
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
@@ -4359,6 +4352,15 @@
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
}
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"dev": true,
@@ -4491,6 +4493,12 @@
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -4595,7 +4603,6 @@
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -4607,7 +4614,6 @@
},
"node_modules/call-bound": {
"version": "1.0.4",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -4846,7 +4852,6 @@
},
"node_modules/color-convert": {
"version": "2.0.1",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -4857,7 +4862,6 @@
},
"node_modules/color-name": {
"version": "1.1.4",
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
@@ -4911,7 +4915,6 @@
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"devOptional": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -4964,6 +4967,15 @@
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/data-urls": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
@@ -5028,7 +5040,6 @@
},
"node_modules/debug": {
"version": "4.4.3",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -5253,7 +5264,6 @@
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -5266,9 +5276,17 @@
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"dev": true,
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/effect": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
@@ -5302,7 +5320,6 @@
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"dev": true,
"license": "MIT"
},
"node_modules/empathic": {
@@ -5362,7 +5379,6 @@
},
"node_modules/es-define-property": {
"version": "1.0.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5370,7 +5386,6 @@
},
"node_modules/es-errors": {
"version": "1.3.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5473,7 +5488,6 @@
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -6016,6 +6030,12 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-check": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
@@ -6115,6 +6135,29 @@
"bser": "2.1.1"
}
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"dev": true,
@@ -6192,7 +6235,6 @@
},
"node_modules/foreground-child": {
"version": "3.3.1",
"devOptional": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
@@ -6205,6 +6247,18 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -6230,7 +6284,6 @@
},
"node_modules/function-bind": {
"version": "1.1.2",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -6263,6 +6316,34 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gaxios": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz",
"integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
@@ -6303,7 +6384,6 @@
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -6343,7 +6423,6 @@
},
"node_modules/get-proto": {
"version": "1.0.1",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -6411,7 +6490,6 @@
},
"node_modules/glob": {
"version": "10.3.10",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
@@ -6443,7 +6521,6 @@
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.1.1",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -6451,7 +6528,6 @@
},
"node_modules/glob/node_modules/minimatch": {
"version": "9.0.9",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
@@ -6492,9 +6568,112 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/google-auth-library": {
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.7.0.tgz",
"integrity": "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.1.4",
"gcp-metadata": "8.1.2",
"google-logging-utils": "1.1.3",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/googleapis": {
"version": "173.0.0",
"resolved": "https://registry.npmjs.org/googleapis/-/googleapis-173.0.0.tgz",
"integrity": "sha512-xEJJYLZ4qeenVyfzispNfRjCe9bsv7CzBv5zYFLvScOze9snJ8S9W6hjQ729CWPQt5mvn/JrcRaCHzQiukt0ng==",
"license": "Apache-2.0",
"dependencies": {
"google-auth-library": "^10.2.0",
"googleapis-common": "^8.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/googleapis-common": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.2.tgz",
"integrity": "sha512-5MXeQzIZaqCH7B+HJWqhQm946VARpZep6acbWSr/fcgF2cQANq7allgX+i/G0EqF0WyUxB277gtWMzRYHMl9tg==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"gaxios": "7.1.3",
"google-auth-library": "10.5.0",
"google-logging-utils": "1.1.3",
"qs": "^6.7.0",
"url-template": "^2.0.8"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/googleapis-common/node_modules/gaxios": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
"integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2",
"rimraf": "^5.0.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/googleapis-common/node_modules/google-auth-library": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz",
"integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.0.0",
"gcp-metadata": "^8.0.0",
"google-logging-utils": "^1.0.0",
"gtoken": "^8.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/googleapis-common/node_modules/rimraf": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
"integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==",
"license": "ISC",
"dependencies": {
"glob": "^10.3.7"
},
"bin": {
"rimraf": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -6526,6 +6705,19 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/gtoken": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz",
"integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==",
"license": "MIT",
"dependencies": {
"gaxios": "^7.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/handlebars": {
"version": "4.7.9",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
@@ -6594,7 +6786,6 @@
},
"node_modules/has-symbols": {
"version": "1.1.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -6619,7 +6810,6 @@
},
"node_modules/hasown": {
"version": "2.0.4",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -6689,7 +6879,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
@@ -7014,7 +7203,6 @@
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -7273,7 +7461,6 @@
},
"node_modules/isexe": {
"version": "2.0.0",
"devOptional": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
@@ -7365,7 +7552,6 @@
},
"node_modules/jackspeak": {
"version": "2.3.6",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
@@ -8533,6 +8719,15 @@
"node": ">=6"
}
},
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/json-buffer": {
"version": "3.0.1",
"dev": true,
@@ -8628,6 +8823,27 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"dev": true,
@@ -8744,7 +8960,6 @@
},
"node_modules/lru-cache": {
"version": "10.4.3",
"dev": true,
"license": "ISC"
},
"node_modules/lru.min": {
@@ -8809,7 +9024,6 @@
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -8894,7 +9108,6 @@
},
"node_modules/minipass": {
"version": "7.1.3",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -8908,7 +9121,6 @@
},
"node_modules/ms": {
"version": "2.1.3",
"dev": true,
"license": "MIT"
},
"node_modules/mysql2": {
@@ -9123,6 +9335,26 @@
"node": ">=10"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-exports-info": {
"version": "1.6.0",
"dev": true,
@@ -9148,6 +9380,24 @@
"semver": "bin/semver.js"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -9217,7 +9467,6 @@
},
"node_modules/object-inspect": {
"version": "1.13.4",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -9671,7 +9920,6 @@
},
"node_modules/path-key": {
"version": "3.1.1",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -9684,7 +9932,6 @@
},
"node_modules/path-scurry": {
"version": "1.11.1",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
@@ -10181,6 +10428,21 @@
],
"license": "MIT"
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"dev": true,
@@ -10768,7 +11030,6 @@
},
"node_modules/shebang-command": {
"version": "2.0.0",
"devOptional": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -10779,7 +11040,6 @@
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -10787,7 +11047,6 @@
},
"node_modules/side-channel": {
"version": "1.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -10805,7 +11064,6 @@
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -10820,7 +11078,6 @@
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -10837,7 +11094,6 @@
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -10855,7 +11111,6 @@
},
"node_modules/signal-exit": {
"version": "4.1.0",
"devOptional": true,
"license": "ISC",
"engines": {
"node": ">=14"
@@ -11042,7 +11297,6 @@
},
"node_modules/string-width": {
"version": "5.1.2",
"dev": true,
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
@@ -11059,7 +11313,6 @@
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -11072,12 +11325,10 @@
},
"node_modules/string-width-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"dev": true,
"license": "MIT"
},
"node_modules/string-width/node_modules/ansi-regex": {
"version": "6.2.2",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -11088,7 +11339,6 @@
},
"node_modules/string-width/node_modules/strip-ansi": {
"version": "7.2.0",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
@@ -11480,7 +11730,6 @@
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -11492,7 +11741,6 @@
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -12279,6 +12527,12 @@
"punycode": "^2.1.0"
}
},
"node_modules/url-template": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz",
"integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==",
"license": "BSD"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"license": "MIT"
@@ -12353,6 +12607,15 @@
"makeerror": "1.0.12"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
@@ -12416,7 +12679,6 @@
},
"node_modules/which": {
"version": "2.0.2",
"devOptional": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -12526,7 +12788,6 @@
},
"node_modules/wrap-ansi": {
"version": "8.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
@@ -12543,7 +12804,6 @@
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
@@ -12559,12 +12819,10 @@
},
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
"version": "4.2.3",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -12577,7 +12835,6 @@
},
"node_modules/wrap-ansi/node_modules/ansi-regex": {
"version": "6.2.2",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -12588,7 +12845,6 @@
},
"node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "6.2.3",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -12599,7 +12855,6 @@
},
"node_modules/wrap-ansi/node_modules/strip-ansi": {
"version": "7.2.0",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
+1
View File
@@ -17,6 +17,7 @@
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"googleapis": "^173.0.0",
"heic2any": "^0.0.4",
"jszip": "^3.10.1",
"next": "14.2.35",
+61
View File
@@ -0,0 +1,61 @@
import { GET } from '@/app/api/sync/history/route'
import { NextRequest } from 'next/server'
jest.mock('@/lib/prisma', () => ({
prisma: {
syncRun: {
findMany: jest.fn(),
},
},
}))
import { prisma } from '@/lib/prisma'
const mockFindMany = prisma.syncRun.findMany as jest.Mock
describe('GET /api/sync/history', () => {
beforeEach(() => jest.clearAllMocks())
it('retourne une liste vide si aucun SyncRun', async () => {
mockFindMany.mockResolvedValue([])
const req = new NextRequest('http://localhost/api/sync/history')
const res = await GET(req)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
it('retourne les syncs formatées', async () => {
const fakeRun = {
id: 'clid1',
createdAt: new Date('2026-06-08T10:00:00Z'),
status: 'success',
created: 3,
updated: 1,
deactivated: 0,
errors: 0,
log: JSON.stringify([{ ref: 'REF-1', action: 'create', status: 'success', message: '✓ REF-1 créé' }]),
user: { name: 'Mathieu' },
}
mockFindMany.mockResolvedValue([fakeRun])
const req = new NextRequest('http://localhost/api/sync/history')
const res = await GET(req)
expect(res.status).toBe(200)
const data = await res.json()
expect(data).toHaveLength(1)
expect(data[0].id).toBe('clid1')
expect(data[0].userName).toBe('Mathieu')
expect(data[0].status).toBe('success')
expect(data[0].created).toBe(3)
expect(data[0].log).toHaveLength(1)
expect(data[0].log[0].ref).toBe('REF-1')
expect(data[0].createdAt).toBe('2026-06-08T10:00:00.000Z')
})
it('retourne 500 si Prisma échoue', async () => {
mockFindMany.mockRejectedValue(new Error('DB error'))
const req = new NextRequest('http://localhost/api/sync/history')
const res = await GET(req)
expect(res.status).toBe(500)
})
})
+64
View File
@@ -0,0 +1,64 @@
import { GET } from '@/app/api/sync/preview/route'
import { NextRequest } from 'next/server'
import type { SyncProduct, ShopifyProductFull } from '@/types/sync'
jest.mock('@/lib/googleSheets', () => ({
readSheetProducts: jest.fn(),
}))
jest.mock('@/lib/shopifySync', () => ({
fetchAllShopifyProducts: jest.fn(),
}))
jest.mock('@/lib/syncDiff', () => ({
computeDiff: jest.fn(),
}))
import { readSheetProducts } from '@/lib/googleSheets'
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
import { computeDiff } from '@/lib/syncDiff'
const mockSheets = readSheetProducts as jest.Mock
const mockShopify = fetchAllShopifyProducts as jest.Mock
const mockDiff = computeDiff as jest.Mock
describe('GET /api/sync/preview', () => {
beforeEach(() => jest.clearAllMocks())
it('retourne le DiffResult complet', async () => {
const fakeSheetProducts: SyncProduct[] = [{ ref: 'REF-1', handle: 'ref-1', title: 'T', description: '', price: '10.00', stock: 1, status: 'active' }]
const fakeShopifyProducts: ShopifyProductFull[] = []
const fakeDiff = {
changes: [{ type: 'create', ref: 'REF-1', sheetProduct: fakeSheetProducts[0] }],
summary: { create: 1, update: 0, deactivate: 0, unchanged: 0 },
}
mockSheets.mockResolvedValue(fakeSheetProducts)
mockShopify.mockResolvedValue(fakeShopifyProducts)
mockDiff.mockReturnValue(fakeDiff)
const req = new NextRequest('http://localhost/api/sync/preview')
const res = await GET(req)
expect(res.status).toBe(200)
const data = await res.json()
expect(data.summary.create).toBe(1)
expect(data.changes).toHaveLength(1)
expect(mockDiff).toHaveBeenCalledWith(fakeSheetProducts, fakeShopifyProducts)
})
it('retourne 500 si Google Sheets échoue', async () => {
mockSheets.mockRejectedValue(new Error('Google Auth error'))
const req = new NextRequest('http://localhost/api/sync/preview')
const res = await GET(req)
expect(res.status).toBe(500)
const data = await res.json()
expect(data.error).toBeTruthy()
})
it('retourne 500 si Shopify échoue', async () => {
mockSheets.mockResolvedValue([])
mockShopify.mockRejectedValue(new Error('Shopify error'))
const req = new NextRequest('http://localhost/api/sync/preview')
const res = await GET(req)
expect(res.status).toBe(500)
})
})
+62
View File
@@ -0,0 +1,62 @@
import { parseSheetRows } from '@/lib/googleSheets'
describe('parseSheetRows', () => {
it('parse une ligne complète', () => {
const rows = [['REF-1042', 'Carrelage sol', 'Beau carrelage', '29.99', '15', 'actif']]
const result = parseSheetRows(rows)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
ref: 'REF-1042',
handle: 'ref-1042',
title: 'Carrelage sol',
description: 'Beau carrelage',
price: '29.99',
stock: 15,
status: 'active',
})
})
it('normalise le handle en minuscules avec tirets', () => {
const rows = [['ART 001/B', 'Produit', '', '10.00', '0', 'actif']]
const [p] = parseSheetRows(rows)
expect(p.handle).toBe('art-001-b')
})
it('ignore les lignes sans référence', () => {
const rows = [['', 'Titre vide', '', '0', '0', ''], ['REF-1', 'OK', '', '5.00', '1', 'actif']]
expect(parseSheetRows(rows)).toHaveLength(1)
})
it('gère un prix avec virgule (format français)', () => {
const rows = [['REF-2', 'Test', '', '19,99', '0', 'actif']]
expect(parseSheetRows(rows)[0].price).toBe('19.99')
})
it('marque comme draft si la colonne statut contient "inac"', () => {
const rows = [
['REF-3', 'A', '', '1.00', '0', 'Inactif'],
['REF-4', 'B', '', '1.00', '0', 'inactif'],
['REF-5', 'C', '', '1.00', '0', 'actif'],
['REF-6', 'D', '', '1.00', '0', ''],
]
const results = parseSheetRows(rows)
expect(results[0].status).toBe('draft')
expect(results[1].status).toBe('draft')
expect(results[2].status).toBe('active')
expect(results[3].status).toBe('active')
})
it('utilise la référence comme titre si le titre est vide', () => {
const rows = [['REF-7', '', '', '5.00', '0', 'actif']]
expect(parseSheetRows(rows)[0].title).toBe('REF-7')
})
it('accepte des lignes avec colonnes manquantes', () => {
const rows = [['REF-8']]
const [p] = parseSheetRows(rows)
expect(p.ref).toBe('REF-8')
expect(p.price).toBe('0.00')
expect(p.stock).toBe(0)
expect(p.status).toBe('active')
})
})
+97
View File
@@ -0,0 +1,97 @@
import { computeDiff } from '@/lib/syncDiff'
import type { SyncProduct, ShopifyProductFull } from '@/types/sync'
const sheet = (ref: string, overrides: Partial<SyncProduct> = {}): SyncProduct => ({
ref,
handle: ref.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
title: `Produit ${ref}`,
description: 'Desc',
price: '10.00',
stock: 5,
status: 'active',
...overrides,
})
const shopify = (ref: string, shopifyId = '111', overrides: Partial<ShopifyProductFull> = {}): ShopifyProductFull => ({
shopifyId,
...sheet(ref, overrides),
...overrides,
})
describe('computeDiff', () => {
it('retourne vide si aucun produit des deux côtés', () => {
const { changes, summary } = computeDiff([], [])
expect(changes).toHaveLength(0)
expect(summary).toEqual({ create: 0, update: 0, deactivate: 0, unchanged: 0 })
})
it('marque comme create si produit présent dans Sheets mais absent de Shopify', () => {
const { changes, summary } = computeDiff([sheet('REF-1')], [])
expect(changes).toHaveLength(1)
expect(changes[0].type).toBe('create')
expect(changes[0].ref).toBe('REF-1')
expect(summary.create).toBe(1)
})
it('marque comme unchanged si produits identiques', () => {
const s = sheet('REF-2')
const sp = shopify('REF-2')
const { changes, summary } = computeDiff([s], [sp])
expect(changes[0].type).toBe('unchanged')
expect(summary.unchanged).toBe(1)
})
it('marque comme update si le titre diffère', () => {
const s = sheet('REF-3', { title: 'Nouveau titre' })
const sp = shopify('REF-3', '222', { title: 'Ancien titre' })
const { changes } = computeDiff([s], [sp])
expect(changes[0].type).toBe('update')
expect(changes[0].changedFields).toContainEqual({
field: 'title',
from: 'Ancien titre',
to: 'Nouveau titre',
})
})
it('marque comme update si le prix diffère', () => {
const s = sheet('REF-4', { price: '25.00' })
const sp = shopify('REF-4', '333', { price: '20.00' })
const { changes } = computeDiff([s], [sp])
expect(changes[0].type).toBe('update')
expect(changes[0].changedFields).toContainEqual({ field: 'price', from: '20.00', to: '25.00' })
})
it('marque comme update si le statut diffère', () => {
const s = sheet('REF-5', { status: 'draft' })
const sp = shopify('REF-5', '444', { status: 'active' })
const { changes } = computeDiff([s], [sp])
expect(changes[0].type).toBe('update')
})
it('marque comme deactivate si produit ACTIF dans Shopify absent des Sheets', () => {
const sp = shopify('REF-6', '555', { status: 'active' })
const { changes, summary } = computeDiff([], [sp])
expect(changes[0].type).toBe('deactivate')
expect(summary.deactivate).toBe(1)
})
it("ne marque PAS comme deactivate si le produit Shopify est déjà draft", () => {
const sp = shopify('REF-7', '666', { status: 'draft' })
const { changes } = computeDiff([], [sp])
expect(changes).toHaveLength(0)
})
it('calcule le résumé correctement sur un mix de changements', () => {
const sheetProducts = [sheet('REF-A'), sheet('REF-B', { title: 'Modifié' }), sheet('REF-C')]
const shopifyProducts = [
shopify('REF-B', '10', { title: 'Original' }),
shopify('REF-C', '11'),
shopify('REF-D', '12', { status: 'active' }),
]
const { summary } = computeDiff(sheetProducts, shopifyProducts)
expect(summary.create).toBe(1) // REF-A
expect(summary.update).toBe(1) // REF-B
expect(summary.unchanged).toBe(1) // REF-C
expect(summary.deactivate).toBe(1) // REF-D
})
})
+214 -1
View File
@@ -1,9 +1,222 @@
'use client'
import { useState, useCallback } from 'react'
import { Header } from '@/components/layout/Header'
import { SyncStepper } from '@/components/sync/SyncStepper'
import { DiffTable } from '@/components/sync/DiffTable'
import { SyncProgress } from '@/components/sync/SyncProgress'
import { SyncHistory } from '@/components/sync/SyncHistory'
import type { DiffResult, SyncChange, SyncHistoryEntry } from '@/types/sync'
type Step = 1 | 2 | 3 | 4
export default function SyncPage() {
const [step, setStep] = useState<Step>(1)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [diff, setDiff] = useState<DiffResult | null>(null)
const [history, setHistory] = useState<SyncHistoryEntry[]>([])
const [historyLoaded, setHistoryLoaded] = useState(false)
// Log streaming state
const [log, setLog] = useState<string[]>([])
const [progress, setProgress] = useState<{ current: number; total: number } | null>(null)
const [summary, setSummary] = useState<{
created: number; updated: number; deactivated: number; errors: number; status: string
} | null>(null)
// Étape 1 → 2 : lancer le preview
const handleAnalyze = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/sync/preview')
const data = await res.json()
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
setDiff(data)
setStep(2)
} catch (err) {
setError((err as Error).message)
} finally {
setLoading(false)
}
}, [])
// Étape 3 → 4 : lancer la sync en streaming
const handleExecute = useCallback(async () => {
if (!diff) return
setStep(4)
setLog([])
setProgress(null)
setSummary(null)
const actionableChanges: SyncChange[] = diff.changes.filter((c) => c.type !== 'unchanged')
try {
const res = await fetch('/api/sync/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ changes: actionableChanges }),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || `HTTP ${res.status}`)
}
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines.filter(Boolean)) {
try {
const event = JSON.parse(line)
if (event.type === 'progress') {
setProgress({ current: event.current, total: event.total })
setLog((prev) => [...prev, event.message])
} else if (event.type === 'done') {
setSummary(event.summary)
setHistoryLoaded(false) // force reload history
}
} catch { /* ligne incomplète */ }
}
}
} catch (err) {
setLog((prev) => [...prev, `❌ Erreur : ${(err as Error).message}`])
}
}, [diff])
// Charger l'historique au besoin
const loadHistory = useCallback(async () => {
if (historyLoaded) return
try {
const res = await fetch('/api/sync/history')
if (res.ok) {
setHistory(await res.json())
setHistoryLoaded(true)
}
} catch { /* silencieux */ }
}, [historyLoaded])
const actionableCount = diff
? diff.summary.create + diff.summary.update + diff.summary.deactivate
: 0
return (
<>
<Header title="🔄 Sync Sheets" />
<div className="p-6"><p className="text-gray-400 text-sm">Module Sync à implémenter (Plan 3)</p></div>
<div className="p-6 flex flex-col gap-6 flex-1 overflow-y-auto">
{/* Stepper */}
<SyncStepper currentStep={step} />
{/* Étape 1 — Analyser */}
{step === 1 && (
<div className="flex flex-col items-center gap-4 py-8">
<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.
</p>
{error && (
<div className="bg-red-950 border border-red-800 text-red-300 text-sm rounded-lg px-4 py-2 max-w-md">
{error}
</div>
)}
<button
onClick={handleAnalyze}
disabled={loading}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-semibold px-6 py-3 rounded-xl text-sm transition-colors"
>
{loading ? ' Analyse en cours' : '🔍 Analyser le Sheets'}
</button>
</div>
)}
{/* Étape 2 — Aperçu diff */}
{step === 2 && diff && (
<div className="flex flex-col gap-4">
<DiffTable diff={diff} />
<div className="flex gap-3 justify-end">
<button
onClick={() => { setStep(1); setDiff(null) }}
className="px-4 py-2 text-sm bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg"
>
← Relancer l'analyse
</button>
<button
onClick={() => setStep(3)}
disabled={actionableCount === 0}
className="px-4 py-2 text-sm bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white font-semibold rounded-lg"
>
Confirmer
</button>
</div>
</div>
)}
{/* Étape 3 — Confirmation */}
{step === 3 && diff && (
<div className="flex flex-col items-center gap-6 py-8">
<div className="bg-slate-900 border border-slate-700 rounded-xl p-6 text-center max-w-sm">
<p className="text-lg font-semibold text-white mb-2">
{actionableCount} changement{actionableCount > 1 ? 's' : ''} vont être appliqués à Shopify
</p>
<p className="text-sm text-slate-400">
{diff.summary.create > 0 && `${diff.summary.create} créations · `}
{diff.summary.update > 0 && `${diff.summary.update} mises à jour · `}
{diff.summary.deactivate > 0 && `${diff.summary.deactivate} désactivations`}
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setStep(2)}
className="px-4 py-2 text-sm bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg"
>
Annuler
</button>
<button
onClick={handleExecute}
className="px-6 py-2 text-sm bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded-lg"
>
Confirmer et synchroniser
</button>
</div>
</div>
)}
{/* Étape 4 — Progression */}
{step === 4 && (
<div className="flex flex-col gap-4">
<SyncProgress log={log} progress={progress} summary={summary} />
{summary && (
<div className="flex gap-3 justify-end">
<button
onClick={() => { setStep(1); 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"
>
Nouvelle sync
</button>
</div>
)}
</div>
)}
{/* Historique */}
<div className="mt-4">
<button
onClick={loadHistory}
className="text-sm text-slate-500 hover:text-slate-300 mb-3"
>
{historyLoaded ? '📋 Historique des syncs' : '📋 Charger l\'historique'}
</button>
{historyLoaded && <SyncHistory entries={history} />}
</div>
</div>
</>
)
}
+113
View File
@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import {
createShopifyProduct,
updateShopifyProduct,
deactivateShopifyProduct,
getFirstVariantId,
} from '@/lib/shopifySync'
import type { SyncChange, SyncLogEntry, SyncStreamEvent } from '@/types/sync'
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
}
let changes: SyncChange[]
try {
const body = await req.json()
changes = body.changes
} catch {
return NextResponse.json({ error: 'Corps JSON invalide' }, { status: 400 })
}
if (!Array.isArray(changes) || changes.length === 0) {
return NextResponse.json({ error: 'Aucun changement à appliquer' }, { status: 400 })
}
const userId = (session.user as { id: string }).id
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const send = (event: SyncStreamEvent) => {
controller.enqueue(encoder.encode(JSON.stringify(event) + '\n'))
}
const log: SyncLogEntry[] = []
let created = 0, updated = 0, deactivated = 0, errors = 0
const total = changes.filter((c) => c.type !== 'unchanged').length
let current = 0
for (const change of changes) {
if (change.type === 'unchanged') continue
current++
try {
if (change.type === 'create') {
await createShopifyProduct(change.sheetProduct!)
created++
const msg = `${change.ref} créé`
log.push({ ref: change.ref, action: 'create', status: 'success', message: msg })
send({ type: 'progress', current, total, message: msg })
} else if (change.type === 'update') {
const variantId = await getFirstVariantId(change.shopifyProduct!.shopifyId)
await updateShopifyProduct(
change.shopifyProduct!.shopifyId,
change.sheetProduct!,
variantId,
)
updated++
const fields = change.changedFields?.map((f) => f.field).join(', ') ?? ''
const msg = `${change.ref} mis à jour (${fields})`
log.push({ ref: change.ref, action: 'update', status: 'success', message: msg })
send({ type: 'progress', current, total, message: msg })
} else if (change.type === 'deactivate') {
await deactivateShopifyProduct(change.shopifyProduct!.shopifyId)
deactivated++
const msg = `${change.ref} désactivé`
log.push({ ref: change.ref, action: 'deactivate', status: 'success', message: msg })
send({ type: 'progress', current, total, message: msg })
}
} catch (err) {
errors++
const msg = `${change.ref} : ${(err as Error).message}`
log.push({ ref: change.ref, action: change.type as SyncLogEntry['action'], status: 'error', message: msg })
send({ type: 'progress', current, total, message: msg })
}
}
const syncStatus = errors === 0 ? 'success' : errors === total ? 'error' : 'partial'
try {
await prisma.syncRun.create({
data: {
userId,
status: syncStatus,
created,
updated,
deactivated,
errors,
log: JSON.stringify(log),
},
})
} catch (dbErr) {
console.error('Erreur sauvegarde SyncRun:', dbErr)
}
send({
type: 'done',
summary: { created, updated, deactivated, errors, status: syncStatus },
})
controller.close()
},
})
return new Response(stream, {
headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8' },
})
}
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import type { SyncHistoryEntry, SyncLogEntry } from '@/types/sync'
export async function GET(_req: NextRequest) {
try {
const runs = await prisma.syncRun.findMany({
orderBy: { createdAt: 'desc' },
take: 20,
include: { user: { select: { name: true } } },
})
const entries: SyncHistoryEntry[] = runs.map((run) => ({
id: run.id,
createdAt: run.createdAt.toISOString(),
userName: run.user.name,
status: run.status as SyncHistoryEntry['status'],
created: run.created,
updated: run.updated,
deactivated: run.deactivated,
errors: run.errors,
log: (() => {
try { return JSON.parse(run.log) as SyncLogEntry[] } catch { return [] }
})(),
}))
return NextResponse.json(entries)
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+19
View File
@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server'
import { readSheetProducts } from '@/lib/googleSheets'
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
import { computeDiff } from '@/lib/syncDiff'
export async function GET(_req: NextRequest) {
try {
const [sheetProducts, shopifyProducts] = await Promise.all([
readSheetProducts(),
fetchAllShopifyProducts(),
])
const diff = computeDiff(sheetProducts, shopifyProducts)
return NextResponse.json(diff)
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+118
View File
@@ -0,0 +1,118 @@
'use client'
import { useState } from 'react'
import type { SyncChange, SyncChangeType, DiffResult } from '@/types/sync'
interface DiffTableProps {
diff: DiffResult
}
const TYPE_CONFIG: Record<SyncChangeType, { label: string; color: string; bg: string }> = {
create: { label: '🟢 Nouveau', color: 'text-green-400', bg: 'bg-green-950/30' },
update: { label: '🔵 Mis à jour', color: 'text-blue-400', bg: 'bg-blue-950/30' },
deactivate: { label: '🔴 Désactivé', color: 'text-red-400', bg: 'bg-red-950/30' },
unchanged: { label: '⚫ Inchangé', color: 'text-slate-500', bg: '' },
}
const PAGE_SIZE = 20
export function DiffTable({ diff }: DiffTableProps) {
const [filter, setFilter] = useState<SyncChangeType | 'all'>('all')
const [page, setPage] = useState(0)
const visible = diff.changes.filter((c) => c.type !== 'unchanged')
const filtered = filter === 'all' ? visible : visible.filter((c) => c.type === filter)
const paginated = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
const totalPages = Math.ceil(filtered.length / PAGE_SIZE)
return (
<div className="flex flex-col gap-3">
{/* Résumé */}
<div className="flex gap-4 flex-wrap text-sm">
{(['create', 'update', 'deactivate', 'unchanged'] as SyncChangeType[]).map((type) => (
<span key={type} className={TYPE_CONFIG[type].color}>
{TYPE_CONFIG[type].label} : {diff.summary[type]}
</span>
))}
</div>
{/* Filtres */}
<div className="flex gap-2 flex-wrap">
{(['all', 'create', 'update', 'deactivate'] as const).map((f) => (
<button
key={f}
onClick={() => { setFilter(f); setPage(0) }}
className={`px-3 py-1 text-xs rounded-full border transition-colors ${
filter === f
? 'bg-indigo-600 border-indigo-500 text-white'
: 'border-slate-700 text-slate-400 hover:text-slate-200'
}`}
>
{f === 'all' ? 'Tous' : TYPE_CONFIG[f].label}
</button>
))}
</div>
{/* Tableau */}
{paginated.length === 0 ? (
<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">
<table className="w-full text-xs">
<thead className="bg-slate-800 text-slate-400">
<tr>
<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">Champs modifiés</th>
</tr>
</thead>
<tbody>
{paginated.map((change) => (
<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-semibold ${TYPE_CONFIG[change.type].color}`}>
{TYPE_CONFIG[change.type].label}
</td>
<td className="px-3 py-2 text-slate-400">
{change.type === 'update' && change.changedFields?.map((f) => (
<span key={f.field} className="inline-block mr-2">
<span className="text-slate-500">{f.field} : </span>
<span className="line-through text-red-400">{f.from}</span>
<span className="text-slate-400"> </span>
<span className="text-green-400">{f.to}</span>
</span>
))}
{change.type === 'create' && (
<span className="text-green-400">{change.sheetProduct?.title}</span>
)}
{change.type === 'deactivate' && (
<span className="text-red-400">{change.shopifyProduct?.title}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex gap-2 justify-end">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="px-3 py-1 text-xs bg-slate-800 rounded disabled:opacity-40 text-slate-300"
> Préc</button>
<span className="text-xs text-slate-500 self-center">
{page + 1} / {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="px-3 py-1 text-xs bg-slate-800 rounded disabled:opacity-40 text-slate-300"
>Suiv </button>
</div>
)}
</div>
)
}
+78
View File
@@ -0,0 +1,78 @@
'use client'
import { useState } from 'react'
import React from 'react'
import type { SyncHistoryEntry } from '@/types/sync'
interface SyncHistoryProps {
entries: SyncHistoryEntry[]
}
const STATUS_STYLE = {
success: 'text-green-400',
partial: 'text-yellow-400',
error: 'text-red-400',
}
export function SyncHistory({ entries }: SyncHistoryProps) {
const [expandedId, setExpandedId] = useState<string | null>(null)
if (entries.length === 0) {
return <p className="text-slate-600 text-sm text-center py-6">Aucune synchronisation effectuée.</p>
}
return (
<div className="border border-slate-700 rounded-xl overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-slate-800 text-slate-400">
<tr>
<th className="px-3 py-2 text-left">Date</th>
<th className="px-3 py-2 text-left">Utilisateur</th>
<th className="px-3 py-2 text-right">Créés</th>
<th className="px-3 py-2 text-right">MàJ</th>
<th className="px-3 py-2 text-right">Désactivés</th>
<th className="px-3 py-2 text-right">Erreurs</th>
<th className="px-3 py-2 text-left">Statut</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<React.Fragment key={entry.id}>
<tr
onClick={() => setExpandedId(expandedId === entry.id ? null : entry.id)}
className="border-t border-slate-800 hover:bg-slate-800/50 cursor-pointer"
>
<td className="px-3 py-2 text-slate-400">
{new Date(entry.createdAt).toLocaleString('fr-FR', {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit',
})}
</td>
<td className="px-3 py-2 text-slate-300">{entry.userName}</td>
<td className="px-3 py-2 text-right text-green-400">{entry.created}</td>
<td className="px-3 py-2 text-right text-blue-400">{entry.updated}</td>
<td className="px-3 py-2 text-right text-red-400">{entry.deactivated}</td>
<td className="px-3 py-2 text-right text-yellow-400">{entry.errors || '—'}</td>
<td className={`px-3 py-2 font-semibold ${STATUS_STYLE[entry.status]}`}>
{entry.status}
</td>
</tr>
{expandedId === entry.id && (
<tr className="border-t border-slate-800 bg-slate-950">
<td colSpan={7} className="px-3 py-3">
<div className="font-mono text-xs max-h-32 overflow-y-auto space-y-0.5">
{entry.log.map((l, i) => (
<p key={i} className={l.status === 'error' ? 'text-red-400' : 'text-slate-400'}>
{l.message}
</p>
))}
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
)
}
+65
View File
@@ -0,0 +1,65 @@
'use client'
interface SyncProgressProps {
log: string[]
progress: { current: number; total: number } | null
summary: { created: number; updated: number; deactivated: number; errors: number; status: string } | null
}
export function SyncProgress({ log, progress, summary }: SyncProgressProps) {
const percent = progress && progress.total > 0
? Math.round((progress.current / progress.total) * 100)
: 0
return (
<div className="flex flex-col gap-4">
{/* Barre de progression */}
{progress && (
<div>
<div className="flex justify-between text-xs text-slate-400 mb-1">
<span>{progress.current} / {progress.total} produits</span>
<span>{percent}%</span>
</div>
<div className="h-2 bg-slate-800 rounded-full overflow-hidden">
<div
className="h-2 bg-indigo-500 rounded-full transition-all duration-300"
style={{ width: `${percent}%` }}
/>
</div>
</div>
)}
{/* Log */}
<div className="bg-slate-950 rounded-xl border border-slate-800 p-3 h-48 overflow-y-auto font-mono text-xs">
{log.length === 0 && (
<p className="text-slate-600">Démarrage de la synchronisation</p>
)}
{log.map((line, i) => (
<p
key={i}
className={line.startsWith('✗') ? 'text-red-400' : 'text-green-400'}
>
{line}
</p>
))}
</div>
{/* Résultat final */}
{summary && (
<div className={`rounded-xl p-4 text-sm font-semibold border ${
summary.status === 'success'
? 'bg-green-950/30 border-green-800 text-green-300'
: summary.status === 'partial'
? 'bg-yellow-950/30 border-yellow-800 text-yellow-300'
: 'bg-red-950/30 border-red-800 text-red-300'
}`}>
{summary.status === 'success' && '✅ '}
{summary.status === 'partial' && '⚠️ '}
{summary.status === 'error' && '❌ '}
Sync terminée {summary.created} créés, {summary.updated} mis à jour,{' '}
{summary.deactivated} désactivés, {summary.errors} erreur{summary.errors > 1 ? 's' : ''}
</div>
)}
</div>
)
}
+46
View File
@@ -0,0 +1,46 @@
'use client'
const STEPS = [
{ id: 1, label: 'Analyse Sheets' },
{ id: 2, label: 'Aperçu diff' },
{ id: 3, label: 'Confirmation' },
{ id: 4, label: 'Synchronisation' },
]
interface SyncStepperProps {
currentStep: number // 14
}
export function SyncStepper({ currentStep }: SyncStepperProps) {
return (
<div className="flex items-center gap-0">
{STEPS.map((step, i) => {
const done = currentStep > step.id
const active = currentStep === step.id
return (
<div key={step.id} className="flex items-center">
{i > 0 && (
<div className={`h-0.5 w-12 ${done ? 'bg-indigo-500' : 'bg-slate-700'}`} />
)}
<div className="flex flex-col items-center gap-1">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold transition-colors ${
done
? 'bg-indigo-500 text-white'
: active
? 'bg-indigo-600 text-white ring-2 ring-indigo-400'
: 'bg-slate-800 text-slate-500 border border-slate-700'
}`}
>
{done ? '✓' : step.id}
</div>
<span className={`text-xs ${active ? 'text-slate-200 font-semibold' : 'text-slate-500'}`}>
{step.label}
</span>
</div>
</div>
)
})}
</div>
)
}
+69
View File
@@ -0,0 +1,69 @@
import type { SyncProduct } from '@/types/sync'
const API_VERSION = 'v4'
function normalizeHandle(ref: string): string {
return ref
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
/**
* Parse des lignes brutes Google Sheets en SyncProduct[].
* Colonnes : A=Référence, B=Nom, C=Description, D=Prix, E=Stock, F=Statut
* Exporté pour les tests unitaires.
*/
export function parseSheetRows(rows: string[][]): SyncProduct[] {
return rows
.map((row): SyncProduct | null => {
const ref = row[0]?.trim()
if (!ref) return null
const rawPrice = (row[3] ?? '').replace(',', '.').trim()
const price = parseFloat(rawPrice || '0')
return {
ref,
handle: normalizeHandle(ref),
title: row[1]?.trim() || ref,
description: row[2]?.trim() || '',
price: isNaN(price) ? '0.00' : price.toFixed(2),
stock: parseInt(row[4] ?? '0', 10) || 0,
status: (row[5] ?? '').toLowerCase().includes('inac') ? 'draft' : 'active',
}
})
.filter((p): p is SyncProduct => p !== null)
}
/**
* Lit tous les produits depuis Google Sheets via l'API v4.
* Utilise un service account (GOOGLE_SERVICE_ACCOUNT_EMAIL + GOOGLE_PRIVATE_KEY).
*/
export async function readSheetProducts(): Promise<SyncProduct[]> {
const email = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL
const key = process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n')
const sheetId = process.env.GOOGLE_SHEETS_ID
const sheetName = process.env.GOOGLE_SHEETS_TAB ?? 'Produits'
if (!email || !key || !sheetId) {
throw new Error('Variables GOOGLE_SERVICE_ACCOUNT_EMAIL, GOOGLE_PRIVATE_KEY et GOOGLE_SHEETS_ID requises')
}
const { google } = await import('googleapis')
const auth = new google.auth.GoogleAuth({
credentials: { client_email: email, private_key: key },
scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'],
})
const sheets = google.sheets({ version: API_VERSION, auth })
const response = await sheets.spreadsheets.values.get({
spreadsheetId: sheetId,
range: `${sheetName}!A2:F`, // A2 = skip header row
})
const rows = (response.data.values ?? []) as string[][]
return parseSheetRows(rows)
}
+171
View File
@@ -0,0 +1,171 @@
import type { ShopifyProductFull, SyncProduct } from '@/types/sync'
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',
},
}
}
interface ShopifyVariant {
id: number
price: string
sku: string
inventory_quantity: number
}
interface ShopifyAPIProduct {
id: number
title: string
handle: string
body_html: string
status: string
variants: ShopifyVariant[]
}
function toSyncProduct(p: ShopifyAPIProduct): ShopifyProductFull {
return {
shopifyId: String(p.id),
ref: p.variants[0]?.sku || p.handle,
handle: p.handle,
title: p.title,
description: p.body_html ?? '',
price: p.variants[0]?.price ?? '0.00',
stock: p.variants[0]?.inventory_quantity ?? 0,
status: p.status === 'active' ? 'active' : 'draft',
}
}
function extractNextUrl(linkHeader: string | null): string | null {
if (!linkHeader) return null
const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/)
return match ? match[1] : null
}
/**
* Récupère tous les produits Shopify (actifs + drafts), en paginant.
* Limite : 250 par page via cursor-based pagination.
*/
export async function fetchAllShopifyProducts(): Promise<ShopifyProductFull[]> {
const { baseUrl, headers } = getConfig()
const all: ShopifyProductFull[] = []
let url: string | null =
`${baseUrl}/products.json?limit=250&fields=id,title,handle,status,body_html,variants&status=any`
while (url) {
const res = await fetch(url, { headers })
if (!res.ok) throw new Error(`Shopify fetch error: ${res.status}`)
const data = await res.json()
all.push(...(data.products as ShopifyAPIProduct[]).map(toSyncProduct))
url = extractNextUrl(res.headers.get('Link'))
}
return all
}
/**
* Crée un nouveau produit Shopify depuis un SyncProduct.
* Retourne l'ID Shopify créé.
*/
export async function createShopifyProduct(product: SyncProduct): Promise<string> {
const { baseUrl, headers } = getConfig()
const body = {
product: {
title: product.title,
body_html: product.description,
handle: product.handle,
status: product.status,
variants: [{ price: product.price, sku: product.ref }],
},
}
const res = await fetch(`${baseUrl}/products.json`, {
method: 'POST',
headers,
body: JSON.stringify(body),
})
if (!res.ok) {
const err = await res.text()
throw new Error(`Shopify createProduct error: ${res.status}${err}`)
}
const data = await res.json()
return String(data.product.id)
}
/**
* Met à jour un produit Shopify existant (titre, description, prix, statut).
*/
export async function updateShopifyProduct(
shopifyId: string,
product: SyncProduct,
variantId: string,
): Promise<void> {
const { baseUrl, headers } = getConfig()
const body = {
product: {
id: shopifyId,
title: product.title,
body_html: product.description,
status: product.status,
variants: [{ id: variantId, price: product.price }],
},
}
const res = await fetch(`${baseUrl}/products/${shopifyId}.json`, {
method: 'PUT',
headers,
body: JSON.stringify(body),
})
if (!res.ok) {
const err = await res.text()
throw new Error(`Shopify updateProduct error: ${res.status}${err}`)
}
}
/**
* Passe un produit Shopify en draft (désactivation — jamais de suppression).
*/
export async function deactivateShopifyProduct(shopifyId: string): Promise<void> {
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/products/${shopifyId}.json`, {
method: 'PUT',
headers,
body: JSON.stringify({ product: { id: shopifyId, status: 'draft' } }),
})
if (!res.ok) {
const err = await res.text()
throw new Error(`Shopify deactivateProduct error: ${res.status}${err}`)
}
}
/**
* Récupère l'ID de la première variante d'un produit Shopify.
* Nécessaire pour mettre à jour le prix (update variant).
*/
export async function getFirstVariantId(shopifyId: string): Promise<string> {
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/products/${shopifyId}/variants.json?limit=1`, { headers })
if (!res.ok) throw new Error(`Shopify getVariants error: ${res.status}`)
const data = await res.json()
const variant = data.variants?.[0]
if (!variant) throw new Error(`Aucune variante pour le produit ${shopifyId}`)
return String(variant.id)
}
+65
View File
@@ -0,0 +1,65 @@
import type { SyncProduct, ShopifyProductFull, SyncChange, DiffResult } from '@/types/sync'
const COMPARABLE_FIELDS = ['title', 'description', 'price', 'status'] as const
function getChangedFields(
sheet: SyncProduct,
shopify: ShopifyProductFull,
): Array<{ field: string; from: string; to: string }> {
return COMPARABLE_FIELDS
.filter((field) => sheet[field] !== shopify[field])
.map((field) => ({
field,
from: String(shopify[field]),
to: String(sheet[field]),
}))
}
/**
* Calcule le diff entre les produits Sheets et Shopify.
* Matching par handle (ref normalisée).
*/
export function computeDiff(
sheetProducts: SyncProduct[],
shopifyProducts: ShopifyProductFull[],
): DiffResult {
const shopifyMap = new Map(shopifyProducts.map((p) => [p.handle, p]))
const sheetHandles = new Set(sheetProducts.map((p) => p.handle))
const changes: SyncChange[] = []
for (const sheet of sheetProducts) {
const shopify = shopifyMap.get(sheet.handle)
if (!shopify) {
changes.push({ type: 'create', ref: sheet.ref, sheetProduct: sheet })
} else {
const changedFields = getChangedFields(sheet, shopify)
if (changedFields.length > 0) {
changes.push({
type: 'update',
ref: sheet.ref,
sheetProduct: sheet,
shopifyProduct: shopify,
changedFields,
})
} else {
changes.push({ type: 'unchanged', ref: sheet.ref, sheetProduct: sheet, shopifyProduct: shopify })
}
}
}
for (const shopify of shopifyProducts) {
if (shopify.status === 'active' && !sheetHandles.has(shopify.handle)) {
changes.push({ type: 'deactivate', ref: shopify.ref, shopifyProduct: shopify })
}
}
const summary = {
create: changes.filter((c) => c.type === 'create').length,
update: changes.filter((c) => c.type === 'update').length,
deactivate: changes.filter((c) => c.type === 'deactivate').length,
unchanged: changes.filter((c) => c.type === 'unchanged').length,
}
return { changes, summary }
}
+67
View File
@@ -0,0 +1,67 @@
export type SyncChangeType = 'create' | 'update' | 'deactivate' | 'unchanged'
/** Produit normalisé depuis Google Sheets */
export interface SyncProduct {
ref: string // ex: "REF-1042" (colonne A telle quelle)
handle: string // ref normalisée en handle Shopify: "ref-1042"
title: string // colonne B
description: string // colonne C (texte brut, stocké en body_html)
price: string // colonne D — "29.99" (2 décimales)
stock: number // colonne E — info affichage uniquement (non synchronisé)
status: 'active' | 'draft' // colonne F : "inactif"/"inactif" → draft, tout autre → active
}
/** Produit Shopify enrichi pour la comparaison */
export interface ShopifyProductFull extends SyncProduct {
shopifyId: string // ID numérique Shopify (string)
}
/** Un changement unitaire calculé par computeDiff */
export interface SyncChange {
type: SyncChangeType
ref: string
sheetProduct?: SyncProduct // présent pour create + update
shopifyProduct?: ShopifyProductFull // présent pour update + deactivate
changedFields?: Array<{ // présent pour update uniquement
field: string
from: string
to: string
}>
}
/** Résultat complet du diff */
export interface DiffResult {
changes: SyncChange[]
summary: {
create: number
update: number
deactivate: number
unchanged: number
}
}
/** Une entrée du log d'exécution */
export interface SyncLogEntry {
ref: string
action: 'create' | 'update' | 'deactivate'
status: 'success' | 'error'
message: string
}
/** Un événement NDJSON streamé par /api/sync/execute */
export type SyncStreamEvent =
| { type: 'progress'; current: number; total: number; message: string }
| { type: 'done'; summary: { created: number; updated: number; deactivated: number; errors: number; status: 'success' | 'partial' | 'error' } }
/** Une entrée d'historique (depuis SyncRun Prisma) */
export interface SyncHistoryEntry {
id: string
createdAt: string // ISO string
userName: string
status: 'success' | 'partial' | 'error'
created: number
updated: number
deactivated: number
errors: number
log: SyncLogEntry[]
}