113 Commits

Author SHA1 Message Date
C_Ma_For ac05d2ac30 chore: add bulk compare-at-price update script + restore middleware auth
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 14:38:34 +02:00
C_Ma_For 60dc9cc0ec feat: map prix marché as compare_at_price (barré) on Shopify
When Prix remisé is set: price = remisé, compare_at_price = marché
When no remise: price = marché, compare_at_price = null
Diff now detects compareAtPrice changes to trigger updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 14:19:37 +02:00
C_Ma_For eb5f605175 feat: refactor mockup page with 3-step flow (product → image → template)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 14:05:25 +02:00
C_Ma_For ac58289bb7 feat: auto-fill mockup dimensions and family from Google Sheets
- Extend Sheets read range from A1:Z to A1:AZ (52 columns)
- Parse longueur, largeur, marque, famille, sousFamille, prixRemise, specificFields
- Add /api/products/sheet-info endpoint to fetch product data by ref
- Auto-fill family and dimensions on product selection in all 3 mockup entry points
- Strengthen carrelage mockup prompt with strict tile-size consistency rules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 13:35:39 +02:00
C_Ma_For 4f617b15b2 feat: add reverse stock sync (Shopify → Sheets)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 12:20:59 +02:00
C_Ma_For 401b43cc04 fix: add rate limiting with retry on 429 in sync-stock
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 11:59:37 +02:00
C_Ma_For d276f6224c fix: log errors in writeShopifyIdToSheet and skip 404s in sync-stock
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 11:40:12 +02:00
C_Ma_For a9850c8efd feat: add bulk stock sync from Sheets to Shopify
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:35:52 +02:00
C_Ma_For 747f8a47b8 fix: handle 'Stock (pièce)' header variant in addition to 'Stock (en pièce)'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 09:47:14 +02:00
C_Ma_For a334987b7c fix: connect inventory item to location before setting stock on product creation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 09:35:09 +02:00
C_Ma_For 06587c1ccd fix: include Surface (m2) in specificFields for metafield resolution
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 09:27:46 +02:00
C_Ma_For 99de0da823 feat: add multi-select upload and unified selection bar on images page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 12:03:20 +02:00
C_Ma_For 3ec0b9fa3c feat: add background image upload to banner builder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 10:46:45 +02:00
C_Ma_For 461667c506 fix: remove unused domain variable in BannerBuilder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 10:38:52 +02:00
C_Ma_For 34ec818ad3 feat: replace product search with searchable combobox in banner builder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 10:35:27 +02:00
C_Ma_For f650225c98 fix: remove unused graphqlUrl variable in product-banner-data route
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 10:02:36 +02:00
C_Ma_For 7ebb7e899d fix: escape apostrophe in BannerBuilder JSX
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 09:57:39 +02:00
C_Ma_For 4ce62c1473 feat: add banner builder with product search and canvas rendering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 09:53:58 +02:00
C_Ma_For fcadea6758 fix: remove unused import in bannieres page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 09:27:50 +02:00
C_Ma_For 0a3f5042fc feat: add bannières page to manage Shopify homepage slideshow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 09:12:52 +02:00
C_Ma_For d616f1d74c feat: add mockup creation from image cards on images page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 08:30:45 +02:00
C_Ma_For eec9bd5dce feat: make AI background removal optional with resize-only upload and batch detour selection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 08:21:46 +02:00
C_Ma_For c95093a81f feat: add reprocess button to re-run AI background removal on image cards
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 20:52:18 +02:00
C_Ma_For 03770d1a76 fix: propagate Shopify 404 through upload route so client retry triggers correctly
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 20:40:50 +02:00
C_Ma_For 5e1b864ce7 fix: retry upload with fresh Shopify ID on 404 (stale cached ID)
Quand l'ID en cache est périmé (produit supprimé/recréé), relance la recherche
par ref pour obtenir le nouvel ID et réessaie l'upload automatiquement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 20:35:43 +02:00
C_Ma_For cc0d57bf2b feat: remove clear Sheets IDs button from creation UI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 20:03:42 +02:00
C_Ma_For 6ca2509ca6 fix: use original filename for upload to allow multiple images per product
32263.heic -> 32263.jpg, 32263-.heic -> 32263-.jpg (non plus les deux 32263.jpg)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 19:56:42 +02:00
C_Ma_For 42989a5ef3 fix: extract ref from filename not folder name in ZIP uploads
PHOTOS:/32263-.heic → ref=32263 (non plus PHOTOS:)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:23:43 +02:00
C_Ma_For f56e52f801 feat: show original filename on image card
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:15:03 +02:00
C_Ma_For 949d7c60b9 debug: log product search result to trace not_found issues
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:01:56 +02:00
C_Ma_For 36447a5c8c fix: improve product search robustness
- search-sheets: ne bloque plus sur l'absence d'ID Shopify en col A
- Shopify GraphQL: first:10 + match exact insensible à la casse au lieu de first:1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:54:56 +02:00
C_Ma_For 920ec50402 fix: extract UGS from filename with sequence numbers and trailing dashes
151721-2.HEIC -> 151721, 29679-G-1.HEIC -> 29679-G, 27228-GRIS-.HEIC -> 27228-GRIS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:45:31 +02:00
C_Ma_For 2f6eea1122 feat: stop button for product creation stream
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:34:34 +02:00
C_Ma_For 42ef490196 fix: strip trailing dashes from filename ref (26774-.HEIC -> 26774)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:24:44 +02:00
C_Ma_For 31b764f458 fix: limit concurrent AI background removal to 2 to prevent browser crash
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:08:32 +02:00
C_Ma_For 8c9bce7aa5 feat: collection picker in product creation flow
- API /api/creation/collections liste toutes les collections Shopify
- Détection automatique depuis la colonne ID FAMILLE du Sheets
- Sélecteur avec recherche si absente ou pour modifier
- Collection affichée dans l'écran de confirmation
- collectionOverride passé à l'exécution comme fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 16:12:54 +02:00
C_Ma_For 9e7423eee3 fix: pass tab filter to execute route so only selected family is created
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:51:47 +02:00
C_Ma_For 6d8be0645d feat: use 'Stock conditionné' for Shopify stock on carrelage tabs
Priorité à Stock conditionné (boîtes) sur Stock (en pièce) si renseigné.
Colonne exclue des métachamps spécifiques.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:42:55 +02:00
C_Ma_For 114e20c9fb feat: persist metafield resolutions across sessions
Les associations colonne→métachamp sont sauvegardées dans data/metafield-resolutions.json
et rechargées automatiquement à la prochaine création de produits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:28:53 +02:00
C_Ma_For caef95bd55 feat: set stock and weight on Shopify product creation
- inventory_management: shopify + inventory_levels/set.json pour le stock
- weight/weight_unit sur le variant pour l'expédition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:11:17 +02:00
C_Ma_For 34ade6beb2 feat: clear Shopify IDs from Sheets to allow product recreation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:05:43 +02:00
C_Ma_For cdde567558 feat: placement mur/sol pour templates carrelage avec prompt adapté 2026-06-24 14:52:57 +02:00
C_Ma_For e85314eb77 feat: gestion templates mockup dans les paramètres avec familles depuis Sheets 2026-06-24 14:42:44 +02:00
C_Ma_For 5a10fd4221 feat: mockup modal avec sélection image existante, familles sheets et métachamps produit 2026-06-24 14:33:19 +02:00
C_Ma_For 93a616e997 feat: affichage images produit dans la liste avec thumbnail et compteur 2026-06-24 14:27:35 +02:00
C_Ma_For 41f5c245d4 feat: mockup modal accessible depuis la liste produits 2026-06-24 14:20:42 +02:00
C_Ma_For f6b0ba9685 feat: product picker with search in mockup page 2026-06-22 14:42:58 +02:00
C_Ma_For 4811b73c69 feat: mockup page with template library and generator UI 2026-06-22 12:27:35 +02:00
C_Ma_For 921d9d6a77 feat: MockupGenerator UI component with generate/regenerate/upload flow 2026-06-22 12:24:22 +02:00
C_Ma_For d138577a9d feat: mockup generation API route using gpt-image-1 2026-06-22 12:14:31 +02:00
C_Ma_For 1ef69dc205 feat: mockup template CRUD API routes 2026-06-22 12:10:26 +02:00
C_Ma_For 01e2fcd5e5 feat: mockup types and template storage library 2026-06-22 12:08:13 +02:00
C_Ma_For 07432bbd95 fix: conserver le type du métachamp lors de l'association manuelle
Sans le type, on envoyait toujours 'single_line_text_field' à Shopify,
causant une erreur quand le métachamp existant est de type number_decimal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 13:05:31 +02:00
C_Ma_For 731d0dbde6 feat: recherche textuelle dans la sélection de métachamp existant
Remplace le <select> par un input filtrant en temps réel sur le nom,
la clé et le namespace — utile quand il y a beaucoup de métachamps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:58:48 +02:00
C_Ma_For 42d0e2f855 feat: écriture de l'ID Shopify en col A du Sheets après création produit
Utilise un compte de service Google avec JWT pour authentifier les écritures
dans le Sheets — sans dépendance npm supplémentaire (crypto natif Node.js).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:42:17 +02:00
C_Ma_For 15f647f58a fix: vérifier le SKU exact dans la réponse Shopify (l'API ignore le filtre sur les SKUs avec tirets)
L'API variants.json ne filtre pas correctement par sku= quand le SKU contient
des tirets — elle retourne des variants aléatoires. On vérifie maintenant que
le variant retourné a bien le SKU demandé.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:01:02 +02:00
C_Ma_For cb227943f3 fix: prioritiser SKU sans suffixe numérique pour éviter faux match (ex: 26618-D-1 → 26618-D)
Un fichier nommé 26618-D-1.jpg trouvait le SKU 26618-D-1 (céramique) avant
de chercher 26618-D (la porte). On essaie maintenant d'abord le ref sans
suffixe purement numérique (-1, -2…).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:43:12 +02:00
C_Ma_For d45c9490c0 feat: curseur tolérance verre pour récupérer les zones semi-transparentes du détourage
Ajoute alphaThreshold (défaut 30) — les pixels dont l'alpha dépasse ce seuil
sont rendus opaques avant la composition sur fond blanc, évitant que le verre
ou les surfaces translucides soient supprimés par le modèle IA.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:28:42 +02:00
C_Ma_For c1fad5d0ed fix: retourner l'UGS la plus spécifique lors de la recherche par nom de fichier
Évite que "26618" matche avant "26618-D" car le tiret est un séparateur
de mot dans la regex — on collecte tous les matches et on garde le plus long.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:02:13 +02:00
C_Ma_For d8a3f26d0b fix: vérifier l'existence Shopify avant création pour éviter les doublons
- Chargement du catalogue Shopify au début de l'exécution (fetchAllShopifyProducts)
- Vérification par SKU (ref) : si déjà présent dans Shopify → skipped, pas de création
- Nouveau type d'événement 'skipped' dans CreationStreamEvent
- CreationProgress affiche les produits ignorés en amber avec le SKU concerné
- Résumé final inclut le compte de produits ignorés

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 00:08:06 +02:00
C_Ma_For 297d47bd19 feat: détection et suppression des doublons produits Shopify
- deleteShopifyProduct() dans shopifySync (DELETE REST API)
- GET /api/products/duplicates : groupe par ref/SKU, trie par ID (ancien en premier)
- DELETE /api/products/[id] : suppression unitaire avec auth
- Page /produits/doublons : liste des groupes, radio pour choisir lequel garder,
  confirmation avant suppression, état par groupe (idle/confirm/deleting/done)
- Lien 'Vérifier les doublons' dans l'en-tête de la page Produits

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 23:57:53 +02:00
C_Ma_For a2c0789625 fix: ignorer les désactivations lors d'un sync filtré par famille
Quand un onglet est sélectionné, les produits Shopify absents du tab
peuvent appartenir à d'autres familles — ne pas les marquer 'deactivate'.
Les désactivations ne s'appliquent qu'en mode catalogue complet (sans filtre).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 23:37:29 +02:00
C_Ma_For 23aa78d3da feat: sélecteur de famille (onglet Sheets) en sync et création
- GET /api/sheets/tabs : liste les onglets Google Sheets disponibles
- readSheetProducts(tab?) et fetchPendingProducts(set, tab?) acceptent un filtre
- /api/sync/preview?tab=xxx et /api/creation/preview?tab=xxx filtrent sur un onglet
- Composant FamilySelector : grille de boutons chargeant les onglets dynamiquement
- Page Sync : étape 0 de sélection famille avant l'analyse
- Page Création : étape famille avant analyse, badge de famille sélectionnée

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 23:31:19 +02:00
C_Ma_For 1b07359415 feat: détection drift Sheets sur le dashboard (approche A)
- /api/dashboard/stats appelle Google Drive API pour récupérer modifiedTime du Sheets
- Compare avec createdAt de la dernière SyncRun → sheetDrift: boolean
- Dashboard affiche un bandeau amber cliquable si Sheets plus récent que la dernière sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 23:14:06 +02:00
C_Ma_For 6f4affcd66 fix: DiffTable colonnes visibles + filtrage lignes parasites dans Sheets
- table-fixed avec colgroup pour éviter que la colonne Ref écrase les autres
- overflow-x-auto pour permettre le scroll si nécessaire
- parseSheetRows : ignore les refs invalides (< 2 chars, caractères spéciaux, mots réservés)
- parseSheetRows : ignore les lignes sans prix valide (séparateurs, notes)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 23:09:08 +02:00
C_Ma_For 211d8c5c01 fix: supprimer variables inutilisées bloquant le build ESLint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 22:49:22 +02:00
C_Ma_For d16cd1fbf5 feat: implémenter le dashboard avec KPIs, dernière sync et actions rapides
- Nouvel endpoint /api/dashboard/stats : comptes produits Shopify (actifs/brouillons/sans image) + dernière sync depuis Prisma
- Nouvel endpoint /api/dashboard/pending : nombre de produits en attente depuis Google Sheets (lazy)
- Page dashboard remplace le stub avec 4 KPIs, bloc dernière sync, et raccourcis vers les sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 22:42:57 +02:00
C_Ma_For fecf56ff09 fix: retry 429 avec backoff + sérialisation uploadAll + 409 traité comme skipped 2026-06-11 14:37:12 +02:00
C_Ma_For 85b0590d85 fix: split avant/après avec overflow-hidden et largeur dynamique 2026-06-11 13:40:14 +02:00
C_Ma_For 725a7508b0 feat: vérification image existante sur Shopify avant upload (statut 'Déjà sur Shopify') 2026-06-11 13:35:47 +02:00
C_Ma_For 4df074f02d fix: image sortie 1000x1000 centrée + affichage split avant/après corrigé 2026-06-11 13:14:41 +02:00
C_Ma_For 06cda68929 fix: match UGS comme mot entier n'importe où dans le nom de fichier 2026-06-11 12:51:27 +02:00
C_Ma_For c19510d413 fix: match UGS en début de nom de fichier exact (évite 30 de matcher 30827) 2026-06-11 12:47:26 +02:00
C_Ma_For 2de5c6670d fix: recherche produit par SKU Shopify avec fallback sans suffixe (30827-dessus → 30827) 2026-06-11 12:20:27 +02:00
C_Ma_For 6a08fccb7a fix: recherche UGS par contains dans le nom de fichier (ex: 30827-dessus contient 30827) 2026-06-10 09:38:16 +02:00
C_Ma_For da52080e3e fix: match UGS en préfixe dans search-sheets pour les images avec suffixe (dessus, -2, etc.) 2026-06-10 09:17:22 +02:00
C_Ma_For f0b6ddf145 fix: propager le type réel du métachamp Shopify pour éviter les erreurs 422 2026-06-09 21:54:41 +02:00
C_Ma_For d1e78e91e4 feat: sélection manuelle de produit Shopify quand réf. introuvable 2026-06-09 21:00:45 +02:00
C_Ma_For 071f126244 fix: re-ajouter import map onnxruntime-web pour détourage IA CDN 2026-06-09 14:54:29 +02:00
C_Ma_For 415b7db643 fix: détecter ligne d'en-têtes dynamiquement (row 1 vide sur OUTILLAGE etc, row 2 = vrais headers) 2026-06-09 14:50:23 +02:00
C_Ma_For 8ede847ba0 fix: recherche produit via Google Sheets (UGS col C → Shopify ID col A) + fallback Shopify 2026-06-09 13:41:53 +02:00
C_Ma_For 193edc80aa fix: originalUrl mis à jour avec JPEG après conversion HEIC (aperçu original noir) 2026-06-09 13:27:10 +02:00
C_Ma_For 1f8db29b15 fix: ErrorBoundary autour de ImagesPage pour capturer crash IA 2026-06-09 13:19:45 +02:00
C_Ma_For 4b9db5bebf fix: revert @imgly npm import → CDN + ErrorBoundary + next.config cleanup 2026-06-09 13:15:04 +02:00
C_Ma_For a77f44b800 fix: @imgly/background-removal via npm + publicPath CDN + asyncWebAssembly webpack 2026-06-09 12:27:18 +02:00
C_Ma_For 0b26a61f90 fix: import map onnxruntime-web pour @imgly/background-removal CDN 2026-06-09 12:09:58 +02:00
C_Ma_For 2efe339576 fix: conversion HEIC serveur via heic-convert (WASM, supporte HEIC Apple vs sharp AVIF only) 2026-06-09 11:50:00 +02:00
C_Ma_For c4edcb568c feat: conversion HEIC serveur via sharp (fallback fiable Chrome/Firefox) 2026-06-09 11:24:24 +02:00
C_Ma_For e3246d022e fix: conversion HEIC — ajout fallback img+canvas pour Chrome macOS 2026-06-09 11:11:17 +02:00
C_Ma_For 9ab8b3ef60 fix: conversion HEIC native d'abord (Safari/macOS), heic2any fallback 30s 2026-06-09 11:07:04 +02:00
C_Ma_For 7da453606e fix(ui): MetafieldResolver — défaut skip, UX claire create/map/ignore + labels 2026-06-09 10:56:50 +02:00
C_Ma_For 8d591bb769 fix: fetchMetafieldDefinitions + ensureMetafieldDefinition via GraphQL (REST n'expose pas cet endpoint) 2026-06-09 10:46:34 +02:00
C_Ma_For bb596c7e32 feat(plan5): page /creation stepper 4 étapes + lien nav + fix TS shopifyMetafields
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:20:36 +02:00
C_Ma_For 1ed8c055d7 feat(plan5): composants PendingRowsTable + MetafieldResolver + CreationProgress 2026-06-09 10:14:12 +02:00
C_Ma_For 40d537efb6 feat(plan5): API POST /api/creation/execute streaming NDJSON 2026-06-09 10:09:27 +02:00
C_Ma_For 015833150a feat(plan5): API GET /api/creation/metafields (TDD) 2026-06-09 09:58:14 +02:00
C_Ma_For 04833b490c feat(plan5): API GET /api/creation/preview (TDD) 2026-06-09 09:57:29 +02:00
C_Ma_For 072110e655 feat(plan5): shopifyCollections — ajout produit à collection 2026-06-09 09:55:57 +02:00
C_Ma_For 421c275d48 feat(plan5): shopifyMetafields — matching + gestion définitions (TDD)
Implémente Task 4 du Plan 5:
- normalizeMetafieldKey: minuscules, accents retirés, espaces/parenth→_, max 30 chars
- matchMetafields: compare clé normalisée d'une colonne avec les clés existantes
- fetchMetafieldDefinitions: récupère les définitions Shopify existantes
- ensureMetafieldDefinition: crée une définition si manquante
- setProductMetafield: définit un métachamp sur un produit

7 tests passants (4 normalizeMetafieldKey + 3 matchMetafields).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 09:52:23 +02:00
C_Ma_For ed091711c8 feat(plan5): openaiClient — génération descriptions GPT-4o (TDD) 2026-06-09 09:42:26 +02:00
C_Ma_For 3aa770321c feat(plan5): creationSheets — lecture lignes en attente (TDD) 2026-06-09 09:40:23 +02:00
C_Ma_For 2e9df3659f feat(plan5): types création + modèle Prisma ProductCreation 2026-06-09 09:33:11 +02:00
C_Ma_For 1e48ca142f fix: remplacer status=any par deux appels active+draft (API REST Shopify) 2026-06-09 00:05:53 +02:00
C_Ma_For c89ab1c818 feat: lire tous les onglets Google Sheets (un onglet par famille de produits) 2026-06-08 23:56:22 +02:00
C_Ma_For 4263b8afd0 fix: onglet Google Sheets par défaut MATRICE au lieu de Produits 2026-06-08 23:52:09 +02:00
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
96 changed files with 9046 additions and 172 deletions
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+799 -5
View File
@@ -8,17 +8,21 @@
"name": "gestion-md",
"version": "0.1.0",
"dependencies": {
"@imgly/background-removal": "^1.7.0",
"@prisma/adapter-better-sqlite3": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"googleapis": "^173.0.0",
"heic-convert": "^2.1.0",
"heic2any": "^0.0.4",
"jszip": "^3.10.1",
"next": "14.2.35",
"next-auth": "^4.24.14",
"onnxruntime-web": "^1.21.0",
"react": "^18",
"react-dom": "^18"
"react-dom": "^18",
"sharp": "^0.34.5"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
@@ -28,6 +32,7 @@
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/sharp": "^0.31.1",
"eslint": "^8",
"eslint-config-next": "14.2.35",
"jest": "^30.4.2",
@@ -812,7 +817,6 @@
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -928,6 +932,533 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@imgly/background-removal": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@imgly/background-removal/-/background-removal-1.7.0.tgz",
"integrity": "sha512-/1ZryrMYg2ckIvJKoTu5Np50JfYMVffDMlVmppw/BdbN3pBTN7e6stI5/7E/LVh9DDzz6J588s7sWqul3fy5wA==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"lodash-es": "^4.17.21",
"ndarray": "~1.0.0",
"zod": "^3.23.8"
},
"peerDependencies": {
"onnxruntime-web": "1.21.0"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"license": "ISC",
@@ -2246,6 +2777,69 @@
}
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
"integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"license": "BSD-3-Clause"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
@@ -2709,7 +3303,6 @@
},
"node_modules/@types/node": {
"version": "20.19.42",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -2737,6 +3330,16 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@types/sharp": {
"version": "0.31.1",
"resolved": "https://registry.npmjs.org/@types/sharp/-/sharp-0.31.1.tgz",
"integrity": "sha512-5nWwamN9ZFHXaYEincMSuza8nNfOof8nmO+mcI+Agx1uMUk4/pQnNIcix+9rLPXzKrm1pS34+6WRDbDV0Jn7ag==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@@ -6214,6 +6817,12 @@
"node": "^10.12.0 || >=12.0.0"
}
},
"node_modules/flatbuffers": {
"version": "25.9.23",
"resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
"integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
"license": "Apache-2.0"
},
"node_modules/flatted": {
"version": "3.4.2",
"dev": true,
@@ -6718,6 +7327,12 @@
"node": ">=18"
}
},
"node_modules/guid-typescript": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
"integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
"license": "ISC"
},
"node_modules/handlebars": {
"version": "4.7.9",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
@@ -6818,6 +7433,32 @@
"node": ">= 0.4"
}
},
"node_modules/heic-convert": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/heic-convert/-/heic-convert-2.1.0.tgz",
"integrity": "sha512-1qDuRvEHifTVAj3pFIgkqGgJIr0M3X7cxEPjEp0oG4mo8GFjq99DpCo8Eg3kg17Cy0MTjxpFdoBHOatj7ZVKtg==",
"license": "ISC",
"dependencies": {
"heic-decode": "^2.0.0",
"jpeg-js": "^0.4.4",
"pngjs": "^6.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/heic-decode": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
"integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==",
"license": "ISC",
"dependencies": {
"libheif-js": "^1.19.8"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/heic2any": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz",
@@ -7034,6 +7675,12 @@
"node": ">= 0.4"
}
},
"node_modules/iota-array": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz",
"integrity": "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==",
"license": "MIT"
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"dev": true,
@@ -7115,6 +7762,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
"license": "MIT"
},
"node_modules/is-bun-module": {
"version": "2.0.0",
"dev": true,
@@ -8641,6 +9294,12 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jpeg-js": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
"license": "BSD-3-Clause"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"license": "MIT"
@@ -8890,6 +9549,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/libheif-js": {
"version": "1.19.8",
"resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.19.8.tgz",
"integrity": "sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ==",
"license": "LGPL-3.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
@@ -8929,6 +9597,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@@ -8945,7 +9619,6 @@
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/loose-envify": {
@@ -9208,6 +9881,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/ndarray": {
"version": "1.0.19",
"resolved": "https://registry.npmjs.org/ndarray/-/ndarray-1.0.19.tgz",
"integrity": "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==",
"license": "MIT",
"dependencies": {
"iota-array": "^1.0.0",
"is-buffer": "^1.0.2"
}
},
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
@@ -9740,6 +10423,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/onnxruntime-common": {
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz",
"integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==",
"license": "MIT"
},
"node_modules/onnxruntime-web": {
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.21.0.tgz",
"integrity": "sha512-adzOe+7uI7lKz6pQNbAsLMQd2Fq5Jhmoxd8LZjJr8m3KvbFyiYyRxRiC57/XXD+jb18voppjeGAjoZmskXG+7A==",
"license": "MIT",
"dependencies": {
"flatbuffers": "^25.1.24",
"guid-typescript": "^1.0.9",
"long": "^5.2.3",
"onnxruntime-common": "1.21.0",
"platform": "^1.3.6",
"protobufjs": "^7.2.4"
}
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
@@ -10070,6 +10773,21 @@
"pathe": "^2.0.3"
}
},
"node_modules/platform": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
"license": "MIT"
},
"node_modules/pngjs": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
"license": "MIT",
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"dev": true,
@@ -10393,6 +11111,30 @@
"devOptional": true,
"license": "ISC"
},
"node_modules/protobufjs": {
"version": "7.6.2",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.2.tgz",
"integrity": "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.1",
"@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.2",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.3.2"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
@@ -11028,6 +11770,50 @@
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"license": "MIT",
@@ -12449,7 +13235,6 @@
},
"node_modules/undici-types": {
"version": "6.21.0",
"dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {
@@ -13021,6 +13806,15 @@
"grammex": "^3.1.11",
"graphmatch": "^1.1.0"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+6 -1
View File
@@ -13,17 +13,21 @@
"seed": "ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts"
},
"dependencies": {
"@imgly/background-removal": "^1.7.0",
"@prisma/adapter-better-sqlite3": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"googleapis": "^173.0.0",
"heic-convert": "^2.1.0",
"heic2any": "^0.0.4",
"jszip": "^3.10.1",
"next": "14.2.35",
"next-auth": "^4.24.14",
"onnxruntime-web": "^1.21.0",
"react": "^18",
"react-dom": "^18"
"react-dom": "^18",
"sharp": "^0.34.5"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
@@ -33,6 +37,7 @@
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/sharp": "^0.31.1",
"eslint": "^8",
"eslint-config-next": "14.2.35",
"jest": "^30.4.2",
@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "ProductCreation" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
"sheetTab" TEXT NOT NULL,
"sheetRow" INTEGER NOT NULL,
"shopifyId" TEXT NOT NULL,
"sku" TEXT NOT NULL,
"title" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'success',
"errorMessage" TEXT NOT NULL DEFAULT '',
CONSTRAINT "ProductCreation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
+22 -7
View File
@@ -7,13 +7,14 @@ datasource db {
}
model User {
id String @id @default(cuid())
email String @unique
name String
password String
role String @default("gestionnaire")
createdAt DateTime @default(now())
syncRuns SyncRun[]
id String @id @default(cuid())
email String @unique
name String
password String
role String @default("gestionnaire")
createdAt DateTime @default(now())
syncRuns SyncRun[]
productCreations ProductCreation[]
}
model SyncRun {
@@ -28,3 +29,17 @@ model SyncRun {
errors Int @default(0)
log String @default("[]")
}
model ProductCreation {
id String @id @default(cuid())
createdAt DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id])
sheetTab String
sheetRow Int
shopifyId String
sku String
title String
status String @default("success")
errorMessage String @default("")
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Met à jour compare_at_price sur tous les produits Shopify existants
* en lisant Prix marché / Prix remisé depuis Google Sheets.
*
* Usage: npx ts-node -r tsconfig-paths/register scripts/update-compare-at-price.ts
*/
import * as dotenv from 'dotenv'
dotenv.config({ path: '.env.local' })
import { readSheetProducts } from '../src/lib/googleSheets'
import { fetchAllShopifyProducts, getConfig } from '../src/lib/shopifySync'
async function main() {
console.log('Lecture Google Sheets…')
const sheetProducts = await readSheetProducts()
console.log(`${sheetProducts.length} produits dans Sheets`)
console.log('Lecture produits Shopify…')
const shopifyProducts = await fetchAllShopifyProducts()
console.log(`${shopifyProducts.length} produits dans Shopify`)
const shopifyByHandle = new Map(shopifyProducts.map(p => [p.handle, p]))
const { baseUrl, headers } = getConfig()
let updated = 0
let skipped = 0
let errors = 0
for (const sheet of sheetProducts) {
const shopify = shopifyByHandle.get(sheet.handle)
if (!shopify) { skipped++; continue }
const newCompareAt = sheet.compareAtPrice ?? null
const currentCompareAt = (shopify as { compareAtPrice?: string }).compareAtPrice ?? null
// Normalise pour comparer (null vs undefined vs "0.00" vs null)
const norm = (v: string | null) => (!v || v === '0.00') ? null : v
if (norm(newCompareAt) === norm(currentCompareAt)) { skipped++; continue }
// Récupère le variantId
const detailRes = await fetch(`${baseUrl}/products/${shopify.shopifyId}.json?fields=variants`, { headers })
if (!detailRes.ok) { console.error(`Erreur lecture variant ${sheet.ref}`); errors++; continue }
const detail = await detailRes.json() as { product: { variants: Array<{ id: number }> } }
const variantId = detail.product.variants[0]?.id
if (!variantId) { errors++; continue }
const res = await fetch(`${baseUrl}/products/${shopify.shopifyId}.json`, {
method: 'PUT',
headers,
body: JSON.stringify({
product: {
id: shopify.shopifyId,
variants: [{ id: variantId, price: sheet.price, compare_at_price: newCompareAt }],
},
}),
})
if (res.ok) {
console.log(`${sheet.ref} — price=${sheet.price} compare_at=${newCompareAt ?? 'null'}`)
updated++
} else {
const err = await res.text()
console.error(`${sheet.ref}${err}`)
errors++
}
// Rate limiting
await new Promise(r => setTimeout(r, 300))
}
console.log(`\nTerminé : ${updated} mis à jour, ${skipped} inchangés, ${errors} erreurs`)
}
main().catch(err => { console.error(err); process.exit(1) })
+297
View File
@@ -0,0 +1,297 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { Header } from '@/components/layout/Header'
import { BannerBuilder } from '@/components/bannieres/BannerBuilder'
import type { SlideshowSection, Slide } from '@/lib/shopifyTheme'
// Convertit "shopify://shop_images/foo.jpg" → URL CDN prévisualisable via proxy
function shopifyRefToProxyUrl(ref: string): string {
if (!ref) return ''
const filename = ref.replace('shopify://shop_images/', '')
return `/api/images/proxy?url=${encodeURIComponent(`https://cdn.shopify.com/s/files/1/0897/7373/6263/files/${filename}`)}`
}
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve((reader.result as string).split(',')[1])
reader.onerror = reject
reader.readAsDataURL(blob)
})
}
interface SlideCardProps {
slide: Slide
index: number
total: number
onMoveUp: () => void
onMoveDown: () => void
onRemove: () => void
onChangeImage: (shopifyRef: string) => void
onChangeLink: (link: string) => void
onOpenBuilder: () => void
}
function SlideCard({ slide, index, total, onMoveUp, onMoveDown, onRemove, onChangeImage, onChangeLink, onOpenBuilder }: SlideCardProps) {
const fileRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
setError(null)
try {
const base64 = await blobToBase64(file)
const res = await fetch('/api/theme/upload-file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageBase64: base64, filename: file.name }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
onChangeImage(data.shopifyRef)
} catch (err) {
setError((err as Error).message)
} finally {
setUploading(false)
e.target.value = ''
}
}
const previewUrl = shopifyRefToProxyUrl(slide.settings.image)
return (
<div className="bg-slate-800 border border-slate-700 rounded-xl overflow-hidden">
{/* Image preview */}
<div className="relative h-44 bg-slate-900">
{previewUrl ? (
<img src={previewUrl} alt={`Slide ${index + 1}`} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-slate-600 text-sm">
Aucune image
</div>
)}
<div className="absolute top-2 left-2 bg-black/60 text-white text-xs px-2 py-0.5 rounded-full">
{index + 1} / {total}
</div>
</div>
{/* Controls */}
<div className="p-3 space-y-2">
{/* Link */}
<div>
<label className="block text-xs text-slate-400 mb-1">Lien (optionnel)</label>
<input
type="text"
value={slide.settings.link ?? ''}
onChange={e => onChangeLink(e.target.value)}
placeholder="https://…"
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
{error && <p className="text-xs text-red-400">{error}</p>}
{/* Actions */}
<div className="flex gap-1.5 flex-wrap">
<button
onClick={onOpenBuilder}
className="flex-1 py-1.5 text-xs bg-purple-700 hover:bg-purple-600 text-white rounded font-medium"
>
🎨 Créer une bannière
</button>
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="py-1.5 px-2 text-xs bg-indigo-600 hover:bg-indigo-500 text-white rounded font-medium disabled:opacity-50"
title="Uploader une image"
>
{uploading ? '…' : '🖼️'}
</button>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleFile} />
<button
onClick={onMoveUp}
disabled={index === 0}
className="py-1.5 px-2 text-xs bg-slate-700 hover:bg-slate-600 text-slate-300 rounded disabled:opacity-30"
title="Monter"
></button>
<button
onClick={onMoveDown}
disabled={index === total - 1}
className="py-1.5 px-2 text-xs bg-slate-700 hover:bg-slate-600 text-slate-300 rounded disabled:opacity-30"
title="Descendre"
></button>
<button
onClick={onRemove}
className="py-1.5 px-2 text-xs bg-slate-700 hover:bg-red-900 text-slate-400 hover:text-red-300 rounded"
title="Supprimer"
></button>
</div>
</div>
</div>
)
}
export default function BannièresPage() {
const [slideshow, setSlideshow] = useState<SlideshowSection | null>(null)
const [themeId, setThemeId] = useState<string>('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
// Index de la slide cible du builder (-1 = nouvelle slide)
const [builderTargetIndex, setBuilderTargetIndex] = useState<number | null>(null)
useEffect(() => {
fetch('/api/theme/slideshow')
.then(r => r.json())
.then(d => {
if (d.error) throw new Error(d.error)
setSlideshow(d.slideshow)
setThemeId(d.themeId)
})
.catch(e => setError(e.message))
.finally(() => setLoading(false))
}, [])
const update = useCallback((fn: (s: SlideshowSection) => SlideshowSection) => {
setSlideshow(prev => prev ? fn(prev) : prev)
setSaved(false)
}, [])
const moveSlide = (index: number, dir: -1 | 1) => {
update(s => {
const slides = [...s.slides]
const tmp = slides[index]
slides[index] = slides[index + dir]
slides[index + dir] = tmp
return { ...s, slides }
})
}
const removeSlide = (index: number) => {
update(s => ({ ...s, slides: s.slides.filter((_, i) => i !== index) }))
}
const addSlide = () => {
update(s => ({
...s,
slides: [...s.slides, {
blockId: `slide_${Math.random().toString(36).slice(2, 8)}`,
settings: { image: '', link: '' },
}],
}))
}
const changeImage = (index: number, shopifyRef: string) => {
update(s => {
const slides = s.slides.map((sl, i) =>
i === index ? { ...sl, settings: { ...sl.settings, image: shopifyRef } } : sl
)
return { ...s, slides }
})
}
const changeLink = (index: number, link: string) => {
update(s => {
const slides = s.slides.map((sl, i) =>
i === index ? { ...sl, settings: { ...sl.settings, link } } : sl
)
return { ...s, slides }
})
}
const save = async () => {
if (!slideshow) return
setSaving(true)
setError(null)
try {
const res = await fetch('/api/theme/slideshow', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slideshow, themeId }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
setSaved(true)
} catch (err) {
setError((err as Error).message)
} finally {
setSaving(false)
}
}
return (
<>
<Header
title="Bannières homepage"
actions={
<div className="flex items-center gap-3">
{saved && <span className="text-xs text-green-400"> Publié sur Shopify</span>}
{error && <span className="text-xs text-red-400">{error}</span>}
<button
onClick={addSlide}
disabled={!slideshow}
className="px-3 py-1.5 text-xs bg-slate-700 hover:bg-slate-600 text-white rounded font-medium disabled:opacity-40"
>
+ Ajouter une slide
</button>
<button
onClick={save}
disabled={!slideshow || saving}
className="px-4 py-1.5 text-xs bg-green-700 hover:bg-green-600 text-white rounded font-medium disabled:opacity-40"
>
{saving ? 'Publication…' : '🚀 Publier sur Shopify'}
</button>
</div>
}
/>
<div className="p-6">
{loading && (
<div className="text-center py-12 text-slate-500 text-sm">Chargement du thème</div>
)}
{!loading && !slideshow && (
<div className="text-center py-12 text-red-400 text-sm">
Section slideshow-custom introuvable dans le thème actif.
</div>
)}
{slideshow && (
<div className="space-y-4">
<p className="text-xs text-slate-500">
{slideshow.slides.length} slide{slideshow.slides.length > 1 ? 's' : ''} · Thème actif (Meka)
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{slideshow.slides.map((slide, i) => (
<SlideCard
key={slide.blockId}
slide={slide}
index={i}
total={slideshow.slides.length}
onMoveUp={() => moveSlide(i, -1)}
onMoveDown={() => moveSlide(i, 1)}
onRemove={() => removeSlide(i)}
onChangeImage={ref => changeImage(i, ref)}
onChangeLink={link => changeLink(i, link)}
onOpenBuilder={() => setBuilderTargetIndex(i)}
/>
))}
</div>
</div>
)}
</div>
{builderTargetIndex !== null && (
<BannerBuilder
onApply={shopifyRef => {
if (builderTargetIndex !== null) changeImage(builderTargetIndex, shopifyRef)
setBuilderTargetIndex(null)
setSaved(false)
}}
onClose={() => setBuilderTargetIndex(null)}
/>
)}
</>
)
}
+419
View File
@@ -0,0 +1,419 @@
'use client'
import { useState, useCallback, useEffect, useRef } from 'react'
import { Header } from '@/components/layout/Header'
import { PendingRowsTable } from '@/components/creation/PendingRowsTable'
import { MetafieldResolver } from '@/components/creation/MetafieldResolver'
import { CreationProgress } from '@/components/creation/CreationProgress'
import { FamilySelector } from '@/components/common/FamilySelector'
import type { PendingProduct, MetafieldResolution, CreationStreamEvent } from '@/types/creation'
type Step = 'famille' | 'analyse' | 'metafields' | 'confirmation' | 'execution'
interface ShopifyCollection { id: string; title: string }
export default function CreationPage() {
const [step, setStep] = useState<Step>('famille')
const [selectedTab, setSelectedTab] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [byTab, setByTab] = useState<Record<string, PendingProduct[]>>({})
const [total, setTotal] = useState(0)
const [specificColumns, setSpecificColumns] = useState<string[]>([])
const [resolutions, setResolutions] = useState<MetafieldResolution[]>([])
const [events, setEvents] = useState<CreationStreamEvent[]>([])
const [done, setDone] = useState(false)
const [stopped, setStopped] = useState(false)
const abortRef = useRef<AbortController | null>(null)
// Sync stock
const [stockSyncing, setStockSyncing] = useState(false)
const [stockResult, setStockResult] = useState<{ updated: number; errors: number; fatalError?: string } | null>(null)
const [stockProgress, setStockProgress] = useState<{ current: number; total: number; title?: string } | null>(null)
const runStockStream = async (url: string, setter: typeof setStockResult) => {
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: '{}' })
if (!res.body) return
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) {
if (!line.trim()) continue
try {
const evt = JSON.parse(line)
if (evt.type === 'progress') setStockProgress({ current: evt.current, total: evt.total, title: evt.title })
if (evt.type === 'done') setter({ updated: evt.updated, errors: evt.errors, fatalError: evt.fatalError })
} catch { /* */ }
}
}
}
const syncStock = async () => {
setStockSyncing(true)
setStockResult(null)
setStockProgress(null)
try { await runStockStream('/api/creation/sync-stock', setStockResult) }
finally { setStockSyncing(false); setStockProgress(null) }
}
const [stockReverseSyncing, setStockReverseSyncing] = useState(false)
const [stockReverseResult, setStockReverseResult] = useState<{ updated: number; errors: number; fatalError?: string } | null>(null)
const syncStockReverse = async () => {
setStockReverseSyncing(true)
setStockReverseResult(null)
setStockProgress(null)
try { await runStockStream('/api/creation/sync-stock-reverse', setStockReverseResult) }
finally { setStockReverseSyncing(false); setStockProgress(null) }
}
// Collection
const [collections, setCollections] = useState<ShopifyCollection[]>([])
const [detectedCollectionId, setDetectedCollectionId] = useState<string>('')
const [collectionOverride, setCollectionOverride] = useState<string>('')
const [collectionSearch, setCollectionSearch] = useState('')
const [loadingCollections, setLoadingCollections] = useState(false)
useEffect(() => {
setLoadingCollections(true)
fetch('/api/creation/collections')
.then(r => r.json())
.then(d => setCollections(d.collections ?? []))
.finally(() => setLoadingCollections(false))
}, [])
const handleSelectTab = (tab: string) => {
setSelectedTab(tab)
setStep('analyse')
setDetectedCollectionId('')
setCollectionOverride('')
setCollectionSearch('')
}
const analyse = async () => {
setLoading(true)
setError(null)
try {
const url = selectedTab
? `/api/creation/preview?tab=${encodeURIComponent(selectedTab)}`
: '/api/creation/preview'
const res = await fetch(url)
const data = await res.json()
if (data.error) throw new Error(data.error)
setByTab(data.byTab)
setTotal(data.total)
setSpecificColumns(data.specificColumns)
// Détecter la collection depuis les produits analysés
const products: PendingProduct[] = Object.values(data.byTab as Record<string, PendingProduct[]>).flat()
const ids = Array.from(new Set(products.map(p => p.collectionId).filter(Boolean)))
if (ids.length === 1) {
setDetectedCollectionId(ids[0])
setCollectionOverride(ids[0])
} else {
setDetectedCollectionId('')
setCollectionOverride('')
}
setStep('metafields')
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur')
} finally {
setLoading(false)
}
}
const handleResolved = useCallback((r: MetafieldResolution[]) => { setResolutions(r) }, [])
const stop = useCallback(() => {
abortRef.current?.abort()
setStopped(true)
setDone(true)
}, [])
const execute = async () => {
const controller = new AbortController()
abortRef.current = controller
setStep('execution')
setEvents([])
setDone(false)
setStopped(false)
try {
const res = await fetch('/api/creation/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resolutions, tab: selectedTab, collectionOverride: collectionOverride || undefined }),
signal: controller.signal,
})
if (!res.body) return
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done: readerDone, value } = await reader.read()
if (readerDone) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.trim()) continue
try {
const event = JSON.parse(line) as CreationStreamEvent
setEvents(prev => [...prev, event])
if (event.type === 'done') setDone(true)
} catch { /* ligne incomplète */ }
}
}
} catch (e) {
if ((e as Error).name !== 'AbortError') {
setEvents(prev => [...prev, { type: 'error', title: '', message: (e as Error).message }])
}
}
setDone(true)
}
const STEPS: { id: Step; label: string }[] = [
{ id: 'analyse', label: 'Analyse' },
{ id: 'metafields', label: 'Métachamps' },
{ id: 'confirmation', label: 'Confirmation' },
{ id: 'execution', label: 'Exécution' },
]
const currentIdx = STEPS.findIndex(s => s.id === step)
const reset = () => {
abortRef.current?.abort()
abortRef.current = null
setStep('famille')
setSelectedTab(null)
setEvents([])
setDone(false)
setStopped(false)
setByTab({})
setTotal(0)
setDetectedCollectionId('')
setCollectionOverride('')
}
const filteredCollections = collectionSearch
? collections.filter(c => c.title.toLowerCase().includes(collectionSearch.toLowerCase()))
: collections
const selectedCollection = collections.find(c => c.id === collectionOverride)
return (
<>
<Header title="Création produits" />
<div className="p-6 space-y-6">
<div className="flex gap-2">
{STEPS.map((s, i) => (
<div key={s.id} className="flex items-center gap-2">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${step === s.id ? 'bg-indigo-600 text-white' : i < currentIdx ? 'bg-green-700 text-white' : 'bg-slate-700 text-gray-400'}`}>
{i + 1}. {s.label}
</span>
{i < STEPS.length - 1 && <span className="text-gray-600"></span>}
</div>
))}
</div>
{/* Étape 0 — Sélection famille */}
{step === 'famille' && (
<div className="space-y-4">
<div className="bg-slate-800 rounded-xl p-6">
<h2 className="text-sm font-semibold text-white mb-4">Choisir une famille</h2>
<FamilySelector onSelect={handleSelectTab} />
</div>
<div className="bg-slate-800 rounded-xl p-6 space-y-6">
<div>
<h2 className="text-sm font-semibold text-white mb-1">Sheets Shopify</h2>
<p className="text-xs text-gray-400 mb-3">Met à jour le stock Shopify depuis les valeurs du Sheets.</p>
<button onClick={syncStock} disabled={stockSyncing || stockReverseSyncing}
className="px-4 py-2 bg-amber-700 hover:bg-amber-600 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
{stockSyncing ? (stockProgress ? `Mise à jour… ${stockProgress.current}/${stockProgress.total}` : 'Chargement…') : 'Synchroniser Sheets → Shopify'}
</button>
{stockSyncing && stockProgress?.title && (
<p className="mt-2 text-xs text-gray-400 truncate"> {stockProgress.title}</p>
)}
{stockResult && (
<div className="mt-2 text-sm">
{stockResult.fatalError
? <p className="text-red-400"> {stockResult.fatalError}</p>
: stockResult.errors === 0
? <p className="text-green-400"> {stockResult.updated} produit{stockResult.updated > 1 ? 's' : ''} mis à jour</p>
: <p className="text-amber-400"> {stockResult.updated} mis à jour {stockResult.errors} erreur{stockResult.errors > 1 ? 's' : ''}</p>
}
</div>
)}
</div>
<hr className="border-slate-700" />
<div>
<h2 className="text-sm font-semibold text-white mb-1">Shopify Sheets</h2>
<p className="text-xs text-gray-400 mb-3">Écrase les stocks du Sheets avec les valeurs actuelles de Shopify.</p>
<button onClick={syncStockReverse} disabled={stockSyncing || stockReverseSyncing}
className="px-4 py-2 bg-blue-700 hover:bg-blue-600 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
{stockReverseSyncing ? (stockProgress ? `Lecture… ${stockProgress.current}/${stockProgress.total}` : 'Chargement…') : 'Synchroniser Shopify → Sheets'}
</button>
{stockReverseSyncing && stockProgress && (
<p className="mt-2 text-xs text-gray-400"> {stockProgress.current}/{stockProgress.total}</p>
)}
{stockReverseResult && (
<div className="mt-2 text-sm">
{stockReverseResult.fatalError
? <p className="text-red-400"> {stockReverseResult.fatalError}</p>
: stockReverseResult.errors === 0
? <p className="text-green-400"> {stockReverseResult.updated} produit{stockReverseResult.updated > 1 ? 's' : ''} mis à jour dans le Sheets</p>
: <p className="text-amber-400"> {stockReverseResult.updated} mis à jour {stockReverseResult.errors} erreur{stockReverseResult.errors > 1 ? 's' : ''}</p>
}
</div>
)}
</div>
</div>
</div>
)}
<div className="bg-slate-800 rounded-xl p-6" style={{ display: step === 'famille' ? 'none' : undefined }}>
{error && <div className="mb-4 p-3 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">{error}</div>}
{step === 'analyse' && (
<div className="space-y-4">
<div className="flex items-center gap-2 bg-indigo-950/50 border border-indigo-800/50 rounded-lg px-3 py-2 w-fit">
<span className="text-indigo-400 text-sm">📂 Famille :</span>
<span className="text-white text-sm font-semibold">{selectedTab}</span>
<button onClick={() => { setStep('famille'); setSelectedTab(null) }} className="ml-2 text-xs text-slate-400 hover:text-slate-200">changer</button>
</div>
<p className="text-gray-300 text-sm">
Détecte les lignes de <strong className="text-white">{selectedTab}</strong> <code className="text-indigo-300">Publié=1</code> et <code className="text-indigo-300">ID vide</code>.
</p>
<button onClick={analyse} disabled={loading}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
{loading ? 'Analyse en cours…' : 'Analyser le Sheets'}
</button>
</div>
)}
{step === 'metafields' && (
<div className="space-y-6">
<PendingRowsTable byTab={byTab} total={total} />
{total > 0 && (
<>
<hr className="border-slate-700" />
{/* Collection Shopify */}
<div>
<h2 className="text-base font-semibold text-white mb-1">Collection Shopify</h2>
{detectedCollectionId ? (
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-green-400"> Détectée depuis le Sheets :</span>
<span className="text-xs text-white font-medium">
{collections.find(c => c.id === detectedCollectionId)?.title ?? `ID ${detectedCollectionId}`}
</span>
<button type="button" onClick={() => { setDetectedCollectionId(''); setCollectionOverride('') }}
className="text-xs text-gray-500 hover:text-gray-300">modifier</button>
</div>
) : (
<p className="text-xs text-amber-400 mb-2"> Aucune collection détectée dans le Sheets choisissez-en une :</p>
)}
{(!detectedCollectionId || collectionOverride !== detectedCollectionId) && (
<div className="space-y-2">
{selectedCollection && (
<div className="flex items-center gap-2">
<span className="text-xs text-blue-300 font-medium"> {selectedCollection.title}</span>
<button type="button" onClick={() => setCollectionOverride('')}
className="text-xs text-gray-500 hover:text-gray-300"></button>
</div>
)}
<input
type="text"
value={collectionSearch}
onChange={e => setCollectionSearch(e.target.value)}
placeholder={loadingCollections ? 'Chargement…' : 'Rechercher une collection Shopify…'}
className="w-full max-w-sm bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
{collectionSearch && filteredCollections.length > 0 && (
<div className="max-w-sm bg-slate-700 border border-slate-600 rounded shadow-lg max-h-48 overflow-y-auto">
{filteredCollections.map(c => (
<button key={c.id} type="button"
onClick={() => { setCollectionOverride(c.id); setCollectionSearch('') }}
className="w-full text-left px-3 py-1.5 text-sm text-white hover:bg-slate-600">
{c.title}
</button>
))}
</div>
)}
{collectionSearch && filteredCollections.length === 0 && (
<p className="text-xs text-gray-400">Aucune collection trouvée</p>
)}
</div>
)}
</div>
<hr className="border-slate-700" />
<div>
<h2 className="text-base font-semibold text-white mb-3">Résolution des métachamps</h2>
<MetafieldResolver specificColumns={specificColumns} onResolved={handleResolved} />
</div>
<button onClick={() => setStep('confirmation')}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors">
Continuer
</button>
</>
)}
</div>
)}
{step === 'confirmation' && (
<div className="space-y-4">
<div className="space-y-2 text-sm">
<p className="text-gray-300"><span className="text-white font-medium">{total} produit{total > 1 ? 's' : ''}</span> à créer dans Shopify</p>
<p className="text-gray-300">
Collection : <span className="text-white font-medium">
{collections.find(c => c.id === collectionOverride)?.title ?? (collectionOverride ? `ID ${collectionOverride}` : <span className="text-amber-400">Aucune</span>)}
</span>
</p>
<p className="text-gray-300"><span className="text-white font-medium">{resolutions.filter(r => r.action !== 'skip').length}</span> type{resolutions.filter(r => r.action !== 'skip').length > 1 ? 's' : ''} de métachamps à assigner</p>
<p className="text-gray-300"><span className="text-white font-medium">{resolutions.filter(r => r.action === 'create').length}</span> nouvelle{resolutions.filter(r => r.action === 'create').length > 1 ? 's' : ''} définition{resolutions.filter(r => r.action === 'create').length > 1 ? 's' : ''} à créer dans Shopify</p>
<p className="text-yellow-300 text-xs mt-2"> Les descriptions seront générées par OpenAI GPT-4o (~0.01$ par produit)</p>
</div>
<div className="flex gap-3">
<button onClick={() => setStep('metafields')}
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-gray-300 rounded-lg text-sm font-medium transition-colors">
Retour
</button>
<button onClick={execute}
className="px-4 py-2 bg-green-700 hover:bg-green-600 text-white rounded-lg text-sm font-medium transition-colors">
Lancer la création ({total} produits)
</button>
</div>
</div>
)}
{step === 'execution' && (
<div className="space-y-4">
<CreationProgress events={events} done={done} />
<div className="flex gap-3">
{!done && (
<button onClick={stop}
className="px-4 py-2 bg-red-700 hover:bg-red-600 text-white rounded-lg text-sm font-medium transition-colors">
Arrêter
</button>
)}
{done && (
<>
{stopped && <p className="text-amber-400 text-sm self-center"> Création interrompue les produits déjà créés sont conservés dans Shopify.</p>}
<button onClick={reset}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors">
Nouvelle session
</button>
</>
)}
</div>
</div>
)}
</div>
</div>
</>
)
}
+142 -3
View File
@@ -1,16 +1,81 @@
'use client'
import { useState } from 'react'
import { useState, useCallback } from 'react'
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 { MockupModalFromImage } from '@/components/mockup/MockupModalFromImage'
import { ErrorBoundary } from '@/components/common/ErrorBoundary'
import { useImages } from '@/hooks/useImages'
import type { ImageItem } from '@/types/images'
export default function ImagesPage() {
const { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem } = useImages()
function ImagesPageInner() {
const { items, addFiles, setFeather, setAlphaThreshold, rotate, uploadOne, uploadAll, uploadSelected, removeItem, assignProduct, reprocess, detour } = useImages()
const [lightboxItem, setLightboxItem] = useState<ImageItem | null>(null)
const [mockupItem, setMockupItem] = useState<ImageItem | null>(null)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [uploadingSelected, setUploadingSelected] = useState(false)
const openMockup = useCallback((id: string) => {
const item = items.find(i => i.id === id)
if (item) setMockupItem(item)
}, [items])
const toggleSelect = useCallback((id: string, value: boolean) => {
setSelected(prev => {
const next = new Set(prev)
if (value) next.add(id)
else next.delete(id)
return next
})
}, [])
const clearSelection = useCallback(() => setSelected(new Set()), [])
const selectAllDetourable = useCallback(() => {
const ids = items
.filter(i => i.status === 'converted' || i.status === 'not_found' || i.status === 'error')
.map(i => i.id) as string[]
setSelected(new Set(ids))
}, [items])
const selectAllUploadable = useCallback(() => {
const ids = items
.filter(i => (i.status === 'ready' || i.status === 'converted') && !!i.shopifyProductId)
.map(i => i.id) as string[]
setSelected(new Set(ids))
}, [items])
const detourSelected = useCallback(() => {
Array.from(selected).forEach(id => detour(id))
setSelected(new Set())
}, [selected, detour])
const handleUploadSelected = useCallback(async () => {
setUploadingSelected(true)
await uploadSelected(selected)
setUploadingSelected(false)
setSelected(new Set())
}, [selected, uploadSelected])
const selectedDetourable = Array.from(selected).filter(id => {
const item = items.find(i => i.id === id)
return item && (item.status === 'converted' || item.status === 'not_found' || item.status === 'error')
})
const selectedUploadable = Array.from(selected).filter(id => {
const item = items.find(i => i.id === id)
return item && (item.status === 'ready' || item.status === 'converted') && !!item.shopifyProductId
})
const detourable = items.filter(i =>
i.status === 'converted' || i.status === 'not_found' || i.status === 'error'
)
const uploadable = items.filter(i =>
(i.status === 'ready' || i.status === 'converted') && !!i.shopifyProductId
)
const showSelectionBar = detourable.length > 0 || uploadable.length > 0
return (
<>
@@ -30,17 +95,80 @@ export default function ImagesPage() {
<GlobalActions items={items} onUploadAll={uploadAll} />
)}
{/* Barre de sélection multiple */}
{showSelectionBar && (
<div className="flex items-center gap-2 px-3 py-2 bg-slate-800 rounded-lg border border-slate-700 flex-wrap">
{/* Compteurs et raccourcis de sélection */}
<div className="flex items-center gap-2 text-xs text-slate-400">
{detourable.length > 0 && (
<button onClick={selectAllDetourable} className="hover:text-purple-300 transition-colors">
Sélectionner à détourer ({detourable.length})
</button>
)}
{detourable.length > 0 && uploadable.length > 0 && (
<span className="text-slate-600">·</span>
)}
{uploadable.length > 0 && (
<button onClick={selectAllUploadable} className="hover:text-indigo-300 transition-colors">
Sélectionner à uploader ({uploadable.length})
</button>
)}
</div>
{/* Actions sur la sélection */}
{selected.size > 0 && (
<>
<span className="text-slate-600 text-xs">|</span>
<span className="text-xs text-white font-medium">
{selected.size} sélectionnée{selected.size > 1 ? 's' : ''}
</span>
<div className="ml-auto flex items-center gap-2">
{selectedDetourable.length > 0 && (
<button
onClick={detourSelected}
className="px-3 py-1 text-xs bg-purple-700 hover:bg-purple-600 text-white font-semibold rounded"
>
Détourer ({selectedDetourable.length})
</button>
)}
{selectedUploadable.length > 0 && (
<button
onClick={handleUploadSelected}
disabled={uploadingSelected}
className="px-3 py-1 text-xs bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded disabled:opacity-50"
>
{uploadingSelected
? `⬆️ Upload en cours…`
: `⬆️ Uploader (${selectedUploadable.length})`}
</button>
)}
<button onClick={clearSelection} className="text-xs text-slate-500 hover:text-slate-300">
</button>
</div>
</>
)}
</div>
)}
{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}
selected={selected.has(item.id)}
onSelect={toggleSelect}
onFeatherChange={setFeather}
onAlphaThresholdChange={setAlphaThreshold}
onRotate={rotate}
onUpload={uploadOne}
onRemove={removeItem}
onOpenLightbox={setLightboxItem}
onAssignProduct={assignProduct}
onReprocess={reprocess}
onDetour={detour}
onMockup={openMockup}
/>
))}
</div>
@@ -56,6 +184,17 @@ export default function ImagesPage() {
</div>
<Lightbox item={lightboxItem} onClose={() => setLightboxItem(null)} />
{mockupItem && (
<MockupModalFromImage item={mockupItem} onClose={() => setMockupItem(null)} />
)}
</>
)
}
export default function ImagesPage() {
return (
<ErrorBoundary>
<ImagesPageInner />
</ErrorBoundary>
)
}
+449
View File
@@ -0,0 +1,449 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { Header } from '@/components/layout/Header'
import { TemplateLibrary } from '@/components/mockup/TemplateLibrary'
import type { MockupTemplate } from '@/types/mockup'
type Tab = 'generate' | 'library'
type Step = 'product' | 'image' | 'template'
interface Product {
shopifyId: string
title: string
ref: string
images: string[]
}
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve((reader.result as string).split(',')[1])
reader.onerror = reject
reader.readAsDataURL(blob)
})
}
export default function MockupPage() {
const [tab, setTab] = useState<Tab>('generate')
const [step, setStep] = useState<Step>('product')
// Step 1 — product
const [products, setProducts] = useState<Product[]>([])
const [search, setSearch] = useState('')
const [showDropdown, setShowDropdown] = useState(false)
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null)
const [family, setFamily] = useState('')
const [placement, setPlacement] = useState('')
const [dimensions, setDimensions] = useState('')
const dropdownRef = useRef<HTMLDivElement>(null)
// Step 2 — image
const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>(null)
const [cutoutB64, setCutoutB64] = useState<string | null>(null)
const [loadingImage, setLoadingImage] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
// Step 3 — template + generation
const [templates, setTemplates] = useState<MockupTemplate[]>([])
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null)
const [instruction, setInstruction] = useState('')
const [generating, setGenerating] = useState(false)
const [resultB64, setResultB64] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [uploadedUrl, setUploadedUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const templateFileRef = useRef<HTMLInputElement>(null)
const [uploadingTemplate, setUploadingTemplate] = useState(false)
useEffect(() => {
fetch('/api/products/list')
.then(r => r.json())
.then(data => setProducts(data.products ?? []))
fetch('/api/mockup/templates')
.then(r => r.json())
.then(data => setTemplates(data as MockupTemplate[]))
}, [])
const filtered = search.length < 2 ? [] : products.filter(p =>
p.title.toLowerCase().includes(search.toLowerCase()) ||
p.ref.toLowerCase().includes(search.toLowerCase())
).slice(0, 20)
const isCarrelage = family.toLowerCase().includes('carrelage')
const filteredTemplates = templates.filter(t => {
if (t.family !== family && t.family !== 'Tous') return false
if (isCarrelage && placement && t.placement && t.placement !== placement) return false
return true
})
const selectProduct = (p: Product) => {
setSelectedProduct(p)
setSearch('')
setShowDropdown(false)
setSelectedImageUrl(null)
setCutoutB64(null)
setResultB64(null)
setUploadedUrl(null)
setStep('image')
fetch(`/api/products/sheet-info?ref=${encodeURIComponent(p.ref)}`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data?.product) return
const { famille, longueur, largeur } = data.product
if (famille) setFamily(famille)
if (longueur && largeur) setDimensions(`${longueur}x${largeur}cm`)
else if (longueur) setDimensions(`${longueur}cm`)
})
}
const selectImage = async (url: string) => {
setSelectedImageUrl(url)
setCutoutB64(null)
setLoadingImage(true)
setError(null)
try {
const res = await fetch(`/api/images/proxy?url=${encodeURIComponent(url)}`)
if (!res.ok) throw new Error('Impossible de charger l\'image')
const blob = await res.blob()
const b64 = await blobToBase64(blob)
setCutoutB64(b64)
} catch {
setError('Impossible de charger cette image')
setSelectedImageUrl(null)
} finally {
setLoadingImage(false)
}
}
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setSelectedImageUrl(null)
const reader = new FileReader()
reader.onload = ev => {
setCutoutB64((ev.target?.result as string).split(',')[1])
setStep('template')
}
reader.readAsDataURL(file)
}
const uploadTemplate = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploadingTemplate(true)
const form = new FormData()
form.append('name', file.name.replace(/\.[^.]+$/, ''))
form.append('family', family || 'Tous')
form.append('description', '')
form.append('image', file)
const res = await fetch('/api/mockup/templates', { method: 'POST', body: form })
const tpl = await res.json() as MockupTemplate
setTemplates(prev => [...prev, tpl])
setSelectedTemplate(tpl.id)
setUploadingTemplate(false)
}
const generate = async () => {
if (!selectedTemplate || !cutoutB64) return
setGenerating(true)
setError(null)
setResultB64(null)
setUploadedUrl(null)
try {
const res = await fetch('/api/mockup/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
templateId: selectedTemplate,
productImageBase64: cutoutB64,
productName: selectedProduct!.title,
family: family || 'Tous',
placement: placement || undefined,
dimensions: dimensions || undefined,
instruction: instruction || undefined,
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
setResultB64(data.imageBase64)
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur')
} finally {
setGenerating(false)
}
}
const upload = async () => {
if (!resultB64 || !selectedProduct) return
setUploading(true)
try {
const res = await fetch('/api/images/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
shopifyProductId: selectedProduct.shopifyId,
imageBase64: resultB64,
filename: `${selectedProduct.ref}-mockup.jpg`,
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
setUploadedUrl(data.url)
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur upload')
} finally {
setUploading(false)
}
}
const StepIndicator = () => (
<div className="flex items-center gap-2 mb-6">
{(['product', 'image', 'template'] as Step[]).map((s, i) => {
const labels = ['Produit', 'Image', 'Template']
const active = step === s
const done = (step === 'image' && s === 'product') ||
(step === 'template' && (s === 'product' || s === 'image'))
return (
<div key={s} className="flex items-center gap-2">
{i > 0 && <div className={`h-px w-6 ${done ? 'bg-indigo-500' : 'bg-slate-600'}`} />}
<button
type="button"
onClick={() => done || active ? setStep(s) : undefined}
className={`flex items-center gap-1.5 text-xs font-medium transition-colors ${
active ? 'text-white' : done ? 'text-indigo-400 hover:text-indigo-300' : 'text-slate-500'
}`}
>
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
active ? 'bg-indigo-600 text-white' : done ? 'bg-indigo-500/30 text-indigo-400' : 'bg-slate-700 text-slate-500'
}`}>
{done ? '✓' : i + 1}
</span>
{labels[i]}
</button>
</div>
)
})}
</div>
)
return (
<>
<Header title="🖼️ Mockups" actions={
<div className="flex gap-2">
{(['generate', 'library'] as Tab[]).map(t => (
<button key={t} type="button" onClick={() => setTab(t)}
className={`px-3 py-1 text-xs rounded-lg transition-colors ${tab === t ? 'bg-indigo-600 text-white' : 'text-gray-400 hover:text-white'}`}>
{t === 'generate' ? 'Générer' : 'Bibliothèque templates'}
</button>
))}
</div>
} />
<div className="p-6 max-w-2xl mx-auto">
{tab === 'library' ? (
<TemplateLibrary />
) : (
<div>
<StepIndicator />
{/* ── STEP 1 : Produit ── */}
{step === 'product' && (
<div className="bg-slate-800 rounded-xl p-5 border border-slate-700 space-y-4">
<h2 className="text-sm font-semibold text-white">Rechercher un produit</h2>
<div className="relative" ref={dropdownRef}>
<input
type="text"
value={search}
onChange={e => { setSearch(e.target.value); setShowDropdown(true) }}
onFocus={() => setShowDropdown(true)}
placeholder="Nom ou référence du produit…"
autoFocus
className="w-full 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"
/>
{showDropdown && filtered.length > 0 && (
<div className="absolute z-10 mt-1 w-full bg-slate-700 border border-slate-600 rounded-lg shadow-xl max-h-72 overflow-y-auto">
{filtered.map(p => (
<button key={p.shopifyId} type="button"
onMouseDown={() => selectProduct(p)}
className="w-full text-left px-3 py-2.5 text-sm text-white hover:bg-slate-600 flex justify-between gap-2 border-b border-slate-600/50 last:border-0">
<span className="truncate">{p.title}</span>
<span className="text-xs text-gray-400 shrink-0 self-center">{p.ref}</span>
</button>
))}
</div>
)}
{showDropdown && search.length >= 2 && filtered.length === 0 && (
<div className="absolute z-10 mt-1 w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2">
<p className="text-xs text-gray-400">Aucun résultat</p>
</div>
)}
</div>
</div>
)}
{/* ── STEP 2 : Image ── */}
{step === 'image' && selectedProduct && (
<div className="bg-slate-800 rounded-xl p-5 border border-slate-700 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-white">Choisir une image</h2>
<p className="text-xs text-gray-400 mt-0.5">{selectedProduct.title} · <span className="font-mono text-indigo-300">{selectedProduct.ref}</span></p>
</div>
<button type="button" onClick={() => { setSelectedProduct(null); setStep('product') }}
className="text-xs text-gray-400 hover:text-white"> Changer</button>
</div>
{selectedProduct.images.length > 0 ? (
<div>
<p className="text-xs text-gray-400 mb-2">Images Shopify</p>
<div className="grid grid-cols-4 gap-2">
{selectedProduct.images.map((url, i) => (
<button key={i} type="button" onClick={() => { selectImage(url); setStep('template') }}
className={`relative rounded-lg overflow-hidden border-2 transition-all ${selectedImageUrl === url ? 'border-indigo-500' : 'border-slate-600 hover:border-slate-400'}`}>
<img src={url} alt={`Image ${i + 1}`} className="w-full h-20 object-cover" />
{loadingImage && selectedImageUrl === url && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<span className="text-xs text-white"></span>
</div>
)}
</button>
))}
</div>
</div>
) : (
<p className="text-xs text-gray-500">Aucune image disponible sur Shopify pour ce produit.</p>
)}
<div className="border-t border-slate-700 pt-3">
<p className="text-xs text-gray-400 mb-2">Ou uploader une image (PNG découpée)</p>
<input ref={fileRef} type="file" accept="image/*" onChange={handleFileUpload} className="text-xs text-gray-300" />
</div>
{error && <p className="text-xs text-red-400">{error}</p>}
</div>
)}
{/* ── STEP 3 : Template + génération ── */}
{step === 'template' && selectedProduct && cutoutB64 && (
<div className="space-y-4">
{/* Recap produit + image */}
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 flex items-center gap-3">
{selectedImageUrl && (
<img src={selectedImageUrl} alt="" className="w-12 h-12 object-cover rounded-lg border border-slate-600 shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{selectedProduct.title}</p>
<p className="text-xs text-gray-400">{selectedProduct.ref}
{dimensions && <span className="ml-2 text-green-400">· {dimensions}</span>}
</p>
</div>
<button type="button" onClick={() => setStep('image')} className="text-xs text-gray-400 hover:text-white shrink-0"> Image</button>
</div>
{/* Famille + placement carrelage */}
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-400 mb-1">Famille</label>
<select value={family} onChange={e => { setFamily(e.target.value); setPlacement('') }}
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
{['Menuiserie', 'Baies vitrées', 'Carrelage', 'Tous'].map(f => <option key={f}>{f}</option>)}
</select>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Dimensions</label>
<input value={dimensions} onChange={e => setDimensions(e.target.value)}
placeholder="ex: 60x60cm"
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500" />
</div>
</div>
{isCarrelage && (
<div>
<p className="text-xs text-gray-400 mb-1.5">Placement</p>
<div className="flex gap-2">
{[{ val: 'sol', label: 'Sol' }, { val: 'mur', label: 'Mur (faïence)' }].map(({ val, label }) => (
<button key={val} type="button" onClick={() => setPlacement(p => p === val ? '' : val)}
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors ${placement === val ? 'bg-indigo-600 text-white' : 'bg-slate-700 text-gray-300 hover:bg-slate-600'}`}>
{label}
</button>
))}
</div>
</div>
)}
</div>
{/* Templates */}
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide">Template</h3>
<button type="button" onClick={() => templateFileRef.current?.click()}
disabled={uploadingTemplate}
className="text-xs text-indigo-400 hover:text-indigo-300 disabled:opacity-40">
{uploadingTemplate ? 'Upload…' : '+ Nouveau template'}
</button>
<input ref={templateFileRef} type="file" accept="image/*" className="hidden" onChange={uploadTemplate} />
</div>
{filteredTemplates.length === 0 ? (
<p className="text-xs text-gray-500">Aucun template pour cette famille. Uploadez-en un.</p>
) : (
<div className="grid grid-cols-4 gap-2">
{filteredTemplates.map(t => (
<button key={t.id} type="button" onClick={() => setSelectedTemplate(t.id)}
className={`relative rounded-lg overflow-hidden border-2 transition-all ${selectedTemplate === t.id ? 'border-indigo-500' : 'border-slate-600 hover:border-slate-400'}`}>
<img src={`/api/mockup/templates/image/${t.filename}`} alt={t.name} className="w-full h-16 object-cover" />
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5">
<p className="text-xs text-white truncate">{t.name}</p>
</div>
</button>
))}
</div>
)}
</div>
{/* Instruction + génération */}
<div className="space-y-2">
<input type="text" value={instruction} onChange={e => setInstruction(e.target.value)}
placeholder="Instruction personnalisée (optionnel)…"
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<button type="button" onClick={generate}
disabled={!selectedTemplate || generating}
className="w-full py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium disabled:opacity-40 hover:bg-indigo-500 transition-colors">
{generating ? 'Génération en cours…' : 'Générer le mockup'}
</button>
</div>
{error && <p className="text-xs text-red-400">{error}</p>}
{/* Résultat */}
{resultB64 && (
<div className="space-y-3">
<img src={`data:image/jpeg;base64,${resultB64}`} alt="Mockup"
className="w-full rounded-xl border border-slate-600" />
<div className="flex gap-2">
<input type="text" value={instruction} onChange={e => setInstruction(e.target.value)}
placeholder="Modifier et regénérer…"
className="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<button type="button" onClick={generate} disabled={generating}
className="px-3 py-1.5 rounded bg-slate-700 text-xs text-white hover:bg-slate-600 disabled:opacity-40">
{generating ? '…' : '↺'}
</button>
</div>
{uploadedUrl ? (
<p className="text-xs text-green-400 text-center"> Uploadé sur Shopify</p>
) : (
<button type="button" onClick={upload} disabled={uploading}
className="w-full py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium disabled:opacity-40 hover:bg-emerald-500 transition-colors">
{uploading ? 'Upload…' : '↑ Uploader sur Shopify'}
</button>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
</>
)
}
+283 -1
View File
@@ -1,9 +1,291 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import Link from 'next/link'
import { Header } from '@/components/layout/Header'
interface ProductStats {
active: number
draft: number
total: number
withoutImages: number
}
interface LastSync {
createdAt: string
userName: string
status: string
created: number
updated: number
deactivated: number
errors: number
}
interface DashboardStats {
products: ProductStats
lastSync: LastSync | null
sheetDrift: boolean
sheetModifiedAt: string | null
}
function StatCard({
icon,
label,
value,
sub,
href,
loading,
error,
accent,
}: {
icon: string
label: string
value: string | number | null
sub?: string
href?: string
loading?: boolean
error?: boolean
accent?: 'indigo' | 'amber' | 'green' | 'red'
}) {
const accentClass = {
indigo: 'border-indigo-500/30 bg-indigo-500/5',
amber: 'border-amber-500/30 bg-amber-500/5',
green: 'border-green-500/30 bg-green-500/5',
red: 'border-red-500/30 bg-red-500/5',
}[accent ?? 'indigo']
const content = (
<div className={`rounded-xl border p-5 flex flex-col gap-1 ${accentClass} ${href ? 'hover:brightness-110 transition-all cursor-pointer' : ''}`}>
<span className="text-xl">{icon}</span>
<p className="text-xs text-slate-400 mt-1">{label}</p>
{loading ? (
<p className="text-2xl font-bold text-slate-600 animate-pulse"></p>
) : error ? (
<p className="text-sm text-red-400">Erreur</p>
) : (
<p className="text-2xl font-bold text-white">{value ?? '—'}</p>
)}
{sub && <p className="text-xs text-slate-500">{sub}</p>}
</div>
)
return href ? <Link href={href}>{content}</Link> : content
}
function ActionCard({ href, icon, label, description }: { href: string; icon: string; label: string; description: string }) {
return (
<Link
href={href}
className="flex items-start gap-3 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl p-4 transition-colors group"
>
<span className="text-2xl mt-0.5">{icon}</span>
<div>
<p className="text-sm font-semibold text-white group-hover:text-indigo-300 transition-colors">{label}</p>
<p className="text-xs text-slate-400 mt-0.5">{description}</p>
</div>
</Link>
)
}
function formatDate(iso: string) {
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit',
}).format(new Date(iso))
}
export default function DashboardPage() {
const [stats, setStats] = useState<DashboardStats | null>(null)
const [statsError, setStatsError] = useState(false)
const [statsLoading, setStatsLoading] = useState(true)
const [pendingCount, setPendingCount] = useState<number | null>(null)
const [pendingLoading, setPendingLoading] = useState(false)
const [pendingError, setPendingError] = useState(false)
useEffect(() => {
fetch('/api/dashboard/stats')
.then((r) => r.json())
.then((data) => {
if (data.error) throw new Error(data.error)
setStats(data)
})
.catch(() => setStatsError(true))
.finally(() => setStatsLoading(false))
}, [])
const loadPending = useCallback(async () => {
setPendingLoading(true)
setPendingError(false)
try {
const r = await fetch('/api/dashboard/pending')
const data = await r.json()
if (data.error) throw new Error(data.error)
setPendingCount(data.count)
} catch {
setPendingError(true)
} finally {
setPendingLoading(false)
}
}, [])
const lastSync = stats?.lastSync
return (
<>
<Header title="📊 Dashboard" />
<div className="p-6"><p className="text-gray-400 text-sm">Dashboard à implémenter (Plan 4)</p></div>
<div className="p-6 flex flex-col gap-8 flex-1 overflow-y-auto">
{/* ── Alerte drift Sheets ───────────────────────────────── */}
{!statsLoading && stats?.sheetDrift && (
<Link href="/sync" className="flex items-center gap-3 bg-amber-950/40 border border-amber-600/50 rounded-xl px-4 py-3 hover:bg-amber-950/60 transition-colors group">
<span className="text-xl"></span>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-amber-300">Google Sheets modifié depuis la dernière sync</p>
<p className="text-xs text-amber-500 mt-0.5">
{stats.sheetModifiedAt
? `Dernière modification : ${formatDate(stats.sheetModifiedAt)}`
: 'Des changements sont peut-être en attente de synchronisation'}
</p>
</div>
<span className="text-xs text-amber-400 group-hover:text-amber-300 whitespace-nowrap">Lancer la sync </span>
</Link>
)}
{/* ── KPIs ─────────────────────────────────────────────── */}
<section>
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Vue d&apos;ensemble</h2>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard
icon="📦"
label="Produits actifs"
value={stats?.products.active ?? null}
sub={stats ? `${stats.products.draft} en brouillon` : undefined}
loading={statsLoading}
error={statsError}
accent="green"
href="/produits"
/>
<StatCard
icon="🖼️"
label="Sans image"
value={stats?.products.withoutImages ?? null}
sub={stats ? `sur ${stats.products.total} produits` : undefined}
loading={statsLoading}
error={statsError}
accent={stats && stats.products.withoutImages > 0 ? 'amber' : 'green'}
href="/images"
/>
<StatCard
icon="🔄"
label="Dernière sync"
value={lastSync ? formatDate(lastSync.createdAt) : 'Aucune'}
sub={lastSync ? `par ${lastSync.userName}` : undefined}
loading={statsLoading}
error={statsError}
accent="indigo"
href="/sync"
/>
{/* Pending — chargement lazy */}
<div
className="rounded-xl border border-indigo-500/30 bg-indigo-500/5 p-5 flex flex-col gap-1 cursor-pointer hover:brightness-110 transition-all"
onClick={pendingCount === null && !pendingLoading ? loadPending : undefined}
>
<span className="text-xl"></span>
<p className="text-xs text-slate-400 mt-1">En attente de création</p>
{pendingLoading ? (
<p className="text-2xl font-bold text-slate-600 animate-pulse"></p>
) : pendingError ? (
<p className="text-sm text-red-400">Erreur réessayer</p>
) : pendingCount !== null ? (
<p className="text-2xl font-bold text-white">{pendingCount}</p>
) : (
<button className="text-xs text-indigo-400 hover:text-indigo-300 text-left mt-1">
Calculer
</button>
)}
{pendingCount !== null && (
<p className="text-xs text-slate-500">depuis Google Sheets</p>
)}
</div>
</div>
</section>
{/* ── Dernière sync détail ──────────────────────────────── */}
{!statsLoading && lastSync && (
<section>
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Dernière synchronisation</h2>
<div className="bg-slate-800 border border-slate-700 rounded-xl p-5 flex flex-col gap-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-white">{formatDate(lastSync.createdAt)}</p>
<p className="text-xs text-slate-400">par {lastSync.userName}</p>
</div>
<span className={`text-xs font-medium px-2 py-1 rounded-full border ${
lastSync.status === 'success'
? 'bg-green-900/30 border-green-700 text-green-400'
: 'bg-red-900/30 border-red-700 text-red-400'
}`}>
{lastSync.status === 'success' ? '✅ Succès' : '❌ Erreur'}
</span>
</div>
<div className="grid grid-cols-4 gap-3 pt-2 border-t border-slate-700">
{[
{ label: 'Créations', value: lastSync.created, color: 'text-green-400' },
{ label: 'Mises à jour', value: lastSync.updated, color: 'text-blue-400' },
{ label: 'Désactivés', value: lastSync.deactivated, color: 'text-amber-400' },
{ label: 'Erreurs', value: lastSync.errors, color: 'text-red-400' },
].map(({ label, value, color }) => (
<div key={label} className="text-center">
<p className={`text-xl font-bold ${color}`}>{value}</p>
<p className="text-xs text-slate-500">{label}</p>
</div>
))}
</div>
<div className="flex justify-end">
<Link href="/sync" className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors">
Nouvelle sync
</Link>
</div>
</div>
</section>
)}
{/* ── Actions rapides ───────────────────────────────────── */}
<section>
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Actions rapides</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<ActionCard
href="/images"
icon="🖼️"
label="Uploader des images"
description="Convertir HEIC, détourer et envoyer sur Shopify"
/>
<ActionCard
href="/sync"
icon="🔄"
label="Synchroniser le catalogue"
description="Comparer Google Sheets avec Shopify et appliquer les changements"
/>
<ActionCard
href="/creation"
icon="✨"
label="Créer des produits"
description="Ajouter les produits en attente depuis Google Sheets"
/>
<ActionCard
href="/produits"
icon="📦"
label="Voir le catalogue"
description="Parcourir et filtrer tous les produits Shopify"
/>
</div>
</section>
</div>
</>
)
}
+40 -2
View File
@@ -1,9 +1,47 @@
'use client'
import { useState } from 'react'
import { useSession } from 'next-auth/react'
import { Header } from '@/components/layout/Header'
import { UsersManager } from '@/components/settings/UsersManager'
import { ConnectionStatus } from '@/components/settings/ConnectionStatus'
import { TemplateLibrary } from '@/components/mockup/TemplateLibrary'
type Tab = 'connexions' | 'utilisateurs' | 'mockup-templates'
export default function ParametresPage() {
const { data: session } = useSession()
const isAdmin = session?.user?.role === 'admin'
const [tab, setTab] = useState<Tab>('connexions')
const tabs: { id: Tab; label: string; adminOnly?: boolean }[] = [
{ id: 'connexions', label: 'Connexions' },
{ id: 'mockup-templates', label: 'Templates mockup' },
{ id: 'utilisateurs', label: 'Utilisateurs', adminOnly: true },
]
return (
<>
<Header title="⚙️ Paramètres" />
<div className="p-6"><p className="text-gray-400 text-sm">Module Paramètres à implémenter (Plan 4)</p></div>
<Header title="Paramètres" />
<div className="p-6 space-y-6">
<div className="flex gap-2">
{tabs.filter(t => !t.adminOnly || isAdmin).map(t => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
tab === t.id
? 'bg-indigo-600 text-white'
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
}`}
>
{t.label}
</button>
))}
</div>
{tab === 'connexions' && <ConnectionStatus />}
{tab === 'mockup-templates' && <TemplateLibrary />}
{isAdmin && tab === 'utilisateurs' && <UsersManager />}
</div>
</>
)
}
@@ -0,0 +1,261 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import Link from 'next/link'
import { Header } from '@/components/layout/Header'
import type { DuplicateGroup } from '@/app/api/products/duplicates/route'
type DeleteState = 'idle' | 'confirm' | 'deleting' | 'done'
interface GroupState {
group: DuplicateGroup
keepId: string
deleteState: DeleteState
deleted: string[] // IDs supprimés
error: string | null
}
export default function DoublonsPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [groupStates, setGroupStates] = useState<GroupState[]>([])
useEffect(() => {
fetch('/api/products/duplicates')
.then((r) => r.json())
.then((data) => {
if (data.error) throw new Error(data.error)
setGroupStates(
(data.groups as DuplicateGroup[]).map((group) => ({
group,
keepId: group.products[0].shopifyId, // garder le plus ancien par défaut
deleteState: 'idle',
deleted: [],
error: null,
}))
)
})
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false))
}, [])
const setKeep = useCallback((groupIdx: number, id: string) => {
setGroupStates((prev) =>
prev.map((gs, i) => i === groupIdx ? { ...gs, keepId: id } : gs)
)
}, [])
const askConfirm = useCallback((groupIdx: number) => {
setGroupStates((prev) =>
prev.map((gs, i) => i === groupIdx ? { ...gs, deleteState: 'confirm', error: null } : gs)
)
}, [])
const cancelConfirm = useCallback((groupIdx: number) => {
setGroupStates((prev) =>
prev.map((gs, i) => i === groupIdx ? { ...gs, deleteState: 'idle' } : gs)
)
}, [])
const deleteGroup = useCallback(async (groupIdx: number) => {
const gs = groupStates[groupIdx]
const toDelete = gs.group.products
.map((p) => p.shopifyId)
.filter((id) => id !== gs.keepId && !gs.deleted.includes(id))
setGroupStates((prev) =>
prev.map((s, i) => i === groupIdx ? { ...s, deleteState: 'deleting', error: null } : s)
)
const newDeleted = [...gs.deleted]
let lastError: string | null = null
for (const id of toDelete) {
const res = await fetch(`/api/products/${id}`, { method: 'DELETE' })
if (res.ok) {
newDeleted.push(id)
} else {
const data = await res.json()
lastError = data.error ?? `Erreur HTTP ${res.status}`
break
}
}
setGroupStates((prev) =>
prev.map((s, i) =>
i === groupIdx
? { ...s, deleted: newDeleted, deleteState: lastError ? 'idle' : 'done', error: lastError }
: s
)
)
}, [groupStates])
const remaining = groupStates.filter((gs) => gs.deleteState !== 'done').length
return (
<>
<Header
title="🔍 Doublons produits"
actions={
<Link href="/produits" className="text-xs text-slate-400 hover:text-slate-200">
Retour au catalogue
</Link>
}
/>
<div className="p-6 flex flex-col gap-6 flex-1 overflow-y-auto">
{loading && (
<div className="flex items-center gap-2 text-slate-400 text-sm py-12 justify-center">
<span className="animate-spin"></span> Analyse du catalogue Shopify
</div>
)}
{error && (
<div className="p-4 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">
{error}
</div>
)}
{!loading && !error && groupStates.length === 0 && (
<div className="text-center py-16">
<p className="text-4xl mb-3"></p>
<p className="text-white font-semibold">Aucun doublon détecté</p>
<p className="text-slate-400 text-sm mt-1">Tous les produits ont une référence unique.</p>
</div>
)}
{!loading && groupStates.length > 0 && (
<>
<div className="flex items-center justify-between">
<p className="text-sm text-slate-400">
<span className="text-white font-semibold">{groupStates.length}</span> groupe{groupStates.length > 1 ? 's' : ''} de doublons détectés
{remaining < groupStates.length && (
<span className="ml-2 text-green-400">· {groupStates.length - remaining} résolu{groupStates.length - remaining > 1 ? 's' : ''}</span>
)}
</p>
</div>
<div className="flex flex-col gap-4">
{groupStates.map((gs, groupIdx) => {
const { group, keepId, deleteState, deleted, error: groupError } = gs
const isDone = deleteState === 'done'
const activeProducts = group.products.filter((p) => !deleted.includes(p.shopifyId))
return (
<div
key={group.ref}
className={`border rounded-xl overflow-hidden transition-all ${
isDone
? 'border-green-700/50 bg-green-950/20 opacity-60'
: 'border-slate-700 bg-slate-800/50'
}`}
>
{/* En-tête groupe */}
<div className="px-4 py-3 border-b border-slate-700 flex items-center justify-between">
<div>
<span className="font-mono text-sm text-indigo-300 font-semibold">{group.ref}</span>
<span className="ml-3 text-xs text-slate-500">
{isDone ? '✅ Résolu' : `${activeProducts.length} produits`}
</span>
</div>
{!isDone && deleteState === 'idle' && (
<button
onClick={() => askConfirm(groupIdx)}
className="text-xs px-3 py-1.5 bg-red-900/40 hover:bg-red-800/60 border border-red-700/50 text-red-300 rounded-lg transition-colors"
>
Supprimer les doublons
</button>
)}
</div>
{/* Liste des produits */}
{!isDone && (
<div className="divide-y divide-slate-700/50">
{activeProducts.map((product) => {
const isKeep = product.shopifyId === keepId
return (
<div
key={product.shopifyId}
className={`px-4 py-3 flex items-center gap-3 ${isKeep ? 'bg-green-950/20' : ''}`}
>
<input
type="radio"
name={`keep-${groupIdx}`}
checked={isKeep}
onChange={() => setKeep(groupIdx, product.shopifyId)}
className="accent-green-500 cursor-pointer"
/>
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{product.title}</p>
<p className="text-xs text-slate-500 mt-0.5">
ID {product.shopifyId} ·{' '}
<span className={product.status === 'active' ? 'text-green-400' : 'text-slate-400'}>
{product.status}
</span>
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{isKeep && (
<span className="text-xs text-green-400 font-medium">À garder</span>
)}
<a
href={product.shopifyUrl}
target="_blank"
rel="noreferrer"
className="text-xs text-slate-500 hover:text-slate-300"
>
Shopify
</a>
</div>
</div>
)
})}
</div>
)}
{/* Zone de confirmation */}
{deleteState === 'confirm' && (
<div className="px-4 py-3 bg-red-950/30 border-t border-red-800/40 flex items-center justify-between gap-4">
<p className="text-sm text-red-300">
Supprimer définitivement{' '}
<strong>{activeProducts.length - 1} produit{activeProducts.length - 1 > 1 ? 's' : ''}</strong>{' '}
de Shopify. Cette action est irréversible.
</p>
<div className="flex gap-2 shrink-0">
<button
onClick={() => cancelConfirm(groupIdx)}
className="text-xs px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded-lg"
>
Annuler
</button>
<button
onClick={() => deleteGroup(groupIdx)}
className="text-xs px-3 py-1.5 bg-red-700 hover:bg-red-600 text-white font-semibold rounded-lg"
>
Confirmer la suppression
</button>
</div>
</div>
)}
{deleteState === 'deleting' && (
<div className="px-4 py-3 border-t border-slate-700 text-sm text-slate-400 flex items-center gap-2">
<span className="animate-spin"></span> Suppression en cours
</div>
)}
{groupError && (
<div className="px-4 py-2 border-t border-red-800/40 text-xs text-red-400">
Erreur : {groupError}
</div>
)}
</div>
)
})}
</div>
</>
)}
</div>
</>
)
}
+57 -2
View File
@@ -1,9 +1,64 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import Link from 'next/link'
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() {
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 (
<>
<Header title="📦 Produits" />
<div className="p-6"><p className="text-gray-400 text-sm">Module Produits à implémenter (Plan 4)</p></div>
<Header
title="Produits"
actions={
<Link href="/produits/doublons" className="text-xs px-3 py-1.5 bg-slate-700 hover:bg-slate-600 border border-slate-600 text-slate-300 rounded-lg transition-colors">
🔍 Vérifier les doublons
</Link>
}
/>
<div className="p-6">
<div className="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>
</>
)
}
+29 -7
View File
@@ -5,12 +5,14 @@ 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 { FamilySelector } from '@/components/common/FamilySelector'
import type { DiffResult, SyncChange, SyncHistoryEntry } from '@/types/sync'
type Step = 1 | 2 | 3 | 4
type Step = 0 | 1 | 2 | 3 | 4
export default function SyncPage() {
const [step, setStep] = useState<Step>(1)
const [step, setStep] = useState<Step>(0)
const [selectedTab, setSelectedTab] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [diff, setDiff] = useState<DiffResult | null>(null)
@@ -24,12 +26,19 @@ export default function SyncPage() {
created: number; updated: number; deactivated: number; errors: number; status: string
} | null>(null)
// Étape 0 → 1 : sélection famille
const handleSelectTab = useCallback((tab: string) => {
setSelectedTab(tab)
setStep(1)
}, [])
// Étape 1 → 2 : lancer le preview
const handleAnalyze = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/sync/preview')
const url = selectedTab ? `/api/sync/preview?tab=${encodeURIComponent(selectedTab)}` : '/api/sync/preview'
const res = await fetch(url)
const data = await res.json()
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
setDiff(data)
@@ -39,7 +48,7 @@ export default function SyncPage() {
} finally {
setLoading(false)
}
}, [])
}, [selectedTab])
// Étape 3 → 4 : lancer la sync en streaming
const handleExecute = useCallback(async () => {
@@ -114,13 +123,26 @@ export default function SyncPage() {
<div className="p-6 flex flex-col gap-6 flex-1 overflow-y-auto">
{/* Stepper */}
<SyncStepper currentStep={step} />
<SyncStepper currentStep={step > 0 ? step : 1} />
{/* Étape 0 — Sélection famille */}
{step === 0 && (
<div className="bg-slate-800/50 border border-slate-700 rounded-xl p-6">
<h2 className="text-sm font-semibold text-white mb-4">Choisir une famille</h2>
<FamilySelector onSelect={handleSelectTab} />
</div>
)}
{/* Étape 1 — Analyser */}
{step === 1 && (
<div className="flex flex-col items-center gap-4 py-8">
<div className="flex items-center gap-2 bg-indigo-950/50 border border-indigo-800/50 rounded-lg px-4 py-2">
<span className="text-indigo-400 text-sm">📂 Famille :</span>
<span className="text-white text-sm font-semibold">{selectedTab}</span>
<button onClick={() => { setStep(0); setSelectedTab(null) }} className="ml-2 text-xs text-slate-400 hover:text-slate-200">changer</button>
</div>
<p className="text-slate-400 text-sm text-center max-w-md">
Lit Google Sheets et compare avec le catalogue Shopify actuel pour générer l&apos;aperçu des changements.
Compare la famille <strong className="text-white">{selectedTab}</strong> avec le catalogue Shopify.
</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">
@@ -196,7 +218,7 @@ export default function SyncPage() {
{summary && (
<div className="flex gap-3 justify-end">
<button
onClick={() => { setStep(1); setDiff(null); setSummary(null); setLog([]) }}
onClick={() => { setStep(0); setSelectedTab(null); setDiff(null); setSummary(null); setLog([]) }}
className="px-4 py-2 text-sm bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded-lg"
>
Nouvelle sync
@@ -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 })
}
@@ -0,0 +1,58 @@
import { NextResponse } from 'next/server'
import { readSheetProducts } from '@/lib/googleSheets'
import { fetchAllShopifyProducts, getConfig } from '@/lib/shopifySync'
export const maxDuration = 300
export async function POST() {
const sheetProducts = await readSheetProducts()
const shopifyProducts = await fetchAllShopifyProducts()
const shopifyByHandle = new Map(shopifyProducts.map(p => [p.handle, p]))
const { baseUrl, headers } = getConfig()
const norm = (v: string | null | undefined) => (!v || v === '0.00') ? null : v
let updated = 0, skipped = 0, errors = 0
const log: string[] = []
for (const sheet of sheetProducts) {
const shopify = shopifyByHandle.get(sheet.handle)
if (!shopify) { skipped++; continue }
const newCompareAt = sheet.compareAtPrice ?? null
const currentCompareAt = (shopify as { compareAtPrice?: string }).compareAtPrice ?? null
if (norm(newCompareAt) === norm(currentCompareAt) && sheet.price === shopify.price) {
skipped++; continue
}
const detailRes = await fetch(`${baseUrl}/products/${shopify.shopifyId}.json?fields=variants`, { headers })
if (!detailRes.ok) { errors++; log.push(`${sheet.ref} — lecture variant`); continue }
const detail = await detailRes.json() as { product: { variants: Array<{ id: number }> } }
const variantId = detail.product.variants[0]?.id
if (!variantId) { errors++; continue }
const res = await fetch(`${baseUrl}/products/${shopify.shopifyId}.json`, {
method: 'PUT',
headers,
body: JSON.stringify({
product: {
id: shopify.shopifyId,
variants: [{ id: variantId, price: sheet.price, compare_at_price: newCompareAt }],
},
}),
})
if (res.ok) {
updated++
log.push(`${sheet.ref} price=${sheet.price} compare_at=${newCompareAt ?? 'null'}`)
} else {
errors++
log.push(`${sheet.ref}${res.status}`)
}
await new Promise(r => setTimeout(r, 300))
}
return NextResponse.json({ updated, skipped, errors, log })
}
+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 })
}
+93
View File
@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
async function getAccessToken(): Promise<string | null> {
const email = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL
const rawKey = process.env.GOOGLE_SERVICE_ACCOUNT_KEY
if (!email || !rawKey) return null
const privateKey = rawKey.replace(/\\n/g, '\n')
const now = Math.floor(Date.now() / 1000)
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url')
const payload = Buffer.from(JSON.stringify({
iss: email,
scope: 'https://www.googleapis.com/auth/spreadsheets',
aud: 'https://oauth2.googleapis.com/token',
iat: now,
exp: now + 3600,
})).toString('base64url')
const { createSign } = await import('crypto')
const sign = createSign('RSA-SHA256')
sign.update(`${header}.${payload}`)
const signature = sign.sign(privateKey, 'base64url')
const jwt = `${header}.${payload}.${signature}`
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt }),
})
if (!tokenRes.ok) return null
const { access_token } = await tokenRes.json() as { access_token: string }
return access_token
}
// POST /api/creation/clear-ids?tab=SOL CARRELAGE
// Efface la colonne A (ID Shopify) de toutes les lignes produit d'un onglet
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const tab = new URL(req.url).searchParams.get('tab')
if (!tab) return NextResponse.json({ error: 'tab requis' }, { status: 400 })
const apiKey = process.env.GOOGLE_API_KEY
const sheetId = process.env.GOOGLE_SHEETS_ID
if (!apiKey || !sheetId) return NextResponse.json({ error: 'Config Google manquante' }, { status: 500 })
// Lire les données de l'onglet
const range = encodeURIComponent(`${tab}!A1:A500`)
const res = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`,
)
if (!res.ok) return NextResponse.json({ error: `Sheets read error: ${res.status}` }, { status: 502 })
const data = await res.json() as { values?: string[][] }
const rows = data.values ?? []
// Trouver la ligne d'en-tête (contient "ID" en col A)
const headerRowIdx = rows.findIndex(r => (r[0] ?? '').trim() === 'ID')
if (headerRowIdx === -1) return NextResponse.json({ error: 'Onglet non reconnu (pas de colonne ID)' }, { status: 400 })
// Identifier les lignes produit qui ont un ID Shopify (numérique)
const clearValues: string[][] = []
const startRow = headerRowIdx + 2 // 1-indexed, skip header row
for (let i = headerRowIdx + 1; i < rows.length; i++) {
const cell = (rows[i]?.[0] ?? '').trim()
clearValues.push([cell && /^\d+$/.test(cell) ? '' : cell])
}
if (clearValues.length === 0) return NextResponse.json({ cleared: 0 })
const accessToken = await getAccessToken()
if (!accessToken) return NextResponse.json({ error: 'Impossible d\'obtenir le token Google' }, { status: 500 })
const writeRange = `${tab}!A${startRow}:A${startRow + clearValues.length - 1}`
const writeRes = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${encodeURIComponent(writeRange)}?valueInputOption=RAW`,
{
method: 'PUT',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ range: writeRange, majorDimension: 'ROWS', values: clearValues }),
},
)
if (!writeRes.ok) {
const err = await writeRes.text()
return NextResponse.json({ error: `Sheets write error: ${err}` }, { status: 502 })
}
const cleared = clearValues.filter(r => r[0] === '').length
return NextResponse.json({ cleared, tab })
}
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
const API_VERSION = '2024-01'
export async function GET() {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) return NextResponse.json({ error: 'Config Shopify manquante' }, { status: 500 })
const baseUrl = `https://${domain}/admin/api/${API_VERSION}`
const headers = { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' }
// Récupère custom collections + smart collections
const [customRes, smartRes] = await Promise.all([
fetch(`${baseUrl}/custom_collections.json?limit=250&fields=id,title`, { headers }),
fetch(`${baseUrl}/smart_collections.json?limit=250&fields=id,title`, { headers }),
])
const custom = customRes.ok ? (await customRes.json()).custom_collections as { id: number; title: string }[] : []
const smart = smartRes.ok ? (await smartRes.json()).smart_collections as { id: number; title: string }[] : []
const collections = [...custom, ...smart]
.map(c => ({ id: String(c.id), title: c.title }))
.sort((a, b) => a.title.localeCompare(b.title))
return NextResponse.json({ collections })
}
+126
View File
@@ -0,0 +1,126 @@
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { fetchPendingProducts, writeShopifyIdToSheet } from '@/lib/creationSheets'
import { generateDescriptions } from '@/lib/openaiClient'
import { ensureMetafieldDefinition, setProductMetafield } from '@/lib/shopifyMetafields'
import { addProductToCollection } from '@/lib/shopifyCollections'
import { createShopifyProduct, fetchAllShopifyProducts } from '@/lib/shopifySync'
import { prisma } from '@/lib/prisma'
import type { MetafieldResolution, CreationStreamEvent } from '@/types/creation'
function encode(event: CreationStreamEvent): string {
return JSON.stringify(event) + '\n'
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) {
return new Response(JSON.stringify({ error: 'Non authentifié' }), { status: 401 })
}
const userId = (session.user as { id: string }).id
const body = await req.json().catch(() => ({})) as { resolutions?: MetafieldResolution[]; tab?: string; collectionOverride?: string }
const resolutions: MetafieldResolution[] = body.resolutions ?? []
const tabFilter: string | undefined = body.tab ?? undefined
const collectionOverride: string | undefined = body.collectionOverride ?? undefined
const resolutionMap = new Map<string, MetafieldResolution>(resolutions.map(r => [r.columnHeader, r]))
const stream = new ReadableStream({
async start(controller) {
const send = (event: CreationStreamEvent) => controller.enqueue(encode(event))
try {
const [existingRows, shopifyProducts] = await Promise.all([
prisma.productCreation.findMany({ select: { sheetTab: true, sheetRow: true } }),
fetchAllShopifyProducts(),
])
const alreadyCreated = new Set(existingRows.map(e => `${e.sheetTab}:${e.sheetRow}`))
const shopifyRefs = new Set(shopifyProducts.map(p => p.ref.trim().toLowerCase()))
const pending = await fetchPendingProducts(alreadyCreated, tabFilter)
const total = pending.length
if (total === 0) {
send({ type: 'done', summary: { created: 0, skipped: 0, errors: 0, status: 'success' } })
controller.close()
return
}
// Crée les définitions manquantes en amont
for (const resolution of resolutions) {
if (resolution.action === 'create') await ensureMetafieldDefinition(resolution)
}
let created = 0
let skipped = 0
let errors = 0
for (let i = 0; i < pending.length; i++) {
const product = pending[i]
// Vérifier si le produit existe déjà dans Shopify par SKU
if (shopifyRefs.has(product.sku.trim().toLowerCase())) {
send({ type: 'skipped', title: product.title, sku: product.sku })
skipped++
continue
}
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Génération descriptions…' })
try {
const { longDesc } = await generateDescriptions({
title: product.title,
rawDescription: '',
comment: product.comment,
vendor: product.vendor,
famille: product.famille,
})
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Création produit Shopify…' })
console.log(`[creation] ${product.sku} stock=${product.stock}`)
const shopifyId = await createShopifyProduct({
ref: product.sku,
handle: product.sku.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
title: product.title,
description: longDesc,
price: product.priceActual || '0.00',
stock: product.stock,
weightKg: product.weightKg,
status: 'active',
})
const effectiveCollectionId = product.collectionId || collectionOverride || ''
if (effectiveCollectionId) await addProductToCollection(shopifyId, effectiveCollectionId)
if (product.subCollectionId) await addProductToCollection(shopifyId, product.subCollectionId)
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Métachamps…' })
for (const [colHeader, value] of Object.entries(product.specificFields)) {
const resolution = resolutionMap.get(colHeader)
if (!resolution || resolution.action === 'skip') continue
await setProductMetafield(shopifyId, resolution, value)
}
await prisma.productCreation.create({
data: { userId, sheetTab: product.tab, sheetRow: product.rowNumber, shopifyId, sku: product.sku, title: product.title, status: 'success' },
})
await writeShopifyIdToSheet(product.tab, product.rowNumber, shopifyId)
created++
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
send({ type: 'error', title: product.title, message })
await prisma.productCreation.create({
data: { userId, sheetTab: product.tab, sheetRow: product.rowNumber, shopifyId: '', sku: product.sku, title: product.title, status: 'error', errorMessage: message },
})
errors++
}
}
const status = errors === 0 ? 'success' : created === 0 ? 'error' : 'partial'
send({ type: 'done', summary: { created, skipped, errors, status } })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
send({ type: 'done', summary: { created: 0, skipped: 0, errors: 1, status: 'error' } })
console.error('[creation/execute]', message)
} finally {
controller.close()
}
},
})
return new Response(stream, {
headers: { 'Content-Type': 'application/x-ndjson', 'Transfer-Encoding': 'chunked' },
})
}
@@ -0,0 +1,37 @@
import { GET } from './route'
import * as shopifyMetafields from '@/lib/shopifyMetafields'
import { getServerSession } from 'next-auth'
jest.mock('next-auth')
jest.mock('@/lib/shopifyMetafields')
const mockSession = getServerSession as jest.MockedFunction<typeof getServerSession>
const mockFetchDefs = shopifyMetafields.fetchMetafieldDefinitions as jest.MockedFunction<typeof shopifyMetafields.fetchMetafieldDefinitions>
const mockMatch = shopifyMetafields.matchMetafields as jest.MockedFunction<typeof shopifyMetafields.matchMetafields>
describe('GET /api/creation/metafields', () => {
beforeEach(() => {
mockSession.mockResolvedValue({ user: { id: 'u1', role: 'admin' } } as never)
mockFetchDefs.mockResolvedValue([
{ id: '1', namespace: 'custom', key: 'puissance', name: 'Puissance', type: 'single_line_text_field' },
])
mockMatch.mockReturnValue({
matched: { 'Puissance (AN)': { columnHeader: 'Puissance (AN)', action: 'map', namespace: 'custom', key: 'puissance', existingDefinitionId: '1' } },
unmatched: ['Batterie (AP)'],
})
})
it('retourne 401 sans session', async () => {
mockSession.mockResolvedValue(null)
const res = await GET(new Request('http://localhost/api/creation/metafields?columns=Puissance') as never)
expect(res.status).toBe(401)
})
it('retourne définitions + matching', async () => {
const res = await GET(new Request('http://localhost/api/creation/metafields?columns=Puissance+%28AN%29%2CBatterie+%28AP%29') as never)
const body = await res.json()
expect(body.definitions).toHaveLength(1)
expect(body.matched['Puissance (AN)'].key).toBe('puissance')
expect(body.unmatched).toContain('Batterie (AP)')
})
})
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { fetchMetafieldDefinitions, matchMetafields } from '@/lib/shopifyMetafields'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
try {
const { searchParams } = new URL(req.url)
const columnsParam = searchParams.get('columns') ?? ''
const columns = columnsParam ? columnsParam.split(',').map(c => c.trim()).filter(Boolean) : []
const definitions = await fetchMetafieldDefinitions()
const { matched, unmatched } = matchMetafields(columns, definitions)
return NextResponse.json({ definitions, matched, unmatched })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
@@ -0,0 +1,50 @@
import { GET } from './route'
import * as creationSheets from '@/lib/creationSheets'
import { getServerSession } from 'next-auth'
jest.mock('next-auth')
jest.mock('@/lib/creationSheets')
jest.mock('@/lib/prisma', () => ({
prisma: { productCreation: { findMany: jest.fn().mockResolvedValue([]) } },
}))
const mockSession = getServerSession as jest.MockedFunction<typeof getServerSession>
const mockFetch = creationSheets.fetchPendingProducts as jest.MockedFunction<typeof creationSheets.fetchPendingProducts>
const makePending = (tab: string, row: number, specific: Record<string, string> = {}) => ({
tab, rowNumber: row, sku: `SKU-${row}`, title: `Produit ${row}`,
priceMarket: '99', priceActual: '79', stock: 5, weightKg: 2,
vendor: 'BOSCH', famille: tab, sousFamille: '', comment: '',
collectionId: '10', subCollectionId: '', hasPhoto: false,
specificFields: specific,
})
describe('GET /api/creation/preview', () => {
beforeEach(() => {
mockSession.mockResolvedValue({ user: { id: 'u1', role: 'admin' } } as never)
mockFetch.mockResolvedValue([
makePending('OUTILLAGE', 2, { 'Puissance (AN)': '1500W' }),
makePending('CARRELAGE', 3),
])
})
it('retourne 401 sans session', async () => {
mockSession.mockResolvedValue(null)
const res = await GET(new Request('http://localhost') as never)
expect(res.status).toBe(401)
})
it('retourne les produits groupés par onglet', async () => {
const res = await GET(new Request('http://localhost') as never)
const body = await res.json()
expect(body.byTab['OUTILLAGE']).toHaveLength(1)
expect(body.byTab['CARRELAGE']).toHaveLength(1)
expect(body.total).toBe(2)
})
it('liste les colonnes spécifiques uniques', async () => {
const res = await GET(new Request('http://localhost') as never)
const body = await res.json()
expect(body.specificColumns).toContain('Puissance (AN)')
})
})
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { fetchPendingProducts } from '@/lib/creationSheets'
import { prisma } from '@/lib/prisma'
import type { PendingProduct } from '@/types/creation'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const tab = new URL(req.url).searchParams.get('tab') ?? undefined
try {
const existing = await prisma.productCreation.findMany({ select: { sheetTab: true, sheetRow: true } })
const alreadyCreated = new Set(existing.map(e => `${e.sheetTab}:${e.sheetRow}`))
const pending = await fetchPendingProducts(alreadyCreated, tab)
const byTab: Record<string, PendingProduct[]> = {}
const specificColumns = new Set<string>()
for (const p of pending) {
if (!byTab[p.tab]) byTab[p.tab] = []
byTab[p.tab].push(p)
Object.keys(p.specificFields).forEach(k => specificColumns.add(k))
}
return NextResponse.json({ byTab, total: pending.length, specificColumns: Array.from(specificColumns).sort() })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { loadResolutionCache, saveResolutionCache } from '@/lib/resolutionCache'
import type { MetafieldResolution } from '@/types/creation'
export async function GET() {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const cache = await loadResolutionCache()
return NextResponse.json(cache)
}
export async function PUT(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const body = await req.json() as Record<string, MetafieldResolution>
await saveResolutionCache(body)
return NextResponse.json({ ok: true })
}
@@ -0,0 +1,181 @@
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { findColIndex, writeShopifyIdToSheet } from '@/lib/creationSheets'
import { getConfig } from '@/lib/shopifySync'
void writeShopifyIdToSheet // réutilise le même mécanisme d'auth Google
export async function POST() {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const stream = new ReadableStream({
async start(controller) {
const send = (obj: object) => controller.enqueue(new TextEncoder().encode(JSON.stringify(obj) + '\n'))
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
const shopifyFetch = async (url: string, opts: RequestInit = {}, retries = 3): Promise<Response> => {
const res = await fetch(url, opts)
if (res.status === 429 && retries > 0) {
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '2', 10)
await sleep(retryAfter * 1000)
return shopifyFetch(url, opts, retries - 1)
}
return res
}
try {
const { baseUrl, headers: shopifyHeaders } = getConfig()
// Récupérer le location_id
const locRes = await shopifyFetch(`${baseUrl}/locations.json`, { headers: shopifyHeaders })
if (!locRes.ok) throw new Error(`Shopify locations error: ${locRes.status}`)
const locData = await locRes.json() as { locations: Array<{ id: number }> }
const locationId = locData.locations[0]?.id
if (!locationId) throw new Error('Aucune location Shopify trouvée')
// Auth Google Sheets
const sheetId = process.env.GOOGLE_SHEETS_ID
const email = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL
const rawKey = process.env.GOOGLE_SERVICE_ACCOUNT_KEY
if (!sheetId || !email || !rawKey) throw new Error('Credentials Google manquants')
const apiKey = process.env.GOOGLE_API_KEY
if (!apiKey) throw new Error('GOOGLE_API_KEY manquant')
const privateKey = rawKey.replace(/\\n/g, '\n')
const now = Math.floor(Date.now() / 1000)
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url')
const payload = Buffer.from(JSON.stringify({
iss: email,
scope: 'https://www.googleapis.com/auth/spreadsheets',
aud: 'https://oauth2.googleapis.com/token',
iat: now, exp: now + 3600,
})).toString('base64url')
const { createSign } = await import('crypto')
const sign = createSign('RSA-SHA256')
sign.update(`${header}.${payload}`)
const signature = sign.sign(privateKey, 'base64url')
const jwt = `${header}.${payload}.${signature}`
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt }),
})
if (!tokenRes.ok) throw new Error(`Google token error: ${tokenRes.status}`)
const { access_token } = await tokenRes.json() as { access_token: string }
// Lire tous les onglets
const metaRes = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`,
)
if (!metaRes.ok) throw new Error(`Sheets metadata error: ${metaRes.status}`)
const meta = await metaRes.json()
const tabs = (meta.sheets as Array<{ properties: { title: string } }>).map(s => s.properties.title)
type Entry = { shopifyId: string; tab: string; row: number; stockColLetter: string }
const entries: Entry[] = []
for (const tab of tabs) {
const range = encodeURIComponent(`${tab}!A1:BZ`)
const res = await fetch(`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`)
if (!res.ok) continue
const data = await res.json()
const rows = (data.values ?? []) as string[][]
if (rows.length < 2) continue
const headerRowIdx = rows.findIndex(r => (r[0] ?? '').trim() === 'ID')
if (headerRowIdx === -1) continue
const hdrs = rows[headerRowIdx]
if (findColIndex(hdrs, 'Publié') === -1) continue
const stockCdtIdx = findColIndex(hdrs, 'Stock conditionné')
const stockIdx = findColIndex(hdrs, 'Stock (en pièce)') !== -1
? findColIndex(hdrs, 'Stock (en pièce)')
: findColIndex(hdrs, 'Stock (pièce)')
// Choisir la colonne cible (Stock conditionné en priorité)
const targetColIdx = stockCdtIdx !== -1 ? stockCdtIdx : stockIdx
if (targetColIdx === -1) continue
const colLetter = targetColIdx < 26
? String.fromCharCode(65 + targetColIdx)
: String.fromCharCode(64 + Math.floor(targetColIdx / 26)) + String.fromCharCode(65 + (targetColIdx % 26))
const get = (row: string[], i: number) => (i >= 0 ? (row[i] ?? '').trim() : '')
for (let ri = 0; ri < rows.slice(headerRowIdx + 1).length; ri++) {
const row = rows[headerRowIdx + 1 + ri]
const shopifyId = get(row, 0)
if (!shopifyId || !/^\d+$/.test(shopifyId)) continue
entries.push({ shopifyId, tab, row: headerRowIdx + 2 + ri, stockColLetter: colLetter })
}
}
send({ type: 'total', total: entries.length })
let updated = 0
let errors = 0
const batchByTab: Record<string, Array<{ range: string; values: string[][] }>> = {}
for (let i = 0; i < entries.length; i++) {
const { shopifyId, tab, row, stockColLetter } = entries[i]
send({ type: 'progress', current: i + 1, total: entries.length })
try {
await sleep(300)
const pRes = await shopifyFetch(`${baseUrl}/products/${shopifyId}.json?fields=variants`, { headers: shopifyHeaders })
if (pRes.status === 404) continue
if (!pRes.ok) { errors++; continue }
const pData = await pRes.json() as { product: { variants: Array<{ inventory_item_id: number }> } }
const inventoryItemId = pData.product.variants[0]?.inventory_item_id
if (!inventoryItemId) { errors++; continue }
await sleep(300)
const invRes = await shopifyFetch(
`${baseUrl}/inventory_levels.json?inventory_item_ids=${inventoryItemId}&location_ids=${locationId}`,
{ headers: shopifyHeaders }
)
if (!invRes.ok) { errors++; continue }
const invData = await invRes.json() as { inventory_levels: Array<{ available: number }> }
const available = invData.inventory_levels[0]?.available ?? 0
if (!batchByTab[tab]) batchByTab[tab] = []
batchByTab[tab].push({ range: `${tab}!${stockColLetter}${row}`, values: [[String(available)]] })
updated++
} catch {
errors++
}
}
// Écrire tous les stocks dans le Sheet en batch par tab
const allData = Object.values(batchByTab).flat()
if (allData.length > 0) {
const batchRes = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values:batchUpdate`,
{
method: 'POST',
headers: { Authorization: `Bearer ${access_token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ valueInputOption: 'RAW', data: allData }),
}
)
if (!batchRes.ok) {
const txt = await batchRes.text()
throw new Error(`Sheets batchUpdate error: ${batchRes.status}${txt}`)
}
}
send({ type: 'done', updated, errors })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
console.error('[sync-stock-reverse] fatal:', message)
send({ type: 'done', updated: 0, errors: 1, fatalError: message })
} finally {
controller.close()
}
},
})
return new Response(stream, {
headers: { 'Content-Type': 'application/x-ndjson', 'Transfer-Encoding': 'chunked' },
})
}
+150
View File
@@ -0,0 +1,150 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { findColIndex } from '@/lib/creationSheets'
import { getConfig } from '@/lib/shopifySync'
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const { tab } = await req.json().catch(() => ({})) as { tab?: string }
const stream = new ReadableStream({
async start(controller) {
const send = (obj: object) => controller.enqueue(new TextEncoder().encode(JSON.stringify(obj) + '\n'))
try {
const { baseUrl, headers } = getConfig()
// Récupérer le location_id une seule fois
const locRes = await fetch(`${baseUrl}/locations.json`, { headers })
if (!locRes.ok) throw new Error(`Shopify locations error: ${locRes.status}`)
const locData = await locRes.json() as { locations: Array<{ id: number }> }
const locationId = locData.locations[0]?.id
if (!locationId) throw new Error('Aucune location Shopify trouvée')
// Lire tous les onglets avec un ID Shopify en colonne A
const apiKey = process.env.GOOGLE_API_KEY
const sheetId = process.env.GOOGLE_SHEETS_ID
if (!apiKey || !sheetId) throw new Error('GOOGLE_API_KEY et GOOGLE_SHEETS_ID requis')
let tabs: string[]
if (tab) {
tabs = [tab]
} else {
const metaRes = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`,
)
if (!metaRes.ok) throw new Error(`Sheets metadata error: ${metaRes.status}`)
const meta = await metaRes.json()
tabs = (meta.sheets as Array<{ properties: { title: string } }>).map(s => s.properties.title)
}
type StockEntry = { shopifyId: string; stock: number; title: string; tab: string }
const entries: StockEntry[] = []
for (const t of tabs) {
const range = encodeURIComponent(`${t}!A1:BZ`)
const res = await fetch(`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`)
if (!res.ok) continue
const data = await res.json()
const rows = (data.values ?? []) as string[][]
if (rows.length < 2) continue
const headerRowIdx = rows.findIndex(r => (r[0] ?? '').trim() === 'ID')
if (headerRowIdx === -1) continue
const hdrs = rows[headerRowIdx]
if (findColIndex(hdrs, 'Publié') === -1) continue
const idIdx = 0
const nomIdx = findColIndex(hdrs, 'Nom')
const stockIdx = findColIndex(hdrs, 'Stock (en pièce)') !== -1
? findColIndex(hdrs, 'Stock (en pièce)')
: findColIndex(hdrs, 'Stock (pièce)')
const stockCdtIdx = findColIndex(hdrs, 'Stock conditionné')
const get = (row: string[], i: number) => (i >= 0 ? (row[i] ?? '').trim() : '')
for (const row of rows.slice(headerRowIdx + 1)) {
const shopifyId = get(row, idIdx)
if (!shopifyId || !/^\d+$/.test(shopifyId)) continue
const rawStock = get(row, stockCdtIdx) || get(row, stockIdx)
const stock = parseInt(rawStock || '0', 10) || 0
entries.push({ shopifyId, stock, title: get(row, nomIdx), tab: t })
}
}
send({ type: 'total', total: entries.length })
let updated = 0
let errors = 0
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
const shopifyFetch = async (url: string, opts: RequestInit, retries = 3): Promise<Response> => {
const res = await fetch(url, opts)
if (res.status === 429 && retries > 0) {
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '2', 10)
await sleep(retryAfter * 1000)
return shopifyFetch(url, opts, retries - 1)
}
return res
}
for (let i = 0; i < entries.length; i++) {
const { shopifyId, stock, title } = entries[i]
send({ type: 'progress', current: i + 1, total: entries.length, title })
try {
await sleep(300) // éviter le rate limiting (2 req/s max)
const pRes = await shopifyFetch(`${baseUrl}/products/${shopifyId}.json?fields=variants`, { headers })
if (pRes.status === 404) continue
if (!pRes.ok) {
send({ type: 'error', title, message: `Produit ${shopifyId} erreur (${pRes.status})` })
errors++
continue
}
const pData = await pRes.json() as { product: { variants: Array<{ inventory_item_id: number }> } }
const inventoryItemId = pData.product.variants[0]?.inventory_item_id
if (!inventoryItemId) { errors++; continue }
await sleep(300)
await shopifyFetch(`${baseUrl}/inventory_levels/connect.json`, {
method: 'POST', headers,
body: JSON.stringify({ location_id: locationId, inventory_item_id: inventoryItemId }),
})
await sleep(300)
const setRes = await shopifyFetch(`${baseUrl}/inventory_levels/set.json`, {
method: 'POST', headers,
body: JSON.stringify({ location_id: locationId, inventory_item_id: inventoryItemId, available: stock }),
})
if (!setRes.ok) {
const txt = await setRes.text()
send({ type: 'error', title, message: `stock set failed: ${setRes.status}${txt}` })
errors++
} else {
updated++
}
} catch (err) {
send({ type: 'error', title, message: err instanceof Error ? err.message : 'Erreur inconnue' })
errors++
}
}
send({ type: 'done', updated, errors })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
console.error('[sync-stock] fatal:', message)
send({ type: 'done', updated: 0, errors: 1, fatalError: message })
} finally {
controller.close()
}
},
})
return new Response(stream, {
headers: { 'Content-Type': 'application/x-ndjson', 'Transfer-Encoding': 'chunked' },
})
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { fetchPendingProducts } from '@/lib/creationSheets'
import { prisma } from '@/lib/prisma'
export async function GET() {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
try {
const existing = await prisma.productCreation.findMany({ select: { sheetTab: true, sheetRow: true } })
const alreadyCreated = new Set(existing.map((e) => `${e.sheetTab}:${e.sheetRow}`))
const pending = await fetchPendingProducts(alreadyCreated)
return NextResponse.json({ count: pending.length })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+114
View File
@@ -0,0 +1,114 @@
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
const API_VERSION = '2024-01'
function getShopifyConfig() {
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
return {
baseUrl: `https://${domain}/admin/api/${API_VERSION}`,
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
}
}
function extractNextUrl(linkHeader: string | null): string | null {
if (!linkHeader) return null
const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/)
return match ? match[1] : null
}
interface LightProduct {
id: number
status: string
images: Array<{ id: number }>
}
async function fetchProductsLight(status: 'active' | 'draft'): Promise<LightProduct[]> {
const { baseUrl, headers } = getShopifyConfig()
const all: LightProduct[] = []
let url: string | null =
`${baseUrl}/products.json?limit=250&fields=id,status,images&status=${status}`
while (url) {
const res = await fetch(url, { headers, cache: 'no-store' })
if (!res.ok) throw new Error(`Shopify fetch error (${status}): ${res.status}`)
const data = await res.json()
all.push(...(data.products as LightProduct[]))
url = extractNextUrl(res.headers.get('Link'))
}
return all
}
async function fetchSheetModifiedTime(): Promise<string | null> {
const apiKey = process.env.GOOGLE_API_KEY
const sheetId = process.env.GOOGLE_SHEETS_ID
if (!apiKey || !sheetId) return null
try {
const res = await fetch(
`https://www.googleapis.com/drive/v3/files/${sheetId}?fields=modifiedTime&key=${apiKey}`,
{ cache: 'no-store' },
)
if (!res.ok) return null
const data = await res.json()
return (data.modifiedTime as string) ?? null
} catch {
return null
}
}
export async function GET() {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
try {
const [activeProducts, draftProducts, lastSyncRun, sheetModifiedTime] = await Promise.all([
fetchProductsLight('active'),
fetchProductsLight('draft'),
prisma.syncRun.findFirst({
orderBy: { createdAt: 'desc' },
include: { user: { select: { name: true } } },
}),
fetchSheetModifiedTime(),
])
const allProducts = [...activeProducts, ...draftProducts]
const withoutImages = allProducts.filter((p) => p.images.length === 0).length
const lastSync = lastSyncRun
? {
createdAt: lastSyncRun.createdAt.toISOString(),
userName: lastSyncRun.user.name,
status: lastSyncRun.status,
created: lastSyncRun.created,
updated: lastSyncRun.updated,
deactivated: lastSyncRun.deactivated,
errors: lastSyncRun.errors,
}
: null
// Drift : Sheets modifié après la dernière sync réussie
const sheetDrift =
sheetModifiedTime !== null &&
lastSyncRun !== null &&
new Date(sheetModifiedTime) > lastSyncRun.createdAt
return NextResponse.json({
products: {
active: activeProducts.length,
draft: draftProducts.length,
total: allProducts.length,
withoutImages,
},
lastSync,
sheetDrift,
sheetModifiedAt: sheetModifiedTime,
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+35
View File
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import convert from 'heic-convert'
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
try {
const formData = await req.formData()
const file = formData.get('file') as File | null
if (!file) return NextResponse.json({ error: 'Fichier manquant' }, { status: 400 })
const inputBuffer = Buffer.from(await file.arrayBuffer())
// heic-convert utilise libheif WASM — supporte HEIC/HEIF sur tous les environnements
const outputBuffer = await convert({
buffer: inputBuffer,
format: 'JPEG',
quality: 0.92,
})
const jpeg = Buffer.from(outputBuffer)
return new NextResponse(jpeg.buffer as ArrayBuffer, {
headers: {
'Content-Type': 'image/jpeg',
'Content-Length': String(jpeg.length),
},
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur conversion'
console.error('[convert-heic API]', message)
return NextResponse.json({ error: message }, { status: 500 })
}
}
+18
View File
@@ -0,0 +1,18 @@
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return new Response('Non authentifié', { status: 401 })
const url = req.nextUrl.searchParams.get('url')
if (!url) return new Response('url requis', { status: 400 })
const res = await fetch(url)
if (!res.ok) return new Response('Fetch failed', { status: 502 })
const buffer = await res.arrayBuffer()
const contentType = res.headers.get('content-type') ?? 'image/jpeg'
return new Response(buffer, { headers: { 'Content-Type': contentType } })
}
+2 -1
View File
@@ -26,6 +26,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json(result)
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
const status = (err as { status?: number }).status === 404 ? 404 : 500
return NextResponse.json({ error: message }, { status })
}
}
+89
View File
@@ -0,0 +1,89 @@
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { readFile } from 'fs/promises'
import { getTemplateImagePath, listTemplates } from '@/lib/mockupTemplates'
import type { MockupGenerateRequest } from '@/types/mockup'
function buildPrompt(req: MockupGenerateRequest): string {
if (req.family === 'Carrelage') {
let surface = 'floor or wall'
let restriction = ''
if (req.placement === 'mur') {
surface = 'wall'
restriction = ' Apply tiles only to the wall surface, not the floor.'
} else if (req.placement === 'sol') {
surface = 'floor'
restriction = ' Apply tiles only to the floor surface, not the walls.'
}
const dimClause = req.dimensions
? ` The real-world tile size is ${req.dimensions}.`
: ''
const instructionClause = req.instruction ? ` Additional instruction: ${req.instruction}.` : ''
return `Replace the ${surface} covering in the reference scene with the provided tile image (${req.productName}).${dimClause}${restriction} CRITICAL RULES — you MUST follow all of them:
1. Every single tile in the entire image must be EXACTLY the same size — no exceptions anywhere in the scene.
2. The tile pattern must be a perfectly uniform grid: identical tile dimensions repeated consistently across the entire ${surface}.
3. Tiles may be partially cut at edges, corners, or where the ${surface} meets other surfaces — but each visible portion must match the same grid unit size.
4. NEVER vary the tile scale or apparent size in different parts of the image, even for artistic or perspective reasons.
5. Copy the tile design, texture, color, and proportions from the provided image exactly — do not alter them.
6. Maintain realistic lighting and perspective consistent with the reference scene.${instructionClause}`
}
const familyContext: Record<string, string> = {
'Menuiserie': 'door installed in a residential entrance or hallway, visible wall and floor context',
'Baies vitrées': 'bay window or sliding door installed in a living room with garden view',
}
const context = familyContext[req.family] ?? 'product installed in an appropriate room setting'
const dimClause = req.dimensions
? ` The product dimensions are exactly ${req.dimensions} — preserve these exact proportions, do NOT resize or distort the product to fit the scene.`
: ''
const instructionClause = req.instruction ? ` Additional instruction: ${req.instruction}.` : ''
return `Replace the product in the reference scene with the provided product image (${req.productName}). Show the ${context}.${dimClause} Keep the product's exact design, texture, colors and proportions identical to the provided image. Maintain realistic perspective and lighting consistent with the scene.${instructionClause}`
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return Response.json({ error: 'Non authentifié' }, { status: 401 })
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) return Response.json({ error: 'OPENAI_API_KEY manquant' }, { status: 500 })
const body = await req.json() as MockupGenerateRequest
const templates = await listTemplates()
const template = templates.find(t => t.id === body.templateId)
if (!template) return Response.json({ error: 'Template introuvable' }, { status: 404 })
const templateBuffer = await readFile(getTemplateImagePath(template.filename))
const templateExt = template.filename.split('.').pop() ?? 'jpg'
const templateMime = templateExt === 'png' ? 'image/png' : 'image/jpeg'
const productBuffer = Buffer.from(body.productImageBase64, 'base64')
const form = new FormData()
form.append('model', 'gpt-image-1')
form.append('prompt', buildPrompt(body))
form.append('n', '1')
form.append('size', '1024x1024')
form.append('image[]', new Blob([templateBuffer], { type: templateMime }), `template.${templateExt}`)
form.append('image[]', new Blob([productBuffer], { type: 'image/png' }), 'product.png')
const openaiRes = await fetch('https://api.openai.com/v1/images/edits', {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` },
body: form,
})
if (!openaiRes.ok) {
const err = await openaiRes.text()
return Response.json({ error: `OpenAI error: ${err}` }, { status: 502 })
}
const data = await openaiRes.json() as { data: Array<{ b64_json?: string }> }
const b64 = data.data?.[0]?.b64_json
if (!b64) return Response.json({ error: 'Pas de résultat OpenAI' }, { status: 502 })
return Response.json({ imageBase64: b64 })
}
@@ -0,0 +1,30 @@
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
const API_VERSION = '2024-01'
export async function GET(_req: NextRequest, { params }: { params: { shopifyId: string } }) {
const session = await getServerSession(authOptions)
if (!session) return Response.json({ error: 'Non authentifié' }, { status: 401 })
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) return Response.json({ error: 'Config Shopify manquante' }, { status: 500 })
const res = await fetch(
`https://${domain}/admin/api/${API_VERSION}/products/${params.shopifyId}/metafields.json`,
{ headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' } },
)
if (!res.ok) return Response.json({ error: `Shopify error: ${res.status}` }, { status: 502 })
const data = await res.json()
const metafields = (data.metafields ?? []) as Array<{
namespace: string
key: string
value: string
type: string
}>
return Response.json({ metafields })
}
@@ -0,0 +1,10 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { deleteTemplate } from '@/lib/mockupTemplates'
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions)
if (!session) return Response.json({ error: 'Non authentifié' }, { status: 401 })
await deleteTemplate(params.id)
return Response.json({ ok: true })
}
@@ -0,0 +1,14 @@
import { readFile } from 'fs/promises'
import { getTemplateImagePath } from '@/lib/mockupTemplates'
export async function GET(_req: Request, { params }: { params: { filename: string } }) {
try {
const filePath = getTemplateImagePath(params.filename)
const buffer = await readFile(filePath)
const ext = params.filename.split('.').pop() ?? 'jpg'
const contentType = ext === 'png' ? 'image/png' : 'image/jpeg'
return new Response(buffer, { headers: { 'Content-Type': contentType } })
} catch {
return new Response('Not found', { status: 404 })
}
}
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { listTemplates, saveTemplate } from '@/lib/mockupTemplates'
export async function GET() {
const session = await getServerSession(authOptions)
if (!session) return Response.json({ error: 'Non authentifié' }, { status: 401 })
const templates = await listTemplates()
return Response.json(templates)
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return Response.json({ error: 'Non authentifié' }, { status: 401 })
const form = await req.formData()
const name = form.get('name') as string
const family = form.get('family') as string
const placement = (form.get('placement') as string) || undefined
const description = (form.get('description') as string) ?? ''
const imageFile = form.get('image') as File
if (!name || !family || !imageFile) {
return Response.json({ error: 'name, family et image requis' }, { status: 400 })
}
const buffer = Buffer.from(await imageFile.arrayBuffer())
const ext = imageFile.name.split('.').pop() ?? 'jpg'
const template = await saveTemplate({ name, family, placement, description, filename: '' }, buffer, ext)
return Response.json(template)
}
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { deleteShopifyProduct } from '@/lib/shopifySync'
export async function DELETE(
_req: NextRequest,
{ params }: { params: { id: string } },
) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
try {
await deleteShopifyProduct(params.id)
return NextResponse.json({ ok: true })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+59
View File
@@ -0,0 +1,59 @@
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
import type { ShopifyProductFull } from '@/types/sync'
export interface DuplicateGroup {
ref: string
products: Array<{
shopifyId: string
title: string
status: string
handle: string
shopifyUrl: string
}>
}
export async function GET() {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
try {
const domain = process.env.SHOPIFY_STORE_DOMAIN ?? ''
const all = await fetchAllShopifyProducts()
// Grouper par ref (SKU)
const byRef = new Map<string, ShopifyProductFull[]>()
for (const p of all) {
const key = p.ref.trim().toLowerCase()
if (!byRef.has(key)) byRef.set(key, [])
byRef.get(key)!.push(p)
}
const groups: DuplicateGroup[] = []
for (const products of Array.from(byRef.values())) {
if (products.length < 2) continue
// Trier : le plus ancien (ID numérique le plus petit) en premier
products.sort((a: ShopifyProductFull, b: ShopifyProductFull) => Number(a.shopifyId) - Number(b.shopifyId))
groups.push({
ref: products[0].ref,
products: products.map((p: ShopifyProductFull) => ({
shopifyId: p.shopifyId,
title: p.title,
status: p.status,
handle: p.handle,
shopifyUrl: `https://${domain}/admin/products/${p.shopifyId}`,
})),
})
}
// Trier les groupes par ref
groups.sort((a, b) => a.ref.localeCompare(b.ref))
return NextResponse.json({ groups, total: groups.length })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+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)
})
})
+34
View File
@@ -0,0 +1,34 @@
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}`,
images: p.images ?? [],
}))
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 })
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server'
const API_VERSION = '2024-01'
// GET /api/products/search-list?q=peinture
// Retourne jusqu'à 10 produits correspondant à la recherche (titre ou SKU)
export async function GET(req: NextRequest) {
const q = new URL(req.url).searchParams.get('q')?.trim()
if (!q || q.length < 2) return NextResponse.json([])
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) return NextResponse.json([], { status: 500 })
const headers = { 'X-Shopify-Access-Token': token }
const baseUrl = `https://${domain}/admin/api/${API_VERSION}`
try {
// Recherche par titre
const res = await fetch(
`${baseUrl}/products.json?title=${encodeURIComponent(q)}&limit=10&fields=id,title,variants`,
{ headers },
)
if (!res.ok) return NextResponse.json([])
const data = await res.json()
const products = (data.products ?? []) as Array<{
id: number
title: string
variants: Array<{ sku: string }>
}>
const results = products.map(p => ({
shopifyId: String(p.id),
title: p.title,
sku: p.variants[0]?.sku ?? '',
}))
return NextResponse.json(results)
} catch {
return NextResponse.json([])
}
}
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
/**
* Cherche un produit dans Google Sheets par sa référence (UGS, col C).
* Retourne le Shopify ID (col A) et le titre (col D) si trouvé.
* Parcourt tous les onglets.
*/
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ found: false }, { status: 401 })
const { searchParams } = new URL(req.url)
const ref = searchParams.get('ref')?.trim()
if (!ref) return NextResponse.json({ found: false }, { status: 400 })
const apiKey = process.env.GOOGLE_API_KEY
const sheetId = process.env.GOOGLE_SHEETS_ID
if (!apiKey || !sheetId) {
return NextResponse.json({ found: false, error: 'GOOGLE_API_KEY et GOOGLE_SHEETS_ID requis' }, { status: 500 })
}
try {
// Récupère la liste des onglets
const metaRes = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`,
)
if (!metaRes.ok) throw new Error(`Sheets metadata error: ${metaRes.status}`)
const meta = await metaRes.json()
const tabs: string[] = (meta.sheets as Array<{ properties: { title: string } }>).map(
s => s.properties.title,
)
let bestMatch: { shopifyId: string; title: string; ugsLength: number } | null = null
for (const tab of tabs) {
const range = encodeURIComponent(`${tab}!A2:D`)
const res = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`,
)
if (!res.ok) continue
const data = await res.json()
const rows = (data.values ?? []) as string[][]
for (const row of rows) {
const shopifyId = (row[0] ?? '').trim()
const ugs = (row[2] ?? '').trim()
const refLower = ref.toLowerCase()
const ugsLower = ugs.toLowerCase()
const wordBoundary = new RegExp(`(^|[^a-z0-9])${ugsLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`)
// Ne requiert pas shopifyId pour le match UGS — ID peut être absent si produit externe
const isMatch = ugs && wordBoundary.test(refLower)
if (isMatch && shopifyId) {
const title = (row[3] ?? '').trim()
if (!bestMatch || ugs.length > bestMatch.ugsLength) {
bestMatch = { shopifyId, title, ugsLength: ugs.length }
}
}
}
}
if (bestMatch) {
return NextResponse.json({ found: true, shopifyId: bestMatch.shopifyId, title: bestMatch.title })
}
return NextResponse.json({ found: false })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur'
return NextResponse.json({ found: false, error: message }, { status: 500 })
}
}
+2
View File
@@ -10,9 +10,11 @@ export async function GET(req: NextRequest) {
try {
const result = await searchProductByRef(ref.trim())
console.log(`[search] ref="${ref.trim()}" → found=${result.found} id=${(result as {shopifyId?: string}).shopifyId ?? '-'}`)
return NextResponse.json(result)
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
console.error(`[search] ref="${ref.trim()}" → ERROR: ${message}`)
return NextResponse.json({ error: message }, { status: 500 })
}
}
+19
View File
@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { readSheetProducts } from '@/lib/googleSheets'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const ref = new URL(req.url).searchParams.get('ref')
if (!ref) return NextResponse.json({ error: 'Paramètre ref manquant' }, { status: 400 })
const products = await readSheetProducts()
const product = products.find(p => p.ref.toLowerCase() === ref.toLowerCase())
if (!product) return NextResponse.json({ error: 'Produit non trouvé' }, { status: 404 })
return NextResponse.json({ product })
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
export async function GET() {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const apiKey = process.env.GOOGLE_API_KEY
const sheetId = process.env.GOOGLE_SHEETS_ID
if (!apiKey || !sheetId) {
return NextResponse.json({ error: 'GOOGLE_API_KEY et GOOGLE_SHEETS_ID requis' }, { status: 500 })
}
try {
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`
const res = await fetch(url, { cache: 'no-store' })
if (!res.ok) {
const err = await res.text()
throw new Error(`Google Sheets API error: ${res.status}${err}`)
}
const data = await res.json()
const tabs: string[] = (data.sheets as Array<{ properties: { title: string } }>).map(
(s) => s.properties.title,
)
return NextResponse.json({ tabs })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+4 -4
View File
@@ -3,15 +3,15 @@ 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) {
export async function GET(req: NextRequest) {
const tab = new URL(req.url).searchParams.get('tab') ?? undefined
try {
const [sheetProducts, shopifyProducts] = await Promise.all([
readSheetProducts(),
readSheetProducts(tab),
fetchAllShopifyProducts(),
])
const diff = computeDiff(sheetProducts, shopifyProducts)
const diff = computeDiff(sheetProducts, shopifyProducts, tab)
return NextResponse.json(diff)
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server'
const API_VERSION = '2024-01'
function getShopifyConfig() {
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) throw new Error('Config Shopify manquante')
return {
baseUrl: `https://${domain}/admin/api/${API_VERSION}`,
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
}
}
export interface BannerProductData {
id: string
title: string
price: string
compareAtPrice: string | null
sku: string
imageUrl: string | null
allImages: string[]
}
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url)
const ref = searchParams.get('ref')
const id = searchParams.get('id')
try {
const { baseUrl, headers } = getShopifyConfig()
let product: Record<string, unknown> | null = null
if (id) {
const res = await fetch(`${baseUrl}/products/${id}.json?fields=id,title,variants,images`, { headers })
if (res.ok) {
const data = await res.json()
product = data.product
}
} else if (ref) {
// Recherche par SKU via GraphQL
const domain = process.env.SHOPIFY_STORE_DOMAIN
const gqlRes = await fetch(`https://${domain}/admin/api/${API_VERSION}/graphql.json`, {
method: 'POST',
headers,
body: JSON.stringify({
query: `{ productVariants(first: 5, query: "sku:${ref.replace(/"/g, '')}") {
edges { node { sku product { id legacyResourceId title } } }
} }`,
}),
})
const gqlData = await gqlRes.json()
const edges = gqlData?.data?.productVariants?.edges ?? []
const match = edges.find((e: { node: { sku: string } }) =>
e.node.sku.toLowerCase() === ref.toLowerCase()
)
if (match) {
const legacyId = match.node.product.legacyResourceId
const res = await fetch(`${baseUrl}/products/${legacyId}.json?fields=id,title,variants,images`, { headers })
if (res.ok) product = (await res.json()).product
}
}
if (!product) return NextResponse.json({ error: 'Produit introuvable' }, { status: 404 })
const variants = product.variants as Array<{ price: string; compare_at_price: string | null; sku: string }>
const images = product.images as Array<{ src: string }>
const variant = variants[0]
const result: BannerProductData = {
id: String(product.id),
title: product.title as string,
price: variant?.price ?? '',
compareAtPrice: variant?.compare_at_price ?? null,
sku: variant?.sku ?? '',
imageUrl: images[0]?.src ?? null,
allImages: images.map(i => i.src),
}
return NextResponse.json(result)
} catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 500 })
}
}
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server'
import {
getActiveThemeId,
getHomepageTemplate,
extractSlideshow,
applySlideshow,
saveHomepageTemplate,
} from '@/lib/shopifyTheme'
import type { SlideshowSection } from '@/lib/shopifyTheme'
export async function GET() {
try {
const themeId = await getActiveThemeId()
const template = await getHomepageTemplate(themeId)
const slideshow = extractSlideshow(template)
if (!slideshow) return NextResponse.json({ error: 'Section slideshow-custom introuvable' }, { status: 404 })
return NextResponse.json({ slideshow, themeId })
} catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 500 })
}
}
export async function PUT(req: NextRequest) {
try {
const body = await req.json() as { slideshow: SlideshowSection; themeId: string }
const template = await getHomepageTemplate(body.themeId)
const updated = applySlideshow(template, body.slideshow)
await saveHomepageTemplate(body.themeId, updated)
return NextResponse.json({ ok: true })
} catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 500 })
}
}
+15
View File
@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from 'next/server'
import { uploadFileToShopify } from '@/lib/shopifyTheme'
export async function POST(req: NextRequest) {
try {
const { imageBase64, filename } = await req.json() as { imageBase64: string; filename: string }
if (!imageBase64 || !filename) {
return NextResponse.json({ error: 'imageBase64 et filename requis' }, { status: 400 })
}
const shopifyRef = await uploadFileToShopify(imageBase64, filename)
return NextResponse.json({ shopifyRef })
} catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 500 })
}
}
+18
View File
@@ -13,6 +13,24 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fr">
<head>
{/* Import map requis par @imgly/background-removal chargé depuis CDN.
Doit être placé avant tout script module — Next.js le respecte car
le layout est rendu côté serveur avant injection des chunks. */}
<script
type="importmap"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: JSON.stringify({
imports: {
'onnxruntime-web': 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.21.0/dist/ort.all.min.mjs',
'onnxruntime-web/webgpu': 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.21.0/dist/ort.webgpu.min.mjs',
'onnxruntime-web/wasm': 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.21.0/dist/ort.wasm.min.mjs',
},
}),
}}
/>
</head>
<body className={inter.className}>
<SessionProvider>{children}</SessionProvider>
</body>
+417
View File
@@ -0,0 +1,417 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { COLOR_SCHEMES, renderBanner } from '@/lib/client/bannerRenderer'
import type { BannerConfig, ColorScheme } from '@/lib/client/bannerRenderer'
import type { BannerProductData } from '@/app/api/theme/product-banner-data/route'
interface Props {
onApply: (shopifyRef: string) => void
onClose: () => void
}
interface SearchSuggestion {
shopifyId: string
title: string
sku: string
}
function formatPrice(price: string): string {
if (!price) return ''
const n = parseFloat(price)
return isNaN(n) ? price : `${Math.round(n)}`
}
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve((reader.result as string).split(',')[1])
reader.onerror = reject
reader.readAsDataURL(blob)
})
}
export function BannerBuilder({ onApply, onClose }: Props) {
// Combobox produit
const [inputValue, setInputValue] = useState('')
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([])
const [showSuggestions, setShowSuggestions] = useState(false)
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
const [loadingProduct, setLoadingProduct] = useState(false)
const [product, setProduct] = useState<BannerProductData | null>(null)
const [searchError, setSearchError] = useState<string | null>(null)
const comboRef = useRef<HTMLDivElement>(null)
// Banner config
const [scheme, setScheme] = useState<ColorScheme>('dark-blue')
const [promoLabel, setPromoLabel] = useState('Destockage - Durée limitée')
const [title, setTitle] = useState('')
const [price, setPrice] = useState('')
const [comparePrice, setComparePrice] = useState('')
const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>(null)
const [backgroundBlob, setBackgroundBlob] = useState<Blob | null>(null)
const [backgroundPreview, setBackgroundPreview] = useState<string | null>(null)
const bgFileRef = useRef<HTMLInputElement>(null)
// Generation
const [rendering, setRendering] = useState(false)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const previewRef = useRef<string | null>(null)
// Ferme les suggestions si clic extérieur
useEffect(() => {
const handler = (e: MouseEvent) => {
if (comboRef.current && !comboRef.current.contains(e.target as Node)) {
setShowSuggestions(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
// Recherche des suggestions (debounce 300ms)
useEffect(() => {
const q = inputValue.trim()
if (q.length < 2) { setSuggestions([]); setShowSuggestions(false); return }
const t = setTimeout(async () => {
setLoadingSuggestions(true)
try {
const res = await fetch(`/api/products/search-list?q=${encodeURIComponent(q)}`)
if (!res.ok) return
const data = await res.json() as SearchSuggestion[]
setSuggestions(data)
setShowSuggestions(data.length > 0)
} catch { /* silencieux */ } finally {
setLoadingSuggestions(false)
}
}, 300)
return () => clearTimeout(t)
}, [inputValue])
const selectSuggestion = async (s: SearchSuggestion) => {
setInputValue(s.title)
setShowSuggestions(false)
setSuggestions([])
setLoadingProduct(true)
setSearchError(null)
setProduct(null)
setPreviewUrl(null)
try {
const res = await fetch(`/api/theme/product-banner-data?id=${s.shopifyId}`)
const data = await res.json()
if (!res.ok) throw new Error(data.error)
const p = data as BannerProductData
setProduct(p)
setTitle(p.title.toUpperCase())
setPrice(formatPrice(p.price))
setComparePrice(p.compareAtPrice ? formatPrice(p.compareAtPrice) : '')
setSelectedImageUrl(p.imageUrl)
} catch (e) {
setSearchError((e as Error).message)
} finally {
setLoadingProduct(false)
}
}
const handleBgFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setBackgroundBlob(file)
const url = URL.createObjectURL(file)
if (backgroundPreview) URL.revokeObjectURL(backgroundPreview)
setBackgroundPreview(url)
e.target.value = ''
}
const removeBg = () => {
if (backgroundPreview) URL.revokeObjectURL(backgroundPreview)
setBackgroundBlob(null)
setBackgroundPreview(null)
}
const getConfig = useCallback((): BannerConfig => ({
scheme,
promoLabel,
title,
price,
comparePrice,
productImageUrl: selectedImageUrl,
backgroundImageBlob: backgroundBlob,
}), [scheme, promoLabel, title, price, comparePrice, selectedImageUrl, backgroundBlob])
const generate = useCallback(async () => {
setRendering(true)
setError(null)
try {
const blob = await renderBanner(getConfig())
const url = URL.createObjectURL(blob)
if (previewRef.current) URL.revokeObjectURL(previewRef.current)
previewRef.current = url
setPreviewUrl(url)
} catch (e) {
setError((e as Error).message)
} finally {
setRendering(false)
}
}, [getConfig])
// Régénère automatiquement quand les paramètres changent (debounce 600ms)
useEffect(() => {
if (!title && !product) return
const t = setTimeout(() => { generate() }, 600)
return () => clearTimeout(t)
}, [scheme, promoLabel, title, price, comparePrice, selectedImageUrl, backgroundBlob]) // eslint-disable-line react-hooks/exhaustive-deps
const applyToSlide = async () => {
if (!previewRef.current) return
setUploading(true)
setError(null)
try {
// Convertit l'objectURL en blob
const res = await fetch(previewRef.current)
const blob = await res.blob()
const base64 = await blobToBase64(blob)
const filename = `banniere-${(product?.sku ?? 'custom').toLowerCase().replace(/[^a-z0-9]/g, '-')}-${Date.now()}.jpg`
const uploadRes = await fetch('/api/theme/upload-file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageBase64: base64, filename }),
})
const uploadData = await uploadRes.json()
if (!uploadRes.ok) throw new Error(uploadData.error)
onApply(uploadData.shopifyRef)
} catch (e) {
setError((e as Error).message)
} finally {
setUploading(false)
}
}
const schemeEntries = Object.entries(COLOR_SCHEMES) as [ColorScheme, typeof COLOR_SCHEMES[ColorScheme]][]
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
onClick={e => { if (e.target === e.currentTarget) onClose() }}
>
<div className="bg-slate-800 border border-slate-700 rounded-xl w-full max-w-5xl max-h-[95vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-700">
<h2 className="text-white font-semibold text-sm">Créer une bannière</h2>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-white text-lg leading-none"></button>
</div>
<div className="flex flex-1 overflow-hidden">
{/* ── Panneau gauche : configuration ─────────────────────────────── */}
<div className="w-80 flex-shrink-0 overflow-y-auto border-r border-slate-700 p-4 space-y-5">
{/* Recherche produit — combobox */}
<section>
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">Produit</h3>
<div className="relative" ref={comboRef}>
<div className="relative">
<input
type="text"
value={inputValue}
onChange={e => { setInputValue(e.target.value); setProduct(null) }}
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
placeholder="Rechercher par nom ou référence…"
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 pr-7 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 text-xs pointer-events-none">
{loadingSuggestions || loadingProduct ? '⏳' : '🔍'}
</span>
</div>
{showSuggestions && suggestions.length > 0 && (
<div className="absolute z-20 w-full mt-1 bg-slate-700 border border-slate-600 rounded shadow-xl max-h-52 overflow-y-auto">
{suggestions.map(s => (
<button
key={s.shopifyId}
type="button"
onMouseDown={e => { e.preventDefault(); selectSuggestion(s) }}
className="w-full text-left px-3 py-2 hover:bg-slate-600 border-b border-slate-600 last:border-0"
>
<p className="text-xs text-white truncate">{s.title}</p>
<p className="text-xs text-slate-400 font-mono">{s.sku}</p>
</button>
))}
</div>
)}
</div>
{searchError && <p className="text-xs text-red-400 mt-1">{searchError}</p>}
{product && (
<div className="mt-2 p-2 bg-slate-700 rounded text-xs">
<p className="text-white font-medium truncate">{product.title}</p>
<p className="text-slate-400">{product.sku} · {formatPrice(product.price)}</p>
{product.allImages.length > 1 && (
<div className="mt-2">
<p className="text-slate-400 mb-1">Choisir l&apos;image :</p>
<div className="flex gap-1 flex-wrap">
{product.allImages.map((url, i) => (
<button
key={i}
type="button"
onClick={() => setSelectedImageUrl(url)}
className={`rounded overflow-hidden border-2 transition-all ${selectedImageUrl === url ? 'border-indigo-500' : 'border-slate-600'}`}
>
<img src={url} alt={`img ${i}`} className="w-12 h-12 object-cover" />
</button>
))}
</div>
</div>
)}
</div>
)}
</section>
{/* Image de fond */}
<section>
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">Image de fond</h3>
{backgroundPreview ? (
<div className="relative rounded overflow-hidden border border-slate-600">
<img src={backgroundPreview} alt="Fond" className="w-full h-24 object-cover" />
<button
type="button"
onClick={removeBg}
className="absolute top-1 right-1 bg-black/70 hover:bg-red-900 text-white text-xs px-1.5 py-0.5 rounded"
>
Retirer
</button>
</div>
) : (
<button
type="button"
onClick={() => bgFileRef.current?.click()}
className="w-full py-3 border-2 border-dashed border-slate-600 hover:border-indigo-500 rounded text-xs text-slate-400 hover:text-indigo-400 transition-colors"
>
+ Uploader une image de fond
</button>
)}
<input ref={bgFileRef} type="file" accept="image/*" className="hidden" onChange={handleBgFile} />
{backgroundPreview && (
<p className="text-xs text-slate-500 mt-1">Un overlay sombre est appliqué automatiquement pour la lisibilité du texte.</p>
)}
</section>
{/* Thème couleur */}
<section>
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">
{backgroundPreview ? 'Couleur du texte' : 'Thème couleur'}
</h3>
<div className="grid grid-cols-2 gap-1.5">
{schemeEntries.map(([key, s]) => (
<button
key={key}
type="button"
onClick={() => setScheme(key)}
style={{ background: s.bg, borderColor: scheme === key ? '#6366f1' : '#475569' }}
className={`px-3 py-2 rounded text-xs border-2 font-medium transition-all`}
>
<span style={{ color: s.titleColor }}>{s.label}</span>
</button>
))}
</div>
</section>
{/* Textes */}
<section>
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">Textes</h3>
<div className="space-y-2">
<div>
<label className="block text-xs text-slate-400 mb-0.5">Badge promotionnel</label>
<input
type="text"
value={promoLabel}
onChange={e => setPromoLabel(e.target.value)}
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-xs text-slate-400 mb-0.5">Titre produit</label>
<textarea
value={title}
onChange={e => setTitle(e.target.value)}
rows={3}
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500 resize-none"
/>
</div>
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs text-slate-400 mb-0.5">Prix affiché</label>
<input
type="text"
value={price}
onChange={e => setPrice(e.target.value)}
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
<div className="flex-1">
<label className="block text-xs text-slate-400 mb-0.5">Prix barré</label>
<input
type="text"
value={comparePrice}
onChange={e => setComparePrice(e.target.value)}
placeholder="optionnel"
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
</div>
</div>
</section>
</div>
{/* ── Panneau droit : aperçu ──────────────────────────────────────── */}
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 flex items-center justify-center bg-slate-900 p-4 overflow-auto">
{rendering && (
<p className="text-slate-500 text-sm">Génération en cours</p>
)}
{!rendering && previewUrl && (
<img
src={previewUrl}
alt="Aperçu bannière"
className="max-w-full rounded shadow-lg"
style={{ maxHeight: '60vh' }}
/>
)}
{!rendering && !previewUrl && (
<div className="text-center text-slate-600">
<p className="text-4xl mb-2">🎨</p>
<p className="text-sm">Recherchez un produit pour commencer</p>
</div>
)}
</div>
{/* Footer actions */}
<div className="p-4 border-t border-slate-700 flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
{error && <p className="text-xs text-red-400">{error}</p>}
<span className="text-xs text-slate-500">Format : 1500 × 500 px</span>
</div>
<div className="flex gap-2">
<button
onClick={generate}
disabled={rendering || (!title && !product)}
className="px-4 py-2 text-xs bg-slate-700 hover:bg-slate-600 text-white rounded font-medium disabled:opacity-40"
>
{rendering ? '⏳ Rendu…' : '↺ Régénérer'}
</button>
<button
onClick={applyToSlide}
disabled={!previewUrl || uploading || rendering}
className="px-4 py-2 text-xs bg-green-700 hover:bg-green-600 text-white rounded font-medium disabled:opacity-40"
>
{uploading ? '⬆️ Upload…' : '✓ Appliquer à la slide'}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
+42
View File
@@ -0,0 +1,42 @@
'use client'
import { Component, type ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: (error: Error, reset: () => void) => ReactNode
}
interface State {
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
reset = () => this.setState({ error: null })
render() {
if (this.state.error) {
if (this.props.fallback) return this.props.fallback(this.state.error, this.reset)
return (
<div className="p-6">
<div className="bg-red-900/30 border border-red-700 rounded-xl p-6 text-center space-y-3">
<p className="text-red-300 font-semibold">Une erreur est survenue</p>
<p className="text-red-400 text-sm font-mono">{this.state.error.message}</p>
<button
onClick={this.reset}
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg text-sm"
>
Réessayer
</button>
</div>
</div>
)
}
return this.props.children
}
}
+59
View File
@@ -0,0 +1,59 @@
'use client'
import { useEffect, useState } from 'react'
interface Props {
onSelect: (tab: string) => void
}
export function FamilySelector({ onSelect }: Props) {
const [tabs, setTabs] = useState<string[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetch('/api/sheets/tabs')
.then((r) => r.json())
.then((data) => {
if (data.error) throw new Error(data.error)
setTabs(data.tabs)
})
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false))
}, [])
if (loading) {
return (
<div className="flex items-center gap-2 text-slate-400 text-sm py-8 justify-center">
<span className="animate-spin"></span> Chargement des familles
</div>
)
}
if (error) {
return (
<div className="p-4 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">
Impossible de charger les familles : {error}
</div>
)
}
return (
<div className="space-y-3">
<p className="text-slate-300 text-sm">
Choisissez une famille (onglet Google Sheets) pour travailler uniquement sur celle-ci.
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
{tabs.map((tab) => (
<button
key={tab}
onClick={() => onSelect(tab)}
className="flex items-center gap-2 px-4 py-3 bg-slate-700 hover:bg-indigo-700 border border-slate-600 hover:border-indigo-500 rounded-xl text-sm text-slate-200 hover:text-white font-medium transition-all text-left group"
>
<span className="text-base group-hover:scale-110 transition-transform">📂</span>
<span className="truncate">{tab}</span>
</button>
))}
</div>
</div>
)
}
@@ -0,0 +1,66 @@
'use client'
import type { CreationStreamEvent } from '@/types/creation'
interface Props {
events: CreationStreamEvent[]
done: boolean
}
export function CreationProgress({ events, done }: Props) {
const lastProgress = [...events].reverse().find(e => e.type === 'progress') as Extract<CreationStreamEvent, { type: 'progress' }> | undefined
const doneEvent = events.find(e => e.type === 'done') as Extract<CreationStreamEvent, { type: 'done' }> | undefined
const errors = events.filter(e => e.type === 'error') as Array<Extract<CreationStreamEvent, { type: 'error' }>>
const skipped = events.filter(e => e.type === 'skipped') as Array<Extract<CreationStreamEvent, { type: 'skipped' }>>
return (
<div className="space-y-4">
{lastProgress && !done && (
<div>
<div className="flex justify-between text-sm text-gray-300 mb-1">
<span>{lastProgress.step}</span>
<span>{lastProgress.current}/{lastProgress.total}</span>
</div>
<div className="w-full bg-slate-700 rounded-full h-2">
<div className="bg-indigo-600 h-2 rounded-full transition-all"
style={{ width: `${(lastProgress.current / lastProgress.total) * 100}%` }} />
</div>
<p className="text-xs text-gray-400 mt-1 truncate">{lastProgress.tab} {lastProgress.title}</p>
</div>
)}
{skipped.length > 0 && (
<div className="space-y-1">
{skipped.map((e, i) => (
<div key={i} className="flex gap-2 p-2 bg-amber-900/20 border border-amber-800/40 rounded text-xs text-amber-300">
<span></span>
<span className="font-medium">{e.title}</span>
<span className="text-amber-500"> SKU {e.sku} déjà présent dans Shopify, ignoré</span>
</div>
))}
</div>
)}
{errors.length > 0 && (
<div className="space-y-1">
{errors.map((e, i) => (
<div key={i} className="flex gap-2 p-2 bg-red-900/30 rounded text-xs text-red-300">
<span className="font-medium">{e.title}</span>
<span className="text-red-400"> {e.message}</span>
</div>
))}
</div>
)}
{doneEvent && (
<div className={`p-4 rounded-lg ${doneEvent.summary.status === 'success' ? 'bg-green-900/30 border border-green-700' : doneEvent.summary.status === 'partial' ? 'bg-yellow-900/30 border border-yellow-700' : 'bg-red-900/30 border border-red-700'}`}>
<p className={`font-semibold ${doneEvent.summary.status === 'success' ? 'text-green-300' : doneEvent.summary.status === 'partial' ? 'text-yellow-300' : 'text-red-300'}`}>
{doneEvent.summary.status === 'success' ? '✓ Terminé' : doneEvent.summary.status === 'partial' ? '⚠ Partiel' : '✗ Erreur'}
</p>
<p className="text-sm text-gray-300 mt-1">
{doneEvent.summary.created} créé{doneEvent.summary.created > 1 ? 's' : ''}
{doneEvent.summary.skipped > 0 && `${doneEvent.summary.skipped} déjà existant${doneEvent.summary.skipped > 1 ? 's' : ''} (ignorés)`}
{doneEvent.summary.errors > 0 && `${doneEvent.summary.errors} erreur${doneEvent.summary.errors > 1 ? 's' : ''}`}
</p>
</div>
)}
{!done && !lastProgress && <p className="text-gray-400 text-sm">Démarrage</p>}
</div>
)
}
@@ -0,0 +1,219 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import type { MetafieldDefinition, MetafieldResolution } from '@/types/creation'
import { normalizeMetafieldKey } from '@/lib/shopifyMetafields'
interface Props {
specificColumns: string[]
onResolved: (resolutions: MetafieldResolution[]) => void
}
export function MetafieldResolver({ specificColumns, onResolved }: Props) {
const [definitions, setDefinitions] = useState<MetafieldDefinition[]>([])
const [matched, setMatched] = useState<Record<string, MetafieldResolution>>({})
const [unmatched, setUnmatched] = useState<string[]>([])
const [resolutions, setResolutions] = useState<Record<string, MetafieldResolution>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [searches, setSearches] = useState<Record<string, string>>({})
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (specificColumns.length === 0) { setLoading(false); onResolved([]); return }
const params = specificColumns.map(c => encodeURIComponent(c)).join(',')
Promise.all([
fetch(`/api/creation/metafields?columns=${params}`).then(r => r.json()),
fetch('/api/creation/resolutions').then(r => r.json()),
])
.then(([data, cache]: [{ error?: string; definitions: MetafieldDefinition[]; matched: Record<string, MetafieldResolution>; unmatched: string[] }, Record<string, MetafieldResolution>]) => {
if (data.error) throw new Error(data.error)
setDefinitions(data.definitions)
setMatched(data.matched)
setUnmatched(data.unmatched)
const init: Record<string, MetafieldResolution> = { ...data.matched }
data.unmatched.forEach((col: string) => {
// Utilise le cache persisté si disponible, sinon 'skip' par défaut
init[col] = cache[col] ?? { columnHeader: col, action: 'skip', namespace: 'custom', key: normalizeMetafieldKey(col) }
})
setResolutions(init)
})
.catch(e => setError((e as Error).message))
.finally(() => setLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [specificColumns.join(',')])
useEffect(() => {
if (!loading) onResolved(Object.values(resolutions))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resolutions, loading])
const updateResolution = (col: string, partial: Partial<MetafieldResolution>) => {
setResolutions(prev => {
const next = { ...prev, [col]: { ...prev[col], ...partial } }
// Sauvegarde en différé pour éviter un appel par frappe
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
saveTimeoutRef.current = setTimeout(() => {
fetch('/api/creation/resolutions', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(next),
}).catch(() => null)
}, 800)
return next
})
}
const filteredDefs = (col: string) => {
const q = (searches[col] ?? '').toLowerCase()
if (!q) return definitions
return definitions.filter(d =>
d.name.toLowerCase().includes(q) ||
d.key.toLowerCase().includes(q) ||
d.namespace.toLowerCase().includes(q)
)
}
if (loading) return <p className="text-gray-400 text-sm">Analyse des métachamps Shopify</p>
if (error) return <p className="text-red-400 text-sm">{error}</p>
if (specificColumns.length === 0) return <p className="text-gray-400 text-sm">Aucune colonne spécifique détectée.</p>
return (
<div className="space-y-4">
{Object.entries(matched).length > 0 && (
<div>
<h3 className="text-sm font-medium text-green-400 mb-2">
{Object.entries(matched).length} colonne{Object.entries(matched).length > 1 ? 's' : ''} avec correspondance automatique
</h3>
<div className="space-y-1">
{Object.entries(matched).map(([col, res]) => (
<div key={col} className="flex items-center gap-3 px-3 py-2 bg-slate-700/50 rounded text-sm">
<span className="text-gray-300 flex-1">{col}</span>
<span className="text-green-400 text-xs"> {res.namespace}.{res.key}</span>
</div>
))}
</div>
</div>
)}
{unmatched.length > 0 && (
<div>
<div className="mb-3 p-3 bg-slate-700/50 rounded-lg border border-slate-600">
<p className="text-xs text-gray-400">
Ces colonnes n&apos;ont pas de métachamp correspondant dans Shopify.
Choisissez pour chacune : <span className="text-indigo-300 font-medium">Créer nouveau</span> (nouveau métachamp Shopify),
<span className="text-blue-300 font-medium"> Associer</span> (utiliser un existant), ou
<span className="text-gray-300 font-medium"> Ignorer</span> (ne pas synchroniser).
</p>
</div>
<div className="space-y-2">
{unmatched.map(col => {
const res = resolutions[col]
if (!res) return null
return (
<div key={col} className={`p-3 rounded-lg space-y-2 border ${res.action === 'skip' ? 'bg-slate-700/40 border-slate-700' : res.action === 'create' ? 'bg-indigo-900/20 border-indigo-700/50' : 'bg-blue-900/20 border-blue-700/50'}`}>
<div className="flex items-center justify-between">
<p className="text-sm text-white font-medium">{col}</p>
{res.action === 'create' && <span className="text-xs text-indigo-300"> Sera créé dans Shopify</span>}
{res.action === 'map' && <span className="text-xs text-blue-300"> Associé à un existant</span>}
{res.action === 'skip' && <span className="text-xs text-gray-500"> Ignoré</span>}
</div>
<div className="flex gap-2">
{(['create', 'map', 'skip'] as const).map(action => (
<button
key={action}
type="button"
onClick={() => updateResolution(col, { action })}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
res.action === action
? action === 'create' ? 'bg-indigo-600 text-white'
: action === 'map' ? 'bg-blue-600 text-white'
: 'bg-slate-500 text-white'
: 'bg-slate-600 text-gray-400 hover:bg-slate-500 hover:text-gray-200'
}`}
>
{action === 'create' ? '+ Créer nouveau' : action === 'map' ? '↔ Associer existant' : '✕ Ignorer'}
</button>
))}
</div>
{res.action === 'create' && (
<div className="flex gap-2 mt-1">
<div className="flex-1">
<label className="block text-xs text-gray-400 mb-1">Clé Shopify</label>
<input
type="text"
value={res.key}
onChange={e => updateResolution(col, { key: e.target.value })}
placeholder="ex: puissance"
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
<div className="w-28">
<label className="block text-xs text-gray-400 mb-1">Namespace</label>
<input
type="text"
value={res.namespace}
onChange={e => updateResolution(col, { namespace: e.target.value })}
placeholder="custom"
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
</div>
)}
{res.action === 'map' && (
<div className="relative" ref={openDropdown === col ? dropdownRef : undefined}>
<label className="block text-xs text-gray-400 mb-1">Métachamp existant</label>
{res.existingDefinitionId ? (
<div className="flex items-center gap-2">
<span className="flex-1 text-xs text-blue-300 bg-slate-600 border border-slate-500 rounded px-2 py-1 truncate">
{definitions.find(d => d.id === res.existingDefinitionId)?.name ?? res.key} ({res.namespace}.{res.key})
</span>
<button type="button" onClick={() => { updateResolution(col, { existingDefinitionId: undefined, key: normalizeMetafieldKey(col), namespace: 'custom' }); setSearches(p => ({ ...p, [col]: '' })); setOpenDropdown(col) }} className="text-xs text-gray-400 hover:text-white px-1"></button>
</div>
) : (
<div>
<input
type="text"
autoFocus
value={searches[col] ?? ''}
onChange={e => { setSearches(p => ({ ...p, [col]: e.target.value })); setOpenDropdown(col) }}
onFocus={() => setOpenDropdown(col)}
placeholder="Rechercher un métachamp…"
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
{openDropdown === col && (
<div className="absolute z-10 mt-1 w-full bg-slate-700 border border-slate-600 rounded shadow-lg max-h-48 overflow-y-auto">
{filteredDefs(col).length === 0 ? (
<p className="text-xs text-gray-400 px-3 py-2">Aucun résultat</p>
) : filteredDefs(col).map(d => (
<button
key={d.id}
type="button"
onMouseDown={() => { updateResolution(col, { existingDefinitionId: d.id, key: d.key, namespace: d.namespace, type: d.type }); setOpenDropdown(null) }}
className="w-full text-left px-3 py-1.5 text-xs text-white hover:bg-slate-600 flex justify-between gap-2"
>
<span>{d.name}</span>
<span className="text-gray-400 shrink-0">{d.namespace}.{d.key}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
)
})}
</div>
{/* Résumé des actions */}
<div className="mt-3 flex gap-4 text-xs text-gray-400">
<span className="text-indigo-300 font-medium">{Object.values(resolutions).filter(r => r.action === 'create').length} à créer</span>
<span className="text-blue-300 font-medium">{Object.values(resolutions).filter(r => r.action === 'map').length} à associer</span>
<span>{Object.values(resolutions).filter(r => r.action === 'skip').length} ignorés</span>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,54 @@
'use client'
import type { PendingProduct } from '@/types/creation'
interface Props {
byTab: Record<string, PendingProduct[]>
total: number
}
export function PendingRowsTable({ byTab, total }: Props) {
if (total === 0) {
return (
<div className="text-center py-12 text-gray-400 text-sm">
Aucun produit en attente (Publié=1 et ID vide).
</div>
)
}
return (
<div className="space-y-6">
{Object.entries(byTab).map(([tab, products]) => (
<div key={tab}>
<h3 className="text-sm font-semibold text-indigo-300 mb-2 uppercase tracking-wide">
{tab} {products.length} produit{products.length > 1 ? 's' : ''}
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-400 border-b border-slate-700">
<th className="pb-2 pr-4 font-medium">SKU</th>
<th className="pb-2 pr-4 font-medium">Titre</th>
<th className="pb-2 pr-4 font-medium">Prix</th>
<th className="pb-2 pr-4 font-medium">Stock</th>
<th className="pb-2 font-medium">Métachamps</th>
</tr>
</thead>
<tbody>
{products.map((p) => (
<tr key={`${p.tab}-${p.rowNumber}`} className="border-b border-slate-700/50">
<td className="py-2 pr-4 font-mono text-indigo-300 text-xs">{p.sku}</td>
<td className="py-2 pr-4 text-white">{p.title}</td>
<td className="py-2 pr-4 text-gray-300">{p.priceActual} </td>
<td className="py-2 pr-4 text-gray-300">{p.stock}</td>
<td className="py-2 text-gray-400 text-xs">
{Object.keys(p.specificFields).length} champ{Object.keys(p.specificFields).length > 1 ? 's' : ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
)
}
+141 -15
View File
@@ -4,23 +4,32 @@ import type { ImageItem } from '@/types/images'
interface ImageCardProps {
item: ImageItem
selected?: boolean
onSelect?: (id: string, selected: boolean) => void
onFeatherChange: (id: string, value: number) => void
onAlphaThresholdChange: (id: string, value: number) => void
onRotate: (id: string, direction: 'cw' | 'ccw') => void
onUpload: (id: string) => void
onRemove: (id: string) => void
onOpenLightbox: (item: ImageItem) => void
onAssignProduct: (id: string, shopifyProductId: string, shopifyProductTitle: string) => void
onReprocess: (id: string) => void
onDetour: (id: string) => void
onMockup: (id: string) => 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' },
pending: { label: '⏳ En attente', color: 'text-slate-400' },
converting: { label: '🔄 Conversion HEIC', color: 'text-yellow-400' },
searching: { label: '🔍 Recherche…', color: 'text-purple-400' },
converted: { label: '✓ Prêt (sans détourage)', color: 'text-amber-400' },
processing: { label: '🤖 Détourage IA', color: 'text-blue-400' },
ready: { label: '✓ Détouré', color: 'text-green-400' },
uploading: { label: '⬆️ Upload', color: 'text-blue-400' },
uploaded: { label: '✅ Uploadé', color: 'text-green-500' },
skipped: { label: '⏭️ Déjà sur Shopify', color: 'text-slate-400' },
not_found: { label: '❌ Réf. introuvable', color: 'text-red-400' },
error: { label: '❌ Erreur', color: 'text-red-400' },
}
function BlobThumbnail({ blob, alt }: { blob: Blob; alt: string }) {
@@ -36,30 +45,102 @@ function BlobThumbnail({ blob, alt }: { blob: Blob; alt: string }) {
return <img src={url} alt={alt} className="w-full h-full object-contain" />
}
function ProductSearch({ itemId, onAssign }: { itemId: string; onAssign: (id: string, shopifyProductId: string, title: string) => void }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState<{ shopifyId: string; title: string }[]>([])
const [loading, setLoading] = useState(false)
async function search() {
if (!query.trim()) return
setLoading(true)
try {
const res = await fetch(`/api/products/search?ref=${encodeURIComponent(query.trim())}`)
const data = await res.json()
if (data.found) setResults([{ shopifyId: data.shopifyId, title: data.title }])
else setResults([])
} finally {
setLoading(false)
}
}
return (
<div className="flex flex-col gap-1.5 mt-1">
<div className="flex gap-1">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && search()}
placeholder="Rechercher un produit…"
className="flex-1 px-2 py-1 text-xs bg-slate-800 border border-slate-600 rounded text-slate-200 placeholder-slate-500 focus:outline-none focus:border-indigo-500"
/>
<button
onClick={search}
disabled={loading}
className="px-2 py-1 text-xs bg-slate-700 hover:bg-slate-600 rounded text-slate-300 disabled:opacity-50"
>
{loading ? '…' : '🔍'}
</button>
</div>
{results.length === 0 && !loading && query && (
<p className="text-xs text-slate-500">Aucun résultat</p>
)}
{results.map((r) => (
<button
key={r.shopifyId}
onClick={() => onAssign(itemId, r.shopifyId, r.title)}
className="text-left px-2 py-1.5 text-xs bg-slate-800 hover:bg-indigo-900 border border-slate-600 hover:border-indigo-500 rounded text-slate-200 truncate"
>
{r.title}
</button>
))}
</div>
)
}
export function ImageCard({
item,
selected = false,
onSelect,
onFeatherChange,
onAlphaThresholdChange,
onRotate,
onUpload,
onRemove,
onOpenLightbox,
onAssignProduct,
onReprocess,
onDetour,
onMockup,
}: 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
const canUpload = (item.status === 'ready' || item.status === 'converted') && !!item.shopifyProductId
const canDetour = (item.status === 'converted' || item.status === 'error' || item.status === 'not_found') && !isProcessing
return (
<div className="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden flex flex-col">
<div className={`bg-slate-900 border rounded-xl overflow-hidden flex flex-col ${selected ? 'border-indigo-500 ring-1 ring-indigo-500' : 'border-slate-700'}`}>
{/* Miniatures avant / après */}
<div
className="relative h-40 bg-slate-800 cursor-pointer"
onClick={() => onOpenLightbox(item)}
>
<div className="absolute inset-0">
{onSelect && (
<div className="absolute top-2 left-2 z-10" onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selected}
onChange={e => onSelect(item.id, e.target.checked)}
className="w-4 h-4 accent-indigo-500 cursor-pointer"
/>
</div>
)}
<div className={`absolute top-0 bottom-0 left-0 overflow-hidden ${item.processedBlob ? 'w-1/2' : 'w-full'}`}>
<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">
<div className="absolute top-0 bottom-0 right-0 w-1/2 overflow-hidden border-l-2 border-indigo-500">
<BlobThumbnail blob={item.processedBlob} alt="Traité" />
</div>
)}
@@ -80,7 +161,8 @@ export function ImageCard({
{/* 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>
<p className="text-xs font-mono text-slate-300 truncate" title={item.filename}>{item.filename}</p>
<p className="text-xs text-slate-500 truncate">Réf : {item.ref}</p>
{item.shopifyProductTitle && (
<p className="text-xs text-slate-500 truncate">{item.shopifyProductTitle}</p>
)}
@@ -88,7 +170,11 @@ export function ImageCard({
{item.error && <p className="text-xs text-red-400 mt-0.5 truncate" title={item.error}>{item.error}</p>}
</div>
{/* Contrôles feather + rotation */}
{item.status === 'not_found' && (
<ProductSearch itemId={item.id} onAssign={onAssignProduct} />
)}
{/* Contrôles feather + tolérance + rotation */}
<div className={canAdjust ? '' : 'opacity-40 pointer-events-none'}>
<label className="block text-xs text-slate-500 mb-1">
Bords : {item.feather} px
@@ -103,6 +189,19 @@ export function ImageCard({
className="w-full accent-indigo-500"
/>
<label className="block text-xs text-slate-500 mb-1 mt-2">
Tolérance verre : {item.alphaThreshold}
</label>
<input
type="range"
min={0}
max={100}
step={5}
value={item.alphaThreshold}
onChange={(e) => onAlphaThresholdChange(item.id, Number(e.target.value))}
className="w-full accent-amber-500"
/>
<div className="flex gap-1 mt-2">
<button
onClick={() => onRotate(item.id, 'ccw')}
@@ -117,7 +216,7 @@ export function ImageCard({
{/* Boutons d'action */}
<div className="flex gap-2 mt-auto pt-1">
{item.status === 'ready' && (
{canUpload && (
<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"
@@ -125,6 +224,33 @@ export function ImageCard({
Uploader
</button>
)}
{canDetour && (
<button
onClick={() => onDetour(item.id)}
title="Lancer le détourage IA"
className="flex-1 py-1.5 text-xs bg-purple-700 hover:bg-purple-600 text-white font-semibold rounded"
>
Détourer
</button>
)}
{(item.status === 'ready' || item.status === 'uploaded' || item.status === 'skipped' || item.status === 'converted') && (
<button
onClick={() => onMockup(item.id)}
title="Créer un mockup"
className="py-1.5 px-2 text-xs bg-slate-800 hover:bg-teal-900 text-slate-400 hover:text-teal-300 rounded"
>
🖼
</button>
)}
{(item.status === 'ready' || item.status === 'uploaded' || item.status === 'skipped' || item.status === 'error') && item.rawPngBlob && (
<button
onClick={() => onReprocess(item.id)}
title="Relancer le détourage IA"
className="py-1.5 px-2 text-xs bg-slate-800 hover:bg-amber-900 text-slate-400 hover:text-amber-300 rounded"
>
🔄
</button>
)}
{item.status === 'uploaded' && item.uploadedUrl && (
<a
href={item.uploadedUrl}
+3
View File
@@ -15,6 +15,9 @@ const NAV_ITEMS: NavItem[] = [
{ href: '/produits', label: 'Produits', icon: '📦' },
{ href: '/images', label: 'Images', icon: '🖼️' },
{ href: '/sync', label: 'Sync Sheets', icon: '🔄' },
{ href: '/creation', label: 'Création', icon: '✨' },
{ href: '/mockup', label: 'Mockups', icon: '🖼️' },
{ href: '/bannieres', label: 'Bannières', icon: '🎨' },
{ href: '/parametres', label: 'Paramètres', icon: '⚙️', adminOnly: true },
]
+170
View File
@@ -0,0 +1,170 @@
'use client'
import { useState, useEffect } from 'react'
import type { MockupTemplate } from '@/types/mockup'
interface Props {
productCutoutBase64: string
productName: string
shopifyId?: string
family: string
dimensions?: string
onUploaded?: (url: string) => void
}
export function MockupGenerator({ productCutoutBase64, productName, shopifyId, family, dimensions, onUploaded }: Props) {
const [templates, setTemplates] = useState<MockupTemplate[]>([])
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null)
const [instruction, setInstruction] = useState('')
const [generating, setGenerating] = useState(false)
const [resultB64, setResultB64] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [uploadedUrl, setUploadedUrl] = useState<string | null>(null)
useEffect(() => {
fetch('/api/mockup/templates')
.then(r => r.json())
.then((data: MockupTemplate[]) => {
const filtered = data.filter(t => !family || t.family === family || t.family === 'Tous')
setTemplates(filtered.length > 0 ? filtered : data)
})
}, [family])
const generate = async () => {
if (!selectedTemplate) return
setGenerating(true)
setError(null)
setResultB64(null)
try {
const res = await fetch('/api/mockup/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
templateId: selectedTemplate,
productImageBase64: productCutoutBase64,
productName,
family,
dimensions: dimensions || undefined,
instruction: instruction || undefined,
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
setResultB64(data.imageBase64)
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur')
} finally {
setGenerating(false)
}
}
const upload = async () => {
if (!resultB64 || !shopifyId) return
setUploading(true)
try {
const filename = `${productName.toLowerCase().replace(/\s+/g, '-')}-mockup.jpg`
const res = await fetch('/api/images/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shopifyProductId: shopifyId, imageBase64: resultB64, filename }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
setUploadedUrl(data.url)
onUploaded?.(data.url)
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur upload')
} finally {
setUploading(false)
}
}
return (
<div className="space-y-4">
<div>
<label className="block text-xs text-gray-400 mb-2">Template de mise en ambiance</label>
{templates.length === 0 ? (
<p className="text-xs text-gray-500">Aucun template disponible ajoutez-en dans la bibliothèque.</p>
) : (
<div className="grid grid-cols-2 gap-2">
{templates.map(t => (
<button
key={t.id}
type="button"
onClick={() => setSelectedTemplate(t.id)}
className={`relative rounded-lg overflow-hidden border-2 transition-all ${selectedTemplate === t.id ? 'border-indigo-500' : 'border-slate-600 hover:border-slate-400'}`}
>
<img
src={`/api/mockup/templates/image/${t.filename}`}
alt={t.name}
className="w-full h-24 object-cover"
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-2 py-1">
<p className="text-xs text-white truncate">{t.name}</p>
</div>
</button>
))}
</div>
)}
</div>
{dimensions ? (
<p className="text-xs text-green-400">Dimensions : {dimensions}</p>
) : (
<p className="text-xs text-amber-400">Dimensions non renseignées le mockup peut ne pas être à l&apos;échelle.</p>
)}
<button
type="button"
onClick={generate}
disabled={!selectedTemplate || generating}
className="w-full py-2 px-4 rounded-lg bg-indigo-600 text-white text-sm font-medium disabled:opacity-40 hover:bg-indigo-500 transition-colors"
>
{generating ? 'Génération en cours…' : 'Générer le mockup'}
</button>
{error && <p className="text-xs text-red-400">{error}</p>}
{resultB64 && (
<div className="space-y-3">
<img
src={`data:image/jpeg;base64,${resultB64}`}
alt="Mockup généré"
className="w-full rounded-lg border border-slate-600"
/>
<div className="flex gap-2">
<input
type="text"
value={instruction}
onChange={e => setInstruction(e.target.value)}
placeholder="Instruction supplémentaire (optionnel)…"
className="flex-1 bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
<button
type="button"
onClick={generate}
disabled={generating}
className="px-3 py-1.5 rounded bg-slate-600 text-xs text-white hover:bg-slate-500 disabled:opacity-40"
>
{generating ? '…' : '↺ Regénérer'}
</button>
</div>
{uploadedUrl ? (
<p className="text-xs text-green-400"> Uploadé sur Shopify</p>
) : (
<button
type="button"
onClick={upload}
disabled={uploading || !shopifyId}
className="w-full py-2 px-4 rounded-lg bg-emerald-600 text-white text-sm font-medium disabled:opacity-40 hover:bg-emerald-500 transition-colors"
>
{uploading ? 'Upload…' : shopifyId ? '↑ Uploader sur Shopify' : 'Produit Shopify requis pour uploader'}
</button>
)}
</div>
)}
</div>
)
}
+348
View File
@@ -0,0 +1,348 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import type { CatalogProduct } from '@/types/products'
import type { MockupTemplate } from '@/types/mockup'
interface Props {
product: CatalogProduct
onClose: () => void
}
interface Metafield {
namespace: string
key: string
value: string
type: string
}
async function urlToBase64(url: string): Promise<string> {
const res = await fetch(`/api/images/proxy?url=${encodeURIComponent(url)}`)
if (!res.ok) throw new Error('Impossible de charger l\'image')
const blob = await res.blob()
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve((reader.result as string).split(',')[1])
reader.onerror = reject
reader.readAsDataURL(blob)
})
}
export function MockupModal({ product, onClose }: Props) {
const [templates, setTemplates] = useState<MockupTemplate[]>([])
const [families, setFamilies] = useState<string[]>([])
const [metafields, setMetafields] = useState<Metafield[]>([])
const [loadingMeta, setLoadingMeta] = useState(true)
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null)
const [family, setFamily] = useState('')
const [placement, setPlacement] = useState<string>('')
const [selectedMeta, setSelectedMeta] = useState<string[]>([])
const [cutoutB64, setCutoutB64] = useState<string | null>(null)
const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>(null)
const [loadingImage, setLoadingImage] = useState(false)
const [instruction, setInstruction] = useState('')
const [generating, setGenerating] = useState(false)
const [resultB64, setResultB64] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [uploadedUrl, setUploadedUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
const templateFileRef = useRef<HTMLInputElement>(null)
const [uploadingTemplate, setUploadingTemplate] = useState(false)
useEffect(() => {
Promise.all([
fetch('/api/mockup/templates').then(r => r.json()),
fetch('/api/sheets/tabs').then(r => r.json()),
fetch(`/api/mockup/product-meta/${product.shopifyId}`).then(r => r.json()),
product.ref ? fetch(`/api/products/sheet-info?ref=${encodeURIComponent(product.ref)}`).then(r => r.ok ? r.json() : null) : Promise.resolve(null),
]).then(([tplData, tabsData, metaData, sheetData]) => {
setTemplates(tplData as MockupTemplate[])
const tabs: string[] = (tabsData.tabs ?? []).filter((t: string) =>
!['COLLECTIONS', 'LIVRAISONS', 'ECOPART', 'RECAP'].some(x => t.toUpperCase().includes(x))
)
setFamilies(tabs)
const sheetFamille = sheetData?.product?.famille
setFamily(sheetFamille && tabs.includes(sheetFamille) ? sheetFamille : tabs[0] ?? '')
setMetafields(metaData.metafields ?? [])
}).finally(() => setLoadingMeta(false))
}, [product.shopifyId, product.ref])
const isCarrelage = family.toLowerCase().includes('carrelage')
const filteredTemplates = templates.filter(t => {
if (t.family !== family && t.family !== 'Tous') return false
if (isCarrelage && placement && t.placement && t.placement !== placement) return false
return true
})
const dimensionStr = selectedMeta.length > 0
? selectedMeta.map(key => {
const mf = metafields.find(m => `${m.namespace}.${m.key}` === key)
return mf ? `${mf.key}: ${mf.value}` : ''
}).filter(Boolean).join(', ')
: undefined
const selectExistingImage = async (url: string) => {
setSelectedImageUrl(url)
setCutoutB64(null)
setLoadingImage(true)
setError(null)
try {
const b64 = await urlToBase64(url)
setCutoutB64(b64)
} catch {
setError('Impossible de charger cette image')
setSelectedImageUrl(null)
} finally {
setLoadingImage(false)
}
}
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setSelectedImageUrl(null)
const reader = new FileReader()
reader.onload = ev => setCutoutB64((ev.target?.result as string).split(',')[1])
reader.readAsDataURL(file)
}
const uploadTemplate = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploadingTemplate(true)
const form = new FormData()
form.append('name', file.name.replace(/\.[^.]+$/, ''))
form.append('family', family || 'Tous')
form.append('description', '')
form.append('image', file)
const res = await fetch('/api/mockup/templates', { method: 'POST', body: form })
const tpl = await res.json() as MockupTemplate
setTemplates(prev => [...prev, tpl])
setSelectedTemplate(tpl.id)
setUploadingTemplate(false)
}
const toggleMeta = (key: string) => {
setSelectedMeta(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key])
}
const generate = async () => {
if (!selectedTemplate || !cutoutB64) return
setGenerating(true)
setError(null)
setResultB64(null)
try {
const res = await fetch('/api/mockup/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
templateId: selectedTemplate,
productImageBase64: cutoutB64,
productName: product.title,
family: family || 'Tous',
placement: placement || undefined,
dimensions: dimensionStr,
instruction: instruction || undefined,
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
setResultB64(data.imageBase64)
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur')
} finally {
setGenerating(false)
}
}
const upload = async () => {
if (!resultB64) return
setUploading(true)
try {
const res = await fetch('/api/images/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shopifyProductId: product.shopifyId, imageBase64: resultB64, filename: `${product.ref}-mockup.jpg` }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
setUploadedUrl(data.url)
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur upload')
} finally {
setUploading(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="bg-slate-800 border border-slate-700 rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-700 sticky top-0 bg-slate-800 z-10">
<div>
<h2 className="text-white font-semibold text-sm">Créer un mockup</h2>
<p className="text-gray-400 text-xs mt-0.5">
{product.title} <span className="font-mono text-indigo-300">{product.ref}</span>
</p>
</div>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-white text-lg leading-none"></button>
</div>
<div className="p-4 space-y-5">
{/* 1. Image de base */}
<section>
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">1. Image du produit</h3>
{product.images.length > 0 && (
<div className="mb-2">
<p className="text-xs text-gray-400 mb-1.5">Images existantes</p>
<div className="flex gap-2 flex-wrap">
{product.images.map((url, i) => (
<button key={i} type="button" onClick={() => selectExistingImage(url)}
className={`relative rounded-lg overflow-hidden border-2 transition-all ${selectedImageUrl === url ? 'border-indigo-500' : 'border-slate-600 hover:border-slate-400'}`}>
<img src={url} alt={`Image ${i + 1}`} className="w-16 h-16 object-cover" />
{loadingImage && selectedImageUrl === url && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<span className="text-xs text-white"></span>
</div>
)}
</button>
))}
</div>
</div>
)}
<div>
<p className="text-xs text-gray-400 mb-1">Ou uploader une image découpée (PNG)</p>
<input ref={fileRef} type="file" accept="image/*" onChange={handleFileUpload} className="text-xs text-gray-300" />
</div>
{cutoutB64 && !loadingImage && (
<p className="text-xs text-green-400 mt-1"> Image prête</p>
)}
</section>
{/* 2. Famille + placement */}
<section>
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">2. Famille</h3>
{families.length > 0 ? (
<div className="space-y-2">
<select value={family} onChange={e => { setFamily(e.target.value); setPlacement('') }}
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
{families.map(f => <option key={f}>{f}</option>)}
</select>
{isCarrelage && (
<div>
<p className="text-xs text-gray-400 mb-1.5">Placement</p>
<div className="flex gap-2">
{[{ val: 'sol', label: 'Sol' }, { val: 'mur', label: 'Mur (faïence)' }].map(({ val, label }) => (
<button key={val} type="button" onClick={() => setPlacement(p => p === val ? '' : val)}
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors ${placement === val ? 'bg-indigo-600 text-white' : 'bg-slate-700 text-gray-300 hover:bg-slate-600'}`}>
{label}
</button>
))}
</div>
</div>
)}
</div>
) : (
<p className="text-xs text-gray-500">{loadingMeta ? 'Chargement…' : 'Aucune famille disponible'}</p>
)}
</section>
{/* 3. Caractéristiques (metafields) */}
{metafields.length > 0 && (
<section>
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">3. Caractéristiques à inclure dans le prompt</h3>
<div className="space-y-1 max-h-36 overflow-y-auto">
{metafields.map(mf => {
const key = `${mf.namespace}.${mf.key}`
return (
<label key={key} className="flex items-center gap-2 cursor-pointer group">
<input type="checkbox" checked={selectedMeta.includes(key)} onChange={() => toggleMeta(key)}
className="rounded border-slate-600 bg-slate-700 text-indigo-500 focus:ring-indigo-500" />
<span className="text-xs text-gray-300 group-hover:text-white">
<span className="font-medium">{mf.key}</span>
<span className="text-gray-500 ml-1">{mf.value}</span>
</span>
</label>
)
})}
</div>
{dimensionStr && (
<p className="text-xs text-indigo-300 mt-1.5">Inclus dans le prompt : {dimensionStr}</p>
)}
</section>
)}
{/* 4. Template */}
<section>
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide">
{metafields.length > 0 ? '4.' : '3.'} Image de référence (template)
</h3>
<button type="button" onClick={() => templateFileRef.current?.click()}
disabled={uploadingTemplate}
className="text-xs text-indigo-400 hover:text-indigo-300 disabled:opacity-40">
{uploadingTemplate ? 'Upload…' : '+ Uploader une nouvelle'}
</button>
<input ref={templateFileRef} type="file" accept="image/*" className="hidden" onChange={uploadTemplate} />
</div>
{filteredTemplates.length === 0 ? (
<p className="text-xs text-gray-500">Aucun template pour cette famille. Uploadez-en un ci-dessus.</p>
) : (
<div className="grid grid-cols-4 gap-2">
{filteredTemplates.map(t => (
<button key={t.id} type="button" onClick={() => setSelectedTemplate(t.id)}
className={`relative rounded-lg overflow-hidden border-2 transition-all ${selectedTemplate === t.id ? 'border-indigo-500' : 'border-slate-600 hover:border-slate-400'}`}>
<img src={`/api/mockup/templates/image/${t.filename}`} alt={t.name} className="w-full h-16 object-cover" />
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5">
<p className="text-xs text-white truncate">{t.name}</p>
</div>
</button>
))}
</div>
)}
</section>
{/* Generate */}
<button type="button" onClick={generate}
disabled={!selectedTemplate || !cutoutB64 || generating || loadingImage}
className="w-full py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium disabled:opacity-40 hover:bg-indigo-500 transition-colors">
{generating ? 'Génération en cours…' : 'Générer le mockup'}
</button>
{error && <p className="text-xs text-red-400">{error}</p>}
{/* Result */}
{resultB64 && (
<div className="space-y-3">
<img src={`data:image/jpeg;base64,${resultB64}`} alt="Mockup" className="w-full rounded-lg border border-slate-600" />
<div className="flex gap-2">
<input type="text" value={instruction} onChange={e => setInstruction(e.target.value)}
placeholder="Instruction pour regénérer (optionnel)…"
className="flex-1 bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<button type="button" onClick={generate} disabled={generating}
className="px-3 py-1.5 rounded bg-slate-600 text-xs text-white hover:bg-slate-500 disabled:opacity-40">
{generating ? '…' : '↺ Regénérer'}
</button>
</div>
{uploadedUrl ? (
<p className="text-xs text-green-400"> Uploadé sur Shopify</p>
) : (
<button type="button" onClick={upload} disabled={uploading}
className="w-full py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium disabled:opacity-40 hover:bg-emerald-500 transition-colors">
{uploading ? 'Upload…' : '↑ Uploader sur Shopify'}
</button>
)}
</div>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,288 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import type { ImageItem } from '@/types/images'
import type { MockupTemplate } from '@/types/mockup'
interface Props {
item: ImageItem
onClose: () => void
}
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve((reader.result as string).split(',')[1])
reader.onerror = reject
reader.readAsDataURL(blob)
})
}
export function MockupModalFromImage({ item, onClose }: Props) {
const [templates, setTemplates] = useState<MockupTemplate[]>([])
const [families, setFamilies] = useState<string[]>([])
const [family, setFamily] = useState('')
const [dimensions, setDimensions] = useState('')
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null)
const [instruction, setInstruction] = useState('')
const [generating, setGenerating] = useState(false)
const [resultB64, setResultB64] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [uploadedUrl, setUploadedUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const templateFileRef = useRef<HTMLInputElement>(null)
const [uploadingTemplate, setUploadingTemplate] = useState(false)
useEffect(() => {
Promise.all([
fetch('/api/mockup/templates').then(r => r.json()),
fetch('/api/sheets/tabs').then(r => r.json()),
item.ref ? fetch(`/api/products/sheet-info?ref=${encodeURIComponent(item.ref)}`).then(r => r.ok ? r.json() : null) : Promise.resolve(null),
]).then(([tplData, tabsData, sheetData]) => {
setTemplates(tplData as MockupTemplate[])
const tabs: string[] = (tabsData.tabs ?? []).filter((t: string) =>
!['COLLECTIONS', 'LIVRAISONS', 'ECOPART', 'RECAP'].some(x => t.toUpperCase().includes(x))
)
setFamilies(tabs)
const sheetFamille = sheetData?.product?.famille
setFamily(sheetFamille && tabs.includes(sheetFamille) ? sheetFamille : tabs[0] ?? '')
const { longueur, largeur } = sheetData?.product ?? {}
if (longueur && largeur) setDimensions(`${longueur}x${largeur}cm`)
else if (longueur) setDimensions(`${longueur}cm`)
})
}, [item.ref])
const filteredTemplates = templates.filter(t =>
t.family === family || t.family === 'Tous'
)
const uploadTemplate = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploadingTemplate(true)
const form = new FormData()
form.append('name', file.name.replace(/\.[^.]+$/, ''))
form.append('family', family || 'Tous')
form.append('description', '')
form.append('image', file)
const res = await fetch('/api/mockup/templates', { method: 'POST', body: form })
const tpl = await res.json() as MockupTemplate
setTemplates(prev => [...prev, tpl])
setSelectedTemplate(tpl.id)
setUploadingTemplate(false)
}
const generate = async () => {
if (!selectedTemplate) return
setGenerating(true)
setError(null)
setResultB64(null)
try {
// Utilise l'image détourée si disponible, sinon l'originale
const sourceBlob = item.processedBlob ?? item.originalBlob
const productImageBase64 = await blobToBase64(sourceBlob)
const res = await fetch('/api/mockup/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
templateId: selectedTemplate,
productImageBase64,
productName: item.shopifyProductTitle ?? item.ref,
family: family || 'Tous',
dimensions: dimensions || undefined,
instruction: instruction || undefined,
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
setResultB64(data.imageBase64)
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur')
} finally {
setGenerating(false)
}
}
const upload = async () => {
if (!resultB64 || !item.shopifyProductId) return
setUploading(true)
try {
const res = await fetch('/api/images/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
shopifyProductId: item.shopifyProductId,
imageBase64: resultB64,
filename: `${item.ref}-mockup.jpg`,
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
setUploadedUrl(data.url)
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur upload')
} finally {
setUploading(false)
}
}
const downloadResult = () => {
if (!resultB64) return
const link = document.createElement('a')
link.href = `data:image/jpeg;base64,${resultB64}`
link.download = `${item.ref}-mockup.jpg`
link.click()
}
const sourceLabel = item.processedBlob ? 'image détourée' : 'image originale'
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
onClick={e => { if (e.target === e.currentTarget) onClose() }}
>
<div className="bg-slate-800 border border-slate-700 rounded-xl w-full max-w-xl max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-700 sticky top-0 bg-slate-800 z-10">
<div>
<h2 className="text-white font-semibold text-sm">Créer un mockup</h2>
<p className="text-gray-400 text-xs mt-0.5">
{item.shopifyProductTitle ?? item.ref}
<span className="ml-2 text-indigo-300 font-mono">{item.ref}</span>
<span className="ml-2 text-slate-500">· {sourceLabel}</span>
</p>
</div>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-white text-lg leading-none"></button>
</div>
<div className="p-4 space-y-5">
{/* Famille */}
<section>
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">Famille</h3>
<select
value={family}
onChange={e => setFamily(e.target.value)}
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
{families.map(f => <option key={f}>{f}</option>)}
</select>
</section>
{/* Template */}
<section>
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide">Template</h3>
<button
type="button"
onClick={() => templateFileRef.current?.click()}
disabled={uploadingTemplate}
className="text-xs text-indigo-400 hover:text-indigo-300 disabled:opacity-40"
>
{uploadingTemplate ? 'Upload…' : '+ Nouveau'}
</button>
<input ref={templateFileRef} type="file" accept="image/*" className="hidden" onChange={uploadTemplate} />
</div>
{filteredTemplates.length === 0 ? (
<p className="text-xs text-gray-500">Aucun template pour cette famille. Uploadez-en un.</p>
) : (
<div className="grid grid-cols-4 gap-2">
{filteredTemplates.map(t => (
<button
key={t.id}
type="button"
onClick={() => setSelectedTemplate(t.id)}
className={`relative rounded-lg overflow-hidden border-2 transition-all ${selectedTemplate === t.id ? 'border-indigo-500' : 'border-slate-600 hover:border-slate-400'}`}
>
<img src={`/api/mockup/templates/image/${t.filename}`} alt={t.name} className="w-full h-16 object-cover" />
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5">
<p className="text-xs text-white truncate">{t.name}</p>
</div>
</button>
))}
</div>
)}
</section>
{/* Instruction libre */}
<section>
<label className="block text-xs font-semibold text-gray-300 uppercase tracking-wide mb-1">
Instruction personnalisée <span className="text-gray-500 font-normal normal-case">(optionnel)</span>
</label>
<input
type="text"
value={instruction}
onChange={e => setInstruction(e.target.value)}
placeholder="Ex: fond beige, lumière naturelle, ambiance chaleureuse…"
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</section>
<button
type="button"
onClick={generate}
disabled={!selectedTemplate || generating}
className="w-full py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium disabled:opacity-40 hover:bg-indigo-500 transition-colors"
>
{generating ? 'Génération en cours…' : 'Générer le mockup'}
</button>
{error && <p className="text-xs text-red-400">{error}</p>}
{/* Résultat */}
{resultB64 && (
<div className="space-y-3">
<img
src={`data:image/jpeg;base64,${resultB64}`}
alt="Mockup"
className="w-full rounded-lg border border-slate-600"
/>
<div className="flex gap-2">
<input
type="text"
value={instruction}
onChange={e => setInstruction(e.target.value)}
placeholder="Modifier l'instruction et regénérer…"
className="flex-1 bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
<button
type="button"
onClick={generate}
disabled={generating}
className="px-3 py-1.5 rounded bg-slate-600 text-xs text-white hover:bg-slate-500 disabled:opacity-40"
>
{generating ? '…' : '↺'}
</button>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={downloadResult}
className="flex-1 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-white text-sm font-medium transition-colors"
>
Télécharger
</button>
{item.shopifyProductId ? (
uploadedUrl ? (
<p className="flex-1 py-2 text-center text-xs text-green-400 self-center"> Uploadé sur Shopify</p>
) : (
<button
type="button"
onClick={upload}
disabled={uploading}
className="flex-1 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium disabled:opacity-40 transition-colors"
>
{uploading ? 'Upload…' : '↑ Uploader sur Shopify'}
</button>
)
) : (
<p className="flex-1 py-2 text-center text-xs text-slate-500 self-center">Pas de produit associé</p>
)}
</div>
</div>
)}
</div>
</div>
</div>
)
}
+186
View File
@@ -0,0 +1,186 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import type { MockupTemplate } from '@/types/mockup'
const CARRELAGE_PLACEMENTS = ['sol', 'mur']
export function TemplateLibrary() {
const [templates, setTemplates] = useState<MockupTemplate[]>([])
const [families, setFamilies] = useState<string[]>(['Tous'])
const [loading, setLoading] = useState(true)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState('')
const [family, setFamily] = useState('Tous')
const [placement, setPlacement] = useState<string>('')
const [description, setDescription] = useState('')
const fileRef = useRef<HTMLInputElement>(null)
const load = () => {
setLoading(true)
Promise.all([
fetch('/api/mockup/templates').then(r => r.json()),
fetch('/api/sheets/tabs').then(r => r.json()),
]).then(([tplData, tabsData]) => {
setTemplates(tplData as MockupTemplate[])
const tabs: string[] = (tabsData.tabs ?? []).filter((t: string) =>
!['COLLECTIONS', 'LIVRAISONS', 'ECOPART', 'RECAP'].some(x => t.toUpperCase().includes(x))
)
const allFamilies = ['Tous', ...tabs]
setFamilies(allFamilies)
if (!allFamilies.includes(family)) setFamily(allFamilies[0])
}).finally(() => setLoading(false))
}
useEffect(load, [])
const isCarrelage = family.toLowerCase().includes('carrelage')
const upload = async (e: React.FormEvent) => {
e.preventDefault()
const file = fileRef.current?.files?.[0]
if (!file || !name || !family) return
if (isCarrelage && !placement) return
setUploading(true)
setError(null)
try {
const form = new FormData()
form.append('name', name)
form.append('family', family)
form.append('description', description)
if (isCarrelage && placement) form.append('placement', placement)
form.append('image', file)
const res = await fetch('/api/mockup/templates', { method: 'POST', body: form })
if (!res.ok) throw new Error((await res.json()).error)
setName(''); setDescription(''); setPlacement('')
if (fileRef.current) fileRef.current.value = ''
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur')
} finally {
setUploading(false)
}
}
const remove = async (id: string) => {
await fetch(`/api/mockup/templates/${id}`, { method: 'DELETE' })
load()
}
const allUsedFamilies = Array.from(new Set(templates.map(t => t.family)))
const displayFamilies = families.filter(f => allUsedFamilies.includes(f) || f === 'Tous')
const groupedTemplates = (familyName: string) => {
const byFamily = templates.filter(t => t.family === familyName)
const isCarr = familyName.toLowerCase().includes('carrelage')
if (!isCarr) return [{ label: null, items: byFamily }]
return CARRELAGE_PLACEMENTS.map(p => ({
label: p === 'mur' ? 'Mur (faïence)' : 'Sol',
items: byFamily.filter(t => t.placement === p),
})).filter(g => g.items.length > 0)
}
return (
<div className="space-y-6">
{/* Upload form */}
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700">
<h2 className="text-sm font-semibold text-white mb-3">Ajouter un template</h2>
<form onSubmit={upload} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-400 mb-1">Nom</label>
<input value={name} onChange={e => setName(e.target.value)}
placeholder="ex: Salle de bain moderne"
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500" />
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Famille</label>
<select value={family} onChange={e => { setFamily(e.target.value); setPlacement('') }}
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
{families.map(f => <option key={f}>{f}</option>)}
</select>
</div>
</div>
{isCarrelage && (
<div>
<label className="block text-xs text-gray-400 mb-1">Placement <span className="text-red-400">*</span></label>
<div className="flex gap-2">
{CARRELAGE_PLACEMENTS.map(p => (
<button key={p} type="button" onClick={() => setPlacement(p)}
className={`px-4 py-1.5 rounded text-sm font-medium transition-colors ${placement === p ? 'bg-indigo-600 text-white' : 'bg-slate-700 text-gray-300 hover:bg-slate-600'}`}>
{p === 'mur' ? 'Mur (faïence)' : 'Sol'}
</button>
))}
</div>
</div>
)}
<div>
<label className="block text-xs text-gray-400 mb-1">Description (optionnel)</label>
<input value={description} onChange={e => setDescription(e.target.value)}
placeholder="ex: Salle de bain lumineuse"
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500" />
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Image</label>
<input ref={fileRef} type="file" accept="image/*" className="text-sm text-gray-300" />
</div>
{error && <p className="text-xs text-red-400">{error}</p>}
<button type="submit" disabled={uploading || !name || !family || (isCarrelage && !placement)}
className="px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg disabled:opacity-40 hover:bg-indigo-500">
{uploading ? 'Ajout…' : '+ Ajouter'}
</button>
</form>
</div>
{/* Templates grouped by family */}
{loading ? (
<p className="text-sm text-gray-400">Chargement</p>
) : templates.length === 0 ? (
<p className="text-sm text-gray-500">Aucun template. Ajoutez-en un ci-dessus.</p>
) : (
<div className="space-y-6">
{displayFamilies.map(f => {
const groups = groupedTemplates(f)
const total = groups.reduce((n, g) => n + g.items.length, 0)
if (total === 0) return null
return (
<div key={f}>
<h3 className="text-sm font-medium text-gray-300 mb-3 flex items-center gap-2">
{f}
<span className="text-xs text-gray-500">{total} template{total > 1 ? 's' : ''}</span>
</h3>
<div className="space-y-4">
{groups.map((group, gi) => (
<div key={gi}>
{group.label && (
<p className="text-xs text-gray-400 font-medium mb-2 uppercase tracking-wide">{group.label}</p>
)}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{group.items.map(t => (
<div key={t.id} className="relative rounded-lg overflow-hidden border border-slate-600 group">
<img src={`/api/mockup/templates/image/${t.filename}`} alt={t.name}
className="w-full h-32 object-cover" />
<div className="p-2 bg-slate-800">
<p className="text-xs text-white font-medium truncate">{t.name}</p>
{t.description && <p className="text-xs text-gray-400 truncate">{t.description}</p>}
</div>
<button type="button" onClick={() => remove(t.id)}
className="absolute top-1 right-1 bg-red-600/80 hover:bg-red-500 text-white text-xs rounded px-1.5 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
</button>
</div>
))}
</div>
</div>
))}
</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>
)
}
+109
View File
@@ -0,0 +1,109 @@
'use client'
import { useState } from 'react'
import type { CatalogProduct } from '@/types/products'
import { MockupModal } from '@/components/mockup/MockupModal'
interface Props {
products: CatalogProduct[]
loading: boolean
}
const PAGE_SIZE = 50
export function ProductTable({ products, loading }: Props) {
const [mockupProduct, setMockupProduct] = useState<CatalogProduct | null>(null)
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">Images</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 pr-4 font-medium">Lien</th>
<th className="pb-3 font-medium"></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">
{p.images.length > 0 ? (
<div className="flex items-center gap-1.5">
<img src={p.images[0]} alt="" className="w-8 h-8 object-cover rounded border border-slate-600" />
{p.images.length > 1 && (
<span className="text-xs text-gray-400">+{p.images.length - 1}</span>
)}
</div>
) : (
<span className="text-xs text-gray-600"></span>
)}
</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 pr-4">
<a
href={p.shopifyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-400 hover:text-indigo-300 text-xs underline"
>
Shopify
</a>
</td>
<td className="py-3">
<button
type="button"
onClick={() => setMockupProduct(p)}
className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-indigo-600 text-gray-300 hover:text-white transition-colors"
>
Mockup
</button>
</td>
</tr>
))}
</tbody>
</table>
{mockupProduct && (
<MockupModal product={mockupProduct} onClose={() => setMockupProduct(null)} />
)}
{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>
)
}
+13 -8
View File
@@ -56,28 +56,33 @@ export function DiffTable({ diff }: DiffTableProps) {
{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">
<div className="border border-slate-700 rounded-xl overflow-x-auto">
<table className="w-full text-xs table-fixed">
<colgroup>
<col className="w-36" />
<col className="w-32" />
<col />
</colgroup>
<thead className="bg-slate-800 text-slate-400">
<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>
<th className="px-3 py-2 text-left">Détail</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}`}>
<td className="px-3 py-2 font-mono text-slate-300 truncate max-w-0" title={change.ref}>{change.ref}</td>
<td className={`px-3 py-2 font-semibold whitespace-nowrap ${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 key={f.field} className="inline-block mr-3">
<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="line-through text-red-400 mr-1">{f.from}</span>
<span className="text-slate-500 mr-1"></span>
<span className="text-green-400">{f.to}</span>
</span>
))}
+172 -38
View File
@@ -1,7 +1,29 @@
'use client'
import { useReducer, useCallback } from 'react'
import { useReducer, useCallback, useRef } from 'react'
import type { ImageItem, ImageStatus } from '@/types/images'
// File d'attente avec concurrence limitée pour le pipeline IA
const CONCURRENCY = 2
function createQueue() {
let running = 0
const queue: Array<() => Promise<void>> = []
function next() {
if (running >= CONCURRENCY || queue.length === 0) return
running++
const task = queue.shift()!
task().finally(() => { running--; next() })
}
return {
enqueue(task: () => Promise<void>) {
queue.push(task)
next()
},
}
}
// ── Reducer ───────────────────────────────────────────────────────────────────
type Action =
@@ -26,7 +48,10 @@ function reducer(state: ImageItem[], action: Action): ImageItem[] {
function refFromFilename(filename: string): string {
const dot = filename.lastIndexOf('.')
return dot === -1 ? filename : filename.slice(0, dot)
const base = dot === -1 ? filename : filename.slice(0, dot)
// 1. Supprime le suffixe de numérotation "-N" (ex: "151721-2" → "151721", "29679-G-1" → "29679-G")
// 2. Supprime les tirets/espaces résiduels en fin (ex: "27228-GRIS-" → "27228-GRIS", "26774-" → "26774")
return base.replace(/-\d+$/, '').replace(/[-_\s]+$/, '')
}
function blobToBase64(blob: Blob): Promise<string> {
@@ -34,7 +59,6 @@ function blobToBase64(blob: Blob): Promise<string> {
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)
}
@@ -47,6 +71,7 @@ function blobToBase64(blob: Blob): Promise<string> {
export function useImages() {
const [items, dispatch] = useReducer(reducer, [])
const queueRef = useRef(createQueue())
const update = useCallback((id: string, patch: Partial<ImageItem>) => {
dispatch({ type: 'UPDATE', id, patch })
@@ -57,11 +82,21 @@ export function useImages() {
async (id: string, ref: string) => {
update(id, { status: 'searching' })
try {
const sheetsRes = await fetch(`/api/products/search-sheets?ref=${encodeURIComponent(ref)}`)
const sheetsData = await sheetsRes.json()
if (sheetsData.found) {
update(id, {
status: 'converted',
shopifyProductId: sheetsData.shopifyId,
shopifyProductTitle: sheetsData.title,
})
return
}
const res = await fetch(`/api/products/search?ref=${encodeURIComponent(ref)}`)
const data = await res.json()
if (data.found) {
update(id, {
status: 'ready',
status: 'converted',
shopifyProductId: data.shopifyId,
shopifyProductTitle: data.title,
})
@@ -75,29 +110,40 @@ export function useImages() {
[update],
)
// Pipeline complet pour un item (avec ref)
const runPipeline = useCallback(
async (id: string, blob: Blob, ref: string, feather: number, rotation: number) => {
// Pipeline léger : conversion HEIC + recherche produit (sans détourage IA)
const runConvertAndSearch = useCallback(
async (id: string, blob: Blob, ref: string) => {
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)
update(id, { originalBlob: jpegBlob })
} catch (err) {
update(id, { status: 'error', error: `Conversion HEIC : ${(err as Error).message}` })
return
}
} else {
update(id, { status: 'searching' })
}
await searchProduct(id, ref)
},
[update, searchProduct],
)
// Pipeline IA complet : détourage + composite + rotation
const runBgRemoval = useCallback(
async (id: string, blob: Blob, feather: number, rotation: number, alphaThreshold: number) => {
const { removeBackgroundRaw, compositeOnWhite } = await import('@/lib/client/bgRemover')
const { rotateBlob } = await import('@/lib/client/imageRotator')
let rawPng: Blob
try {
update(id, { status: 'processing', progress: null })
rawPng = await removeBackgroundRaw(jpegBlob, (p) => update(id, { progress: p }))
rawPng = await removeBackgroundRaw(blob, (p) => update(id, { progress: p }))
} catch (err) {
update(id, { status: 'error', error: `Détourage IA : ${(err as Error).message}` })
return
@@ -105,28 +151,27 @@ export function useImages() {
let finalBlob: Blob
try {
const composited = await compositeOnWhite(rawPng, feather)
const composited = await compositeOnWhite(rawPng, feather, alphaThreshold)
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(id, { rawPngBlob: rawPng, processedBlob: finalBlob, status: 'ready' })
},
[update, searchProduct],
[update],
)
// Recomposite rapide (feather ou rotation changés) — ne relance pas l'IA
// Recomposite rapide (feather, alphaThreshold ou rotation changés) — ne relance pas l'IA
const recomposite = useCallback(
async (id: string, rawPngBlob: Blob, feather: number, rotation: number) => {
async (id: string, rawPngBlob: Blob, feather: number, rotation: number, alphaThreshold: number) => {
const { compositeOnWhite } = await import('@/lib/client/bgRemover')
const { rotateBlob } = await import('@/lib/client/imageRotator')
try {
const composited = await compositeOnWhite(rawPngBlob, feather)
const composited = await compositeOnWhite(rawPngBlob, feather, alphaThreshold)
const finalBlob = await rotateBlob(composited, rotation)
update(id, { processedBlob: finalBlob, feather, rotation })
update(id, { processedBlob: finalBlob, feather, rotation, alphaThreshold })
} catch (err) {
update(id, { status: 'error', error: `Retraitement : ${(err as Error).message}` })
}
@@ -152,6 +197,7 @@ export function useImages() {
processedBlob: null,
rotation: 0,
feather: 2,
alphaThreshold: 30,
shopifyProductId: null,
shopifyProductTitle: null,
uploadedUrl: null,
@@ -159,9 +205,13 @@ export function useImages() {
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)
const itemsWithRef = newItems.map(item => {
const basename = item.filename.split('/').pop() ?? item.filename
return { ...item, ref: refFromFilename(basename) }
})
dispatch({ type: 'ADD', items: itemsWithRef })
for (const item of itemsWithRef) {
queueRef.current.enqueue(() => runConvertAndSearch(item.id, item.originalBlob, item.ref))
}
} else {
const ref = refFromFilename(file.name)
@@ -174,6 +224,7 @@ export function useImages() {
processedBlob: null,
rotation: 0,
feather: 2,
alphaThreshold: 30,
shopifyProductId: null,
shopifyProductTitle: null,
uploadedUrl: null,
@@ -182,18 +233,51 @@ export function useImages() {
progress: null,
}
dispatch({ type: 'ADD', items: [newItem] })
runPipeline(newItem.id, newItem.originalBlob, newItem.ref, newItem.feather, newItem.rotation)
queueRef.current.enqueue(() => runConvertAndSearch(newItem.id, newItem.originalBlob, newItem.ref))
}
}
},
[runPipeline],
[runConvertAndSearch],
)
// Lancer le détourage IA sur une image (depuis statut converted ou error)
const detour = useCallback(
(id: string) => {
const item = items.find((i) => i.id === id)
if (!item) return
queueRef.current.enqueue(() =>
runBgRemoval(item.id, item.originalBlob, item.feather, item.rotation, item.alphaThreshold)
)
},
[items, runBgRemoval],
)
// Relancer le pipeline complet (conversion + détourage) sur une image déjà traitée
const reprocess = useCallback(
(id: string) => {
const item = items.find((i) => i.id === id)
if (!item) return
queueRef.current.enqueue(() =>
runBgRemoval(item.id, item.originalBlob, item.feather, item.rotation, item.alphaThreshold)
)
},
[items, runBgRemoval],
)
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)
recomposite(id, item.rawPngBlob, value, item.rotation, item.alphaThreshold)
},
[items, recomposite],
)
const setAlphaThreshold = useCallback(
(id: string, value: number) => {
const item = items.find((i) => i.id === id)
if (!item || !item.rawPngBlob) return
recomposite(id, item.rawPngBlob, item.feather, item.rotation, value)
},
[items, recomposite],
)
@@ -204,7 +288,7 @@ export function useImages() {
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)
recomposite(id, item.rawPngBlob, item.feather, newRotation, item.alphaThreshold)
},
[items, recomposite],
)
@@ -212,21 +296,48 @@ export function useImages() {
const uploadOne = useCallback(
async (id: string) => {
const item = items.find((i) => i.id === id)
if (!item || item.status !== 'ready' || !item.processedBlob || !item.shopifyProductId) return
if (!item) return
const canUpload = item.status === 'ready' || item.status === 'converted'
if (!canUpload || !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 }),
})
// Si détouré : utilise processedBlob ; sinon : redimensionne à 1000×1000
let uploadBlob: Blob
if (item.processedBlob) {
uploadBlob = item.processedBlob
} else {
const { resizeToSquare } = await import('@/lib/client/imageResizer')
uploadBlob = await resizeToSquare(item.originalBlob)
}
const base64 = await blobToBase64(uploadBlob)
const basename = item.filename.split('/').pop()?.replace(/\.[^.]+$/, '') ?? item.ref
const filename = `${basename}.jpg`
const doUpload = async (shopifyProductId: string) =>
fetch('/api/images/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shopifyProductId, imageBase64: base64, filename }),
})
let res = await doUpload(item.shopifyProductId)
// 404 = ID périmé → relancer la recherche par ref
if (res.status === 404) {
const searchRes = await fetch(`/api/products/search?ref=${encodeURIComponent(item.ref)}`)
const searchData = await searchRes.json()
if (searchData.found && searchData.shopifyId) {
update(id, { shopifyProductId: searchData.shopifyId, shopifyProductTitle: searchData.title })
res = await doUpload(searchData.shopifyId)
}
}
const data = await res.json()
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
update(id, { status: 'uploaded', uploadedUrl: data.url })
update(id, { status: data.skipped ? 'skipped' : 'uploaded', uploadedUrl: data.url })
} catch (err) {
update(id, { status: 'error', error: `Upload : ${(err as Error).message}` })
}
@@ -234,13 +345,36 @@ export function useImages() {
[items, update],
)
const uploadAll = useCallback(() => {
items.filter((i) => i.status === 'ready').forEach((i) => uploadOne(i.id))
const uploadAll = useCallback(async () => {
const ready = items.filter((i) => i.status === 'ready' || i.status === 'converted')
for (const item of ready) {
await uploadOne(item.id)
await new Promise(r => setTimeout(r, 500))
}
}, [items, uploadOne])
const uploadSelected = useCallback(async (ids: Set<string>) => {
const toUpload = items.filter(i =>
ids.has(i.id) && (i.status === 'ready' || i.status === 'converted') && !!i.shopifyProductId
)
for (const item of toUpload) {
await uploadOne(item.id)
await new Promise(r => setTimeout(r, 500))
}
}, [items, uploadOne])
const removeItem = useCallback((id: string) => {
dispatch({ type: 'REMOVE', id })
}, [])
return { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem }
const assignProduct = useCallback(
(id: string, shopifyProductId: string, shopifyProductTitle: string) => {
const item = items.find((i) => i.id === id)
const newStatus = item?.processedBlob ? 'ready' : 'converted'
update(id, { shopifyProductId, shopifyProductTitle, status: newStatus })
},
[items, update],
)
return { items, addFiles, setFeather, setAlphaThreshold, rotate, uploadOne, uploadAll, uploadSelected, removeItem, assignProduct, reprocess, detour }
}
+236
View File
@@ -0,0 +1,236 @@
// Rendu de bannières homepage sur canvas OffscreenCanvas
// Format : 1500×500 (paysage, ratio 3:1)
export const BANNER_WIDTH = 1500
export const BANNER_HEIGHT = 500
export type ColorScheme = 'dark-blue' | 'dark-green' | 'white' | 'red' | 'black'
export const COLOR_SCHEMES: Record<ColorScheme, {
label: string
bg: string
badgeBg: string
badgeText: string
titleColor: string
priceColor: string
strikePriceColor: string
subtitleColor: string
}> = {
'dark-blue': {
label: 'Bleu nuit',
bg: '#0f1c3f',
badgeBg: '#f0c040',
badgeText: '#1a1a1a',
titleColor: '#ffffff',
priceColor: '#ff7a2f',
strikePriceColor: '#8899bb',
subtitleColor: '#aabbdd',
},
'dark-green': {
label: 'Vert forêt',
bg: '#0f2b1e',
badgeBg: '#4caf50',
badgeText: '#ffffff',
titleColor: '#ffffff',
priceColor: '#ffcc00',
strikePriceColor: '#7aaa88',
subtitleColor: '#99cc99',
},
'white': {
label: 'Blanc',
bg: '#ffffff',
badgeBg: '#e53935',
badgeText: '#ffffff',
titleColor: '#1a1a2e',
priceColor: '#e53935',
strikePriceColor: '#aaaaaa',
subtitleColor: '#555566',
},
'red': {
label: 'Rouge',
bg: '#b71c1c',
badgeBg: '#ffeb3b',
badgeText: '#1a1a1a',
titleColor: '#ffffff',
priceColor: '#ffeb3b',
strikePriceColor: '#ff8a80',
subtitleColor: '#ffcdd2',
},
'black': {
label: 'Noir',
bg: '#111111',
badgeBg: '#e0e0e0',
badgeText: '#111111',
titleColor: '#ffffff',
priceColor: '#ffcc00',
strikePriceColor: '#666666',
subtitleColor: '#aaaaaa',
},
}
function wrapText(
ctx: OffscreenCanvasRenderingContext2D,
text: string,
maxWidth: number,
): string[] {
const words = text.split(' ')
const lines: string[] = []
let current = ''
for (const word of words) {
const test = current ? `${current} ${word}` : word
if (ctx.measureText(test).width > maxWidth && current) {
lines.push(current)
current = word
} else {
current = test
}
}
if (current) lines.push(current)
return lines
}
function roundRect(
ctx: OffscreenCanvasRenderingContext2D,
x: number, y: number, w: number, h: number, r: number,
) {
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.lineTo(x + w - r, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + r)
ctx.lineTo(x + w, y + h - r)
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
ctx.lineTo(x + r, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - r)
ctx.lineTo(x, y + r)
ctx.quadraticCurveTo(x, y, x + r, y)
ctx.closePath()
}
export interface BannerConfig {
scheme: ColorScheme
promoLabel: string // ex: "Destockage - Durée limitée"
title: string // ex: "PEINTURE FAÇADE PLIOLITE PEF PLIO BLANC MAT CECIL, 10 L"
price: string // ex: "79 €"
comparePrice: string // ex: "119 €" (barré)
productImageUrl: string | null
backgroundImageBlob?: Blob | null // image de fond uploadée (remplace la couleur unie)
}
export async function renderBanner(config: BannerConfig): Promise<Blob> {
const W = BANNER_WIDTH
const H = BANNER_HEIGHT
const canvas = new OffscreenCanvas(W, H)
const ctx = canvas.getContext('2d')!
const scheme = COLOR_SCHEMES[config.scheme]
// ── Fond ─────────────────────────────────────────────────────────────────
if (config.backgroundImageBlob) {
try {
const bgImg = await createImageBitmap(config.backgroundImageBlob)
// Cover : remplit tout le canvas en centrant
const scale = Math.max(W / bgImg.width, H / bgImg.height)
const dw = bgImg.width * scale
const dh = bgImg.height * scale
const dx = (W - dw) / 2
const dy = (H - dh) / 2
ctx.drawImage(bgImg, dx, dy, dw, dh)
bgImg.close()
// Overlay semi-transparent pour lisibilité du texte
ctx.fillStyle = 'rgba(0,0,0,0.35)'
ctx.fillRect(0, 0, W, H)
} catch {
ctx.fillStyle = scheme.bg
ctx.fillRect(0, 0, W, H)
}
} else {
ctx.fillStyle = scheme.bg
ctx.fillRect(0, 0, W, H)
}
// Zone texte : 55% gauche, zone image : 45% droite
const textZoneW = Math.round(W * 0.55)
const imageZoneX = textZoneW
const imageZoneW = W - textZoneW
const pad = 60
// ── Image produit ─────────────────────────────────────────────────────────
if (config.productImageUrl) {
try {
const res = await fetch(`/api/images/proxy?url=${encodeURIComponent(config.productImageUrl)}`)
const blob = await res.blob()
const img = await createImageBitmap(blob)
// Calcul pour centrer/contenir dans la zone droite avec padding
const maxW = imageZoneW - pad * 2
const maxH = H - pad * 2
const scale = Math.min(maxW / img.width, maxH / img.height)
const dw = img.width * scale
const dh = img.height * scale
const dx = imageZoneX + (imageZoneW - dw) / 2
const dy = (H - dh) / 2
ctx.drawImage(img, dx, dy, dw, dh)
img.close()
} catch {
// Image non chargeable — on continue sans
}
}
// ── Badge promo ───────────────────────────────────────────────────────────
let curY = 80
if (config.promoLabel.trim()) {
ctx.font = 'bold 26px Arial, sans-serif'
const badgeText = config.promoLabel.toUpperCase()
const metrics = ctx.measureText(badgeText)
const bw = metrics.width + 32
const bh = 44
ctx.fillStyle = scheme.badgeBg
roundRect(ctx, pad, curY, bw, bh, 8)
ctx.fill()
ctx.fillStyle = scheme.badgeText
ctx.textBaseline = 'middle'
ctx.fillText(badgeText, pad + 16, curY + bh / 2)
curY += bh + 28
}
// ── Titre produit ─────────────────────────────────────────────────────────
const titleMaxW = textZoneW - pad * 2
ctx.font = 'bold 52px Arial, sans-serif'
ctx.fillStyle = scheme.titleColor
ctx.textBaseline = 'top'
const titleLines = wrapText(ctx, config.title, titleMaxW)
const displayLines = titleLines.slice(0, 3) // max 3 lignes
for (const line of displayLines) {
ctx.fillText(line, pad, curY)
curY += 60
}
curY += 16
// ── Prix ──────────────────────────────────────────────────────────────────
if (config.price.trim()) {
ctx.font = 'bold 72px Arial, sans-serif'
ctx.fillStyle = scheme.priceColor
ctx.textBaseline = 'top'
ctx.fillText(config.price, pad, curY)
if (config.comparePrice.trim()) {
const priceW = ctx.measureText(config.price).width
ctx.font = '36px Arial, sans-serif'
ctx.fillStyle = scheme.strikePriceColor
const cpText = `Au lieu de ${config.comparePrice}`
const cpX = pad + priceW + 20
const cpY = curY + 32
ctx.fillText(cpText, cpX, cpY)
// Ligne de barrage
const cpW = ctx.measureText(cpText).width
ctx.strokeStyle = scheme.strikePriceColor
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(cpX, cpY + 22)
ctx.lineTo(cpX + cpW, cpY + 22)
ctx.stroke()
}
}
return canvas.convertToBlob({ type: 'image/jpeg', quality: 0.95 })
}
+28 -8
View File
@@ -1,6 +1,6 @@
const CDN_VERSION = '1.7.0'
const CDN_URL = `https://cdn.jsdelivr.net/npm/@imgly/background-removal@${CDN_VERSION}/dist/index.mjs`
const CDN_URL = 'https://cdn.jsdelivr.net/npm/@imgly/background-removal@1.7.0/dist/index.mjs'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let _removeBg: ((blob: Blob, opts: Record<string, unknown>) => Promise<Blob>) | null = null
async function loadLib() {
@@ -24,7 +24,6 @@ export async function removeBackgroundRaw(
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) => {
@@ -39,8 +38,9 @@ export async function removeBackgroundRaw(
* 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)
* @param alphaThreshold — pixels avec alpha > seuil deviennent opaques (0 = désactivé, ex: 30 pour récupérer le verre)
*/
export function compositeOnWhite(pngBlob: Blob, feather: number): Promise<Blob> {
export function compositeOnWhite(pngBlob: Blob, feather: number, alphaThreshold = 0): Promise<Blob> {
const objectUrl = URL.createObjectURL(pngBlob)
return new Promise((resolve, reject) => {
@@ -56,6 +56,16 @@ export function compositeOnWhite(pngBlob: Blob, feather: number): Promise<Blob>
fgCtx.drawImage(img, 0, 0)
// Remonte les pixels semi-transparents (ex: verre) à pleine opacité
if (alphaThreshold > 0) {
const imageData = fgCtx.getImageData(0, 0, width, height)
const data = imageData.data
for (let i = 3; i < data.length; i += 4) {
if (data[i] > alphaThreshold) data[i] = 255
}
fgCtx.putImageData(imageData, 0, 0)
}
if (feather > 0) {
fgCtx.globalCompositeOperation = 'destination-over'
fgCtx.filter = `blur(${feather}px)`
@@ -64,13 +74,23 @@ export function compositeOnWhite(pngBlob: Blob, feather: number): Promise<Blob>
fgCtx.globalCompositeOperation = 'source-over'
}
// Canvas de sortie 1000×1000 blanc, sujet centré avec padding de 5%
const SIZE = 1000
const PADDING = 0.05
const available = Math.round(SIZE * (1 - PADDING * 2))
const scale = Math.min(available / width, available / height)
const dw = Math.round(width * scale)
const dh = Math.round(height * scale)
const dx = Math.round((SIZE - dw) / 2)
const dy = Math.round((SIZE - dh) / 2)
const out = document.createElement('canvas')
out.width = width
out.height = height
out.width = SIZE
out.height = SIZE
const ctx = out.getContext('2d')!
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, width, height)
ctx.drawImage(fgCanvas, 0, 0)
ctx.fillRect(0, 0, SIZE, SIZE)
ctx.drawImage(fgCanvas, dx, dy, dw, dh)
URL.revokeObjectURL(objectUrl)
out.toBlob(
+86 -4
View File
@@ -1,10 +1,12 @@
/**
* Convertit un Blob HEIC/HEIF en Blob JPEG via heic2any.
* Convertit un Blob HEIC/HEIF en Blob JPEG.
* Tente d'abord la conversion native du navigateur (instantané sur Safari/macOS),
* puis repasse sur heic2any si le natif échoue (Chrome, Firefox).
* À utiliser uniquement côté client (navigateur).
*/
export async function convertHeicToJpeg(blob: Blob): Promise<Blob> {
if (!(blob instanceof Blob)) {
throw new TypeError('L\'entrée doit être un Blob')
throw new TypeError("L'entrée doit être un Blob")
}
const type = blob.type.toLowerCase()
@@ -12,14 +14,35 @@ export async function convertHeicToJpeg(blob: Blob): Promise<Blob> {
throw new TypeError(`Format attendu : HEIC/HEIF. Reçu : ${blob.type || '(vide)'}`)
}
// Méthode 1 : conversion native (Safari 14+, macOS Chrome 105+)
try {
const result = await convertNative(blob)
console.log('[heicConverter] ✅ Conversion native réussie')
return result
} catch (e) {
console.warn(`[heicConverter] Conversion native échouée (${(e as Error).message}), repasse sur heic2any…`)
}
// Méthode 2 : conversion serveur via /api/images/convert-heic (sharp, fiable sur tous les navigateurs)
try {
const result = await convertViaServer(blob)
console.log('[heicConverter] ✅ Conversion serveur réussie')
return result
} catch (e) {
console.warn(`[heicConverter] Conversion serveur échouée (${(e as Error).message}), repasse sur heic2any…`)
}
// Méthode 3 : heic2any fallback (plus lent, ~10-30s)
const heic2any = (await import('heic2any')).default
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout conversion HEIC (120s)')), 120_000),
setTimeout(
() => reject(new Error('Timeout conversion HEIC (30s). Essayez de convertir le fichier en JPG avant de le déposer.')),
30_000,
),
)
const conversion = heic2any({ blob, toType: 'image/jpeg', quality: 0.92 })
const result = await Promise.race([conversion, timeout])
const output = Array.isArray(result) ? result[0] : result
@@ -27,5 +50,64 @@ export async function convertHeicToJpeg(blob: Blob): Promise<Blob> {
throw new Error("heic2any n'a pas retourné un Blob")
}
console.log('[heicConverter] ✅ Conversion heic2any réussie')
return output
}
async function convertNative(blob: Blob): Promise<Blob> {
const typed = blob.type ? blob : new Blob([blob], { type: 'image/heic' })
// Tentative 1 : createImageBitmap (Safari 14+)
try {
const bitmap = await createImageBitmap(typed)
const canvas = document.createElement('canvas')
canvas.width = bitmap.width
canvas.height = bitmap.height
canvas.getContext('2d')!.drawImage(bitmap, 0, 0)
bitmap.close()
return await canvasToBlob(canvas)
} catch {
// Pas supporté — essai suivant
}
// Tentative 2 : <img> + canvas (Chrome sur macOS — utilise le décodeur HEIC natif de macOS)
return new Promise<Blob>((resolve, reject) => {
const url = URL.createObjectURL(typed)
const img = new Image()
img.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
canvas.getContext('2d')!.drawImage(img, 0, 0)
URL.revokeObjectURL(url)
canvasToBlob(canvas).then(resolve).catch(reject)
}
img.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('Décodage HEIC non supporté par ce navigateur'))
}
img.src = url
})
}
async function convertViaServer(blob: Blob): Promise<Blob> {
const formData = new FormData()
const file = blob instanceof File ? blob : new File([blob], 'image.heic', { type: 'image/heic' })
formData.append('file', file)
const res = await fetch('/api/images/convert-heic', { method: 'POST', body: formData })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
throw new Error(err.error ?? `HTTP ${res.status}`)
}
return res.blob()
}
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
b => (b ? resolve(b) : reject(new Error('canvas.toBlob a retourné null'))),
'image/jpeg',
0.92,
)
})
}
+15
View File
@@ -0,0 +1,15 @@
export async function resizeToSquare(blob: Blob, size = 1000): Promise<Blob> {
const img = await createImageBitmap(blob)
const canvas = new OffscreenCanvas(size, size)
const ctx = canvas.getContext('2d')!
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, size, size)
const scale = Math.min(size / img.width, size / img.height)
const w = img.width * scale
const h = img.height * scale
const x = (size - w) / 2
const y = (size - h) / 2
ctx.drawImage(img, x, y, w, h)
img.close()
return canvas.convertToBlob({ type: 'image/jpeg', quality: 0.92 })
}
+103
View File
@@ -0,0 +1,103 @@
import { parseTabRows, findColIndex, extractSpecificFields } from './creationSheets'
const HEADERS = [
'ID', 'Type', 'UGS', 'Nom', 'Publié', 'Mis en avant ?',
'Description courte', 'Description longue',
'Date de début de promo', 'Date de fin de promo',
'État de la TVA', 'Classe de TVA', 'En stock ?',
'Stock (en pièce)', 'Vendre individuellement ?',
'Prix marché', 'Prix remisé', 'Code ecopart', 'Ecopart montant',
'Famille', 'Sous famille',
"Classe d'expédition (en retrait, livraison)",
'Conditionnement', 'Surface (m2)', 'Poids (kg) fonction unité',
'Images', 'Unité', 'Marque', 'Neuf ?',
"Commentaire\nexemple : Modèle d'expo (lègères rayures)",
'Longueur (cm)', 'Largeur (cm)', 'Hauteur (cm)', 'Épaisseur (cm)',
'Poids par carton', 'Stock conditionné (en carton)',
'Fabrication', 'Garantie', 'Coloris',
'Puissance (AN)', 'Batterie (AP)', 'Couple (AQ)',
'PHOTO ?', 'ID FAMILLE', 'ID SOUS FAMILLE',
]
const makeRow = (overrides: Record<number, string> = {}): string[] => {
const base: Record<number, string> = {
0: '', // ID vide = à créer
2: 'SKU-001', // UGS
3: 'Perceuse Test',
4: '1', // Publié
13: '5', // Stock
15: '99.99', // Prix marché
16: '79.99', // Prix remisé
19: 'OUTILLAGE',
24: '2.5', // Poids
27: 'BOSCH', // Marque
39: '1500W', // Puissance
40: 'Li-Ion', // Batterie
// Couple (index 41) laissé vide
[HEADERS.length - 2]: '10', // ID FAMILLE
[HEADERS.length - 1]: '20', // ID SOUS FAMILLE
}
return HEADERS.map((_, i) => overrides[i] ?? base[i] ?? '')
}
describe('findColIndex', () => {
it('trouve un en-tête exact', () => {
expect(findColIndex(HEADERS, 'UGS')).toBe(2)
})
it('trouve un en-tête commençant par Commentaire', () => {
const idx = findColIndex(HEADERS, 'Commentaire')
expect(idx).toBeGreaterThan(-1)
})
it('retourne -1 si non trouvé', () => {
expect(findColIndex(HEADERS, 'Inexistant')).toBe(-1)
})
})
describe('extractSpecificFields', () => {
it('exclut les colonnes communes', () => {
const row = makeRow()
const specific = extractSpecificFields(HEADERS, row)
expect(Object.keys(specific)).not.toContain('Nom')
expect(Object.keys(specific)).not.toContain('UGS')
})
it('inclut les colonnes spécifiques non vides', () => {
const row = makeRow()
const specific = extractSpecificFields(HEADERS, row)
expect(specific['Puissance (AN)']).toBe('1500W')
expect(specific['Batterie (AP)']).toBe('Li-Ion')
})
it('exclut les colonnes spécifiques vides', () => {
const row = makeRow()
const specific = extractSpecificFields(HEADERS, row)
expect(Object.keys(specific)).not.toContain('Couple (AQ)')
})
})
describe('parseTabRows', () => {
it('ignore les lignes avec ID déjà rempli', () => {
const row = makeRow({ 0: '12345' })
const result = parseTabRows('OUTILLAGE', HEADERS, [row])
expect(result).toHaveLength(0)
})
it('ignore les lignes avec Publié ≠ 1', () => {
const row = makeRow({ 4: '0' })
const result = parseTabRows('OUTILLAGE', HEADERS, [row])
expect(result).toHaveLength(0)
})
it('retourne un PendingProduct pour une ligne valide', () => {
const row = makeRow()
const result = parseTabRows('OUTILLAGE', HEADERS, [row])
expect(result).toHaveLength(1)
expect(result[0].sku).toBe('SKU-001')
expect(result[0].title).toBe('Perceuse Test')
expect(result[0].priceActual).toBe('79.99')
expect(result[0].collectionId).toBe('10')
expect(result[0].specificFields['Puissance (AN)']).toBe('1500W')
})
it('attribue le bon numéro de ligne (1-indexé depuis row 2)', () => {
const rows = [makeRow(), makeRow({ 3: 'Deuxième' })]
const result = parseTabRows('OUTILLAGE', HEADERS, rows)
expect(result[0].rowNumber).toBe(2)
expect(result[1].rowNumber).toBe(3)
})
})
+184
View File
@@ -0,0 +1,184 @@
import type { PendingProduct } from '@/types/creation'
const COMMON_HEADERS = new Set([
'ID', 'Type', 'UGS', 'Nom', 'Publié', 'Mis en avant ?',
'Description courte', 'Description longue',
'Date de début de promo', 'Date de fin de promo',
'État de la TVA', 'Classe de TVA', 'En stock ?',
'Stock (en pièce)', 'Stock (pièce)', 'Stock conditionné', 'Vendre individuellement ?',
'Prix marché', 'Prix remisé', 'Code ecopart', 'Ecopart montant',
'Famille', 'Sous famille',
"Classe d'expédition (en retrait, livraison)",
'Conditionnement', 'Poids (kg) fonction unité',
'Images', 'Unité', 'Marque', 'Neuf ?', 'PHOTO ?',
'ID FAMILLE', 'ID SOUS FAMILLE',
])
export function findColIndex(headers: string[], search: string): number {
const exact = headers.findIndex(h => h === search)
if (exact !== -1) return exact
return headers.findIndex(h => h.startsWith(search))
}
export function extractSpecificFields(headers: string[], row: string[]): Record<string, string> {
const result: Record<string, string> = {}
headers.forEach((header, i) => {
if (!header) return
const isCommon = COMMON_HEADERS.has(header) || header.startsWith('Commentaire')
if (isCommon) return
const val = (row[i] ?? '').trim()
if (val) result[header] = val
})
return result
}
export function parseTabRows(tab: string, headers: string[], rows: string[][], dataStartRow = 2): PendingProduct[] {
const idx = {
id: findColIndex(headers, 'ID'),
sku: findColIndex(headers, 'UGS'),
nom: findColIndex(headers, 'Nom'),
publie: findColIndex(headers, 'Publié'),
stock: findColIndex(headers, 'Stock (en pièce)') !== -1 ? findColIndex(headers, 'Stock (en pièce)') : findColIndex(headers, 'Stock (pièce)'),
stockConditionne: findColIndex(headers, 'Stock conditionné'),
prixMarche: findColIndex(headers, 'Prix marché'),
prixRemise: findColIndex(headers, 'Prix remisé'),
poids: findColIndex(headers, 'Poids (kg) fonction unité'),
marque: findColIndex(headers, 'Marque'),
famille: findColIndex(headers, 'Famille'),
sousFamille: findColIndex(headers, 'Sous famille'),
commentaire: findColIndex(headers, 'Commentaire'),
photo: findColIndex(headers, 'PHOTO ?'),
collectionId: findColIndex(headers, 'ID FAMILLE'),
subCollectionId: findColIndex(headers, 'ID SOUS FAMILLE'),
}
const get = (row: string[], i: number) => (i >= 0 ? (row[i] ?? '').trim() : '')
return rows
.map((row, rowIdx): PendingProduct | null => {
const id = get(row, idx.id)
const publie = get(row, idx.publie)
if (id !== '' || publie !== '1') return null
return {
tab,
rowNumber: dataStartRow + rowIdx,
sku: get(row, idx.sku),
title: get(row, idx.nom),
priceMarket: get(row, idx.prixMarche).replace(',', '.'),
priceActual: get(row, idx.prixRemise).replace(',', '.'),
stock: parseInt(get(row, idx.stockConditionne) || get(row, idx.stock) || '0', 10) || 0,
weightKg: parseFloat(get(row, idx.poids).replace(',', '.') || '0') || 0,
vendor: get(row, idx.marque),
famille: get(row, idx.famille),
sousFamille: get(row, idx.sousFamille),
comment: get(row, idx.commentaire),
collectionId: get(row, idx.collectionId),
subCollectionId: get(row, idx.subCollectionId),
hasPhoto: get(row, idx.photo) !== '',
specificFields: extractSpecificFields(headers, row),
}
})
.filter((p): p is PendingProduct => p !== null)
}
/**
* Écrit l'ID Shopify en colonne A de la ligne correspondante dans Google Sheets.
* Utilise un compte de service avec accès éditeur sur la feuille.
*/
export async function writeShopifyIdToSheet(tab: string, rowNumber: number, shopifyId: string): Promise<void> {
const sheetId = process.env.GOOGLE_SHEETS_ID
const email = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL
const rawKey = process.env.GOOGLE_SERVICE_ACCOUNT_KEY
if (!sheetId || !email || !rawKey) return
// Génère un JWT pour l'authentification Google
const privateKey = rawKey.replace(/\\n/g, '\n')
const now = Math.floor(Date.now() / 1000)
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url')
const payload = Buffer.from(JSON.stringify({
iss: email,
scope: 'https://www.googleapis.com/auth/spreadsheets',
aud: 'https://oauth2.googleapis.com/token',
iat: now,
exp: now + 3600,
})).toString('base64url')
const { createSign } = await import('crypto')
const sign = createSign('RSA-SHA256')
sign.update(`${header}.${payload}`)
const signature = sign.sign(privateKey, 'base64url')
const jwt = `${header}.${payload}.${signature}`
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt }),
})
if (!tokenRes.ok) {
console.error(`[sheets] token error ${tokenRes.status} for ${tab}!A${rowNumber}`)
return
}
const { access_token } = await tokenRes.json() as { access_token: string }
const range = encodeURIComponent(`${tab}!A${rowNumber}`)
const writeRes = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?valueInputOption=RAW`,
{
method: 'PUT',
headers: { Authorization: `Bearer ${access_token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ range: `${tab}!A${rowNumber}`, majorDimension: 'ROWS', values: [[shopifyId]] }),
},
)
if (!writeRes.ok) {
const txt = await writeRes.text()
console.error(`[sheets] write error ${writeRes.status} for ${tab}!A${rowNumber}: ${txt}`)
}
}
export async function fetchPendingProducts(
alreadyCreatedRows: Set<string>,
tabFilter?: string,
): Promise<PendingProduct[]> {
const apiKey = process.env.GOOGLE_API_KEY
const sheetId = process.env.GOOGLE_SHEETS_ID
if (!apiKey || !sheetId) throw new Error('GOOGLE_API_KEY et GOOGLE_SHEETS_ID requis')
let tabs: string[]
if (tabFilter) {
tabs = [tabFilter]
} else {
const metaRes = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`,
)
if (!metaRes.ok) throw new Error(`Sheets metadata error: ${metaRes.status}`)
const meta = await metaRes.json()
tabs = (meta.sheets as Array<{ properties: { title: string } }>).map(
s => s.properties.title,
)
}
const all: PendingProduct[] = []
for (const tab of tabs) {
const range = encodeURIComponent(`${tab}!A1:BZ`)
const res = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`,
)
if (!res.ok) continue
const data = await res.json()
const rows = (data.values ?? []) as string[][]
if (rows.length < 2) continue
// La ligne d'en-têtes est la première ligne qui contient "ID" en col A
// (certains onglets ont une ligne de section en row 1, les vrais en-têtes sont en row 2)
const headerRowIdx = rows.findIndex(r => (r[0] ?? '').trim() === 'ID')
if (headerRowIdx === -1) continue // pas un onglet produit
const headers = rows[headerRowIdx]
const dataRows = rows.slice(headerRowIdx + 1)
// Ignorer les onglets sans colonne "Publié" (COLLECTIONS, LIVRAISONS, etc.)
if (findColIndex(headers, 'Publié') === -1) continue
const pending = parseTabRows(tab, headers, dataRows, headerRowIdx + 2).filter(
p => !alreadyCreatedRows.has(`${p.tab}:${p.rowNumber}`),
)
all.push(...pending)
}
return all
}
+95 -19
View File
@@ -1,4 +1,5 @@
import type { SyncProduct } from '@/types/sync'
import { findColIndex, extractSpecificFields } from '@/lib/creationSheets'
function normalizeHandle(ref: string): string {
return ref
@@ -7,28 +8,81 @@ function normalizeHandle(ref: string): string {
.replace(/^-+|-+$/g, '')
}
function get(row: string[], idx: number): string {
return idx >= 0 ? (row[idx] ?? '').trim() : ''
}
/**
* 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.
* La première ligne doit être la ligne d'en-têtes.
* Colonnes détectées dynamiquement : UGS, Nom, Prix marché, Stock, Publié
*/
export function parseSheetRows(rows: string[][]): SyncProduct[] {
return rows
.map((row): SyncProduct | null => {
const ref = row[0]?.trim()
if (!ref) return null
if (rows.length === 0) return []
const rawPrice = (row[3] ?? '').replace(',', '.').trim()
// Trouver la ligne d'en-têtes : celle qui contient "UGS"
const headerRowIndex = rows.findIndex((row) =>
row.some((cell) => cell?.trim().toUpperCase() === 'UGS')
)
if (headerRowIndex < 0) return []
const headers = rows[headerRowIndex].map((h) => h?.trim() ?? '')
const idx = {
sku: findColIndex(headers, 'UGS'),
nom: findColIndex(headers, 'Nom'),
prixMarche: findColIndex(headers, 'Prix marché'),
prixRemise: findColIndex(headers, 'Prix remisé'),
stock: findColIndex(headers, 'Stock (pièce)') !== -1 ? findColIndex(headers, 'Stock (pièce)') : findColIndex(headers, 'Stock (en pièce)'),
publie: findColIndex(headers, 'Publié'),
poids: findColIndex(headers, 'Poids (kg) fonction unité'),
marque: findColIndex(headers, 'Marque'),
famille: findColIndex(headers, 'Famille'),
sousFamille: findColIndex(headers, 'Sous famille'),
longueur: findColIndex(headers, 'Longueur'),
largeur: findColIndex(headers, 'Largeur'),
}
// Onglet sans colonne UGS ou Prix marché = onglet non-produit, on skip
if (idx.sku < 0 || idx.prixMarche < 0) return []
return rows.slice(headerRowIndex + 1)
.map((row): SyncProduct | null => {
const ref = get(row, idx.sku)
if (!ref || ref.length < 2) return null
const rawPrice = get(row, idx.prixMarche).replace(',', '.')
const price = parseFloat(rawPrice || '0')
// Ligne sans prix valide = séparateur ou note, pas un produit
if (isNaN(price) || price <= 0) return null
const publie = get(row, idx.publie).toLowerCase()
const status = publie.includes('inac') || publie === 'non' || publie === 'false' ? 'draft' : 'active'
const rawRemise = get(row, idx.prixRemise).replace(',', '.')
const prixRemise = parseFloat(rawRemise || '0')
const hasRemise = !isNaN(prixRemise) && prixRemise > 0
const longueur = get(row, idx.longueur)
const largeur = get(row, idx.largeur)
const specificFields = extractSpecificFields(headers, row)
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',
title: get(row, idx.nom) || ref,
description: '',
price: hasRemise ? prixRemise.toFixed(2) : price.toFixed(2),
compareAtPrice: hasRemise ? price.toFixed(2) : undefined,
stock: parseInt(get(row, idx.stock) || '0', 10) || 0,
weightKg: parseFloat(get(row, idx.poids).replace(',', '.') || '0') || undefined,
status,
prixRemise: hasRemise ? prixRemise.toFixed(2) : undefined,
marque: get(row, idx.marque) || undefined,
famille: get(row, idx.famille) || undefined,
sousFamille: get(row, idx.sousFamille) || undefined,
longueur: longueur || undefined,
largeur: largeur || undefined,
specificFields: Object.keys(specificFields).length > 0 ? specificFields : undefined,
}
})
.filter((p): p is SyncProduct => p !== null)
@@ -36,27 +90,49 @@ export function parseSheetRows(rows: string[][]): SyncProduct[] {
/**
* Lit tous les produits depuis Google Sheets via l'API v4 (API Key publique).
* Si GOOGLE_SHEETS_TAB est défini, lit uniquement cet onglet.
* Sinon, lit tous les onglets du fichier et combine les produits.
* Le fichier Sheets doit être partagé en lecture ("Toute personne avec le lien").
*/
export async function readSheetProducts(): Promise<SyncProduct[]> {
export async function readSheetProducts(tab?: string): Promise<SyncProduct[]> {
const apiKey = process.env.GOOGLE_API_KEY
const sheetId = process.env.GOOGLE_SHEETS_ID
const sheetName = process.env.GOOGLE_SHEETS_TAB ?? 'Produits'
const envTab = process.env.GOOGLE_SHEETS_TAB
if (!apiKey || !sheetId) {
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 sheetNames = tab ? [tab] : envTab ? [envTab] : await listSheetTabs(sheetId, apiKey)
const results = await Promise.all(
sheetNames.map((name) => readOneTab(sheetId, apiKey, name)),
)
return results.flat()
}
async function listSheetTabs(sheetId: string, apiKey: string): Promise<string[]> {
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`
const res = await fetch(url)
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)
return (data.sheets as Array<{ properties: { title: string } }>).map(
(s) => s.properties.title,
)
}
async function readOneTab(sheetId: string, apiKey: string, sheetName: string): Promise<SyncProduct[]> {
const range = encodeURIComponent(`${sheetName}!A1:AZ`)
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`
const res = await fetch(url)
if (!res.ok) {
const err = await res.text()
throw new Error(`Google Sheets API error (onglet "${sheetName}"): ${res.status}${err}`)
}
const data = await res.json()
return parseSheetRows((data.values ?? []) as string[][])
}
+62
View File
@@ -0,0 +1,62 @@
import { readFile, writeFile, mkdir, unlink } from 'fs/promises'
import { existsSync } from 'fs'
import path from 'path'
import { randomUUID } from 'crypto'
import type { MockupTemplate } from '@/types/mockup'
export const TEMPLATES_DIR = path.join(process.cwd(), 'data', 'mockup-templates')
const IMAGES_DIR = path.join(TEMPLATES_DIR, 'images')
const INDEX_PATH = path.join(TEMPLATES_DIR, 'index.json')
async function ensureDirs() {
await mkdir(IMAGES_DIR, { recursive: true })
}
async function readIndex(): Promise<MockupTemplate[]> {
if (!existsSync(INDEX_PATH)) return []
const raw = await readFile(INDEX_PATH, 'utf-8')
return JSON.parse(raw) as MockupTemplate[]
}
async function writeIndex(templates: MockupTemplate[]): Promise<void> {
await ensureDirs()
await writeFile(INDEX_PATH, JSON.stringify(templates, null, 2))
}
export async function listTemplates(): Promise<MockupTemplate[]> {
return readIndex()
}
export async function listTemplatesByFamily(family: string): Promise<MockupTemplate[]> {
const all = await readIndex()
return all.filter(t => t.family === family)
}
export async function saveTemplate(
meta: Omit<MockupTemplate, 'id'>,
imageBuffer: Buffer,
ext: string,
): Promise<MockupTemplate> {
await ensureDirs()
const id = randomUUID()
const filename = `${id}.${ext}`
await writeFile(path.join(IMAGES_DIR, filename), imageBuffer)
const template: MockupTemplate = { ...meta, id, filename }
const all = await readIndex()
all.push(template)
await writeIndex(all)
return template
}
export async function deleteTemplate(id: string): Promise<void> {
const all = await readIndex()
const tpl = all.find(t => t.id === id)
if (!tpl) throw new Error('Template not found')
const filePath = path.join(IMAGES_DIR, tpl.filename)
if (existsSync(filePath)) await unlink(filePath)
await writeIndex(all.filter(t => t.id !== id))
}
export function getTemplateImagePath(filename: string): string {
return path.join(IMAGES_DIR, filename)
}
+46
View File
@@ -0,0 +1,46 @@
import { generateDescriptions } from './openaiClient'
const mockFetch = jest.fn()
global.fetch = mockFetch
const mockResponse = (content: string) => ({
ok: true,
json: async () => ({ choices: [{ message: { content } }] }),
})
describe('generateDescriptions', () => {
beforeEach(() => {
process.env.OPENAI_API_KEY = 'sk-test'
mockFetch.mockReset()
})
it('retourne shortDesc et longDesc depuis OpenAI', async () => {
mockFetch
.mockResolvedValueOnce(mockResponse('<p>Description courte</p>'))
.mockResolvedValueOnce(mockResponse('<p>Description longue</p>'))
const result = await generateDescriptions({
title: 'Perceuse', rawDescription: 'Une perceuse', comment: '', vendor: 'BOSCH', famille: 'OUTILLAGE',
})
expect(result.shortDesc).toBe('<p>Description courte</p>')
expect(result.longDesc).toBe('<p>Description longue</p>')
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('lance les deux requêtes en parallèle', async () => {
mockFetch.mockResolvedValue(mockResponse('<p>ok</p>'))
await generateDescriptions({ title: 'T', rawDescription: '', comment: '', vendor: '', famille: '' })
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('lance une erreur si OPENAI_API_KEY manquant', async () => {
delete process.env.OPENAI_API_KEY
await expect(generateDescriptions({ title: 'T', rawDescription: '', comment: '', vendor: '', famille: '' }))
.rejects.toThrow('OPENAI_API_KEY')
})
it('lance une erreur si la réponse OpenAI est non-ok', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 429, text: async () => 'rate limited' })
await expect(generateDescriptions({ title: 'T', rawDescription: '', comment: '', vendor: '', famille: '' }))
.rejects.toThrow('429')
})
})
+46
View File
@@ -0,0 +1,46 @@
interface DescriptionInput {
title: string
rawDescription: string
comment: string
vendor: string
famille: string
}
interface Descriptions {
shortDesc: string
longDesc: string
}
async function callOpenAI(prompt: string): Promise<string> {
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) throw new Error('OPENAI_API_KEY requis')
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ model: 'gpt-4o', messages: [{ role: 'user', content: prompt }], temperature: 0.7 }),
})
if (!res.ok) {
const err = await res.text()
throw new Error(`OpenAI API error: ${res.status}${err}`)
}
const data = await res.json()
return (data.choices?.[0]?.message?.content ?? '').trim()
}
function buildPrompt(input: DescriptionInput, length: number): string {
return `A partir des caractéristiques suivantes, crée un texte descriptif de ${length} caractères en faisant attention au SEO pour qu'il soit en rapport avec la catégorie ${input.famille} et le produit ${input.title} :
Nom = ${input.title}
Description : ${input.rawDescription}
Commentaire : ${input.comment}
Marque : ${input.vendor}
Formate ce texte en HTML.
Ta réponse doit commencer directement par une balise HTML (comme <p> ou <h1>), sans bloc \`\`\`html, sans aucun mot ou encadré autour.`
}
export async function generateDescriptions(input: DescriptionInput): Promise<Descriptions> {
const [shortDesc, longDesc] = await Promise.all([
callOpenAI(buildPrompt(input, 400)),
callOpenAI(buildPrompt(input, 1000)),
])
return { shortDesc, longDesc }
}
+20
View File
@@ -0,0 +1,20 @@
import { readFile, writeFile, mkdir } from 'fs/promises'
import { join } from 'path'
import type { MetafieldResolution } from '@/types/creation'
const CACHE_DIR = join(process.cwd(), 'data')
const CACHE_FILE = join(CACHE_DIR, 'metafield-resolutions.json')
export async function loadResolutionCache(): Promise<Record<string, MetafieldResolution>> {
try {
const raw = await readFile(CACHE_FILE, 'utf-8')
return JSON.parse(raw) as Record<string, MetafieldResolution>
} catch {
return {}
}
}
export async function saveResolutionCache(cache: Record<string, MetafieldResolution>): Promise<void> {
await mkdir(CACHE_DIR, { recursive: true })
await writeFile(CACHE_FILE, JSON.stringify(cache, null, 2), 'utf-8')
}
+132 -32
View File
@@ -21,34 +21,81 @@ export type ShopifyProductResult =
/**
* Recherche un produit Shopify par sa référence.
* Tente d'abord par handle (ex: "ref-1042"), puis par titre exact.
* Stratégie : SKU exact → SKU sans suffixe → handle → titre.
* Les images peuvent être nommées "30827-dessus.jpg" alors que le SKU est "30827".
*/
export async function searchProductByRef(ref: string): Promise<ShopifyProductResult> {
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}`)
// Recherche par SKU via l'API GraphQL (supporte les SKUs avec tirets, contrairement au REST)
const graphqlUrl = `https://${process.env.SHOPIFY_STORE_DOMAIN}/admin/api/${API_VERSION}/graphql.json`
const searchBySku = async (sku: string): Promise<ShopifyProductResult> => {
const query = `{
productVariants(first: 10, query: "sku:${sku.replace(/"/g, '')}") {
edges {
node {
sku
product { id legacyResourceId title }
}
}
}
}`
const res = await fetch(graphqlUrl, {
method: 'POST',
headers,
body: JSON.stringify({ query }),
})
if (!res.ok) return { found: false }
const data = await res.json()
return (data.products ?? []) as Array<{ id: number; title: string; handle: string }>
const edges = data?.data?.productVariants?.edges ?? []
// Cherche le match exact (insensible à la casse) parmi les résultats
const match = edges.find((e: { node: { sku: string } }) =>
e.node.sku.toLowerCase() === sku.toLowerCase()
)
if (!match) return { found: false }
return {
found: true,
shopifyId: match.node.product.legacyResourceId,
title: match.node.product.title,
}
}
// Si le ref se termine par un suffixe purement numérique (ex: "26618-D-1" → "26618-D"),
// on essaie d'abord sans ce suffixe pour éviter de matcher un autre produit qui aurait
// accidentellement ce SKU complet
const withoutNumericSuffix = /[-_]\d+$/.test(ref) ? ref.replace(/[-_]\d+$/, '') : null
// 1. SKU sans suffixe numérique en priorité (ex: "26618-D-1" → "26618-D")
if (withoutNumericSuffix) {
const result = await searchBySku(withoutNumericSuffix)
if (result.found) return result
}
// 2. SKU exact
let result = await searchBySku(ref)
if (result.found) return result
// 3. SKU sans suffixe non-numérique : "30827-dessus" → "30827"
const withoutSuffix = ref.replace(/[-_ ][^-_ ]*$/, '')
if (withoutSuffix !== ref && withoutSuffix !== withoutNumericSuffix) {
result = await searchBySku(withoutSuffix)
if (result.found) return result
}
// 3. Fallback handle Shopify
const handle = ref.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
const tryFetch = async (params: string) => {
const res = await fetch(`${baseUrl}/products.json?${params}&fields=id,title&limit=1`, { headers })
if (!res.ok) return []
const data = await res.json()
return (data.products ?? []) as Array<{ id: number; title: string }>
}
let products = await tryFetch(`handle=${encodeURIComponent(handle)}`)
if (products.length === 0) {
products = await tryFetch(`title=${encodeURIComponent(ref)}`)
}
if (products.length === 0) products = await tryFetch(`title=${encodeURIComponent(ref)}`)
if (products.length === 0) return { found: false }
return {
found: true,
shopifyId: String(products[0].id),
title: products[0].title,
}
return { found: true, shopifyId: String(products[0].id), title: products[0].title }
}
/**
@@ -57,27 +104,80 @@ export async function searchProductByRef(ref: string): Promise<ShopifyProductRes
* @param imageBase64 — contenu JPEG encodé en base64 (sans le préfixe data:)
* @param filename — nom de fichier avec extension (ex: "REF-1042.jpg")
*/
/**
* Vérifie si une image avec ce nom de fichier existe déjà sur le produit.
* Compare le nom de base (sans extension) avec les src des images existantes.
*/
export async function productImageExists(
shopifyProductId: string,
filename: string,
): Promise<string | null> {
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/products/${shopifyProductId}/images.json?fields=id,src`, { headers })
if (!res.ok) return null
const data = await res.json()
const images = (data.images ?? []) as Array<{ id: number; src: string }>
// Compare le nom de base sans extension (ex: "30827" dans "30827.jpg")
const baseName = filename.replace(/\.[^.]+$/, '').toLowerCase()
const existing = images.find(img => {
const imgFile = img.src.split('/').pop()?.split('?')[0] ?? ''
return imgFile.replace(/\.[^.]+$/, '').toLowerCase() === baseName
})
return existing ? existing.src : null
}
export async function uploadProductImage(
shopifyProductId: string,
imageBase64: string,
filename: string,
): Promise<{ url: string }> {
): Promise<{ url: string; skipped?: boolean }> {
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/products/${shopifyProductId}/images.json`, {
method: 'POST',
headers,
body: JSON.stringify({ image: { attachment: imageBase64, filename } }),
})
// Vérifie si l'image existe déjà
const existingUrl = await productImageExists(shopifyProductId, filename)
if (existingUrl) return { url: existingUrl, skipped: true }
if (!res.ok) {
const body = await res.text()
throw new Error(`Shopify upload error: ${res.status}${body}`)
// Retry avec backoff exponentiel (gère le 429 rate limit)
const MAX_RETRIES = 4
let delay = 1000
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const res = await fetch(`${baseUrl}/products/${shopifyProductId}/images.json`, {
method: 'POST',
headers,
body: JSON.stringify({ image: { attachment: imageBase64, filename } }),
})
// 409 Conflict = image déjà présente → traiter comme skipped
if (res.status === 409) {
return { url: `https://placeholder/already-exists/${filename}`, skipped: true }
}
// 429 Too Many Requests → attendre et réessayer
if (res.status === 429 && attempt < MAX_RETRIES) {
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '0', 10)
await new Promise(r => setTimeout(r, retryAfter > 0 ? retryAfter * 1000 : delay))
delay *= 2
continue
}
if (res.status === 404) {
const err = new Error(`Shopify upload error: 404 — product not found`) as Error & { status: number }
err.status = 404
throw err
}
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 }
}
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 }
throw new Error('Shopify upload error: trop de tentatives (rate limit persistant)')
}
+27
View File
@@ -0,0 +1,27 @@
const API_VERSION = '2024-01'
function getConfig() {
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
return {
baseUrl: `https://${domain}/admin/api/${API_VERSION}`,
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
}
}
export async function addProductToCollection(shopifyProductId: string, collectionId: string): Promise<void> {
if (!collectionId?.trim()) return
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/collects.json`, {
method: 'POST',
headers,
body: JSON.stringify({ collect: { product_id: shopifyProductId, collection_id: collectionId } }),
})
if (!res.ok) {
const err = await res.text()
if (!err.includes('already') && !err.includes('taken')) {
throw new Error(`Shopify addToCollection error: ${res.status}${err}`)
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import { normalizeMetafieldKey, matchMetafields } from './shopifyMetafields'
import type { MetafieldDefinition } from '@/types/creation'
describe('normalizeMetafieldKey', () => {
it('minuscules + accents retirés', () => {
expect(normalizeMetafieldKey('Éclairage LED')).toBe('eclairage_led')
})
it('parenthèses et espaces → underscore', () => {
expect(normalizeMetafieldKey('Puissance (AN)')).toBe('puissance_an')
})
it('limité à 30 caractères', () => {
const long = 'a'.repeat(40)
expect(normalizeMetafieldKey(long)).toHaveLength(30)
})
it('underscores multiples réduits à un', () => {
expect(normalizeMetafieldKey('Poids par carton')).toBe('poids_par_carton')
})
})
describe('matchMetafields', () => {
const definitions: MetafieldDefinition[] = [
{ id: '1', namespace: 'custom', key: 'puissance_an', name: 'Puissance (AN)', type: 'single_line_text_field' },
{ id: '2', namespace: 'custom', key: 'coloris', name: 'Coloris', type: 'single_line_text_field' },
]
it('trouve une correspondance exacte sur la clé normalisée', () => {
const result = matchMetafields(['Puissance (AN)', 'Batterie (AP)'], definitions)
expect(result.matched['Puissance (AN)'].existingDefinitionId).toBe('1')
})
it('met les colonnes sans correspondance dans unmatched', () => {
const result = matchMetafields(['Puissance (AN)', 'Batterie (AP)'], definitions)
expect(result.unmatched).toContain('Batterie (AP)')
})
it('ne met pas en unmatched les colonnes qui ont un match', () => {
const result = matchMetafields(['Puissance (AN)'], definitions)
expect(result.unmatched).not.toContain('Puissance (AN)')
})
})
+177
View File
@@ -0,0 +1,177 @@
import type { MetafieldDefinition, MetafieldResolution } from '@/types/creation'
export function normalizeMetafieldKey(header: string): string {
return header
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '') // retire les accents
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.replace(/_+/g, '_')
.substring(0, 30)
}
export interface MatchResult {
matched: Record<string, MetafieldResolution>
unmatched: string[]
}
export function matchMetafields(columnHeaders: string[], definitions: MetafieldDefinition[]): MatchResult {
const matched: Record<string, MetafieldResolution> = {}
const unmatched: string[] = []
for (const header of columnHeaders) {
const key = normalizeMetafieldKey(header)
const def = definitions.find(d => d.key === key)
if (def) {
matched[header] = { columnHeader: header, action: 'map', namespace: def.namespace, key: def.key, type: def.type, existingDefinitionId: def.id }
} else {
unmatched.push(header)
}
}
return { matched, unmatched }
}
/**
* Récupère toutes les définitions de métachamps produit via l'API GraphQL Admin.
* L'API REST ne dispose pas de cet endpoint — seul GraphQL l'expose.
*/
export async function fetchMetafieldDefinitions(): Promise<MetafieldDefinition[]> {
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
const query = `{
metafieldDefinitions(ownerType: PRODUCT, first: 250) {
edges {
node {
id
namespace
key
name
type { name }
}
}
}
}`
const res = await fetch(`https://${domain}/admin/api/2024-01/graphql.json`, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
})
if (!res.ok) throw new Error(`Shopify GraphQL error: ${res.status}`)
const data = await res.json()
if (data.errors) {
// Si les définitions ne sont pas accessibles, on retourne une liste vide
// plutôt que de bloquer — l'utilisateur verra toutes les colonnes comme "non matchées"
console.warn('[shopifyMetafields] GraphQL errors:', data.errors)
return []
}
return (data.data?.metafieldDefinitions?.edges ?? []).map((edge: {
node: { id: string; namespace: string; key: string; name: string; type: { name: string } }
}) => ({
id: edge.node.id,
namespace: edge.node.namespace,
key: edge.node.key,
name: edge.node.name,
type: edge.node.type?.name ?? 'single_line_text_field',
}))
}
/**
* Crée une définition de métachamp produit via GraphQL si elle n'existe pas encore.
*/
export async function ensureMetafieldDefinition(resolution: MetafieldResolution): Promise<void> {
if (resolution.action !== 'create') return
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
const mutation = `mutation CreateMetafieldDefinition($definition: MetafieldDefinitionInput!) {
metafieldDefinitionCreate(definition: $definition) {
createdDefinition { id key namespace }
userErrors { field message }
}
}`
const res = await fetch(`https://${domain}/admin/api/2024-01/graphql.json`, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({
query: mutation,
variables: {
definition: {
namespace: resolution.namespace,
key: resolution.key,
name: resolution.columnHeader,
type: 'single_line_text_field',
ownerType: 'PRODUCT',
},
},
}),
})
if (!res.ok) throw new Error(`Shopify GraphQL metafield create error: ${res.status}`)
const data = await res.json()
const errors = data.data?.metafieldDefinitionCreate?.userErrors ?? []
const isAlreadyTaken = errors.some((e: { message: string }) =>
e.message.toLowerCase().includes('taken') || e.message.toLowerCase().includes('already'),
)
if (errors.length > 0 && !isAlreadyTaken) {
throw new Error(`Shopify metafield definition error: ${errors.map((e: { message: string }) => e.message).join(', ')}`)
}
}
export async function setProductMetafield(
shopifyProductId: string,
resolution: MetafieldResolution,
value: string,
): Promise<void> {
if (resolution.action === 'skip' || !value.trim()) return
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
const gid = `gid://shopify/Product/${shopifyProductId}`
const type = resolution.type ?? 'single_line_text_field'
const mutation = `mutation MetafieldsSet($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields { key namespace value }
userErrors { field message }
}
}`
const res = await fetch(`https://${domain}/admin/api/2024-01/graphql.json`, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({
query: mutation,
variables: {
metafields: [{
ownerId: gid,
namespace: resolution.namespace,
key: resolution.key,
value,
type,
}],
},
}),
})
if (!res.ok) {
const err = await res.text()
throw new Error(`Shopify metafieldsSet HTTP error: ${res.status}${err}`)
}
const data = await res.json()
const errors = data.data?.metafieldsSet?.userErrors ?? []
if (errors.length > 0) {
throw new Error(`Shopify metafieldsSet error: ${errors.map((e: { message: string }) => e.message).join(', ')}`)
}
}
+82 -7
View File
@@ -2,7 +2,7 @@ import type { ShopifyProductFull, SyncProduct } from '@/types/sync'
const API_VERSION = '2024-01'
function getConfig() {
export function getConfig() {
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) {
@@ -20,6 +20,7 @@ function getConfig() {
interface ShopifyVariant {
id: number
price: string
compare_at_price: string | null
sku: string
inventory_quantity: number
}
@@ -31,6 +32,7 @@ interface ShopifyAPIProduct {
body_html: string
status: string
variants: ShopifyVariant[]
images: Array<{ src: string }>
}
function toSyncProduct(p: ShopifyAPIProduct): ShopifyProductFull {
@@ -41,8 +43,10 @@ function toSyncProduct(p: ShopifyAPIProduct): ShopifyProductFull {
title: p.title,
description: p.body_html ?? '',
price: p.variants[0]?.price ?? '0.00',
compareAtPrice: p.variants[0]?.compare_at_price ?? undefined,
stock: p.variants[0]?.inventory_quantity ?? 0,
status: p.status === 'active' ? 'active' : 'draft',
images: (p.images ?? []).map(i => i.src),
}
}
@@ -54,18 +58,26 @@ function extractNextUrl(linkHeader: string | null): string | null {
/**
* Récupère tous les produits Shopify (actifs + drafts), en paginant.
* Limite : 250 par page via cursor-based pagination.
* L'API REST ne supporte pas status=any — on fait deux passes (active + draft).
*/
export async function fetchAllShopifyProducts(): Promise<ShopifyProductFull[]> {
const [active, draft] = await Promise.all([
fetchByStatus('active'),
fetchByStatus('draft'),
])
return [...active, ...draft]
}
async function fetchByStatus(status: 'active' | 'draft'): Promise<ShopifyProductFull[]> {
const { baseUrl, headers } = getConfig()
const all: ShopifyProductFull[] = []
let url: string | null =
`${baseUrl}/products.json?limit=250&fields=id,title,handle,status,body_html,variants&status=any`
`${baseUrl}/products.json?limit=250&fields=id,title,handle,status,body_html,variants,images&status=${status}`
while (url) {
const res = await fetch(url, { headers })
if (!res.ok) throw new Error(`Shopify fetch error: ${res.status}`)
if (!res.ok) throw new Error(`Shopify fetch error (${status}): ${res.status}`)
const data = await res.json()
all.push(...(data.products as ShopifyAPIProduct[]).map(toSyncProduct))
url = extractNextUrl(res.headers.get('Link'))
@@ -81,13 +93,24 @@ export async function fetchAllShopifyProducts(): Promise<ShopifyProductFull[]> {
export async function createShopifyProduct(product: SyncProduct): Promise<string> {
const { baseUrl, headers } = getConfig()
const variant: Record<string, unknown> = {
price: product.price,
compare_at_price: product.compareAtPrice ?? null,
sku: product.ref,
inventory_management: 'shopify',
}
if (product.weightKg != null && product.weightKg > 0) {
variant.weight = product.weightKg
variant.weight_unit = 'kg'
}
const body = {
product: {
title: product.title,
body_html: product.description,
handle: product.handle,
status: product.status,
variants: [{ price: product.price, sku: product.ref }],
variants: [variant],
},
}
@@ -103,7 +126,44 @@ export async function createShopifyProduct(product: SyncProduct): Promise<string
}
const data = await res.json()
return String(data.product.id)
const shopifyId = String(data.product.id)
const variantId = String(data.product.variants[0].id)
// Récupérer le location_id par défaut pour setter le stock
try {
const locRes = await fetch(`${baseUrl}/locations.json`, { headers })
if (locRes.ok) {
const locData = await locRes.json() as { locations: Array<{ id: number }> }
const locationId = locData.locations[0]?.id
const inventoryItemId = data.product.variants[0].inventory_item_id
if (locationId && inventoryItemId) {
// Connect l'inventory item à la location avant de setter la quantité
await fetch(`${baseUrl}/inventory_levels/connect.json`, {
method: 'POST',
headers,
body: JSON.stringify({ location_id: locationId, inventory_item_id: inventoryItemId }),
})
const setRes = await fetch(`${baseUrl}/inventory_levels/set.json`, {
method: 'POST',
headers,
body: JSON.stringify({
location_id: locationId,
inventory_item_id: inventoryItemId,
available: product.stock ?? 0,
}),
})
if (!setRes.ok) {
const errText = await setRes.text()
console.error(`[shopify] inventory_levels/set failed: ${setRes.status}${errText}`)
}
}
}
} catch (err) {
console.error('[shopify] stock set error:', err)
}
void variantId
return shopifyId
}
/**
@@ -122,7 +182,7 @@ export async function updateShopifyProduct(
title: product.title,
body_html: product.description,
status: product.status,
variants: [{ id: variantId, price: product.price }],
variants: [{ id: variantId, price: product.price, compare_at_price: product.compareAtPrice ?? null }],
},
}
@@ -156,6 +216,21 @@ export async function deactivateShopifyProduct(shopifyId: string): Promise<void>
}
}
/**
* Supprime définitivement un produit Shopify.
*/
export async function deleteShopifyProduct(shopifyId: string): Promise<void> {
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/products/${shopifyId}.json`, {
method: 'DELETE',
headers,
})
if (!res.ok) {
const err = await res.text()
throw new Error(`Shopify deleteProduct error: ${res.status}${err}`)
}
}
/**
* Récupère l'ID de la première variante d'un produit Shopify.
* Nécessaire pour mettre à jour le prix (update variant).
+211
View File
@@ -0,0 +1,211 @@
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}`,
graphqlUrl: `https://${domain}/admin/api/${API_VERSION}/graphql.json`,
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
}
}
export interface SlideSettings {
image: string // "shopify://shop_images/filename.jpg"
link?: string
}
export interface Slide {
blockId: string
settings: SlideSettings
}
export interface SlideshowSection {
sectionId: string
slides: Slide[]
blockOrder: string[]
sectionSettings: Record<string, unknown>
}
// ── Trouver le thème actif ────────────────────────────────────────────────────
export async function getActiveThemeId(): Promise<string> {
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/themes.json`, { headers })
const data = await res.json()
const main = (data.themes as Array<{ id: number; role: string }>).find(t => t.role === 'main')
if (!main) throw new Error('Aucun thème actif trouvé')
return String(main.id)
}
// ── Lire le template homepage ─────────────────────────────────────────────────
export async function getHomepageTemplate(themeId: string): Promise<Record<string, unknown>> {
const { baseUrl, headers } = getConfig()
const url = `${baseUrl}/themes/${themeId}/assets.json?asset%5Bkey%5D=templates%2Findex.json`
const res = await fetch(url, { headers })
const data = await res.json()
const value = (data.asset as { value: string }).value
return JSON.parse(value)
}
// ── Écrire le template homepage ───────────────────────────────────────────────
export async function saveHomepageTemplate(
themeId: string,
template: Record<string, unknown>,
): Promise<void> {
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/themes/${themeId}/assets.json`, {
method: 'PUT',
headers,
body: JSON.stringify({ asset: { key: 'templates/index.json', value: JSON.stringify(template, null, 2) } }),
})
if (!res.ok) {
const body = await res.text()
throw new Error(`Shopify theme write error: ${res.status}${body}`)
}
}
// ── Extraire le slideshow depuis le template ──────────────────────────────────
export function extractSlideshow(template: Record<string, unknown>): SlideshowSection | null {
const sections = template.sections as Record<string, unknown>
if (!sections) return null
const entry = Object.entries(sections).find(([, v]) => {
const sec = v as Record<string, unknown>
return typeof sec.type === 'string' && sec.type.includes('slideshow-custom')
})
if (!entry) return null
const [sectionId, sec] = entry
const section = sec as Record<string, unknown>
const blocks = section.blocks as Record<string, { type: string; settings: SlideSettings }>
const blockOrder = section.block_order as string[]
const slides: Slide[] = (blockOrder ?? [])
.filter(id => blocks[id]?.type === 'slide')
.map(id => ({ blockId: id, settings: blocks[id].settings }))
return {
sectionId,
slides,
blockOrder: blockOrder ?? [],
sectionSettings: (section.settings as Record<string, unknown>) ?? {},
}
}
// ── Mettre à jour les slides dans le template ─────────────────────────────────
export function applySlideshow(
template: Record<string, unknown>,
slideshow: SlideshowSection,
): Record<string, unknown> {
const sections = { ...(template.sections as Record<string, unknown>) }
const existing = sections[slideshow.sectionId] as Record<string, unknown>
const blocks: Record<string, unknown> = {}
for (const slide of slideshow.slides) {
blocks[slide.blockId] = { type: 'slide', settings: slide.settings }
}
sections[slideshow.sectionId] = {
...existing,
blocks,
block_order: slideshow.slides.map(s => s.blockId),
settings: slideshow.sectionSettings,
}
return { ...template, sections }
}
// ── Uploader une image dans Shopify Files et retourner la ref shopify:// ──────
export async function uploadFileToShopify(
imageBase64: string,
filename: string,
): Promise<string> {
const { graphqlUrl, headers } = getConfig()
// 1. Créer un staged upload
const stageRes = await fetch(graphqlUrl, {
method: 'POST',
headers,
body: JSON.stringify({
query: `mutation stagedUploadsCreate($input: [StagedUploadInput!]!) {
stagedUploadsCreate(input: $input) {
stagedTargets {
url
resourceUrl
parameters { name value }
}
userErrors { field message }
}
}`,
variables: {
input: [{
filename,
mimeType: 'image/jpeg',
resource: 'FILE',
fileSize: String(Math.ceil(imageBase64.length * 0.75)),
httpMethod: 'POST',
}],
},
}),
})
const stageData = await stageRes.json()
const target = stageData?.data?.stagedUploadsCreate?.stagedTargets?.[0]
if (!target) throw new Error('Staged upload échoué')
// 2. Uploader le fichier vers l'URL staging
const binaryStr = atob(imageBase64)
const bytes = new Uint8Array(binaryStr.length)
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i)
const blob = new Blob([bytes], { type: 'image/jpeg' })
const form = new FormData()
for (const { name, value } of target.parameters as Array<{ name: string; value: string }>) {
form.append(name, value)
}
form.append('file', blob, filename)
const uploadRes = await fetch(target.url, { method: 'POST', body: form })
if (!uploadRes.ok) throw new Error(`Upload staging échoué: ${uploadRes.status}`)
// 3. Enregistrer le fichier dans Shopify Files
const createRes = await fetch(graphqlUrl, {
method: 'POST',
headers,
body: JSON.stringify({
query: `mutation fileCreate($files: [FileCreateInput!]!) {
fileCreate(files: $files) {
files {
... on MediaImage {
id
image { url }
}
}
userErrors { field message }
}
}`,
variables: {
files: [{ originalSource: target.resourceUrl, contentType: 'IMAGE' }],
},
}),
})
const createData = await createRes.json()
const fileUrl = createData?.data?.fileCreate?.files?.[0]?.image?.url as string | undefined
if (!fileUrl) {
// Fallback: construire la ref depuis le resourceUrl
const resourceUrl = target.resourceUrl as string
const fname = resourceUrl.split('/').pop()?.split('?')[0] ?? filename
return `shopify://shop_images/${fname}`
}
// Extraire le nom de fichier depuis l'URL CDN
const cdnFilename = fileUrl.split('/files/')[1]?.split('?')[0] ?? filename
return `shopify://shop_images/${cdnFilename}`
}
+10 -4
View File
@@ -1,6 +1,6 @@
import type { SyncProduct, ShopifyProductFull, SyncChange, DiffResult } from '@/types/sync'
const COMPARABLE_FIELDS = ['title', 'description', 'price', 'status'] as const
const COMPARABLE_FIELDS = ['title', 'description', 'price', 'compareAtPrice', 'status'] as const
function getChangedFields(
sheet: SyncProduct,
@@ -18,10 +18,13 @@ function getChangedFields(
/**
* Calcule le diff entre les produits Sheets et Shopify.
* Matching par handle (ref normalisée).
* Si tabFilter est fourni, les désactivations sont ignorées : on ne peut pas savoir
* si un produit Shopify absent du tab appartient à une autre famille ou a été supprimé.
*/
export function computeDiff(
sheetProducts: SyncProduct[],
shopifyProducts: ShopifyProductFull[],
tabFilter?: string,
): DiffResult {
const shopifyMap = new Map(shopifyProducts.map((p) => [p.handle, p]))
const sheetHandles = new Set(sheetProducts.map((p) => p.handle))
@@ -48,9 +51,12 @@ export function computeDiff(
}
}
for (const shopify of shopifyProducts) {
if (shopify.status === 'active' && !sheetHandles.has(shopify.handle)) {
changes.push({ type: 'deactivate', ref: shopify.ref, shopifyProduct: shopify })
// Désactivations uniquement en mode catalogue complet (sans filtre par famille)
if (!tabFilter) {
for (const shopify of shopifyProducts) {
if (shopify.status === 'active' && !sheetHandles.has(shopify.handle)) {
changes.push({ type: 'deactivate', ref: shopify.ref, shopifyProduct: shopify })
}
}
}
+59
View File
@@ -0,0 +1,59 @@
/** Un produit en attente de création, extrait d'un onglet Sheets */
export interface PendingProduct {
tab: string // nom de l'onglet (famille)
rowNumber: number // numéro de ligne 1-indexé dans le sheet
sku: string // UGS
title: string // Nom
priceMarket: string // Prix marché (compare_at_price), ex: "29.99"
priceActual: string // Prix remisé (price), ex: "19.99"
stock: number
weightKg: number
vendor: string // Marque
famille: string // Famille (contexte IA)
sousFamille: string
comment: string // Commentaire
collectionId: string // ID FAMILLE → collection Shopify
subCollectionId: string // ID SOUS FAMILLE
hasPhoto: boolean
specificFields: Record<string, string> // header → valeur pour les métachamps
}
/** Définition de métachamp existante dans Shopify */
export interface MetafieldDefinition {
id: string // ex: "123"
namespace: string // ex: "custom"
key: string // ex: "puissance"
name: string // ex: "Puissance"
type: string // ex: "single_line_text_field"
}
/** Résolution d'une colonne spécifique vers un métachamp */
export interface MetafieldResolution {
columnHeader: string // en-tête colonne Sheets, ex: "Puissance (AN)"
action: 'create' | 'map' | 'skip'
namespace: string // "custom" par défaut
key: string // clé normalisée
type?: string // type Shopify réel, ex: 'multi_line_text_field', 'number_decimal'
existingDefinitionId?: string // si action === 'map'
}
/** Événement streamé par /api/creation/execute */
export type CreationStreamEvent =
| { type: 'progress'; current: number; total: number; tab: string; title: string; step: string }
| { type: 'error'; title: string; message: string }
| { type: 'skipped'; title: string; sku: string }
| { type: 'done'; summary: { created: number; skipped: number; errors: number; status: 'success' | 'partial' | 'error' } }
/** Résumé d'une création (stocké en DB) */
export interface CreationRecord {
id: string
createdAt: string
userName: string
sheetTab: string
sheetRow: number
shopifyId: string
sku: string
title: string
status: 'success' | 'error'
errorMessage?: string
}
+1
View File
@@ -0,0 +1 @@
declare module 'heic-convert';
+5 -2
View File
@@ -1,11 +1,13 @@
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
| 'converted' // converti + recherche terminée, détourage non lancé
| 'processing' // détourage IA en cours
| 'ready' // détouré, prêt à uploader
| 'uploading' // upload Shopify en cours
| 'uploaded' // uploadé avec succès
| 'skipped' // image déjà présente sur le produit Shopify
| 'not_found' // référence produit introuvable dans Shopify
| 'error' // erreur à n'importe quelle étape
@@ -23,6 +25,7 @@ export interface ImageItem {
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
alphaThreshold: number // 0100, défaut 30 — pixels semi-transparents rendus opaques
shopifyProductId: string | null
shopifyProductTitle: string | null
uploadedUrl: string | null
+22
View File
@@ -0,0 +1,22 @@
export interface MockupTemplate {
id: string
name: string
family: string
placement?: string
filename: string
description: string
}
export interface MockupGenerateRequest {
templateId: string
productImageBase64: string
productName: string
dimensions?: string
family: string
placement?: string
instruction?: string
}
export interface MockupGenerateResult {
imageBase64: string
}
+26
View File
@@ -0,0 +1,26 @@
// 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
images: string[]
}
/** 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
}
+13 -2
View File
@@ -7,13 +7,24 @@ export interface SyncProduct {
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é)
stock: number // colonne E
weightKg?: number // poids en kg pour l'expédition
status: 'active' | 'draft' // colonne F : "inactif"/"inactif" → draft, tout autre → active
compareAtPrice?: string
// Champs enrichis
prixRemise?: string
marque?: string
famille?: string
sousFamille?: string
longueur?: string
largeur?: string
specificFields?: Record<string, string>
}
/** Produit Shopify enrichi pour la comparaison */
export interface ShopifyProductFull extends SyncProduct {
shopifyId: string // ID numérique Shopify (string)
shopifyId: string
images?: string[]
}
/** Un changement unitaire calculé par computeDiff */