44 Commits

Author SHA1 Message Date
C_Ma_For 4db43f833b feat(plan4): page /parametres avec onglets Connexions + Utilisateurs 2026-06-08 23:29:32 +02:00
C_Ma_For 4e4268d678 feat(plan4): composants ConnectionStatus + UsersManager 2026-06-08 23:28:29 +02:00
C_Ma_For 1934d0b655 feat(plan4): page /produits avec catalogue Shopify + filtres 2026-06-08 23:26:41 +02:00
C_Ma_For 119123e006 feat(plan4): composants ProductFiltersBar + ProductTable 2026-06-08 23:26:01 +02:00
C_Ma_For 5825640105 feat(plan4): API GET /api/admin/connections/test 2026-06-08 23:23:53 +02:00
C_Ma_For f27b62a403 feat(plan4): API CRUD /api/admin/users (TDD) 2026-06-08 23:23:10 +02:00
C_Ma_For 9303aab78e feat(plan4): API GET /api/products/list avec filtre statut (TDD) 2026-06-08 23:21:18 +02:00
C_Ma_For 6dd6dc301e feat(plan4): types CatalogProduct, ProductFilters, ProductListResponse 2026-06-08 23:18:29 +02:00
C_Ma_For e576df334a fix: fusionner imports next/server dupliqués 2026-06-08 23:05:51 +02:00
C_Ma_For dc88e56f49 fix: supprimer API_VERSION inutilisé + eslint-disable _req 2026-06-08 22:59:30 +02:00
C_Ma_For e04e286111 fix: API Key Google Sheets (remplace service account) + signatures routes GET 2026-06-08 22:52:40 +02:00
C_Ma_For abf5210de6 fix: corriger erreurs ESLint sync (apostrophes, imports inutilisés)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:37:54 +02:00
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
C_Ma_For cf5bbf91d5 feat: module Images complet — drop, traitement IA, Shopify, upload 2026-06-08 20:00:20 +02:00
C_Ma_For fe9c3b9ccb fix: inclure 'uploading' dans le compteur processing de GlobalActions 2026-06-08 19:18:40 +02:00
C_Ma_For 568fe0d38e feat: composants Lightbox plein écran + GlobalActions barre d'upload 2026-06-08 17:45:22 +02:00
C_Ma_For 683db58f4b feat: composant ImageCard avec miniatures avant/après, feather, rotation, upload 2026-06-08 17:44:45 +02:00
C_Ma_For 4c1cf73435 feat: composant DropZone drag & drop HEIC/JPG/PNG/ZIP 2026-06-08 17:43:40 +02:00
C_Ma_For 901199c45d feat: hook useImages — pipeline HEIC→IA→feather→rotation→search→upload 2026-06-08 17:41:00 +02:00
C_Ma_For 50469700d0 feat: utilitaires client heicConverter, bgRemover, imageRotator, zipImporter (TS)
Porte les 4 utilitaires de traitement d'images en TypeScript pour utilisation côté client :
- heicConverter.ts : convertit HEIC/HEIF en JPEG via heic2any
- bgRemover.ts : supprime les fonds d'images avec @imgly/background-removal + composite sur blanc
- imageRotator.ts : pivote les images JPEG via Canvas
- zipImporter.ts : extrait les images d'un fichier ZIP avec structure dossier-ref/image.ext

Tous les modules utilisent des APIs du navigateur et doivent être importés dynamiquement depuis du code 'use client'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:30:01 +02:00
C_Ma_For a5ced27e93 feat: API route POST /api/images/upload (TDD) 2026-06-08 17:25:40 +02:00
C_Ma_For c9422be78e feat: API route GET /api/products/search (TDD)
Implémentation test-driven du endpoint de recherche de produits par référence.
- Tests unitaires complets validant les cas nominaux et d'erreur
- Validation du paramètre ref avec gestion des chaînes vides
- Intégration avec searchProductByRef de lib/shopify
- Gestion appropriée des erreurs HTTP (400, 500)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:15:58 +02:00
C_Ma_For 53008d1d43 fix: valider data.image.src dans uploadProductImage 2026-06-08 17:10:26 +02:00
C_Ma_For 9675366db9 feat: client Shopify Admin REST API (search + upload) 2026-06-08 16:21:55 +02:00
C_Ma_For c97a7faaa9 feat: types ImageItem + dépendances heic2any jszip
- Install heic2any and jszip dependencies
- Create src/types/images.ts with ImageItem, ImageStatus, ImageProgress, and ZipEntry types
- heic2any includes bundled TypeScript types (no declaration file needed)
- ImageItem is the central data model for the image processing module with support for:
  - HEIC to JPEG conversion
  - AI-powered background removal
  - Shopify product lookup and upload
  - Progress tracking and error handling
  - Rotation and feather adjustments (cached PNG blob for fast re-processing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 15:47:34 +02:00
C_Ma_For b25a712a69 fix: correct adapter import name PrismaBetterSqlite3 in seed.mjs 2026-06-07 11:42:57 +02:00
C_Ma_For ee70ea377f fix: use better-sqlite3 adapter in seed.mjs (Prisma 7 requirement) 2026-06-07 11:41:42 +02:00
C_Ma_For 8bd49050f6 fix: add .dockerignore to exclude .env files from build context (was overriding DATABASE_URL) 2026-06-07 02:00:21 +02:00
C_Ma_For 79ebb6ced3 fix: use npm ci --omit=dev in runner (resolves all transitive prisma deps) 2026-06-07 01:37:03 +02:00
C_Ma_For d80e50e9de fix: recreate .bin/prisma as symlink in runner (preserves __dirname for WASM resolution) 2026-06-07 01:32:37 +02:00
C_Ma_For f022238a40 fix: copy full node_modules/prisma package for WASM files in runner stage 2026-06-07 01:28:41 +02:00
C_Ma_For f23c954ecd chore: add public/ directory (required by Next.js standalone build) 2026-06-07 01:25:00 +02:00
C_Ma_For 96700ae252 fix: eslint disable-next-line for required any casts (NextAuth v4 limitation) 2026-06-07 01:22:07 +02:00
53 changed files with 3665 additions and 86 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
.next
.env
.env.local
.env.production
.env.development
data/
*.db
.git
README.md
+20 -9
View File
@@ -1,11 +1,11 @@
# Étape 1 : dépendances # Étape 1 : dépendances complètes (dev incluses pour le build)
FROM node:20-alpine AS deps FROM node:20-alpine AS deps
WORKDIR /app WORKDIR /app
RUN apk add --no-cache python3 make g++ RUN apk add --no-cache python3 make g++
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
# Étape 2 : build # Étape 2 : build Next.js
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
RUN apk add --no-cache python3 make g++ RUN apk add --no-cache python3 make g++
@@ -20,25 +20,36 @@ FROM node:20-alpine AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
# Outils natifs requis pour better-sqlite3
RUN apk add --no-cache python3 make g++ RUN apk add --no-cache python3 make g++
RUN addgroup --system --gid 1001 nodejs \ RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs && adduser --system --uid 1001 nextjs
# Installer uniquement les dépendances de production
# (résout automatiquement toutes les dépendances transitives : effect, dotenv, etc.)
COPY package*.json ./
RUN npm ci --omit=dev
# Ajouter le CLI Prisma (devDependency → non installé par --omit=dev)
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
RUN ln -sf /app/node_modules/prisma/build/index.js /app/node_modules/.bin/prisma
# Client Prisma généré (spécifique à la plateforme linux/alpine)
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
# Artifacts du build Next.js
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/.next/static ./.next/static
# Schema + migrations Prisma
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/node_modules/.bin/prisma ./node_modules/.bin/prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
COPY --from=builder /app/node_modules/@prisma/adapter-better-sqlite3 ./node_modules/@prisma/adapter-better-sqlite3
COPY --from=builder /app/node_modules/dotenv ./node_modules/dotenv
COPY scripts/start.sh ./start.sh COPY scripts/start.sh ./start.sh
RUN mkdir -p data && chown -R nextjs:nodejs data . RUN mkdir -p data && chown -R nextjs:nodejs data . && chmod +x start.sh
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
+413 -63
View File
@@ -12,6 +12,9 @@
"@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",
"heic2any": "^0.0.4",
"jszip": "^3.10.1",
"next": "14.2.35", "next": "14.2.35",
"next-auth": "^4.24.14", "next-auth": "^4.24.14",
"react": "^18", "react": "^18",
@@ -927,7 +930,6 @@
}, },
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"string-width": "^5.1.2", "string-width": "^5.1.2",
@@ -943,7 +945,6 @@
}, },
"node_modules/@isaacs/cliui/node_modules/ansi-regex": { "node_modules/@isaacs/cliui/node_modules/ansi-regex": {
"version": "6.2.2", "version": "6.2.2",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -954,7 +955,6 @@
}, },
"node_modules/@isaacs/cliui/node_modules/strip-ansi": { "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
"version": "7.2.0", "version": "7.2.0",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^6.2.2" "ansi-regex": "^6.2.2"
@@ -1945,7 +1945,6 @@
}, },
"node_modules/@pkgjs/parseargs": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"engines": { "engines": {
@@ -3403,7 +3402,6 @@
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 14" "node": ">= 14"
@@ -3455,7 +3453,6 @@
}, },
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -3463,7 +3460,6 @@
}, },
"node_modules/ansi-styles": { "node_modules/ansi-styles": {
"version": "4.3.0", "version": "4.3.0",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
@@ -4291,7 +4287,6 @@
}, },
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-js": { "node_modules/base64-js": {
@@ -4357,6 +4352,15 @@
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" "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": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"dev": true, "dev": true,
@@ -4489,6 +4493,12 @@
"ieee754": "^1.1.13" "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": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -4593,7 +4603,6 @@
}, },
"node_modules/call-bind-apply-helpers": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.2", "version": "1.0.2",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -4605,7 +4614,6 @@
}, },
"node_modules/call-bound": { "node_modules/call-bound": {
"version": "1.0.4", "version": "1.0.4",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.2", "call-bind-apply-helpers": "^1.0.2",
@@ -4844,7 +4852,6 @@
}, },
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@@ -4855,7 +4862,6 @@
}, },
"node_modules/color-name": { "node_modules/color-name": {
"version": "1.1.4", "version": "1.1.4",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/commander": { "node_modules/commander": {
@@ -4894,6 +4900,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/create-require": { "node_modules/create-require": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@@ -4903,7 +4915,6 @@
}, },
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"path-key": "^3.1.0", "path-key": "^3.1.0",
@@ -4956,6 +4967,15 @@
"dev": true, "dev": true,
"license": "BSD-2-Clause" "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": { "node_modules/data-urls": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
@@ -5020,7 +5040,6 @@
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -5245,7 +5264,6 @@
}, },
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.1", "call-bind-apply-helpers": "^1.0.1",
@@ -5258,9 +5276,17 @@
}, },
"node_modules/eastasianwidth": { "node_modules/eastasianwidth": {
"version": "0.2.0", "version": "0.2.0",
"dev": true,
"license": "MIT" "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": { "node_modules/effect": {
"version": "3.20.0", "version": "3.20.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
@@ -5294,7 +5320,6 @@
}, },
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "9.2.2", "version": "9.2.2",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/empathic": { "node_modules/empathic": {
@@ -5354,7 +5379,6 @@
}, },
"node_modules/es-define-property": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -5362,7 +5386,6 @@
}, },
"node_modules/es-errors": { "node_modules/es-errors": {
"version": "1.3.0", "version": "1.3.0",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -5465,7 +5488,6 @@
}, },
"node_modules/es-object-atoms": { "node_modules/es-object-atoms": {
"version": "1.1.2", "version": "1.1.2",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0" "es-errors": "^1.3.0"
@@ -6008,6 +6030,12 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/fast-check": {
"version": "3.23.2", "version": "3.23.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
@@ -6107,6 +6135,29 @@
"bser": "2.1.1" "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": { "node_modules/file-entry-cache": {
"version": "6.0.1", "version": "6.0.1",
"dev": true, "dev": true,
@@ -6184,7 +6235,6 @@
}, },
"node_modules/foreground-child": { "node_modules/foreground-child": {
"version": "3.3.1", "version": "3.3.1",
"devOptional": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.6",
@@ -6197,6 +6247,18 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/fs-constants": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -6222,7 +6284,6 @@
}, },
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
@@ -6255,6 +6316,34 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/generate-function": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
@@ -6295,7 +6384,6 @@
}, },
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.2", "call-bind-apply-helpers": "^1.0.2",
@@ -6335,7 +6423,6 @@
}, },
"node_modules/get-proto": { "node_modules/get-proto": {
"version": "1.0.1", "version": "1.0.1",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"dunder-proto": "^1.0.1", "dunder-proto": "^1.0.1",
@@ -6403,7 +6490,6 @@
}, },
"node_modules/glob": { "node_modules/glob": {
"version": "10.3.10", "version": "10.3.10",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"foreground-child": "^3.1.0", "foreground-child": "^3.1.0",
@@ -6435,7 +6521,6 @@
}, },
"node_modules/glob/node_modules/brace-expansion": { "node_modules/glob/node_modules/brace-expansion": {
"version": "2.1.1", "version": "2.1.1",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
@@ -6443,7 +6528,6 @@
}, },
"node_modules/glob/node_modules/minimatch": { "node_modules/glob/node_modules/minimatch": {
"version": "9.0.9", "version": "9.0.9",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.2" "brace-expansion": "^2.0.2"
@@ -6484,9 +6568,112 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -6518,6 +6705,19 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "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",
@@ -6586,7 +6786,6 @@
}, },
"node_modules/has-symbols": { "node_modules/has-symbols": {
"version": "1.1.0", "version": "1.1.0",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -6611,7 +6810,6 @@
}, },
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.4", "version": "2.0.4",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -6620,6 +6818,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/heic2any": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz",
"integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==",
"license": "MIT"
},
"node_modules/hono": { "node_modules/hono": {
"version": "4.12.23", "version": "4.12.23",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
@@ -6675,7 +6879,6 @@
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"agent-base": "^7.1.2", "agent-base": "^7.1.2",
@@ -6740,6 +6943,12 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"dev": true, "dev": true,
@@ -6994,7 +7203,6 @@
}, },
"node_modules/is-fullwidth-code-point": { "node_modules/is-fullwidth-code-point": {
"version": "3.0.0", "version": "3.0.0",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -7253,7 +7461,6 @@
}, },
"node_modules/isexe": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"devOptional": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/istanbul-lib-coverage": { "node_modules/istanbul-lib-coverage": {
@@ -7345,7 +7552,6 @@
}, },
"node_modules/jackspeak": { "node_modules/jackspeak": {
"version": "2.3.6", "version": "2.3.6",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"@isaacs/cliui": "^8.0.2" "@isaacs/cliui": "^8.0.2"
@@ -8513,6 +8719,15 @@
"node": ">=6" "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": { "node_modules/json-buffer": {
"version": "3.0.1", "version": "3.0.1",
"dev": true, "dev": true,
@@ -8560,6 +8775,75 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"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": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"dev": true, "dev": true,
@@ -8606,6 +8890,15 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lilconfig": { "node_modules/lilconfig": {
"version": "3.1.3", "version": "3.1.3",
"dev": true, "dev": true,
@@ -8667,7 +8960,6 @@
}, },
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "10.4.3", "version": "10.4.3",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/lru.min": { "node_modules/lru.min": {
@@ -8732,7 +9024,6 @@
}, },
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -8817,7 +9108,6 @@
}, },
"node_modules/minipass": { "node_modules/minipass": {
"version": "7.1.3", "version": "7.1.3",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
@@ -8831,7 +9121,6 @@
}, },
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/mysql2": { "node_modules/mysql2": {
@@ -9046,6 +9335,26 @@
"node": ">=10" "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": { "node_modules/node-exports-info": {
"version": "1.6.0", "version": "1.6.0",
"dev": true, "dev": true,
@@ -9071,6 +9380,24 @@
"semver": "bin/semver.js" "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": { "node_modules/node-int64": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -9140,7 +9467,6 @@
}, },
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -9527,6 +9853,12 @@
"dev": true, "dev": true,
"license": "BlueOak-1.0.0" "license": "BlueOak-1.0.0"
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"dev": true, "dev": true,
@@ -9588,7 +9920,6 @@
}, },
"node_modules/path-key": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -9601,7 +9932,6 @@
}, },
"node_modules/path-scurry": { "node_modules/path-scurry": {
"version": "1.11.1", "version": "1.11.1",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"lru-cache": "^10.2.0", "lru-cache": "^10.2.0",
@@ -10028,6 +10358,12 @@
} }
} }
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"dev": true, "dev": true,
@@ -10092,6 +10428,21 @@
], ],
"license": "MIT" "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": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"dev": true, "dev": true,
@@ -10671,9 +11022,14 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"shebang-regex": "^3.0.0" "shebang-regex": "^3.0.0"
@@ -10684,7 +11040,6 @@
}, },
"node_modules/shebang-regex": { "node_modules/shebang-regex": {
"version": "3.0.0", "version": "3.0.0",
"devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -10692,7 +11047,6 @@
}, },
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.0",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -10710,7 +11064,6 @@
}, },
"node_modules/side-channel-list": { "node_modules/side-channel-list": {
"version": "1.0.1", "version": "1.0.1",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -10725,7 +11078,6 @@
}, },
"node_modules/side-channel-map": { "node_modules/side-channel-map": {
"version": "1.0.1", "version": "1.0.1",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bound": "^1.0.2", "call-bound": "^1.0.2",
@@ -10742,7 +11094,6 @@
}, },
"node_modules/side-channel-weakmap": { "node_modules/side-channel-weakmap": {
"version": "1.0.2", "version": "1.0.2",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bound": "^1.0.2", "call-bound": "^1.0.2",
@@ -10760,7 +11111,6 @@
}, },
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "4.1.0", "version": "4.1.0",
"devOptional": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">=14" "node": ">=14"
@@ -10947,7 +11297,6 @@
}, },
"node_modules/string-width": { "node_modules/string-width": {
"version": "5.1.2", "version": "5.1.2",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"eastasianwidth": "^0.2.0", "eastasianwidth": "^0.2.0",
@@ -10964,7 +11313,6 @@
"node_modules/string-width-cjs": { "node_modules/string-width-cjs": {
"name": "string-width", "name": "string-width",
"version": "4.2.3", "version": "4.2.3",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
@@ -10977,12 +11325,10 @@
}, },
"node_modules/string-width-cjs/node_modules/emoji-regex": { "node_modules/string-width-cjs/node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/string-width/node_modules/ansi-regex": { "node_modules/string-width/node_modules/ansi-regex": {
"version": "6.2.2", "version": "6.2.2",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -10993,7 +11339,6 @@
}, },
"node_modules/string-width/node_modules/strip-ansi": { "node_modules/string-width/node_modules/strip-ansi": {
"version": "7.2.0", "version": "7.2.0",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^6.2.2" "ansi-regex": "^6.2.2"
@@ -11385,7 +11730,6 @@
}, },
"node_modules/strip-ansi": { "node_modules/strip-ansi": {
"version": "6.0.1", "version": "6.0.1",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
@@ -11397,7 +11741,6 @@
"node_modules/strip-ansi-cjs": { "node_modules/strip-ansi-cjs": {
"name": "strip-ansi", "name": "strip-ansi",
"version": "6.0.1", "version": "6.0.1",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
@@ -12184,6 +12527,12 @@
"punycode": "^2.1.0" "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": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"license": "MIT" "license": "MIT"
@@ -12258,6 +12607,15 @@
"makeerror": "1.0.12" "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": { "node_modules/webidl-conversions": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
@@ -12321,7 +12679,6 @@
}, },
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"devOptional": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"isexe": "^2.0.0" "isexe": "^2.0.0"
@@ -12431,7 +12788,6 @@
}, },
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "8.1.0", "version": "8.1.0",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-styles": "^6.1.0", "ansi-styles": "^6.1.0",
@@ -12448,7 +12804,6 @@
"node_modules/wrap-ansi-cjs": { "node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi", "name": "wrap-ansi",
"version": "7.0.0", "version": "7.0.0",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-styles": "^4.0.0", "ansi-styles": "^4.0.0",
@@ -12464,12 +12819,10 @@
}, },
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/wrap-ansi-cjs/node_modules/string-width": { "node_modules/wrap-ansi-cjs/node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
@@ -12482,7 +12835,6 @@
}, },
"node_modules/wrap-ansi/node_modules/ansi-regex": { "node_modules/wrap-ansi/node_modules/ansi-regex": {
"version": "6.2.2", "version": "6.2.2",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -12493,7 +12845,6 @@
}, },
"node_modules/wrap-ansi/node_modules/ansi-styles": { "node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "6.2.3", "version": "6.2.3",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -12504,7 +12855,6 @@
}, },
"node_modules/wrap-ansi/node_modules/strip-ansi": { "node_modules/wrap-ansi/node_modules/strip-ansi": {
"version": "7.2.0", "version": "7.2.0",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^6.2.2" "ansi-regex": "^6.2.2"
+3
View File
@@ -17,6 +17,9 @@
"@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",
"heic2any": "^0.0.4",
"jszip": "^3.10.1",
"next": "14.2.35", "next": "14.2.35",
"next-auth": "^4.24.14", "next-auth": "^4.24.14",
"react": "^18", "react": "^18",
+13 -5
View File
@@ -1,7 +1,7 @@
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
import { hash } from 'bcryptjs' import { hash } from 'bcryptjs'
import path from 'path'
const prisma = new PrismaClient()
const password = process.env.SEED_ADMIN_PASSWORD const password = process.env.SEED_ADMIN_PASSWORD
if (!password) { if (!password) {
@@ -9,14 +9,22 @@ if (!password) {
process.exit(1) process.exit(1)
} }
const dbUrl = process.env.DATABASE_URL ?? 'file:./data/app.db'
const dbPath = dbUrl.startsWith('file:')
? path.resolve(process.cwd(), dbUrl.slice('file:'.length))
: dbUrl
const adapter = new PrismaBetterSqlite3({ url: dbPath })
const prisma = new PrismaClient({ adapter })
await prisma.user.upsert({ await prisma.user.upsert({
where: { email: 'mathieu.fort@gmail.com' }, where: { email: 'mathieu.fort@gmail.com' },
update: {}, update: {},
create: { create: {
email: 'mathieu.fort@gmail.com', email: 'mathieu.fort@gmail.com',
name: 'Mathieu', name: 'Mathieu',
password: await hash(password, 12), password: await hash(password, 12),
role: 'admin', role: 'admin',
}, },
}) })
console.log('✅ Compte admin créé : mathieu.fort@gmail.com') console.log('✅ Compte admin créé : mathieu.fort@gmail.com')
View File
+75
View File
@@ -0,0 +1,75 @@
import { POST } from '@/app/api/images/upload/route'
import { NextRequest } from 'next/server'
jest.mock('@/lib/shopify', () => ({
uploadProductImage: jest.fn(),
}))
import { uploadProductImage } from '@/lib/shopify'
const mockUpload = uploadProductImage as jest.Mock
describe('POST /api/images/upload', () => {
beforeEach(() => jest.clearAllMocks())
it('retourne 400 si shopifyProductId manquant', async () => {
const req = new NextRequest('http://localhost/api/images/upload', {
method: 'POST',
body: JSON.stringify({ imageBase64: 'abc', filename: 'test.jpg' }),
headers: { 'Content-Type': 'application/json' },
})
const res = await POST(req)
expect(res.status).toBe(400)
})
it('retourne 400 si imageBase64 manquant', async () => {
const req = new NextRequest('http://localhost/api/images/upload', {
method: 'POST',
body: JSON.stringify({ shopifyProductId: '123', filename: 'test.jpg' }),
headers: { 'Content-Type': 'application/json' },
})
const res = await POST(req)
expect(res.status).toBe(400)
})
it('retourne 400 si filename manquant', async () => {
const req = new NextRequest('http://localhost/api/images/upload', {
method: 'POST',
body: JSON.stringify({ shopifyProductId: '123', imageBase64: 'abc' }),
headers: { 'Content-Type': 'application/json' },
})
const res = await POST(req)
expect(res.status).toBe(400)
})
it('retourne l\'URL après upload réussi', async () => {
mockUpload.mockResolvedValue({ url: 'https://cdn.shopify.com/image.jpg' })
const req = new NextRequest('http://localhost/api/images/upload', {
method: 'POST',
body: JSON.stringify({
shopifyProductId: '123456',
imageBase64: 'base64data==',
filename: 'REF-1042.jpg',
}),
headers: { 'Content-Type': 'application/json' },
})
const res = await POST(req)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ url: 'https://cdn.shopify.com/image.jpg' })
expect(mockUpload).toHaveBeenCalledWith('123456', 'base64data==', 'REF-1042.jpg')
})
it('retourne 500 en cas d\'erreur Shopify', async () => {
mockUpload.mockRejectedValue(new Error('Shopify upload error: 422'))
const req = new NextRequest('http://localhost/api/images/upload', {
method: 'POST',
body: JSON.stringify({
shopifyProductId: '123',
imageBase64: 'abc',
filename: 'test.jpg',
}),
headers: { 'Content-Type': 'application/json' },
})
const res = await POST(req)
expect(res.status).toBe(500)
})
})
+53
View File
@@ -0,0 +1,53 @@
import { GET } from '@/app/api/products/search/route'
import { NextRequest } from 'next/server'
jest.mock('@/lib/shopify', () => ({
searchProductByRef: jest.fn(),
}))
import { searchProductByRef } from '@/lib/shopify'
const mockSearch = searchProductByRef as jest.Mock
describe('GET /api/products/search', () => {
beforeEach(() => jest.clearAllMocks())
it('retourne 400 si le paramètre ref est absent', async () => {
const req = new NextRequest('http://localhost/api/products/search')
const res = await GET(req)
expect(res.status).toBe(400)
const data = await res.json()
expect(data.error).toMatch(/ref/)
})
it('retourne 400 si ref est une chaîne vide', async () => {
const req = new NextRequest('http://localhost/api/products/search?ref=')
const res = await GET(req)
expect(res.status).toBe(400)
})
it('retourne { found: false } si produit introuvable', async () => {
mockSearch.mockResolvedValue({ found: false })
const req = new NextRequest('http://localhost/api/products/search?ref=REF-9999')
const res = await GET(req)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ found: false })
expect(mockSearch).toHaveBeenCalledWith('REF-9999')
})
it('retourne le produit trouvé', async () => {
mockSearch.mockResolvedValue({ found: true, shopifyId: '123456', title: 'Carrelage REF-1042' })
const req = new NextRequest('http://localhost/api/products/search?ref=REF-1042')
const res = await GET(req)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ found: true, shopifyId: '123456', title: 'Carrelage REF-1042' })
})
it('retourne 500 en cas d\'erreur Shopify', async () => {
mockSearch.mockRejectedValue(new Error('Shopify API error: 503'))
const req = new NextRequest('http://localhost/api/products/search?ref=REF-1042')
const res = await GET(req)
expect(res.status).toBe(500)
const data = await res.json()
expect(data.error).toBeTruthy()
})
})
+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)
})
})
+1
View File
@@ -22,6 +22,7 @@ import { checkRateLimit, recordFailedAttempt, clearAttempts } from '@/lib/rate-l
const mockFindUnique = prisma.user.findUnique as jest.Mock const mockFindUnique = prisma.user.findUnique as jest.Mock
describe('NextAuth authorize', () => { describe('NextAuth authorize', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const authorize = (authOptions.providers[0] as any).options.authorize const authorize = (authOptions.providers[0] as any).options.authorize
beforeEach(() => jest.clearAllMocks()) beforeEach(() => jest.clearAllMocks())
+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
})
})
+54 -2
View File
@@ -1,9 +1,61 @@
'use client'
import { useState } from 'react'
import { Header } from '@/components/layout/Header' import { Header } from '@/components/layout/Header'
import { DropZone } from '@/components/images/DropZone'
import { ImageCard } from '@/components/images/ImageCard'
import { Lightbox } from '@/components/images/Lightbox'
import { GlobalActions } from '@/components/images/GlobalActions'
import { useImages } from '@/hooks/useImages'
import type { ImageItem } from '@/types/images'
export default function ImagesPage() { export default function ImagesPage() {
const { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem } = useImages()
const [lightboxItem, setLightboxItem] = useState<ImageItem | null>(null)
return ( return (
<> <>
<Header title="🖼️ Images" /> <Header
<div className="p-6"><p className="text-gray-400 text-sm">Module Images à implémenter (Plan 2)</p></div> title="🖼️ Images"
actions={
<span className="text-xs text-slate-500">
{items.length > 0 ? `${items.length} image${items.length > 1 ? 's' : ''}` : ''}
</span>
}
/>
<div className="p-6 flex flex-col gap-4 flex-1 overflow-y-auto">
<DropZone onFiles={addFiles} />
{items.length > 0 && (
<GlobalActions items={items} onUploadAll={uploadAll} />
)}
{items.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{items.map((item) => (
<ImageCard
key={item.id}
item={item}
onFeatherChange={setFeather}
onRotate={rotate}
onUpload={uploadOne}
onRemove={removeItem}
onOpenLightbox={setLightboxItem}
/>
))}
</div>
)}
{items.length === 0 && (
<div className="text-center py-12 text-slate-600">
<p className="text-4xl mb-3">📂</p>
<p className="text-sm">Déposez des images pour commencer</p>
<p className="text-xs mt-1">Le nom du fichier (sans extension) doit correspondre à une référence produit Shopify</p>
</div>
)}
</div>
<Lightbox item={lightboxItem} onClose={() => setLightboxItem(null)} />
</> </>
) )
} }
+34 -2
View File
@@ -1,9 +1,41 @@
'use client'
import { useState } from 'react'
import { useSession } from 'next-auth/react'
import { Header } from '@/components/layout/Header' import { Header } from '@/components/layout/Header'
import { UsersManager } from '@/components/settings/UsersManager'
import { ConnectionStatus } from '@/components/settings/ConnectionStatus'
type Tab = 'connexions' | 'utilisateurs'
export default function ParametresPage() { export default function ParametresPage() {
const { data: session } = useSession()
const isAdmin = session?.user?.role === 'admin'
const [tab, setTab] = useState<Tab>('connexions')
return ( return (
<> <>
<Header title="⚙️ Paramètres" /> <Header title="Paramètres" />
<div className="p-6"><p className="text-gray-400 text-sm">Module Paramètres à implémenter (Plan 4)</p></div> <div className="p-6 space-y-6">
{isAdmin && (
<div className="flex gap-2">
{(['connexions', 'utilisateurs'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
tab === t
? 'bg-indigo-600 text-white'
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
}`}
>
{t === 'connexions' ? 'Connexions' : 'Utilisateurs'}
</button>
))}
</div>
)}
{(!isAdmin || tab === 'connexions') && <ConnectionStatus />}
{isAdmin && tab === 'utilisateurs' && <UsersManager />}
</div>
</> </>
) )
} }
+49 -2
View File
@@ -1,9 +1,56 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { Header } from '@/components/layout/Header' import { Header } from '@/components/layout/Header'
import { ProductFiltersBar } from '@/components/products/ProductFiltersBar'
import { ProductTable } from '@/components/products/ProductTable'
import type { CatalogProduct, ProductFilters } from '@/types/products'
export default function ProduitsPage() { export default function ProduitsPage() {
const [allProducts, setAllProducts] = useState<CatalogProduct[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filters, setFilters] = useState<ProductFilters>({ status: 'all', search: '' })
useEffect(() => {
setLoading(true)
setError(null)
const params = filters.status !== 'all' ? `?status=${filters.status}` : ''
fetch(`/api/products/list${params}`)
.then((r) => r.json())
.then((data) => {
if (data.error) throw new Error(data.error)
setAllProducts(data.products)
})
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false))
}, [filters.status])
const displayed = useMemo(() => {
if (!filters.search.trim()) return allProducts
const q = filters.search.toLowerCase()
return allProducts.filter(
(p) => p.title.toLowerCase().includes(q) || p.ref.toLowerCase().includes(q),
)
}, [allProducts, filters.search])
return ( return (
<> <>
<Header title="📦 Produits" /> <Header title="Produits" />
<div className="p-6"><p className="text-gray-400 text-sm">Module Produits à implémenter (Plan 4)</p></div> <div className="p-6">
<div className="bg-slate-800 rounded-xl p-6">
<ProductFiltersBar
filters={filters}
total={displayed.length}
onChange={(f) => setFilters((prev) => ({ ...prev, ...f }))}
/>
{error && (
<div className="mb-4 p-3 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">
{error}
</div>
)}
<ProductTable products={displayed} loading={loading} />
</div>
</div>
</> </>
) )
} }
+214 -1
View File
@@ -1,9 +1,222 @@
'use client'
import { useState, useCallback } from 'react'
import { Header } from '@/components/layout/Header' 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() { 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 ( return (
<> <>
<Header title="🔄 Sync Sheets" /> <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&apos;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&apos;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>
</> </>
) )
} }
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
async function testShopify(): Promise<{ ok: boolean; message: string }> {
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) return { ok: false, message: 'Variables manquantes' }
try {
const res = await fetch(`https://${domain}/admin/api/2024-01/shop.json`, {
headers: { 'X-Shopify-Access-Token': token },
})
if (!res.ok) return { ok: false, message: `HTTP ${res.status}` }
const data = await res.json()
return { ok: true, message: `Boutique : ${data.shop?.name ?? domain}` }
} catch (e) {
return { ok: false, message: e instanceof Error ? e.message : 'Erreur réseau' }
}
}
async function testSheets(): Promise<{ ok: boolean; message: string }> {
const apiKey = process.env.GOOGLE_API_KEY
const sheetId = process.env.GOOGLE_SHEETS_ID
if (!apiKey || !sheetId) return { ok: false, message: 'Variables manquantes' }
try {
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=properties.title`
const res = await fetch(url)
if (!res.ok) return { ok: false, message: `HTTP ${res.status}` }
const data = await res.json()
return { ok: true, message: `Feuille : ${data.properties?.title ?? sheetId}` }
} catch (e) {
return { ok: false, message: e instanceof Error ? e.message : 'Erreur réseau' }
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function GET(_req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session || session.user.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const [shopify, sheets] = await Promise.all([testShopify(), testSheets()])
return NextResponse.json({ shopify, sheets })
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs'
async function requireAdmin() {
const session = await getServerSession(authOptions)
if (!session || session.user.role !== 'admin') return null
return session
}
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
const session = await requireAdmin()
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const body = await req.json().catch(() => ({}))
const { name, role, password } = body as Record<string, string>
const data: Record<string, string> = {}
if (name) data.name = name
if (role) data.role = role
if (password) data.password = await bcrypt.hash(password, 12)
if (Object.keys(data).length === 0) {
return NextResponse.json({ error: 'Aucun champ à mettre à jour' }, { status: 400 })
}
const user = await prisma.user.update({
where: { id: params.id },
data,
select: { id: true, email: true, name: true, role: true, createdAt: true },
})
return NextResponse.json(user)
}
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
const session = await requireAdmin()
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
if (params.id === session.user.id) {
return NextResponse.json({ error: 'Impossible de se supprimer soi-même' }, { status: 400 })
}
await prisma.user.delete({ where: { id: params.id } })
return NextResponse.json({ ok: true })
}
+61
View File
@@ -0,0 +1,61 @@
import { GET, POST } from './route'
import { getServerSession } from 'next-auth'
import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs'
jest.mock('next-auth')
jest.mock('@/lib/prisma', () => ({ prisma: { user: { findMany: jest.fn(), create: jest.fn() } } }))
jest.mock('bcryptjs')
const mockSession = getServerSession as jest.MockedFunction<typeof getServerSession>
const mockFindMany = prisma.user.findMany as jest.MockedFunction<typeof prisma.user.findMany>
const mockCreate = prisma.user.create as jest.MockedFunction<typeof prisma.user.create>
const mockHash = bcrypt.hash as jest.MockedFunction<typeof bcrypt.hash>
const adminSession = { user: { id: 'admin-id', role: 'admin' } }
const gestSession = { user: { id: 'gest-id', role: 'gestionnaire' } }
describe('GET /api/admin/users', () => {
it('retourne 403 si non admin', async () => {
mockSession.mockResolvedValue(gestSession as never)
const res = await GET(new Request('http://localhost') as never)
expect(res.status).toBe(403)
})
it('retourne la liste des utilisateurs', async () => {
mockSession.mockResolvedValue(adminSession as never)
mockFindMany.mockResolvedValue([
{ id: '1', email: 'a@b.com', name: 'Alice', role: 'admin', createdAt: new Date() } as never,
])
const res = await GET(new Request('http://localhost') as never)
const body = await res.json()
expect(body).toHaveLength(1)
expect(body[0].email).toBe('a@b.com')
expect(body[0].password).toBeUndefined()
})
})
describe('POST /api/admin/users', () => {
it('retourne 403 si non admin', async () => {
mockSession.mockResolvedValue(gestSession as never)
const res = await POST(new Request('http://localhost', { method: 'POST', body: '{}' }) as never)
expect(res.status).toBe(403)
})
it('crée un utilisateur avec mot de passe hashé', async () => {
mockSession.mockResolvedValue(adminSession as never)
mockHash.mockResolvedValue('hashed' as never)
mockCreate.mockResolvedValue({ id: 'new', email: 'b@c.com', name: 'Bob', role: 'gestionnaire', createdAt: new Date() } as never)
const body = JSON.stringify({ email: 'b@c.com', name: 'Bob', password: 'pass123', role: 'gestionnaire' })
const res = await POST(new Request('http://localhost', { method: 'POST', body, headers: { 'Content-Type': 'application/json' } }) as never)
expect(res.status).toBe(201)
expect(mockHash).toHaveBeenCalledWith('pass123', 12)
})
it('retourne 400 si champs manquants', async () => {
mockSession.mockResolvedValue(adminSession as never)
const body = JSON.stringify({ email: 'b@c.com' })
const res = await POST(new Request('http://localhost', { method: 'POST', body, headers: { 'Content-Type': 'application/json' } }) as never)
expect(res.status).toBe(400)
})
})
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs'
async function requireAdmin() {
const session = await getServerSession(authOptions)
if (!session || session.user.role !== 'admin') return null
return session
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function GET(_req: NextRequest) {
const session = await requireAdmin()
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const users = await prisma.user.findMany({
orderBy: { createdAt: 'asc' },
select: { id: true, email: true, name: true, role: true, createdAt: true },
})
return NextResponse.json(users)
}
export async function POST(req: NextRequest) {
const session = await requireAdmin()
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const body = await req.json().catch(() => ({}))
const { email, name, password, role } = body as Record<string, string>
if (!email || !name || !password || !role) {
return NextResponse.json({ error: 'email, name, password et role requis' }, { status: 400 })
}
const hashed = await bcrypt.hash(password, 12)
const user = await prisma.user.create({
data: { email, name, password: hashed, role },
select: { id: true, email: true, name: true, role: true, createdAt: true },
})
return NextResponse.json(user, { status: 201 })
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server'
import { uploadProductImage } from '@/lib/shopify'
export async function POST(req: NextRequest) {
let body: Record<string, unknown>
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Corps JSON invalide' }, { status: 400 })
}
const { shopifyProductId, imageBase64, filename } = body
if (!shopifyProductId || typeof shopifyProductId !== 'string') {
return NextResponse.json({ error: 'shopifyProductId requis' }, { status: 400 })
}
if (!imageBase64 || typeof imageBase64 !== 'string') {
return NextResponse.json({ error: 'imageBase64 requis' }, { status: 400 })
}
if (!filename || typeof filename !== 'string') {
return NextResponse.json({ error: 'filename requis' }, { status: 400 })
}
try {
const result = await uploadProductImage(shopifyProductId, imageBase64, filename)
return NextResponse.json(result)
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+59
View File
@@ -0,0 +1,59 @@
import { GET } from './route'
import * as shopifySync from '@/lib/shopifySync'
import type { ShopifyProductFull } from '@/types/sync'
jest.mock('@/lib/shopifySync')
const mockFetch = shopifySync.fetchAllShopifyProducts as jest.MockedFunction<typeof shopifySync.fetchAllShopifyProducts>
const PRODUCTS: ShopifyProductFull[] = [
{ shopifyId: '1', ref: 'REF-001', handle: 'ref-001', title: 'Brique rouge', description: '', price: '12.50', stock: 100, status: 'active' },
{ shopifyId: '2', ref: 'REF-002', handle: 'ref-002', title: 'Parpaing', description: '', price: '5.00', stock: 0, status: 'draft' },
{ shopifyId: '3', ref: 'REF-003', handle: 'ref-003', title: 'Ardoise', description: '', price: '80.00', stock: 20, status: 'active' },
]
describe('GET /api/products/list', () => {
beforeEach(() => {
process.env.SHOPIFY_STORE_DOMAIN = 'test.myshopify.com'
process.env.SHOPIFY_ADMIN_API_TOKEN = 'token'
mockFetch.mockResolvedValue(PRODUCTS)
})
afterEach(() => jest.clearAllMocks())
it('retourne tous les produits sans filtre', async () => {
const req = new Request('http://localhost/api/products/list')
const res = await GET(req as never)
const body = await res.json()
expect(body.total).toBe(3)
expect(body.products).toHaveLength(3)
})
it('filtre par status=active', async () => {
const req = new Request('http://localhost/api/products/list?status=active')
const res = await GET(req as never)
const body = await res.json()
expect(body.total).toBe(2)
body.products.forEach((p: { status: string }) => expect(p.status).toBe('active'))
})
it('filtre par status=draft', async () => {
const req = new Request('http://localhost/api/products/list?status=draft')
const res = await GET(req as never)
const body = await res.json()
expect(body.total).toBe(1)
expect(body.products[0].ref).toBe('REF-002')
})
it('retourne shopifyUrl pour chaque produit', async () => {
const req = new Request('http://localhost/api/products/list')
const res = await GET(req as never)
const body = await res.json()
expect(body.products[0].shopifyUrl).toContain('test.myshopify.com/admin/products/1')
})
it('retourne 500 si Shopify inaccessible', async () => {
mockFetch.mockRejectedValue(new Error('network error'))
const req = new Request('http://localhost/api/products/list')
const res = await GET(req as never)
expect(res.status).toBe(500)
})
})
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server'
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
import type { CatalogProduct, ProductListResponse } from '@/types/products'
export async function GET(req: NextRequest): Promise<NextResponse<ProductListResponse | { error: string }>> {
try {
const { searchParams } = new URL(req.url)
const statusFilter = searchParams.get('status') ?? 'all'
const domain = process.env.SHOPIFY_STORE_DOMAIN ?? ''
const shopifyProducts = await fetchAllShopifyProducts()
let products: CatalogProduct[] = shopifyProducts.map((p) => ({
shopifyId: p.shopifyId,
ref: p.ref,
handle: p.handle,
title: p.title,
price: p.price,
stock: p.stock,
status: p.status as CatalogProduct['status'],
shopifyUrl: `https://${domain}/admin/products/${p.shopifyId}`,
}))
if (statusFilter !== 'all') {
products = products.filter((p) => p.status === statusFilter)
}
return NextResponse.json({ products, total: products.length })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+18
View File
@@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from 'next/server'
import { searchProductByRef } from '@/lib/shopify'
export async function GET(req: NextRequest) {
const ref = req.nextUrl.searchParams.get('ref')
if (!ref || ref.trim() === '') {
return NextResponse.json({ error: 'Paramètre ref requis' }, { status: 400 })
}
try {
const result = await searchProductByRef(ref.trim())
return NextResponse.json(result)
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+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' },
})
}
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import type { SyncHistoryEntry, SyncLogEntry } from '@/types/sync'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
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 })
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
import { readSheetProducts } from '@/lib/googleSheets'
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
import { computeDiff } from '@/lib/syncDiff'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
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 })
}
}
+73
View File
@@ -0,0 +1,73 @@
'use client'
import { useCallback, useRef, useState } from 'react'
interface DropZoneProps {
onFiles: (files: File[]) => void
disabled?: boolean
}
const ACCEPTED_TYPES = ['.heic', '.heif', '.jpg', '.jpeg', '.png', '.zip']
const ACCEPT_ATTR = ACCEPTED_TYPES.join(',')
export function DropZone({ onFiles, disabled = false }: DropZoneProps) {
const [isDragging, setIsDragging] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const handleFiles = useCallback(
(fileList: FileList | null) => {
if (!fileList) return
const files = Array.from(fileList).filter((f) => {
const ext = '.' + f.name.split('.').pop()?.toLowerCase()
return ACCEPTED_TYPES.includes(ext)
})
if (files.length > 0) onFiles(files)
},
[onFiles],
)
const onDragOver = (e: React.DragEvent) => {
e.preventDefault()
if (!disabled) setIsDragging(true)
}
const onDragLeave = () => setIsDragging(false)
const onDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
if (!disabled) handleFiles(e.dataTransfer.files)
}
return (
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={() => !disabled && inputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
isDragging
? 'border-indigo-400 bg-indigo-950/30'
: disabled
? 'border-slate-700 bg-slate-800/20 cursor-not-allowed'
: 'border-slate-600 bg-slate-800/10 hover:border-indigo-500 hover:bg-indigo-950/20'
}`}
>
<div className="text-3xl mb-2">🖼</div>
<p className="text-sm font-semibold text-slate-300">
Déposez vos images ici ou cliquez pour parcourir
</p>
<p className="text-xs text-slate-500 mt-1">
HEIC, JPG, PNG, ZIP le nom du fichier = référence produit
</p>
<input
ref={inputRef}
type="file"
multiple
accept={ACCEPT_ATTR}
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
disabled={disabled}
/>
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
'use client'
import type { ImageItem } from '@/types/images'
interface GlobalActionsProps {
items: ImageItem[]
onUploadAll: () => void
}
export function GlobalActions({ items, onUploadAll }: GlobalActionsProps) {
if (items.length === 0) return null
const ready = items.filter((i) => i.status === 'ready').length
const processing = items.filter((i) => ['pending', 'converting', 'processing', 'searching', 'uploading'].includes(i.status)).length
const notFound = items.filter((i) => i.status === 'not_found').length
const uploaded = items.filter((i) => i.status === 'uploaded').length
const errors = items.filter((i) => i.status === 'error').length
return (
<div className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 flex items-center gap-6 flex-wrap">
<div className="flex gap-4 text-xs text-slate-400 flex-1 flex-wrap">
{processing > 0 && <span> {processing} en traitement</span>}
{ready > 0 && <span className="text-green-400 font-semibold"> {ready} prêtes</span>}
{notFound > 0 && <span className="text-red-400"> {notFound} introuvables</span>}
{errors > 0 && <span className="text-red-400"> {errors} erreurs</span>}
{uploaded > 0 && <span className="text-slate-500"> {uploaded} uploadées</span>}
</div>
{ready > 0 && (
<button
onClick={onUploadAll}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors"
>
Tout uploader ({ready})
</button>
)}
</div>
)
}
+146
View File
@@ -0,0 +1,146 @@
'use client'
import { useEffect, useState } from 'react'
import type { ImageItem } from '@/types/images'
interface ImageCardProps {
item: ImageItem
onFeatherChange: (id: string, value: number) => void
onRotate: (id: string, direction: 'cw' | 'ccw') => void
onUpload: (id: string) => void
onRemove: (id: string) => void
onOpenLightbox: (item: ImageItem) => void
}
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
pending: { label: '⏳ En attente', color: 'text-slate-400' },
converting: { label: '🔄 Conversion HEIC', color: 'text-yellow-400' },
processing: { label: '🤖 Détourage IA', color: 'text-blue-400' },
searching: { label: '🔍 Recherche…', color: 'text-purple-400' },
ready: { label: '✓ Prêt', color: 'text-green-400' },
uploading: { label: '⬆️ Upload…', color: 'text-blue-400' },
uploaded: { label: '✅ Uploadé', color: 'text-green-500' },
not_found: { label: '❌ Réf. introuvable', color: 'text-red-400' },
error: { label: '❌ Erreur', color: 'text-red-400' },
}
function BlobThumbnail({ blob, alt }: { blob: Blob; alt: string }) {
const [url, setUrl] = useState<string | null>(null)
useEffect(() => {
const objectUrl = URL.createObjectURL(blob)
setUrl(objectUrl)
return () => URL.revokeObjectURL(objectUrl)
}, [blob])
if (!url) return <div className="w-full h-full bg-slate-700 animate-pulse" />
return <img src={url} alt={alt} className="w-full h-full object-contain" />
}
export function ImageCard({
item,
onFeatherChange,
onRotate,
onUpload,
onRemove,
onOpenLightbox,
}: ImageCardProps) {
const statusInfo = STATUS_LABELS[item.status] ?? { label: item.status, color: 'text-slate-400' }
const isProcessing = ['pending', 'converting', 'processing', 'searching', 'uploading'].includes(item.status)
const canAdjust = !!item.rawPngBlob && !isProcessing
return (
<div className="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden flex flex-col">
{/* Miniatures avant / après */}
<div
className="relative h-40 bg-slate-800 cursor-pointer"
onClick={() => onOpenLightbox(item)}
>
<div className="absolute inset-0">
<BlobThumbnail blob={item.originalBlob} alt="Original" />
</div>
{item.processedBlob && (
<div className="absolute inset-0 border-l-2 border-indigo-500 left-1/2 w-1/2">
<BlobThumbnail blob={item.processedBlob} alt="Traité" />
</div>
)}
{/* Barre de progression pendant l'IA */}
{item.status === 'processing' && item.progress && (
<div className="absolute bottom-0 inset-x-0 bg-black/60 px-2 py-1">
<div className="h-1 bg-slate-700 rounded">
<div
className="h-1 bg-indigo-500 rounded transition-all"
style={{ width: `${item.progress.percent}%` }}
/>
</div>
<p className="text-xs text-slate-400 mt-0.5">{item.progress.key} {item.progress.percent}%</p>
</div>
)}
</div>
{/* Infos */}
<div className="p-3 flex flex-col gap-2 flex-1">
<div>
<p className="text-xs font-mono text-slate-300 truncate">{item.ref}</p>
{item.shopifyProductTitle && (
<p className="text-xs text-slate-500 truncate">{item.shopifyProductTitle}</p>
)}
<p className={`text-xs font-semibold mt-0.5 ${statusInfo.color}`}>{statusInfo.label}</p>
{item.error && <p className="text-xs text-red-400 mt-0.5 truncate" title={item.error}>{item.error}</p>}
</div>
{/* Contrôles feather + rotation */}
<div className={canAdjust ? '' : 'opacity-40 pointer-events-none'}>
<label className="block text-xs text-slate-500 mb-1">
Bords : {item.feather} px
</label>
<input
type="range"
min={0}
max={5}
step={1}
value={item.feather}
onChange={(e) => onFeatherChange(item.id, Number(e.target.value))}
className="w-full accent-indigo-500"
/>
<div className="flex gap-1 mt-2">
<button
onClick={() => onRotate(item.id, 'ccw')}
className="flex-1 py-1 text-xs bg-slate-800 hover:bg-slate-700 rounded text-slate-300"
> -90°</button>
<button
onClick={() => onRotate(item.id, 'cw')}
className="flex-1 py-1 text-xs bg-slate-800 hover:bg-slate-700 rounded text-slate-300"
> +90°</button>
</div>
</div>
{/* Boutons d'action */}
<div className="flex gap-2 mt-auto pt-1">
{item.status === 'ready' && (
<button
onClick={() => onUpload(item.id)}
className="flex-1 py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded"
>
Uploader
</button>
)}
{item.status === 'uploaded' && item.uploadedUrl && (
<a
href={item.uploadedUrl}
target="_blank"
rel="noopener noreferrer"
className="flex-1 py-1.5 text-xs bg-green-800 hover:bg-green-700 text-white font-semibold rounded text-center"
>
Voir sur Shopify
</a>
)}
<button
onClick={() => onRemove(item.id)}
className="py-1.5 px-2 text-xs bg-slate-800 hover:bg-red-900 text-slate-400 hover:text-red-300 rounded"
></button>
</div>
</div>
</div>
)
}
+70
View File
@@ -0,0 +1,70 @@
'use client'
import { useEffect, useState } from 'react'
import type { ImageItem } from '@/types/images'
interface LightboxProps {
item: ImageItem | null
onClose: () => void
}
function BlobImg({ blob, alt }: { blob: Blob; alt: string }) {
const [url, setUrl] = useState<string | null>(null)
useEffect(() => {
const u = URL.createObjectURL(blob)
setUrl(u)
return () => URL.revokeObjectURL(u)
}, [blob])
if (!url) return null
return <img src={url} alt={alt} className="max-h-full max-w-full object-contain" />
}
export function Lightbox({ item, onClose }: LightboxProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
if (!item) return null
return (
<div
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="bg-slate-900 rounded-xl max-w-5xl w-full max-h-[90vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700">
<div>
<span className="text-sm font-mono text-slate-300">{item.ref}</span>
{item.shopifyProductTitle && (
<span className="text-xs text-slate-500 ml-2">{item.shopifyProductTitle}</span>
)}
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-white text-xl leading-none"
></button>
</div>
<div className="flex flex-1 overflow-hidden min-h-0">
<div className="flex-1 flex flex-col items-center justify-center p-4 bg-slate-950 border-r border-slate-700">
<p className="text-xs text-slate-500 mb-2">Avant</p>
<BlobImg blob={item.originalBlob} alt="Avant" />
</div>
<div className="flex-1 flex flex-col items-center justify-center p-4 bg-white/5">
<p className="text-xs text-slate-500 mb-2">Après</p>
{item.processedBlob
? <BlobImg blob={item.processedBlob} alt="Après" />
: <p className="text-slate-600 text-sm">Traitement en cours</p>
}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,38 @@
'use client'
import type { ProductFilters } from '@/types/products'
interface Props {
filters: ProductFilters
total: number
onChange: (f: Partial<ProductFilters>) => void
}
export function ProductFiltersBar({ filters, total, onChange }: Props) {
return (
<div className="flex flex-wrap gap-3 items-center mb-4">
<input
type="text"
placeholder="Rechercher ref ou titre..."
value={filters.search}
onChange={(e) => onChange({ search: e.target.value })}
className="flex-1 min-w-48 bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<div className="flex gap-2">
{(['all', 'active', 'draft'] as const).map((s) => (
<button
key={s}
onClick={() => onChange({ status: s })}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
filters.status === s
? 'bg-indigo-600 text-white'
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
}`}
>
{s === 'all' ? 'Tous' : s === 'active' ? 'Actifs' : 'Inactifs'}
</button>
))}
</div>
<span className="text-sm text-gray-400 ml-auto">{total} produit{total > 1 ? 's' : ''}</span>
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
'use client'
import type { CatalogProduct } from '@/types/products'
interface Props {
products: CatalogProduct[]
loading: boolean
}
const PAGE_SIZE = 50
export function ProductTable({ products, loading }: Props) {
if (loading) {
return (
<div className="flex items-center justify-center py-20 text-gray-400">
Chargement des produits
</div>
)
}
if (products.length === 0) {
return (
<div className="text-center py-20 text-gray-400 text-sm">
Aucun produit trouvé.
</div>
)
}
const displayed = products.slice(0, PAGE_SIZE)
return (
<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-3 pr-4 font-medium">Référence</th>
<th className="pb-3 pr-4 font-medium">Titre</th>
<th className="pb-3 pr-4 font-medium">Prix</th>
<th className="pb-3 pr-4 font-medium">Stock</th>
<th className="pb-3 pr-4 font-medium">Statut</th>
<th className="pb-3 font-medium">Lien</th>
</tr>
</thead>
<tbody>
{displayed.map((p) => (
<tr key={p.shopifyId} className="border-b border-slate-700/50 hover:bg-slate-700/30 transition-colors">
<td className="py-3 pr-4 font-mono text-indigo-300">{p.ref}</td>
<td className="py-3 pr-4 text-white">{p.title}</td>
<td className="py-3 pr-4 text-gray-300">{p.price} </td>
<td className="py-3 pr-4 text-gray-300">{p.stock}</td>
<td className="py-3 pr-4">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
p.status === 'active'
? 'bg-green-900/50 text-green-300'
: 'bg-gray-700 text-gray-400'
}`}>
{p.status === 'active' ? 'Actif' : 'Inactif'}
</span>
</td>
<td className="py-3">
<a
href={p.shopifyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-400 hover:text-indigo-300 text-xs underline"
>
Shopify
</a>
</td>
</tr>
))}
</tbody>
</table>
{products.length > PAGE_SIZE && (
<p className="text-center text-gray-400 text-xs mt-4">
Affichage des {PAGE_SIZE} premiers résultats sur {products.length}.
</p>
)}
</div>
)
}
@@ -0,0 +1,66 @@
'use client'
import { useState } from 'react'
interface TestResult {
ok: boolean
message: string
}
interface Results {
shopify: TestResult
sheets: TestResult
}
export function ConnectionStatus() {
const [results, setResults] = useState<Results | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const runTest = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/admin/connections/test')
if (!res.ok) throw new Error('Accès refusé')
setResults(await res.json())
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur')
} finally {
setLoading(false)
}
}
return (
<div className="bg-slate-800 rounded-xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Test des connexions</h2>
<button
onClick={runTest}
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 mb-4"
>
{loading ? 'Test en cours…' : 'Tester les connexions'}
</button>
{error && <p className="text-red-400 text-sm mb-4">{error}</p>}
{results && (
<div className="space-y-3">
{(
[
{ key: 'shopify', label: 'Shopify Admin API' },
{ key: 'sheets', label: 'Google Sheets API' },
] as const
).map(({ key, label }) => (
<div key={key} className="flex items-center gap-3 p-3 bg-slate-700 rounded-lg">
<span className={`text-lg ${results[key].ok ? 'text-green-400' : 'text-red-400'}`}>
{results[key].ok ? '✓' : '✗'}
</span>
<div>
<p className="text-sm font-medium text-white">{label}</p>
<p className="text-xs text-gray-400">{results[key].message}</p>
</div>
</div>
))}
</div>
)}
</div>
)
}
+151
View File
@@ -0,0 +1,151 @@
'use client'
import { useState, useEffect } from 'react'
interface User {
id: string
email: string
name: string
role: string
createdAt: string
}
interface FormData {
email: string
name: string
password: string
role: 'admin' | 'gestionnaire'
}
const EMPTY_FORM: FormData = { email: '', name: '', password: '', role: 'gestionnaire' }
export function UsersManager() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [form, setForm] = useState<FormData>(EMPTY_FORM)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const load = () => {
setLoading(true)
fetch('/api/admin/users')
.then((r) => r.json())
.then(setUsers)
.catch(() => setError('Impossible de charger les utilisateurs'))
.finally(() => setLoading(false))
}
useEffect(load, [])
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
setError(null)
setSuccess(null)
try {
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
setSuccess(`Utilisateur ${data.name} créé.`)
setForm(EMPTY_FORM)
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur')
} finally {
setSubmitting(false)
}
}
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Supprimer ${name} ?`)) return
try {
const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' })
const data = await res.json()
if (!res.ok) throw new Error(data.error)
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur suppression')
}
}
return (
<div className="bg-slate-800 rounded-xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Gestion des utilisateurs</h2>
{/* Liste */}
{loading ? (
<p className="text-gray-400 text-sm mb-6">Chargement</p>
) : (
<div className="mb-6 space-y-2">
{users.map((u) => (
<div key={u.id} className="flex items-center justify-between p-3 bg-slate-700 rounded-lg">
<div>
<p className="text-sm font-medium text-white">{u.name}</p>
<p className="text-xs text-gray-400">{u.email} <span className="capitalize">{u.role}</span></p>
</div>
<button
onClick={() => handleDelete(u.id, u.name)}
className="text-red-400 hover:text-red-300 text-xs px-2 py-1 rounded hover:bg-red-900/30 transition-colors"
>
Supprimer
</button>
</div>
))}
{users.length === 0 && <p className="text-gray-400 text-sm">Aucun utilisateur.</p>}
</div>
)}
{/* Formulaire création */}
<form onSubmit={handleCreate} className="space-y-3">
<h3 className="text-sm font-medium text-gray-300">Nouvel utilisateur</h3>
<div className="grid grid-cols-2 gap-3">
<input
required
type="email"
placeholder="Email"
value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
className="bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<input
required
type="text"
placeholder="Nom"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
className="bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<input
required
type="password"
placeholder="Mot de passe"
value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
className="bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<select
value={form.role}
onChange={(e) => setForm((f) => ({ ...f, role: e.target.value as FormData['role'] }))}
className="bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="gestionnaire">Gestionnaire</option>
<option value="admin">Admin</option>
</select>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{success && <p className="text-green-400 text-sm">{success}</p>}
<button
type="submit"
disabled={submitting}
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"
>
{submitting ? 'Création…' : "Créer l'utilisateur"}
</button>
</form>
</div>
)
}
+118
View File
@@ -0,0 +1,118 @@
'use client'
import { useState } from 'react'
import type { 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>
)
}
+246
View File
@@ -0,0 +1,246 @@
'use client'
import { useReducer, useCallback } from 'react'
import type { ImageItem, ImageStatus } from '@/types/images'
// ── Reducer ───────────────────────────────────────────────────────────────────
type Action =
| { type: 'ADD'; items: ImageItem[] }
| { type: 'UPDATE'; id: string; patch: Partial<ImageItem> }
| { type: 'REMOVE'; id: string }
function reducer(state: ImageItem[], action: Action): ImageItem[] {
switch (action.type) {
case 'ADD':
return [...state, ...action.items]
case 'UPDATE':
return state.map((item) =>
item.id === action.id ? { ...item, ...action.patch } : item,
)
case 'REMOVE':
return state.filter((item) => item.id !== action.id)
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function refFromFilename(filename: string): string {
const dot = filename.lastIndexOf('.')
return dot === -1 ? filename : filename.slice(0, dot)
}
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
// Supprimer le préfixe "data:image/jpeg;base64,"
const base64 = result.split(',')[1]
resolve(base64)
}
reader.onerror = () => reject(new Error('FileReader error'))
reader.readAsDataURL(blob)
})
}
// ── Hook ──────────────────────────────────────────────────────────────────────
export function useImages() {
const [items, dispatch] = useReducer(reducer, [])
const update = useCallback((id: string, patch: Partial<ImageItem>) => {
dispatch({ type: 'UPDATE', id, patch })
}, [])
// Recherche produit Shopify
const searchProduct = useCallback(
async (id: string, ref: string) => {
update(id, { status: 'searching' })
try {
const res = await fetch(`/api/products/search?ref=${encodeURIComponent(ref)}`)
const data = await res.json()
if (data.found) {
update(id, {
status: 'ready',
shopifyProductId: data.shopifyId,
shopifyProductTitle: data.title,
})
} else {
update(id, { status: 'not_found' })
}
} catch (err) {
update(id, { status: 'error', error: `Recherche produit : ${(err as Error).message}` })
}
},
[update],
)
// Pipeline complet pour un item (avec ref)
const runPipeline = useCallback(
async (id: string, blob: Blob, ref: string, feather: number, rotation: number) => {
const { convertHeicToJpeg } = await import('@/lib/client/heicConverter')
const { removeBackgroundRaw, compositeOnWhite } = await import('@/lib/client/bgRemover')
const { rotateBlob } = await import('@/lib/client/imageRotator')
let jpegBlob = blob
if (blob.type === 'image/heic' || blob.type === 'image/heif' || blob.type === '') {
try {
update(id, { status: 'converting' })
jpegBlob = await convertHeicToJpeg(blob)
} catch (err) {
update(id, { status: 'error', error: `Conversion HEIC : ${(err as Error).message}` })
return
}
}
let rawPng: Blob
try {
update(id, { status: 'processing', progress: null })
rawPng = await removeBackgroundRaw(jpegBlob, (p) => update(id, { progress: p }))
} catch (err) {
update(id, { status: 'error', error: `Détourage IA : ${(err as Error).message}` })
return
}
let finalBlob: Blob
try {
const composited = await compositeOnWhite(rawPng, feather)
finalBlob = await rotateBlob(composited, rotation)
} catch (err) {
update(id, { status: 'error', error: `Post-traitement : ${(err as Error).message}` })
return
}
update(id, { rawPngBlob: rawPng, processedBlob: finalBlob })
await searchProduct(id, ref)
},
[update, searchProduct],
)
// Recomposite rapide (feather ou rotation changés) — ne relance pas l'IA
const recomposite = useCallback(
async (id: string, rawPngBlob: Blob, feather: number, rotation: number) => {
const { compositeOnWhite } = await import('@/lib/client/bgRemover')
const { rotateBlob } = await import('@/lib/client/imageRotator')
try {
const composited = await compositeOnWhite(rawPngBlob, feather)
const finalBlob = await rotateBlob(composited, rotation)
update(id, { processedBlob: finalBlob, feather, rotation })
} catch (err) {
update(id, { status: 'error', error: `Retraitement : ${(err as Error).message}` })
}
},
[update],
)
// ── Actions exposées ───────────────────────────────────────────────────────
const addFiles = useCallback(
async (files: File[]) => {
const { readImagesFromZip } = await import('@/lib/client/zipImporter')
for (const file of files) {
if (file.name.toLowerCase().endsWith('.zip')) {
const entries = await readImagesFromZip(file)
const newItems: ImageItem[] = entries.map((entry) => ({
id: crypto.randomUUID(),
filename: `${entry.folder}/${entry.filename}.${entry.ext}`,
ref: entry.folder,
originalBlob: entry.blob,
rawPngBlob: null,
processedBlob: null,
rotation: 0,
feather: 2,
shopifyProductId: null,
shopifyProductTitle: null,
uploadedUrl: null,
status: 'pending' as ImageStatus,
error: null,
progress: null,
}))
dispatch({ type: 'ADD', items: newItems })
for (const item of newItems) {
runPipeline(item.id, item.originalBlob, item.ref, item.feather, item.rotation)
}
} else {
const ref = refFromFilename(file.name)
const newItem: ImageItem = {
id: crypto.randomUUID(),
filename: file.name,
ref,
originalBlob: file,
rawPngBlob: null,
processedBlob: null,
rotation: 0,
feather: 2,
shopifyProductId: null,
shopifyProductTitle: null,
uploadedUrl: null,
status: 'pending',
error: null,
progress: null,
}
dispatch({ type: 'ADD', items: [newItem] })
runPipeline(newItem.id, newItem.originalBlob, newItem.ref, newItem.feather, newItem.rotation)
}
}
},
[runPipeline],
)
const setFeather = useCallback(
(id: string, value: number) => {
const item = items.find((i) => i.id === id)
if (!item || !item.rawPngBlob) return
recomposite(id, item.rawPngBlob, value, item.rotation)
},
[items, recomposite],
)
const rotate = useCallback(
(id: string, direction: 'cw' | 'ccw') => {
const item = items.find((i) => i.id === id)
if (!item || !item.rawPngBlob) return
const delta = direction === 'cw' ? 90 : -90
const newRotation = ((item.rotation + delta) % 360 + 360) % 360
recomposite(id, item.rawPngBlob, item.feather, newRotation)
},
[items, recomposite],
)
const uploadOne = useCallback(
async (id: string) => {
const item = items.find((i) => i.id === id)
if (!item || item.status !== 'ready' || !item.processedBlob || !item.shopifyProductId) return
update(id, { status: 'uploading' })
try {
const base64 = await blobToBase64(item.processedBlob)
const filename = `${item.ref}.jpg`
const res = await fetch('/api/images/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shopifyProductId: item.shopifyProductId, imageBase64: base64, filename }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
update(id, { status: 'uploaded', uploadedUrl: data.url })
} catch (err) {
update(id, { status: 'error', error: `Upload : ${(err as Error).message}` })
}
},
[items, update],
)
const uploadAll = useCallback(() => {
items.filter((i) => i.status === 'ready').forEach((i) => uploadOne(i.id))
}, [items, uploadOne])
const removeItem = useCallback((id: string) => {
dispatch({ type: 'REMOVE', id })
}, [])
return { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem }
}
+2 -2
View File
@@ -45,8 +45,8 @@ export const authOptions: NextAuthOptions = {
// NextAuth v4 : req est un RequestInternal partiel sans typings complets. // NextAuth v4 : req est un RequestInternal partiel sans typings complets.
// Le cast est nécessaire pour accéder à x-forwarded-for. // Le cast est nécessaire pour accéder à x-forwarded-for.
const ip = // eslint-disable-next-line @typescript-eslint/no-explicit-any
(req as any)?.headers?.['x-forwarded-for']?.split(',')[0]?.trim() ?? 'unknown' const ip = (req as any)?.headers?.['x-forwarded-for']?.split(',')[0]?.trim() ?? 'unknown'
const key = `${ip}:${credentials.email}` const key = `${ip}:${credentials.email}`
if (!checkRateLimit(key)) { if (!checkRateLimit(key)) {
+89
View File
@@ -0,0 +1,89 @@
const CDN_VERSION = '1.7.0'
const CDN_URL = `https://cdn.jsdelivr.net/npm/@imgly/background-removal@${CDN_VERSION}/dist/index.mjs`
let _removeBg: ((blob: Blob, opts: Record<string, unknown>) => Promise<Blob>) | null = null
async function loadLib() {
if (_removeBg) return _removeBg
const mod = await import(/* webpackIgnore: true */ CDN_URL)
_removeBg = mod.removeBackground
return _removeBg!
}
export interface ProgressEvent {
key: string
percent: number
}
/**
* Supprime le fond d'un Blob image via @imgly IA.
* Retourne un PNG avec fond transparent (à combiner avec compositeOnWhite).
*/
export async function removeBackgroundRaw(
blob: Blob,
onProgress?: (e: ProgressEvent) => void,
): Promise<Blob> {
const removeBg = await loadLib()
return removeBg(blob, {
output: { format: 'image/png', type: 'foreground', quality: 1 },
progress: (key: string, current: number, total: number) => {
if (onProgress && total > 0) {
onProgress({ key, percent: Math.round((current / total) * 100) })
}
},
})
}
/**
* Composite un PNG transparent sur fond blanc avec feather optionnel.
* @param pngBlob — PNG avec transparence (résultat de removeBackgroundRaw)
* @param feather — récupération des bords en px (0 = strict, 5 = doux)
*/
export function compositeOnWhite(pngBlob: Blob, feather: number): Promise<Blob> {
const objectUrl = URL.createObjectURL(pngBlob)
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
const { width, height } = img
const fgCanvas = document.createElement('canvas')
fgCanvas.width = width
fgCanvas.height = height
const fgCtx = fgCanvas.getContext('2d')!
fgCtx.drawImage(img, 0, 0)
if (feather > 0) {
fgCtx.globalCompositeOperation = 'destination-over'
fgCtx.filter = `blur(${feather}px)`
fgCtx.drawImage(img, 0, 0)
fgCtx.filter = 'none'
fgCtx.globalCompositeOperation = 'source-over'
}
const out = document.createElement('canvas')
out.width = width
out.height = height
const ctx = out.getContext('2d')!
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, width, height)
ctx.drawImage(fgCanvas, 0, 0)
URL.revokeObjectURL(objectUrl)
out.toBlob(
(b) => (b ? resolve(b) : reject(new Error('canvas.toBlob a retourné null'))),
'image/jpeg',
0.92,
)
}
img.onerror = () => {
URL.revokeObjectURL(objectUrl)
reject(new Error('Impossible de charger le PNG'))
}
img.src = objectUrl
})
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Convertit un Blob HEIC/HEIF en Blob JPEG via heic2any.
* À utiliser uniquement côté client (navigateur).
*/
export async function convertHeicToJpeg(blob: Blob): Promise<Blob> {
if (!(blob instanceof Blob)) {
throw new TypeError('L\'entrée doit être un Blob')
}
const type = blob.type.toLowerCase()
if (type !== '' && !['image/heic', 'image/heif'].includes(type)) {
throw new TypeError(`Format attendu : HEIC/HEIF. Reçu : ${blob.type || '(vide)'}`)
}
const heic2any = (await import('heic2any')).default
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout conversion HEIC (120s)')), 120_000),
)
const conversion = heic2any({ blob, toType: 'image/jpeg', quality: 0.92 })
const result = await Promise.race([conversion, timeout])
const output = Array.isArray(result) ? result[0] : result
if (!(output instanceof Blob)) {
throw new Error("heic2any n'a pas retourné un Blob")
}
return output
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Pivote un Blob JPEG via Canvas.
* @param blob — image source (JPEG)
* @param degrees — angle en degrés (0, 90, 180, 270)
* @returns Blob JPEG pivoté, ou le blob original si degrees === 0
*/
export function rotateBlob(blob: Blob, degrees: number): Promise<Blob> {
const norm = ((degrees % 360) + 360) % 360
if (norm === 0) return Promise.resolve(blob)
const url = URL.createObjectURL(blob)
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
const swap = norm === 90 || norm === 270
const w = swap ? img.naturalHeight : img.naturalWidth
const h = swap ? img.naturalWidth : img.naturalHeight
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')!
ctx.translate(w / 2, h / 2)
ctx.rotate((norm * Math.PI) / 180)
ctx.drawImage(img, -img.naturalWidth / 2, -img.naturalHeight / 2)
URL.revokeObjectURL(url)
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('rotateBlob : toBlob a retourné null'))),
'image/jpeg',
0.92,
)
}
img.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('rotateBlob : impossible de charger le blob'))
}
img.src = url
})
}
+47
View File
@@ -0,0 +1,47 @@
import JSZip from 'jszip'
import type { ZipEntry } from '@/types/images'
const IMAGE_EXTS = new Set(['heic', 'heif', 'jpg', 'jpeg', 'png'])
/**
* Extrait toutes les images d'un fichier ZIP.
* Structure attendue : dossier-ref/image.ext (le nom du dossier = référence produit).
*/
export async function readImagesFromZip(file: File): Promise<ZipEntry[]> {
const zip = await JSZip.loadAsync(file)
const entries: Array<{ relativePath: string; entry: JSZip.JSZipObject }> = []
zip.forEach((relativePath, entry) => {
if (!entry.dir) entries.push({ relativePath, entry })
})
const results: ZipEntry[] = []
for (const { relativePath, entry } of entries) {
if (relativePath.startsWith('__MACOSX/')) continue
const parts = relativePath.split('/').filter((p) => p.length > 0)
const filename = parts[parts.length - 1]
if (filename.startsWith('.')) continue
const dotIdx = filename.lastIndexOf('.')
if (dotIdx === -1) continue
const ext = filename.slice(dotIdx + 1).toLowerCase()
if (!IMAGE_EXTS.has(ext)) continue
const folder = parts.length > 1 ? parts[0] : filename.slice(0, dotIdx)
const isHeic = ext === 'heic' || ext === 'heif'
const mimeType = isHeic
? 'image/heic'
: ext === 'jpg' || ext === 'jpeg'
? 'image/jpeg'
: 'image/png'
const rawBlob = await entry.async('blob')
const blob = new Blob([rawBlob], { type: mimeType })
results.push({ folder, filename: filename.slice(0, dotIdx), ext, blob, isHeic })
}
return results
}
+62
View File
@@ -0,0 +1,62 @@
import type { SyncProduct } from '@/types/sync'
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 (API Key publique).
* Le fichier Sheets doit être partagé en lecture ("Toute personne avec le lien").
*/
export async function readSheetProducts(): Promise<SyncProduct[]> {
const apiKey = process.env.GOOGLE_API_KEY
const sheetId = process.env.GOOGLE_SHEETS_ID
const sheetName = process.env.GOOGLE_SHEETS_TAB ?? 'Produits'
if (!apiKey || !sheetId) {
throw new Error('Variables GOOGLE_API_KEY et GOOGLE_SHEETS_ID requises')
}
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: ${res.status}${err}`)
}
const data = await res.json()
const rows = (data.values ?? []) as string[][]
return parseSheetRows(rows)
}
+83
View File
@@ -0,0 +1,83 @@
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 type ShopifyProductResult =
| { found: false }
| { found: true; shopifyId: string; title: string }
/**
* Recherche un produit Shopify par sa référence.
* Tente d'abord par handle (ex: "ref-1042"), puis par titre exact.
*/
export async function searchProductByRef(ref: string): Promise<ShopifyProductResult> {
const { baseUrl, headers } = getConfig()
// Normalise la référence en handle Shopify (minuscules, tirets)
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,handle&limit=1`, { headers })
if (!res.ok) throw new Error(`Shopify API error: ${res.status}`)
const data = await res.json()
return (data.products ?? []) as Array<{ id: number; title: string; handle: string }>
}
let products = await tryFetch(`handle=${encodeURIComponent(handle)}`)
if (products.length === 0) {
products = await tryFetch(`title=${encodeURIComponent(ref)}`)
}
if (products.length === 0) return { found: false }
return {
found: true,
shopifyId: String(products[0].id),
title: products[0].title,
}
}
/**
* Upload une image (base64) vers un produit Shopify via l'API REST.
* @param shopifyProductId — ID numérique du produit (string)
* @param imageBase64 — contenu JPEG encodé en base64 (sans le préfixe data:)
* @param filename — nom de fichier avec extension (ex: "REF-1042.jpg")
*/
export async function uploadProductImage(
shopifyProductId: string,
imageBase64: string,
filename: string,
): Promise<{ url: string }> {
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/products/${shopifyProductId}/images.json`, {
method: 'POST',
headers,
body: JSON.stringify({ image: { attachment: imageBase64, filename } }),
})
if (!res.ok) {
const body = await res.text()
throw new Error(`Shopify upload error: ${res.status}${body}`)
}
const data = await res.json()
if (!data.image?.src) {
throw new Error('Shopify response invalide : champ image.src manquant')
}
return { url: data.image.src as string }
}
+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 }
}
+40
View File
@@ -0,0 +1,40 @@
export type ImageStatus =
| 'pending' // ajouté, pas encore traité
| 'converting' // HEIC → JPEG en cours
| 'processing' // détourage IA en cours
| 'searching' // recherche produit Shopify en cours
| 'ready' // prêt à uploader
| 'uploading' // upload Shopify en cours
| 'uploaded' // uploadé avec succès
| 'not_found' // référence produit introuvable dans Shopify
| 'error' // erreur à n'importe quelle étape
export interface ImageProgress {
key: string
percent: number
}
export interface ImageItem {
id: string
filename: string // nom de fichier original (ex: "REF-1042.heic")
ref: string // référence produit extraite (ex: "REF-1042")
originalBlob: Blob // image originale (pour la vue "avant")
rawPngBlob: Blob | null // PNG transparent issu du détourage IA (cache pour feather)
processedBlob: Blob | null // JPEG final sur fond blanc, pivoté (pour upload + vue "après")
rotation: number // 0, 90, 180, 270
feather: number // 05, défaut 2
shopifyProductId: string | null
shopifyProductTitle: string | null
uploadedUrl: string | null
status: ImageStatus
error: string | null
progress: ImageProgress | null
}
export interface ZipEntry {
folder: string // nom du dossier = référence produit
filename: string // nom de fichier dans le dossier
ext: string // extension sans point, en minuscules
blob: Blob // blob avec type MIME correct
isHeic: boolean
}
+25
View File
@@ -0,0 +1,25 @@
// src/types/products.ts
/** Produit Shopify simplifié pour affichage catalogue */
export interface CatalogProduct {
shopifyId: string
ref: string // SKU de la première variante
handle: string
title: string
price: string // "29.99"
stock: number // inventory_quantity variante 1
status: 'active' | 'draft' | 'archived'
shopifyUrl: string // https://materiaux-destock.myshopify.com/admin/products/<id>
}
/** Filtres appliqués à la liste */
export interface ProductFilters {
status: 'all' | 'active' | 'draft'
search: string // filtre titre ou ref (côté client)
}
/** Réponse de l'API /api/products/list */
export interface ProductListResponse {
products: CatalogProduct[]
total: number
}
+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[]
}