63 Commits

Author SHA1 Message Date
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
C_Ma_For e576df334a fix: fusionner imports next/server dupliqués 2026-06-08 23:05:51 +02:00
C_Ma_For dc88e56f49 fix: supprimer API_VERSION inutilisé + eslint-disable _req 2026-06-08 22:59:30 +02:00
C_Ma_For e04e286111 fix: API Key Google Sheets (remplace service account) + signatures routes GET 2026-06-08 22:52:40 +02:00
C_Ma_For abf5210de6 fix: corriger erreurs ESLint sync (apostrophes, imports inutilisés)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:37:54 +02:00
C_Ma_For 97f011a24b feat: module Sync Sheets complet — stepper 4 étapes, diff, streaming, historique
- Replace placeholder page.tsx with full implementation:
  - 4-step stepper (Analyser → Diff → Confirmer → Progression)
  - Step 1: Call /api/sync/preview to analyze Sheet vs Shopify
  - Step 2: Display DiffTable with changes summary
  - Step 3: Confirmation with actionable changes count
  - Step 4: Real-time progress streaming from /api/sync/execute
  - History section with loadable entries
- Fix type annotations in preview.test.ts (SyncProduct, ShopifyProductFull)
- All 40 tests passing, TypeScript strict mode clean

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:44:26 +02:00
C_Ma_For 1daf9bda72 feat: types sync + dépendance googleapis 2026-06-08 20:42:00 +02:00
67 changed files with 5427 additions and 133 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.
+1117 -68
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -13,16 +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",
@@ -32,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("")
}
+61
View File
@@ -0,0 +1,61 @@
import { GET } from '@/app/api/sync/history/route'
import { NextRequest } from 'next/server'
jest.mock('@/lib/prisma', () => ({
prisma: {
syncRun: {
findMany: jest.fn(),
},
},
}))
import { prisma } from '@/lib/prisma'
const mockFindMany = prisma.syncRun.findMany as jest.Mock
describe('GET /api/sync/history', () => {
beforeEach(() => jest.clearAllMocks())
it('retourne une liste vide si aucun SyncRun', async () => {
mockFindMany.mockResolvedValue([])
const req = new NextRequest('http://localhost/api/sync/history')
const res = await GET(req)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
it('retourne les syncs formatées', async () => {
const fakeRun = {
id: 'clid1',
createdAt: new Date('2026-06-08T10:00:00Z'),
status: 'success',
created: 3,
updated: 1,
deactivated: 0,
errors: 0,
log: JSON.stringify([{ ref: 'REF-1', action: 'create', status: 'success', message: '✓ REF-1 créé' }]),
user: { name: 'Mathieu' },
}
mockFindMany.mockResolvedValue([fakeRun])
const req = new NextRequest('http://localhost/api/sync/history')
const res = await GET(req)
expect(res.status).toBe(200)
const data = await res.json()
expect(data).toHaveLength(1)
expect(data[0].id).toBe('clid1')
expect(data[0].userName).toBe('Mathieu')
expect(data[0].status).toBe('success')
expect(data[0].created).toBe(3)
expect(data[0].log).toHaveLength(1)
expect(data[0].log[0].ref).toBe('REF-1')
expect(data[0].createdAt).toBe('2026-06-08T10:00:00.000Z')
})
it('retourne 500 si Prisma échoue', async () => {
mockFindMany.mockRejectedValue(new Error('DB error'))
const req = new NextRequest('http://localhost/api/sync/history')
const res = await GET(req)
expect(res.status).toBe(500)
})
})
+64
View File
@@ -0,0 +1,64 @@
import { GET } from '@/app/api/sync/preview/route'
import { NextRequest } from 'next/server'
import type { SyncProduct, ShopifyProductFull } from '@/types/sync'
jest.mock('@/lib/googleSheets', () => ({
readSheetProducts: jest.fn(),
}))
jest.mock('@/lib/shopifySync', () => ({
fetchAllShopifyProducts: jest.fn(),
}))
jest.mock('@/lib/syncDiff', () => ({
computeDiff: jest.fn(),
}))
import { readSheetProducts } from '@/lib/googleSheets'
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
import { computeDiff } from '@/lib/syncDiff'
const mockSheets = readSheetProducts as jest.Mock
const mockShopify = fetchAllShopifyProducts as jest.Mock
const mockDiff = computeDiff as jest.Mock
describe('GET /api/sync/preview', () => {
beforeEach(() => jest.clearAllMocks())
it('retourne le DiffResult complet', async () => {
const fakeSheetProducts: SyncProduct[] = [{ ref: 'REF-1', handle: 'ref-1', title: 'T', description: '', price: '10.00', stock: 1, status: 'active' }]
const fakeShopifyProducts: ShopifyProductFull[] = []
const fakeDiff = {
changes: [{ type: 'create', ref: 'REF-1', sheetProduct: fakeSheetProducts[0] }],
summary: { create: 1, update: 0, deactivate: 0, unchanged: 0 },
}
mockSheets.mockResolvedValue(fakeSheetProducts)
mockShopify.mockResolvedValue(fakeShopifyProducts)
mockDiff.mockReturnValue(fakeDiff)
const req = new NextRequest('http://localhost/api/sync/preview')
const res = await GET(req)
expect(res.status).toBe(200)
const data = await res.json()
expect(data.summary.create).toBe(1)
expect(data.changes).toHaveLength(1)
expect(mockDiff).toHaveBeenCalledWith(fakeSheetProducts, fakeShopifyProducts)
})
it('retourne 500 si Google Sheets échoue', async () => {
mockSheets.mockRejectedValue(new Error('Google Auth error'))
const req = new NextRequest('http://localhost/api/sync/preview')
const res = await GET(req)
expect(res.status).toBe(500)
const data = await res.json()
expect(data.error).toBeTruthy()
})
it('retourne 500 si Shopify échoue', async () => {
mockSheets.mockResolvedValue([])
mockShopify.mockRejectedValue(new Error('Shopify error'))
const req = new NextRequest('http://localhost/api/sync/preview')
const res = await GET(req)
expect(res.status).toBe(500)
})
})
+62
View File
@@ -0,0 +1,62 @@
import { parseSheetRows } from '@/lib/googleSheets'
describe('parseSheetRows', () => {
it('parse une ligne complète', () => {
const rows = [['REF-1042', 'Carrelage sol', 'Beau carrelage', '29.99', '15', 'actif']]
const result = parseSheetRows(rows)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
ref: 'REF-1042',
handle: 'ref-1042',
title: 'Carrelage sol',
description: 'Beau carrelage',
price: '29.99',
stock: 15,
status: 'active',
})
})
it('normalise le handle en minuscules avec tirets', () => {
const rows = [['ART 001/B', 'Produit', '', '10.00', '0', 'actif']]
const [p] = parseSheetRows(rows)
expect(p.handle).toBe('art-001-b')
})
it('ignore les lignes sans référence', () => {
const rows = [['', 'Titre vide', '', '0', '0', ''], ['REF-1', 'OK', '', '5.00', '1', 'actif']]
expect(parseSheetRows(rows)).toHaveLength(1)
})
it('gère un prix avec virgule (format français)', () => {
const rows = [['REF-2', 'Test', '', '19,99', '0', 'actif']]
expect(parseSheetRows(rows)[0].price).toBe('19.99')
})
it('marque comme draft si la colonne statut contient "inac"', () => {
const rows = [
['REF-3', 'A', '', '1.00', '0', 'Inactif'],
['REF-4', 'B', '', '1.00', '0', 'inactif'],
['REF-5', 'C', '', '1.00', '0', 'actif'],
['REF-6', 'D', '', '1.00', '0', ''],
]
const results = parseSheetRows(rows)
expect(results[0].status).toBe('draft')
expect(results[1].status).toBe('draft')
expect(results[2].status).toBe('active')
expect(results[3].status).toBe('active')
})
it('utilise la référence comme titre si le titre est vide', () => {
const rows = [['REF-7', '', '', '5.00', '0', 'actif']]
expect(parseSheetRows(rows)[0].title).toBe('REF-7')
})
it('accepte des lignes avec colonnes manquantes', () => {
const rows = [['REF-8']]
const [p] = parseSheetRows(rows)
expect(p.ref).toBe('REF-8')
expect(p.price).toBe('0.00')
expect(p.stock).toBe(0)
expect(p.status).toBe('active')
})
})
+97
View File
@@ -0,0 +1,97 @@
import { computeDiff } from '@/lib/syncDiff'
import type { SyncProduct, ShopifyProductFull } from '@/types/sync'
const sheet = (ref: string, overrides: Partial<SyncProduct> = {}): SyncProduct => ({
ref,
handle: ref.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
title: `Produit ${ref}`,
description: 'Desc',
price: '10.00',
stock: 5,
status: 'active',
...overrides,
})
const shopify = (ref: string, shopifyId = '111', overrides: Partial<ShopifyProductFull> = {}): ShopifyProductFull => ({
shopifyId,
...sheet(ref, overrides),
...overrides,
})
describe('computeDiff', () => {
it('retourne vide si aucun produit des deux côtés', () => {
const { changes, summary } = computeDiff([], [])
expect(changes).toHaveLength(0)
expect(summary).toEqual({ create: 0, update: 0, deactivate: 0, unchanged: 0 })
})
it('marque comme create si produit présent dans Sheets mais absent de Shopify', () => {
const { changes, summary } = computeDiff([sheet('REF-1')], [])
expect(changes).toHaveLength(1)
expect(changes[0].type).toBe('create')
expect(changes[0].ref).toBe('REF-1')
expect(summary.create).toBe(1)
})
it('marque comme unchanged si produits identiques', () => {
const s = sheet('REF-2')
const sp = shopify('REF-2')
const { changes, summary } = computeDiff([s], [sp])
expect(changes[0].type).toBe('unchanged')
expect(summary.unchanged).toBe(1)
})
it('marque comme update si le titre diffère', () => {
const s = sheet('REF-3', { title: 'Nouveau titre' })
const sp = shopify('REF-3', '222', { title: 'Ancien titre' })
const { changes } = computeDiff([s], [sp])
expect(changes[0].type).toBe('update')
expect(changes[0].changedFields).toContainEqual({
field: 'title',
from: 'Ancien titre',
to: 'Nouveau titre',
})
})
it('marque comme update si le prix diffère', () => {
const s = sheet('REF-4', { price: '25.00' })
const sp = shopify('REF-4', '333', { price: '20.00' })
const { changes } = computeDiff([s], [sp])
expect(changes[0].type).toBe('update')
expect(changes[0].changedFields).toContainEqual({ field: 'price', from: '20.00', to: '25.00' })
})
it('marque comme update si le statut diffère', () => {
const s = sheet('REF-5', { status: 'draft' })
const sp = shopify('REF-5', '444', { status: 'active' })
const { changes } = computeDiff([s], [sp])
expect(changes[0].type).toBe('update')
})
it('marque comme deactivate si produit ACTIF dans Shopify absent des Sheets', () => {
const sp = shopify('REF-6', '555', { status: 'active' })
const { changes, summary } = computeDiff([], [sp])
expect(changes[0].type).toBe('deactivate')
expect(summary.deactivate).toBe(1)
})
it("ne marque PAS comme deactivate si le produit Shopify est déjà draft", () => {
const sp = shopify('REF-7', '666', { status: 'draft' })
const { changes } = computeDiff([], [sp])
expect(changes).toHaveLength(0)
})
it('calcule le résumé correctement sur un mix de changements', () => {
const sheetProducts = [sheet('REF-A'), sheet('REF-B', { title: 'Modifié' }), sheet('REF-C')]
const shopifyProducts = [
shopify('REF-B', '10', { title: 'Original' }),
shopify('REF-C', '11'),
shopify('REF-D', '12', { status: 'active' }),
]
const { summary } = computeDiff(sheetProducts, shopifyProducts)
expect(summary.create).toBe(1) // REF-A
expect(summary.update).toBe(1) // REF-B
expect(summary.unchanged).toBe(1) // REF-C
expect(summary.deactivate).toBe(1) // REF-D
})
})
+166
View File
@@ -0,0 +1,166 @@
'use client'
import { useState, useCallback } from 'react'
import { Header } from '@/components/layout/Header'
import { PendingRowsTable } from '@/components/creation/PendingRowsTable'
import { MetafieldResolver } from '@/components/creation/MetafieldResolver'
import { CreationProgress } from '@/components/creation/CreationProgress'
import type { PendingProduct, MetafieldResolution, CreationStreamEvent } from '@/types/creation'
type Step = 'analyse' | 'metafields' | 'confirmation' | 'execution'
export default function CreationPage() {
const [step, setStep] = useState<Step>('analyse')
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 analyse = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/creation/preview')
const data = await res.json()
if (data.error) throw new Error(data.error)
setByTab(data.byTab)
setTotal(data.total)
setSpecificColumns(data.specificColumns)
setStep('metafields')
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur')
} finally {
setLoading(false)
}
}
const handleResolved = useCallback((r: MetafieldResolution[]) => { setResolutions(r) }, [])
const execute = async () => {
setStep('execution')
setEvents([])
setDone(false)
const res = await fetch('/api/creation/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resolutions }),
})
if (!res.body) return
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done: readerDone, value } = await reader.read()
if (readerDone) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.trim()) continue
try {
const event = JSON.parse(line) as CreationStreamEvent
setEvents(prev => [...prev, event])
if (event.type === 'done') setDone(true)
} catch { /* ligne incomplète */ }
}
}
setDone(true)
}
const STEPS: { id: Step; label: string }[] = [
{ id: 'analyse', label: 'Analyse' },
{ id: 'metafields', label: 'Métachamps' },
{ id: 'confirmation', label: 'Confirmation' },
{ id: 'execution', label: 'Exécution' },
]
const currentIdx = STEPS.findIndex(s => s.id === step)
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>
<div className="bg-slate-800 rounded-xl p-6">
{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">
<p className="text-gray-300 text-sm">
Lit tous les onglets du Google Sheets et détecte les lignes <code className="text-indigo-300">Publié=1</code> et <code className="text-indigo-300">ID vide</code>.
</p>
<button onClick={analyse} disabled={loading}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
{loading ? 'Analyse en cours…' : 'Analyser le Sheets'}
</button>
</div>
)}
{step === 'metafields' && (
<div className="space-y-6">
<PendingRowsTable byTab={byTab} total={total} />
{total > 0 && (
<>
<hr className="border-slate-700" />
<div>
<h2 className="text-base font-semibold text-white mb-3">Résolution des métachamps</h2>
<MetafieldResolver specificColumns={specificColumns} onResolved={handleResolved} />
</div>
<button onClick={() => setStep('confirmation')}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors">
Continuer
</button>
</>
)}
</div>
)}
{step === 'confirmation' && (
<div className="space-y-4">
<div className="space-y-2 text-sm">
<p className="text-gray-300"><span className="text-white font-medium">{total} produit{total > 1 ? 's' : ''}</span> à créer dans Shopify</p>
<p className="text-gray-300"><span className="text-white font-medium">{resolutions.filter(r => r.action !== 'skip').length}</span> type{resolutions.filter(r => r.action !== 'skip').length > 1 ? 's' : ''} de métachamps à assigner</p>
<p className="text-gray-300"><span className="text-white font-medium">{resolutions.filter(r => r.action === 'create').length}</span> nouvelle{resolutions.filter(r => r.action === 'create').length > 1 ? 's' : ''} définition{resolutions.filter(r => r.action === 'create').length > 1 ? 's' : ''} à créer dans Shopify</p>
<p className="text-yellow-300 text-xs mt-2"> Les descriptions seront générées par OpenAI GPT-4o (~0.01$ par produit)</p>
</div>
<div className="flex gap-3">
<button onClick={() => setStep('metafields')}
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-gray-300 rounded-lg text-sm font-medium transition-colors">
Retour
</button>
<button onClick={execute}
className="px-4 py-2 bg-green-700 hover:bg-green-600 text-white rounded-lg text-sm font-medium transition-colors">
Lancer la création ({total} produits)
</button>
</div>
</div>
)}
{step === 'execution' && (
<div className="space-y-4">
<CreationProgress events={events} done={done} />
{done && (
<button onClick={() => { setStep('analyse'); setEvents([]); setDone(false); setByTab({}); setTotal(0) }}
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>
</>
)
}
+12 -2
View File
@@ -5,11 +5,12 @@ 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 { 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, rotate, uploadOne, uploadAll, removeItem, assignProduct } = useImages()
const [lightboxItem, setLightboxItem] = useState<ImageItem | null>(null)
return (
@@ -41,6 +42,7 @@ export default function ImagesPage() {
onUpload={uploadOne}
onRemove={removeItem}
onOpenLightbox={setLightboxItem}
onAssignProduct={assignProduct}
/>
))}
</div>
@@ -59,3 +61,11 @@ export default function ImagesPage() {
</>
)
}
export default function ImagesPage() {
return (
<ErrorBoundary>
<ImagesPageInner />
</ErrorBoundary>
)
}
+268 -1
View File
@@ -1,9 +1,276 @@
'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
}
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
const syncStatusColor =
lastSync?.status === 'success' ? 'text-green-400' :
lastSync?.status === 'error' ? 'text-red-400' : 'text-slate-400'
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">
{/* ── 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>
</>
)
}
+34 -2
View File
@@ -1,9 +1,41 @@
'use client'
import { useState } from 'react'
import { useSession } from 'next-auth/react'
import { Header } from '@/components/layout/Header'
import { UsersManager } from '@/components/settings/UsersManager'
import { ConnectionStatus } from '@/components/settings/ConnectionStatus'
type Tab = 'connexions' | 'utilisateurs'
export default function ParametresPage() {
const { data: session } = useSession()
const isAdmin = session?.user?.role === 'admin'
const [tab, setTab] = useState<Tab>('connexions')
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">
{isAdmin && (
<div className="flex gap-2">
{(['connexions', 'utilisateurs'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
tab === t
? 'bg-indigo-600 text-white'
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
}`}
>
{t === 'connexions' ? 'Connexions' : 'Utilisateurs'}
</button>
))}
</div>
)}
{(!isAdmin || tab === 'connexions') && <ConnectionStatus />}
{isAdmin && tab === 'utilisateurs' && <UsersManager />}
</div>
</>
)
}
+49 -2
View File
@@ -1,9 +1,56 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { Header } from '@/components/layout/Header'
import { 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" />
<div className="p-6">
<div className="bg-slate-800 rounded-xl p-6">
<ProductFiltersBar
filters={filters}
total={displayed.length}
onChange={(f) => setFilters((prev) => ({ ...prev, ...f }))}
/>
{error && (
<div className="mb-4 p-3 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">
{error}
</div>
)}
<ProductTable products={displayed} loading={loading} />
</div>
</div>
</>
)
}
+214 -1
View File
@@ -1,9 +1,222 @@
'use client'
import { useState, useCallback } from 'react'
import { Header } from '@/components/layout/Header'
import { SyncStepper } from '@/components/sync/SyncStepper'
import { DiffTable } from '@/components/sync/DiffTable'
import { SyncProgress } from '@/components/sync/SyncProgress'
import { SyncHistory } from '@/components/sync/SyncHistory'
import type { DiffResult, SyncChange, SyncHistoryEntry } from '@/types/sync'
type Step = 1 | 2 | 3 | 4
export default function SyncPage() {
const [step, setStep] = useState<Step>(1)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [diff, setDiff] = useState<DiffResult | null>(null)
const [history, setHistory] = useState<SyncHistoryEntry[]>([])
const [historyLoaded, setHistoryLoaded] = useState(false)
// Log streaming state
const [log, setLog] = useState<string[]>([])
const [progress, setProgress] = useState<{ current: number; total: number } | null>(null)
const [summary, setSummary] = useState<{
created: number; updated: number; deactivated: number; errors: number; status: string
} | null>(null)
// Étape 1 → 2 : lancer le preview
const handleAnalyze = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/sync/preview')
const data = await res.json()
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
setDiff(data)
setStep(2)
} catch (err) {
setError((err as Error).message)
} finally {
setLoading(false)
}
}, [])
// Étape 3 → 4 : lancer la sync en streaming
const handleExecute = useCallback(async () => {
if (!diff) return
setStep(4)
setLog([])
setProgress(null)
setSummary(null)
const actionableChanges: SyncChange[] = diff.changes.filter((c) => c.type !== 'unchanged')
try {
const res = await fetch('/api/sync/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ changes: actionableChanges }),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || `HTTP ${res.status}`)
}
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines.filter(Boolean)) {
try {
const event = JSON.parse(line)
if (event.type === 'progress') {
setProgress({ current: event.current, total: event.total })
setLog((prev) => [...prev, event.message])
} else if (event.type === 'done') {
setSummary(event.summary)
setHistoryLoaded(false) // force reload history
}
} catch { /* ligne incomplète */ }
}
}
} catch (err) {
setLog((prev) => [...prev, `❌ Erreur : ${(err as Error).message}`])
}
}, [diff])
// Charger l'historique au besoin
const loadHistory = useCallback(async () => {
if (historyLoaded) return
try {
const res = await fetch('/api/sync/history')
if (res.ok) {
setHistory(await res.json())
setHistoryLoaded(true)
}
} catch { /* silencieux */ }
}, [historyLoaded])
const actionableCount = diff
? diff.summary.create + diff.summary.update + diff.summary.deactivate
: 0
return (
<>
<Header title="🔄 Sync Sheets" />
<div className="p-6"><p className="text-gray-400 text-sm">Module Sync à implémenter (Plan 3)</p></div>
<div className="p-6 flex flex-col gap-6 flex-1 overflow-y-auto">
{/* Stepper */}
<SyncStepper currentStep={step} />
{/* Étape 1 — Analyser */}
{step === 1 && (
<div className="flex flex-col items-center gap-4 py-8">
<p className="text-slate-400 text-sm text-center max-w-md">
Lit Google Sheets et compare avec le catalogue Shopify actuel pour générer l&apos;aperçu des changements.
</p>
{error && (
<div className="bg-red-950 border border-red-800 text-red-300 text-sm rounded-lg px-4 py-2 max-w-md">
{error}
</div>
)}
<button
onClick={handleAnalyze}
disabled={loading}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-semibold px-6 py-3 rounded-xl text-sm transition-colors"
>
{loading ? '⏳ Analyse en cours…' : '🔍 Analyser le Sheets'}
</button>
</div>
)}
{/* Étape 2 — Aperçu diff */}
{step === 2 && diff && (
<div className="flex flex-col gap-4">
<DiffTable diff={diff} />
<div className="flex gap-3 justify-end">
<button
onClick={() => { setStep(1); setDiff(null) }}
className="px-4 py-2 text-sm bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg"
>
Relancer l&apos;analyse
</button>
<button
onClick={() => setStep(3)}
disabled={actionableCount === 0}
className="px-4 py-2 text-sm bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white font-semibold rounded-lg"
>
Confirmer
</button>
</div>
</div>
)}
{/* Étape 3 — Confirmation */}
{step === 3 && diff && (
<div className="flex flex-col items-center gap-6 py-8">
<div className="bg-slate-900 border border-slate-700 rounded-xl p-6 text-center max-w-sm">
<p className="text-lg font-semibold text-white mb-2">
{actionableCount} changement{actionableCount > 1 ? 's' : ''} vont être appliqués à Shopify
</p>
<p className="text-sm text-slate-400">
{diff.summary.create > 0 && `${diff.summary.create} créations · `}
{diff.summary.update > 0 && `${diff.summary.update} mises à jour · `}
{diff.summary.deactivate > 0 && `${diff.summary.deactivate} désactivations`}
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setStep(2)}
className="px-4 py-2 text-sm bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg"
>
Annuler
</button>
<button
onClick={handleExecute}
className="px-6 py-2 text-sm bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded-lg"
>
Confirmer et synchroniser
</button>
</div>
</div>
)}
{/* Étape 4 — Progression */}
{step === 4 && (
<div className="flex flex-col gap-4">
<SyncProgress log={log} progress={progress} summary={summary} />
{summary && (
<div className="flex gap-3 justify-end">
<button
onClick={() => { setStep(1); setDiff(null); setSummary(null); setLog([]) }}
className="px-4 py-2 text-sm bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded-lg"
>
Nouvelle sync
</button>
</div>
)}
</div>
)}
{/* Historique */}
<div className="mt-4">
<button
onClick={loadHistory}
className="text-sm text-slate-500 hover:text-slate-300 mb-3"
>
{historyLoaded ? '📋 Historique des syncs' : '📋 Charger l\'historique'}
</button>
{historyLoaded && <SyncHistory entries={history} />}
</div>
</div>
</>
)
}
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
async function testShopify(): Promise<{ ok: boolean; message: string }> {
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) return { ok: false, message: 'Variables manquantes' }
try {
const res = await fetch(`https://${domain}/admin/api/2024-01/shop.json`, {
headers: { 'X-Shopify-Access-Token': token },
})
if (!res.ok) return { ok: false, message: `HTTP ${res.status}` }
const data = await res.json()
return { ok: true, message: `Boutique : ${data.shop?.name ?? domain}` }
} catch (e) {
return { ok: false, message: e instanceof Error ? e.message : 'Erreur réseau' }
}
}
async function testSheets(): Promise<{ ok: boolean; message: string }> {
const apiKey = process.env.GOOGLE_API_KEY
const sheetId = process.env.GOOGLE_SHEETS_ID
if (!apiKey || !sheetId) return { ok: false, message: 'Variables manquantes' }
try {
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=properties.title`
const res = await fetch(url)
if (!res.ok) return { ok: false, message: `HTTP ${res.status}` }
const data = await res.json()
return { ok: true, message: `Feuille : ${data.properties?.title ?? sheetId}` }
} catch (e) {
return { ok: false, message: e instanceof Error ? e.message : 'Erreur réseau' }
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function GET(_req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session || session.user.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const [shopify, sheets] = await Promise.all([testShopify(), testSheets()])
return NextResponse.json({ shopify, sheets })
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs'
async function requireAdmin() {
const session = await getServerSession(authOptions)
if (!session || session.user.role !== 'admin') return null
return session
}
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
const session = await requireAdmin()
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const body = await req.json().catch(() => ({}))
const { name, role, password } = body as Record<string, string>
const data: Record<string, string> = {}
if (name) data.name = name
if (role) data.role = role
if (password) data.password = await bcrypt.hash(password, 12)
if (Object.keys(data).length === 0) {
return NextResponse.json({ error: 'Aucun champ à mettre à jour' }, { status: 400 })
}
const user = await prisma.user.update({
where: { id: params.id },
data,
select: { id: true, email: true, name: true, role: true, createdAt: true },
})
return NextResponse.json(user)
}
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
const session = await requireAdmin()
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
if (params.id === session.user.id) {
return NextResponse.json({ error: 'Impossible de se supprimer soi-même' }, { status: 400 })
}
await prisma.user.delete({ where: { id: params.id } })
return NextResponse.json({ ok: true })
}
+61
View File
@@ -0,0 +1,61 @@
import { GET, POST } from './route'
import { getServerSession } from 'next-auth'
import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs'
jest.mock('next-auth')
jest.mock('@/lib/prisma', () => ({ prisma: { user: { findMany: jest.fn(), create: jest.fn() } } }))
jest.mock('bcryptjs')
const mockSession = getServerSession as jest.MockedFunction<typeof getServerSession>
const mockFindMany = prisma.user.findMany as jest.MockedFunction<typeof prisma.user.findMany>
const mockCreate = prisma.user.create as jest.MockedFunction<typeof prisma.user.create>
const mockHash = bcrypt.hash as jest.MockedFunction<typeof bcrypt.hash>
const adminSession = { user: { id: 'admin-id', role: 'admin' } }
const gestSession = { user: { id: 'gest-id', role: 'gestionnaire' } }
describe('GET /api/admin/users', () => {
it('retourne 403 si non admin', async () => {
mockSession.mockResolvedValue(gestSession as never)
const res = await GET(new Request('http://localhost') as never)
expect(res.status).toBe(403)
})
it('retourne la liste des utilisateurs', async () => {
mockSession.mockResolvedValue(adminSession as never)
mockFindMany.mockResolvedValue([
{ id: '1', email: 'a@b.com', name: 'Alice', role: 'admin', createdAt: new Date() } as never,
])
const res = await GET(new Request('http://localhost') as never)
const body = await res.json()
expect(body).toHaveLength(1)
expect(body[0].email).toBe('a@b.com')
expect(body[0].password).toBeUndefined()
})
})
describe('POST /api/admin/users', () => {
it('retourne 403 si non admin', async () => {
mockSession.mockResolvedValue(gestSession as never)
const res = await POST(new Request('http://localhost', { method: 'POST', body: '{}' }) as never)
expect(res.status).toBe(403)
})
it('crée un utilisateur avec mot de passe hashé', async () => {
mockSession.mockResolvedValue(adminSession as never)
mockHash.mockResolvedValue('hashed' as never)
mockCreate.mockResolvedValue({ id: 'new', email: 'b@c.com', name: 'Bob', role: 'gestionnaire', createdAt: new Date() } as never)
const body = JSON.stringify({ email: 'b@c.com', name: 'Bob', password: 'pass123', role: 'gestionnaire' })
const res = await POST(new Request('http://localhost', { method: 'POST', body, headers: { 'Content-Type': 'application/json' } }) as never)
expect(res.status).toBe(201)
expect(mockHash).toHaveBeenCalledWith('pass123', 12)
})
it('retourne 400 si champs manquants', async () => {
mockSession.mockResolvedValue(adminSession as never)
const body = JSON.stringify({ email: 'b@c.com' })
const res = await POST(new Request('http://localhost', { method: 'POST', body, headers: { 'Content-Type': 'application/json' } }) as never)
expect(res.status).toBe(400)
})
})
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs'
async function requireAdmin() {
const session = await getServerSession(authOptions)
if (!session || session.user.role !== 'admin') return null
return session
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function GET(_req: NextRequest) {
const session = await requireAdmin()
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const users = await prisma.user.findMany({
orderBy: { createdAt: 'asc' },
select: { id: true, email: true, name: true, role: true, createdAt: true },
})
return NextResponse.json(users)
}
export async function POST(req: NextRequest) {
const session = await requireAdmin()
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const body = await req.json().catch(() => ({}))
const { email, name, password, role } = body as Record<string, string>
if (!email || !name || !password || !role) {
return NextResponse.json({ error: 'email, name, password et role requis' }, { status: 400 })
}
const hashed = await bcrypt.hash(password, 12)
const user = await prisma.user.create({
data: { email, name, password: hashed, role },
select: { id: true, email: true, name: true, role: true, createdAt: true },
})
return NextResponse.json(user, { status: 201 })
}
+107
View File
@@ -0,0 +1,107 @@
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { fetchPendingProducts } from '@/lib/creationSheets'
import { generateDescriptions } from '@/lib/openaiClient'
import { ensureMetafieldDefinition, setProductMetafield } from '@/lib/shopifyMetafields'
import { addProductToCollection } from '@/lib/shopifyCollections'
import { createShopifyProduct } from '@/lib/shopifySync'
import { prisma } from '@/lib/prisma'
import type { MetafieldResolution, CreationStreamEvent } from '@/types/creation'
function encode(event: CreationStreamEvent): string {
return JSON.stringify(event) + '\n'
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) {
return new Response(JSON.stringify({ error: 'Non authentifié' }), { status: 401 })
}
const userId = (session.user as { id: string }).id
const body = await req.json().catch(() => ({})) as { resolutions?: MetafieldResolution[] }
const resolutions: MetafieldResolution[] = body.resolutions ?? []
const resolutionMap = new Map<string, MetafieldResolution>(resolutions.map(r => [r.columnHeader, r]))
const stream = new ReadableStream({
async start(controller) {
const send = (event: CreationStreamEvent) => controller.enqueue(encode(event))
try {
const 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)
const total = pending.length
if (total === 0) {
send({ type: 'done', summary: { created: 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 errors = 0
for (let i = 0; i < pending.length; i++) {
const product = pending[i]
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Génération descriptions…' })
try {
const { longDesc } = await generateDescriptions({
title: product.title,
rawDescription: '',
comment: product.comment,
vendor: product.vendor,
famille: product.famille,
})
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Création produit Shopify…' })
const shopifyId = await createShopifyProduct({
ref: product.sku,
handle: product.sku.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
title: product.title,
description: longDesc,
price: product.priceActual || '0.00',
stock: product.stock,
status: 'active',
})
if (product.collectionId) await addProductToCollection(shopifyId, product.collectionId)
if (product.subCollectionId) await addProductToCollection(shopifyId, product.subCollectionId)
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Métachamps…' })
for (const [colHeader, value] of Object.entries(product.specificFields)) {
const resolution = resolutionMap.get(colHeader)
if (!resolution || resolution.action === 'skip') continue
await setProductMetafield(shopifyId, resolution, value)
}
await prisma.productCreation.create({
data: { userId, sheetTab: product.tab, sheetRow: product.rowNumber, shopifyId, sku: product.sku, title: product.title, status: 'success' },
})
created++
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
send({ type: 'error', title: product.title, message })
await prisma.productCreation.create({
data: { userId, sheetTab: product.tab, sheetRow: product.rowNumber, shopifyId: '', sku: product.sku, title: product.title, status: 'error', errorMessage: message },
})
errors++
}
}
const status = errors === 0 ? 'success' : created === 0 ? 'error' : 'partial'
send({ type: 'done', summary: { created, errors, status } })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
send({ type: 'done', summary: { created: 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'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function GET(_req: NextRequest) {
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)
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 { 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 })
}
}
+88
View File
@@ -0,0 +1,88 @@
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
}
export async function GET() {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
try {
const [activeProducts, draftProducts, lastSyncRun] = await Promise.all([
fetchProductsLight('active'),
fetchProductsLight('draft'),
prisma.syncRun.findFirst({
orderBy: { createdAt: 'desc' },
include: { user: { select: { name: true } } },
}),
])
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
return NextResponse.json({
products: {
active: activeProducts.length,
draft: draftProducts.length,
total: allProducts.length,
withoutImages,
},
lastSync,
})
} 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 })
}
}
+59
View File
@@ -0,0 +1,59 @@
import { GET } from './route'
import * as shopifySync from '@/lib/shopifySync'
import type { ShopifyProductFull } from '@/types/sync'
jest.mock('@/lib/shopifySync')
const mockFetch = shopifySync.fetchAllShopifyProducts as jest.MockedFunction<typeof shopifySync.fetchAllShopifyProducts>
const PRODUCTS: ShopifyProductFull[] = [
{ shopifyId: '1', ref: 'REF-001', handle: 'ref-001', title: 'Brique rouge', description: '', price: '12.50', stock: 100, status: 'active' },
{ shopifyId: '2', ref: 'REF-002', handle: 'ref-002', title: 'Parpaing', description: '', price: '5.00', stock: 0, status: 'draft' },
{ shopifyId: '3', ref: 'REF-003', handle: 'ref-003', title: 'Ardoise', description: '', price: '80.00', stock: 20, status: 'active' },
]
describe('GET /api/products/list', () => {
beforeEach(() => {
process.env.SHOPIFY_STORE_DOMAIN = 'test.myshopify.com'
process.env.SHOPIFY_ADMIN_API_TOKEN = 'token'
mockFetch.mockResolvedValue(PRODUCTS)
})
afterEach(() => jest.clearAllMocks())
it('retourne tous les produits sans filtre', async () => {
const req = new Request('http://localhost/api/products/list')
const res = await GET(req as never)
const body = await res.json()
expect(body.total).toBe(3)
expect(body.products).toHaveLength(3)
})
it('filtre par status=active', async () => {
const req = new Request('http://localhost/api/products/list?status=active')
const res = await GET(req as never)
const body = await res.json()
expect(body.total).toBe(2)
body.products.forEach((p: { status: string }) => expect(p.status).toBe('active'))
})
it('filtre par status=draft', async () => {
const req = new Request('http://localhost/api/products/list?status=draft')
const res = await GET(req as never)
const body = await res.json()
expect(body.total).toBe(1)
expect(body.products[0].ref).toBe('REF-002')
})
it('retourne shopifyUrl pour chaque produit', async () => {
const req = new Request('http://localhost/api/products/list')
const res = await GET(req as never)
const body = await res.json()
expect(body.products[0].shopifyUrl).toContain('test.myshopify.com/admin/products/1')
})
it('retourne 500 si Shopify inaccessible', async () => {
mockFetch.mockRejectedValue(new Error('network error'))
const req = new Request('http://localhost/api/products/list')
const res = await GET(req as never)
expect(res.status).toBe(500)
})
})
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server'
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
import type { CatalogProduct, ProductListResponse } from '@/types/products'
export async function GET(req: NextRequest): Promise<NextResponse<ProductListResponse | { error: string }>> {
try {
const { searchParams } = new URL(req.url)
const statusFilter = searchParams.get('status') ?? 'all'
const domain = process.env.SHOPIFY_STORE_DOMAIN ?? ''
const shopifyProducts = await fetchAllShopifyProducts()
let products: CatalogProduct[] = shopifyProducts.map((p) => ({
shopifyId: p.shopifyId,
ref: p.ref,
handle: p.handle,
title: p.title,
price: p.price,
stock: p.stock,
status: p.status as CatalogProduct['status'],
shopifyUrl: `https://${domain}/admin/products/${p.shopifyId}`,
}))
if (statusFilter !== 'all') {
products = products.filter((p) => p.status === statusFilter)
}
return NextResponse.json({ products, total: products.length })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
/**
* Cherche un produit dans Google Sheets par sa référence (UGS, col C).
* Retourne le Shopify ID (col A) et le titre (col D) si trouvé.
* Parcourt tous les onglets.
*/
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ found: false }, { status: 401 })
const { searchParams } = new URL(req.url)
const ref = searchParams.get('ref')?.trim()
if (!ref) return NextResponse.json({ found: false }, { status: 400 })
const apiKey = process.env.GOOGLE_API_KEY
const sheetId = process.env.GOOGLE_SHEETS_ID
if (!apiKey || !sheetId) {
return NextResponse.json({ found: false, error: 'GOOGLE_API_KEY et GOOGLE_SHEETS_ID requis' }, { status: 500 })
}
try {
// Récupère la liste des onglets
const metaRes = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`,
)
if (!metaRes.ok) throw new Error(`Sheets metadata error: ${metaRes.status}`)
const meta = await metaRes.json()
const tabs: string[] = (meta.sheets as Array<{ properties: { title: string } }>).map(
s => s.properties.title,
)
for (const tab of tabs) {
const range = encodeURIComponent(`${tab}!A2:D`)
const res = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`,
)
if (!res.ok) continue
const data = await res.json()
const rows = (data.values ?? []) as string[][]
for (const row of rows) {
const shopifyId = (row[0] ?? '').trim() // col A = ID Shopify
// col C = UGS (index 2 car on commence à A)
const ugs = (row[2] ?? '').trim()
// L'UGS doit apparaître comme mot entier dans le nom de fichier
// (entouré de séparateurs ou en début/fin) — ex: "dessus-30827-vue" ✓, "308270" ✗
const refLower = ref.toLowerCase()
const ugsLower = ugs.toLowerCase()
const wordBoundary = new RegExp(`(^|[^a-z0-9])${ugsLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`)
const isMatch = ugs && shopifyId && wordBoundary.test(refLower)
if (isMatch) {
const title = (row[3] ?? '').trim() // col D = Nom
return NextResponse.json({ found: true, shopifyId, title })
}
}
}
return NextResponse.json({ found: false })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur'
return NextResponse.json({ found: false, error: message }, { status: 500 })
}
}
+113
View File
@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import {
createShopifyProduct,
updateShopifyProduct,
deactivateShopifyProduct,
getFirstVariantId,
} from '@/lib/shopifySync'
import type { SyncChange, SyncLogEntry, SyncStreamEvent } from '@/types/sync'
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
}
let changes: SyncChange[]
try {
const body = await req.json()
changes = body.changes
} catch {
return NextResponse.json({ error: 'Corps JSON invalide' }, { status: 400 })
}
if (!Array.isArray(changes) || changes.length === 0) {
return NextResponse.json({ error: 'Aucun changement à appliquer' }, { status: 400 })
}
const userId = (session.user as { id: string }).id
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const send = (event: SyncStreamEvent) => {
controller.enqueue(encoder.encode(JSON.stringify(event) + '\n'))
}
const log: SyncLogEntry[] = []
let created = 0, updated = 0, deactivated = 0, errors = 0
const total = changes.filter((c) => c.type !== 'unchanged').length
let current = 0
for (const change of changes) {
if (change.type === 'unchanged') continue
current++
try {
if (change.type === 'create') {
await createShopifyProduct(change.sheetProduct!)
created++
const msg = `${change.ref} créé`
log.push({ ref: change.ref, action: 'create', status: 'success', message: msg })
send({ type: 'progress', current, total, message: msg })
} else if (change.type === 'update') {
const variantId = await getFirstVariantId(change.shopifyProduct!.shopifyId)
await updateShopifyProduct(
change.shopifyProduct!.shopifyId,
change.sheetProduct!,
variantId,
)
updated++
const fields = change.changedFields?.map((f) => f.field).join(', ') ?? ''
const msg = `${change.ref} mis à jour (${fields})`
log.push({ ref: change.ref, action: 'update', status: 'success', message: msg })
send({ type: 'progress', current, total, message: msg })
} else if (change.type === 'deactivate') {
await deactivateShopifyProduct(change.shopifyProduct!.shopifyId)
deactivated++
const msg = `${change.ref} désactivé`
log.push({ ref: change.ref, action: 'deactivate', status: 'success', message: msg })
send({ type: 'progress', current, total, message: msg })
}
} catch (err) {
errors++
const msg = `${change.ref} : ${(err as Error).message}`
log.push({ ref: change.ref, action: change.type as SyncLogEntry['action'], status: 'error', message: msg })
send({ type: 'progress', current, total, message: msg })
}
}
const syncStatus = errors === 0 ? 'success' : errors === total ? 'error' : 'partial'
try {
await prisma.syncRun.create({
data: {
userId,
status: syncStatus,
created,
updated,
deactivated,
errors,
log: JSON.stringify(log),
},
})
} catch (dbErr) {
console.error('Erreur sauvegarde SyncRun:', dbErr)
}
send({
type: 'done',
summary: { created, updated, deactivated, errors, status: syncStatus },
})
controller.close()
},
})
return new Response(stream, {
headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8' },
})
}
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import type { SyncHistoryEntry, SyncLogEntry } from '@/types/sync'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function GET(_req: NextRequest) {
try {
const runs = await prisma.syncRun.findMany({
orderBy: { createdAt: 'desc' },
take: 20,
include: { user: { select: { name: true } } },
})
const entries: SyncHistoryEntry[] = runs.map((run) => ({
id: run.id,
createdAt: run.createdAt.toISOString(),
userName: run.user.name,
status: run.status as SyncHistoryEntry['status'],
created: run.created,
updated: run.updated,
deactivated: run.deactivated,
errors: run.errors,
log: (() => {
try { return JSON.parse(run.log) as SyncLogEntry[] } catch { return [] }
})(),
}))
return NextResponse.json(entries)
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
import { readSheetProducts } from '@/lib/googleSheets'
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
import { computeDiff } from '@/lib/syncDiff'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function GET(_req: NextRequest) {
try {
const [sheetProducts, shopifyProducts] = await Promise.all([
readSheetProducts(),
fetchAllShopifyProducts(),
])
const diff = computeDiff(sheetProducts, shopifyProducts)
return NextResponse.json(diff)
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+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>
+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
}
}
@@ -0,0 +1,53 @@
'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' }>>
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>
)}
{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.errors > 0 && `${doneEvent.summary.errors} erreur${doneEvent.summary.errors > 1 ? 's' : ''}`}
</p>
</div>
)}
{!done && !lastProgress && <p className="text-gray-400 text-sm">Démarrage</p>}
</div>
)
}
@@ -0,0 +1,162 @@
'use client'
import { useState, useEffect } from 'react'
import type { MetafieldDefinition, MetafieldResolution } from '@/types/creation'
import { normalizeMetafieldKey } from '@/lib/shopifyMetafields'
interface Props {
specificColumns: string[]
onResolved: (resolutions: MetafieldResolution[]) => void
}
export function MetafieldResolver({ specificColumns, onResolved }: Props) {
const [definitions, setDefinitions] = useState<MetafieldDefinition[]>([])
const [matched, setMatched] = useState<Record<string, MetafieldResolution>>({})
const [unmatched, setUnmatched] = useState<string[]>([])
const [resolutions, setResolutions] = useState<Record<string, MetafieldResolution>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (specificColumns.length === 0) { setLoading(false); onResolved([]); return }
const params = specificColumns.map(c => encodeURIComponent(c)).join(',')
fetch(`/api/creation/metafields?columns=${params}`)
.then(r => r.json())
.then(data => {
if (data.error) throw new Error(data.error)
setDefinitions(data.definitions)
setMatched(data.matched)
setUnmatched(data.unmatched)
const init: Record<string, MetafieldResolution> = { ...data.matched }
// Défaut : 'skip' — l'utilisateur choisit explicitement ce qu'il veut créer
data.unmatched.forEach((col: string) => {
init[col] = { columnHeader: col, action: 'skip', namespace: 'custom', key: normalizeMetafieldKey(col) }
})
setResolutions(init)
})
.catch(e => setError(e.message))
.finally(() => setLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [specificColumns.join(',')])
useEffect(() => {
if (!loading) onResolved(Object.values(resolutions))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resolutions, loading])
const updateResolution = (col: string, partial: Partial<MetafieldResolution>) => {
setResolutions(prev => ({ ...prev, [col]: { ...prev[col], ...partial } }))
}
if (loading) return <p className="text-gray-400 text-sm">Analyse des métachamps Shopify</p>
if (error) return <p className="text-red-400 text-sm">{error}</p>
if (specificColumns.length === 0) return <p className="text-gray-400 text-sm">Aucune colonne spécifique détectée.</p>
return (
<div className="space-y-4">
{Object.entries(matched).length > 0 && (
<div>
<h3 className="text-sm font-medium text-green-400 mb-2">
{Object.entries(matched).length} colonne{Object.entries(matched).length > 1 ? 's' : ''} avec correspondance automatique
</h3>
<div className="space-y-1">
{Object.entries(matched).map(([col, res]) => (
<div key={col} className="flex items-center gap-3 px-3 py-2 bg-slate-700/50 rounded text-sm">
<span className="text-gray-300 flex-1">{col}</span>
<span className="text-green-400 text-xs"> {res.namespace}.{res.key}</span>
</div>
))}
</div>
</div>
)}
{unmatched.length > 0 && (
<div>
<div className="mb-3 p-3 bg-slate-700/50 rounded-lg border border-slate-600">
<p className="text-xs text-gray-400">
Ces colonnes n&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>
<label className="block text-xs text-gray-400 mb-1">Métachamp existant</label>
<select
value={res.existingDefinitionId ?? ''}
onChange={e => { const def = definitions.find(d => d.id === e.target.value); if (def) updateResolution(col, { existingDefinitionId: def.id, key: def.key, namespace: def.namespace }) }}
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">-- Choisir un métachamp existant --</option>
{definitions.map(d => <option key={d.id} value={d.id}>{d.name} ({d.namespace}.{d.key})</option>)}
</select>
</div>
)}
</div>
)
})}
</div>
{/* Résumé des actions */}
<div className="mt-3 flex gap-4 text-xs text-gray-400">
<span className="text-indigo-300 font-medium">{Object.values(resolutions).filter(r => r.action === 'create').length} à créer</span>
<span className="text-blue-300 font-medium">{Object.values(resolutions).filter(r => r.action === 'map').length} à associer</span>
<span>{Object.values(resolutions).filter(r => r.action === 'skip').length} ignorés</span>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,54 @@
'use client'
import type { PendingProduct } from '@/types/creation'
interface Props {
byTab: Record<string, PendingProduct[]>
total: number
}
export function PendingRowsTable({ byTab, total }: Props) {
if (total === 0) {
return (
<div className="text-center py-12 text-gray-400 text-sm">
Aucun produit en attente (Publié=1 et ID vide).
</div>
)
}
return (
<div className="space-y-6">
{Object.entries(byTab).map(([tab, products]) => (
<div key={tab}>
<h3 className="text-sm font-semibold text-indigo-300 mb-2 uppercase tracking-wide">
{tab} {products.length} produit{products.length > 1 ? 's' : ''}
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-400 border-b border-slate-700">
<th className="pb-2 pr-4 font-medium">SKU</th>
<th className="pb-2 pr-4 font-medium">Titre</th>
<th className="pb-2 pr-4 font-medium">Prix</th>
<th className="pb-2 pr-4 font-medium">Stock</th>
<th className="pb-2 font-medium">Métachamps</th>
</tr>
</thead>
<tbody>
{products.map((p) => (
<tr key={`${p.tab}-${p.rowNumber}`} className="border-b border-slate-700/50">
<td className="py-2 pr-4 font-mono text-indigo-300 text-xs">{p.sku}</td>
<td className="py-2 pr-4 text-white">{p.title}</td>
<td className="py-2 pr-4 text-gray-300">{p.priceActual} </td>
<td className="py-2 pr-4 text-gray-300">{p.stock}</td>
<td className="py-2 text-gray-400 text-xs">
{Object.keys(p.specificFields).length} champ{Object.keys(p.specificFields).length > 1 ? 's' : ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
)
}
+62 -2
View File
@@ -9,6 +9,7 @@ interface ImageCardProps {
onUpload: (id: string) => void
onRemove: (id: string) => void
onOpenLightbox: (item: ImageItem) => void
onAssignProduct: (id: string, shopifyProductId: string, shopifyProductTitle: string) => void
}
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
@@ -19,6 +20,7 @@ const STATUS_LABELS: Record<string, { label: string; color: string }> = {
ready: { label: '✓ Prêt', color: 'text-green-400' },
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' },
}
@@ -36,6 +38,59 @@ function BlobThumbnail({ blob, alt }: { blob: Blob; alt: string }) {
return <img src={url} alt={alt} className="w-full h-full object-contain" />
}
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,
onFeatherChange,
@@ -43,6 +98,7 @@ export function ImageCard({
onUpload,
onRemove,
onOpenLightbox,
onAssignProduct,
}: ImageCardProps) {
const statusInfo = STATUS_LABELS[item.status] ?? { label: item.status, color: 'text-slate-400' }
const isProcessing = ['pending', 'converting', 'processing', 'searching', 'uploading'].includes(item.status)
@@ -55,11 +111,11 @@ export function ImageCard({
className="relative h-40 bg-slate-800 cursor-pointer"
onClick={() => onOpenLightbox(item)}
>
<div className="absolute inset-0">
<div className={`absolute top-0 bottom-0 left-0 overflow-hidden ${item.processedBlob ? 'w-1/2' : 'w-full'}`}>
<BlobThumbnail blob={item.originalBlob} alt="Original" />
</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>
)}
@@ -88,6 +144,10 @@ export function ImageCard({
{item.error && <p className="text-xs text-red-400 mt-0.5 truncate" title={item.error}>{item.error}</p>}
</div>
{item.status === 'not_found' && (
<ProductSearch itemId={item.id} onAssign={onAssignProduct} />
)}
{/* Contrôles feather + rotation */}
<div className={canAdjust ? '' : 'opacity-40 pointer-events-none'}>
<label className="block text-xs text-slate-500 mb-1">
+1
View File
@@ -15,6 +15,7 @@ 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: '/parametres', label: 'Paramètres', icon: '⚙️', adminOnly: true },
]
@@ -0,0 +1,38 @@
'use client'
import type { ProductFilters } from '@/types/products'
interface Props {
filters: ProductFilters
total: number
onChange: (f: Partial<ProductFilters>) => void
}
export function ProductFiltersBar({ filters, total, onChange }: Props) {
return (
<div className="flex flex-wrap gap-3 items-center mb-4">
<input
type="text"
placeholder="Rechercher ref ou titre..."
value={filters.search}
onChange={(e) => onChange({ search: e.target.value })}
className="flex-1 min-w-48 bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<div className="flex gap-2">
{(['all', 'active', 'draft'] as const).map((s) => (
<button
key={s}
onClick={() => onChange({ status: s })}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
filters.status === s
? 'bg-indigo-600 text-white'
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
}`}
>
{s === 'all' ? 'Tous' : s === 'active' ? 'Actifs' : 'Inactifs'}
</button>
))}
</div>
<span className="text-sm text-gray-400 ml-auto">{total} produit{total > 1 ? 's' : ''}</span>
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
'use client'
import type { CatalogProduct } from '@/types/products'
interface Props {
products: CatalogProduct[]
loading: boolean
}
const PAGE_SIZE = 50
export function ProductTable({ products, loading }: Props) {
if (loading) {
return (
<div className="flex items-center justify-center py-20 text-gray-400">
Chargement des produits
</div>
)
}
if (products.length === 0) {
return (
<div className="text-center py-20 text-gray-400 text-sm">
Aucun produit trouvé.
</div>
)
}
const displayed = products.slice(0, PAGE_SIZE)
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-400 border-b border-slate-700">
<th className="pb-3 pr-4 font-medium">Référence</th>
<th className="pb-3 pr-4 font-medium">Titre</th>
<th className="pb-3 pr-4 font-medium">Prix</th>
<th className="pb-3 pr-4 font-medium">Stock</th>
<th className="pb-3 pr-4 font-medium">Statut</th>
<th className="pb-3 font-medium">Lien</th>
</tr>
</thead>
<tbody>
{displayed.map((p) => (
<tr key={p.shopifyId} className="border-b border-slate-700/50 hover:bg-slate-700/30 transition-colors">
<td className="py-3 pr-4 font-mono text-indigo-300">{p.ref}</td>
<td className="py-3 pr-4 text-white">{p.title}</td>
<td className="py-3 pr-4 text-gray-300">{p.price} </td>
<td className="py-3 pr-4 text-gray-300">{p.stock}</td>
<td className="py-3 pr-4">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
p.status === 'active'
? 'bg-green-900/50 text-green-300'
: 'bg-gray-700 text-gray-400'
}`}>
{p.status === 'active' ? 'Actif' : 'Inactif'}
</span>
</td>
<td className="py-3">
<a
href={p.shopifyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-400 hover:text-indigo-300 text-xs underline"
>
Shopify
</a>
</td>
</tr>
))}
</tbody>
</table>
{products.length > PAGE_SIZE && (
<p className="text-center text-gray-400 text-xs mt-4">
Affichage des {PAGE_SIZE} premiers résultats sur {products.length}.
</p>
)}
</div>
)
}
@@ -0,0 +1,66 @@
'use client'
import { useState } from 'react'
interface TestResult {
ok: boolean
message: string
}
interface Results {
shopify: TestResult
sheets: TestResult
}
export function ConnectionStatus() {
const [results, setResults] = useState<Results | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const runTest = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/admin/connections/test')
if (!res.ok) throw new Error('Accès refusé')
setResults(await res.json())
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur')
} finally {
setLoading(false)
}
}
return (
<div className="bg-slate-800 rounded-xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Test des connexions</h2>
<button
onClick={runTest}
disabled={loading}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors mb-4"
>
{loading ? 'Test en cours…' : 'Tester les connexions'}
</button>
{error && <p className="text-red-400 text-sm mb-4">{error}</p>}
{results && (
<div className="space-y-3">
{(
[
{ key: 'shopify', label: 'Shopify Admin API' },
{ key: 'sheets', label: 'Google Sheets API' },
] as const
).map(({ key, label }) => (
<div key={key} className="flex items-center gap-3 p-3 bg-slate-700 rounded-lg">
<span className={`text-lg ${results[key].ok ? 'text-green-400' : 'text-red-400'}`}>
{results[key].ok ? '✓' : '✗'}
</span>
<div>
<p className="text-sm font-medium text-white">{label}</p>
<p className="text-xs text-gray-400">{results[key].message}</p>
</div>
</div>
))}
</div>
)}
</div>
)
}
+151
View File
@@ -0,0 +1,151 @@
'use client'
import { useState, useEffect } from 'react'
interface User {
id: string
email: string
name: string
role: string
createdAt: string
}
interface FormData {
email: string
name: string
password: string
role: 'admin' | 'gestionnaire'
}
const EMPTY_FORM: FormData = { email: '', name: '', password: '', role: 'gestionnaire' }
export function UsersManager() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [form, setForm] = useState<FormData>(EMPTY_FORM)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const load = () => {
setLoading(true)
fetch('/api/admin/users')
.then((r) => r.json())
.then(setUsers)
.catch(() => setError('Impossible de charger les utilisateurs'))
.finally(() => setLoading(false))
}
useEffect(load, [])
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
setError(null)
setSuccess(null)
try {
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
setSuccess(`Utilisateur ${data.name} créé.`)
setForm(EMPTY_FORM)
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur')
} finally {
setSubmitting(false)
}
}
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Supprimer ${name} ?`)) return
try {
const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' })
const data = await res.json()
if (!res.ok) throw new Error(data.error)
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur suppression')
}
}
return (
<div className="bg-slate-800 rounded-xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Gestion des utilisateurs</h2>
{/* Liste */}
{loading ? (
<p className="text-gray-400 text-sm mb-6">Chargement</p>
) : (
<div className="mb-6 space-y-2">
{users.map((u) => (
<div key={u.id} className="flex items-center justify-between p-3 bg-slate-700 rounded-lg">
<div>
<p className="text-sm font-medium text-white">{u.name}</p>
<p className="text-xs text-gray-400">{u.email} <span className="capitalize">{u.role}</span></p>
</div>
<button
onClick={() => handleDelete(u.id, u.name)}
className="text-red-400 hover:text-red-300 text-xs px-2 py-1 rounded hover:bg-red-900/30 transition-colors"
>
Supprimer
</button>
</div>
))}
{users.length === 0 && <p className="text-gray-400 text-sm">Aucun utilisateur.</p>}
</div>
)}
{/* Formulaire création */}
<form onSubmit={handleCreate} className="space-y-3">
<h3 className="text-sm font-medium text-gray-300">Nouvel utilisateur</h3>
<div className="grid grid-cols-2 gap-3">
<input
required
type="email"
placeholder="Email"
value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
className="bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<input
required
type="text"
placeholder="Nom"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
className="bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<input
required
type="password"
placeholder="Mot de passe"
value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
className="bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<select
value={form.role}
onChange={(e) => setForm((f) => ({ ...f, role: e.target.value as FormData['role'] }))}
className="bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="gestionnaire">Gestionnaire</option>
<option value="admin">Admin</option>
</select>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{success && <p className="text-green-400 text-sm">{success}</p>}
<button
type="submit"
disabled={submitting}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors"
>
{submitting ? 'Création…' : "Créer l'utilisateur"}
</button>
</form>
</div>
)
}
+118
View File
@@ -0,0 +1,118 @@
'use client'
import { useState } from 'react'
import type { SyncChangeType, DiffResult } from '@/types/sync'
interface DiffTableProps {
diff: DiffResult
}
const TYPE_CONFIG: Record<SyncChangeType, { label: string; color: string; bg: string }> = {
create: { label: '🟢 Nouveau', color: 'text-green-400', bg: 'bg-green-950/30' },
update: { label: '🔵 Mis à jour', color: 'text-blue-400', bg: 'bg-blue-950/30' },
deactivate: { label: '🔴 Désactivé', color: 'text-red-400', bg: 'bg-red-950/30' },
unchanged: { label: '⚫ Inchangé', color: 'text-slate-500', bg: '' },
}
const PAGE_SIZE = 20
export function DiffTable({ diff }: DiffTableProps) {
const [filter, setFilter] = useState<SyncChangeType | 'all'>('all')
const [page, setPage] = useState(0)
const visible = diff.changes.filter((c) => c.type !== 'unchanged')
const filtered = filter === 'all' ? visible : visible.filter((c) => c.type === filter)
const paginated = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
const totalPages = Math.ceil(filtered.length / PAGE_SIZE)
return (
<div className="flex flex-col gap-3">
{/* Résumé */}
<div className="flex gap-4 flex-wrap text-sm">
{(['create', 'update', 'deactivate', 'unchanged'] as SyncChangeType[]).map((type) => (
<span key={type} className={TYPE_CONFIG[type].color}>
{TYPE_CONFIG[type].label} : {diff.summary[type]}
</span>
))}
</div>
{/* Filtres */}
<div className="flex gap-2 flex-wrap">
{(['all', 'create', 'update', 'deactivate'] as const).map((f) => (
<button
key={f}
onClick={() => { setFilter(f); setPage(0) }}
className={`px-3 py-1 text-xs rounded-full border transition-colors ${
filter === f
? 'bg-indigo-600 border-indigo-500 text-white'
: 'border-slate-700 text-slate-400 hover:text-slate-200'
}`}
>
{f === 'all' ? 'Tous' : TYPE_CONFIG[f].label}
</button>
))}
</div>
{/* Tableau */}
{paginated.length === 0 ? (
<p className="text-slate-600 text-sm py-4 text-center">Aucun changement dans cette catégorie.</p>
) : (
<div className="border border-slate-700 rounded-xl overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-slate-800 text-slate-400">
<tr>
<th className="px-3 py-2 text-left">Ref</th>
<th className="px-3 py-2 text-left">Type</th>
<th className="px-3 py-2 text-left">Champs modifiés</th>
</tr>
</thead>
<tbody>
{paginated.map((change) => (
<tr key={change.ref} className={`border-t border-slate-800 ${TYPE_CONFIG[change.type].bg}`}>
<td className="px-3 py-2 font-mono text-slate-300">{change.ref}</td>
<td className={`px-3 py-2 font-semibold ${TYPE_CONFIG[change.type].color}`}>
{TYPE_CONFIG[change.type].label}
</td>
<td className="px-3 py-2 text-slate-400">
{change.type === 'update' && change.changedFields?.map((f) => (
<span key={f.field} className="inline-block mr-2">
<span className="text-slate-500">{f.field} : </span>
<span className="line-through text-red-400">{f.from}</span>
<span className="text-slate-400"> </span>
<span className="text-green-400">{f.to}</span>
</span>
))}
{change.type === 'create' && (
<span className="text-green-400">{change.sheetProduct?.title}</span>
)}
{change.type === 'deactivate' && (
<span className="text-red-400">{change.shopifyProduct?.title}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex gap-2 justify-end">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="px-3 py-1 text-xs bg-slate-800 rounded disabled:opacity-40 text-slate-300"
> Préc</button>
<span className="text-xs text-slate-500 self-center">
{page + 1} / {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="px-3 py-1 text-xs bg-slate-800 rounded disabled:opacity-40 text-slate-300"
>Suiv </button>
</div>
)}
</div>
)
}
+78
View File
@@ -0,0 +1,78 @@
'use client'
import { useState } from 'react'
import React from 'react'
import type { SyncHistoryEntry } from '@/types/sync'
interface SyncHistoryProps {
entries: SyncHistoryEntry[]
}
const STATUS_STYLE = {
success: 'text-green-400',
partial: 'text-yellow-400',
error: 'text-red-400',
}
export function SyncHistory({ entries }: SyncHistoryProps) {
const [expandedId, setExpandedId] = useState<string | null>(null)
if (entries.length === 0) {
return <p className="text-slate-600 text-sm text-center py-6">Aucune synchronisation effectuée.</p>
}
return (
<div className="border border-slate-700 rounded-xl overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-slate-800 text-slate-400">
<tr>
<th className="px-3 py-2 text-left">Date</th>
<th className="px-3 py-2 text-left">Utilisateur</th>
<th className="px-3 py-2 text-right">Créés</th>
<th className="px-3 py-2 text-right">MàJ</th>
<th className="px-3 py-2 text-right">Désactivés</th>
<th className="px-3 py-2 text-right">Erreurs</th>
<th className="px-3 py-2 text-left">Statut</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<React.Fragment key={entry.id}>
<tr
onClick={() => setExpandedId(expandedId === entry.id ? null : entry.id)}
className="border-t border-slate-800 hover:bg-slate-800/50 cursor-pointer"
>
<td className="px-3 py-2 text-slate-400">
{new Date(entry.createdAt).toLocaleString('fr-FR', {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit',
})}
</td>
<td className="px-3 py-2 text-slate-300">{entry.userName}</td>
<td className="px-3 py-2 text-right text-green-400">{entry.created}</td>
<td className="px-3 py-2 text-right text-blue-400">{entry.updated}</td>
<td className="px-3 py-2 text-right text-red-400">{entry.deactivated}</td>
<td className="px-3 py-2 text-right text-yellow-400">{entry.errors || '—'}</td>
<td className={`px-3 py-2 font-semibold ${STATUS_STYLE[entry.status]}`}>
{entry.status}
</td>
</tr>
{expandedId === entry.id && (
<tr className="border-t border-slate-800 bg-slate-950">
<td colSpan={7} className="px-3 py-3">
<div className="font-mono text-xs max-h-32 overflow-y-auto space-y-0.5">
{entry.log.map((l, i) => (
<p key={i} className={l.status === 'error' ? 'text-red-400' : 'text-slate-400'}>
{l.message}
</p>
))}
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
)
}
+65
View File
@@ -0,0 +1,65 @@
'use client'
interface SyncProgressProps {
log: string[]
progress: { current: number; total: number } | null
summary: { created: number; updated: number; deactivated: number; errors: number; status: string } | null
}
export function SyncProgress({ log, progress, summary }: SyncProgressProps) {
const percent = progress && progress.total > 0
? Math.round((progress.current / progress.total) * 100)
: 0
return (
<div className="flex flex-col gap-4">
{/* Barre de progression */}
{progress && (
<div>
<div className="flex justify-between text-xs text-slate-400 mb-1">
<span>{progress.current} / {progress.total} produits</span>
<span>{percent}%</span>
</div>
<div className="h-2 bg-slate-800 rounded-full overflow-hidden">
<div
className="h-2 bg-indigo-500 rounded-full transition-all duration-300"
style={{ width: `${percent}%` }}
/>
</div>
</div>
)}
{/* Log */}
<div className="bg-slate-950 rounded-xl border border-slate-800 p-3 h-48 overflow-y-auto font-mono text-xs">
{log.length === 0 && (
<p className="text-slate-600">Démarrage de la synchronisation</p>
)}
{log.map((line, i) => (
<p
key={i}
className={line.startsWith('✗') ? 'text-red-400' : 'text-green-400'}
>
{line}
</p>
))}
</div>
{/* Résultat final */}
{summary && (
<div className={`rounded-xl p-4 text-sm font-semibold border ${
summary.status === 'success'
? 'bg-green-950/30 border-green-800 text-green-300'
: summary.status === 'partial'
? 'bg-yellow-950/30 border-yellow-800 text-yellow-300'
: 'bg-red-950/30 border-red-800 text-red-300'
}`}>
{summary.status === 'success' && '✅ '}
{summary.status === 'partial' && '⚠️ '}
{summary.status === 'error' && '❌ '}
Sync terminée {summary.created} créés, {summary.updated} mis à jour,{' '}
{summary.deactivated} désactivés, {summary.errors} erreur{summary.errors > 1 ? 's' : ''}
</div>
)}
</div>
)
}
+46
View File
@@ -0,0 +1,46 @@
'use client'
const STEPS = [
{ id: 1, label: 'Analyse Sheets' },
{ id: 2, label: 'Aperçu diff' },
{ id: 3, label: 'Confirmation' },
{ id: 4, label: 'Synchronisation' },
]
interface SyncStepperProps {
currentStep: number // 14
}
export function SyncStepper({ currentStep }: SyncStepperProps) {
return (
<div className="flex items-center gap-0">
{STEPS.map((step, i) => {
const done = currentStep > step.id
const active = currentStep === step.id
return (
<div key={step.id} className="flex items-center">
{i > 0 && (
<div className={`h-0.5 w-12 ${done ? 'bg-indigo-500' : 'bg-slate-700'}`} />
)}
<div className="flex flex-col items-center gap-1">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold transition-colors ${
done
? 'bg-indigo-500 text-white'
: active
? 'bg-indigo-600 text-white ring-2 ring-indigo-400'
: 'bg-slate-800 text-slate-500 border border-slate-700'
}`}
>
{done ? '✓' : step.id}
</div>
<span className={`text-xs ${active ? 'text-slate-200 font-semibold' : 'text-slate-500'}`}>
{step.label}
</span>
</div>
</div>
)
})}
</div>
)
}
+31 -4
View File
@@ -57,6 +57,19 @@ export function useImages() {
async (id: string, ref: string) => {
update(id, { status: 'searching' })
try {
// 1. Cherche d'abord dans Google Sheets (col C = UGS → col A = Shopify ID)
const sheetsRes = await fetch(`/api/products/search-sheets?ref=${encodeURIComponent(ref)}`)
const sheetsData = await sheetsRes.json()
if (sheetsData.found) {
update(id, {
status: 'ready',
shopifyProductId: sheetsData.shopifyId,
shopifyProductTitle: sheetsData.title,
})
return
}
// 2. Fallback : recherche directe dans Shopify par handle/titre
const res = await fetch(`/api/products/search?ref=${encodeURIComponent(ref)}`)
const data = await res.json()
if (data.found) {
@@ -88,6 +101,8 @@ export function useImages() {
try {
update(id, { status: 'converting' })
jpegBlob = await convertHeicToJpeg(blob)
// Met à jour originalBlob avec le JPEG converti (Chrome ne peut pas afficher HEIC)
update(id, { originalBlob: jpegBlob })
} catch (err) {
update(id, { status: 'error', error: `Conversion HEIC : ${(err as Error).message}` })
return
@@ -226,7 +241,7 @@ export function useImages() {
})
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 +249,25 @@ 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')
for (const item of ready) {
await uploadOne(item.id)
// Pause de 500ms entre chaque upload pour éviter le rate limit Shopify
await new Promise(r => setTimeout(r, 500))
}
}, [items, uploadOne])
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) => {
update(id, { shopifyProductId, shopifyProductTitle, status: 'ready' })
},
[update],
)
return { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem, assignProduct }
}
+16 -7
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) => {
@@ -64,13 +63,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,
)
})
}
+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)
})
})
+123
View File
@@ -0,0 +1,123 @@
import type { PendingProduct } from '@/types/creation'
const COMMON_HEADERS = new Set([
'ID', 'Type', 'UGS', 'Nom', 'Publié', 'Mis en avant ?',
'Description courte', 'Description longue',
'Date de début de promo', 'Date de fin de promo',
'État de la TVA', 'Classe de TVA', 'En stock ?',
'Stock (en pièce)', 'Vendre individuellement ?',
'Prix marché', 'Prix remisé', 'Code ecopart', 'Ecopart montant',
'Famille', 'Sous famille',
"Classe d'expédition (en retrait, livraison)",
'Conditionnement', 'Surface (m2)', 'Poids (kg) fonction unité',
'Images', 'Unité', 'Marque', 'Neuf ?', 'PHOTO ?',
'ID FAMILLE', 'ID SOUS FAMILLE',
])
export function findColIndex(headers: string[], search: string): number {
const exact = headers.findIndex(h => h === search)
if (exact !== -1) return exact
return headers.findIndex(h => h.startsWith(search))
}
export function extractSpecificFields(headers: string[], row: string[]): Record<string, string> {
const result: Record<string, string> = {}
headers.forEach((header, i) => {
if (!header) return
const isCommon = COMMON_HEADERS.has(header) || header.startsWith('Commentaire')
if (isCommon) return
const val = (row[i] ?? '').trim()
if (val) result[header] = val
})
return result
}
export function parseTabRows(tab: string, headers: string[], rows: string[][], dataStartRow = 2): PendingProduct[] {
const idx = {
id: findColIndex(headers, 'ID'),
sku: findColIndex(headers, 'UGS'),
nom: findColIndex(headers, 'Nom'),
publie: findColIndex(headers, 'Publié'),
stock: findColIndex(headers, 'Stock (en pièce)'),
prixMarche: findColIndex(headers, 'Prix marché'),
prixRemise: findColIndex(headers, 'Prix remisé'),
poids: findColIndex(headers, 'Poids (kg) fonction unité'),
marque: findColIndex(headers, 'Marque'),
famille: findColIndex(headers, 'Famille'),
sousFamille: findColIndex(headers, 'Sous famille'),
commentaire: findColIndex(headers, 'Commentaire'),
photo: findColIndex(headers, 'PHOTO ?'),
collectionId: findColIndex(headers, 'ID FAMILLE'),
subCollectionId: findColIndex(headers, 'ID SOUS FAMILLE'),
}
const get = (row: string[], i: number) => (i >= 0 ? (row[i] ?? '').trim() : '')
return rows
.map((row, rowIdx): PendingProduct | null => {
const id = get(row, idx.id)
const publie = get(row, idx.publie)
if (id !== '' || publie !== '1') return null
return {
tab,
rowNumber: dataStartRow + rowIdx,
sku: get(row, idx.sku),
title: get(row, idx.nom),
priceMarket: get(row, idx.prixMarche).replace(',', '.'),
priceActual: get(row, idx.prixRemise).replace(',', '.'),
stock: parseInt(get(row, idx.stock) || '0', 10) || 0,
weightKg: parseFloat(get(row, idx.poids).replace(',', '.') || '0') || 0,
vendor: get(row, idx.marque),
famille: get(row, idx.famille),
sousFamille: get(row, idx.sousFamille),
comment: get(row, idx.commentaire),
collectionId: get(row, idx.collectionId),
subCollectionId: get(row, idx.subCollectionId),
hasPhoto: get(row, idx.photo) !== '',
specificFields: extractSpecificFields(headers, row),
}
})
.filter((p): p is PendingProduct => p !== null)
}
export async function fetchPendingProducts(
alreadyCreatedRows: Set<string>,
): 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')
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,
)
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
}
+84
View File
@@ -0,0 +1,84 @@
import type { SyncProduct } from '@/types/sync'
function normalizeHandle(ref: string): string {
return ref
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
/**
* Parse des lignes brutes Google Sheets en SyncProduct[].
* Colonnes : A=Référence, B=Nom, C=Description, D=Prix, E=Stock, F=Statut
* Exporté pour les tests unitaires.
*/
export function parseSheetRows(rows: string[][]): SyncProduct[] {
return rows
.map((row): SyncProduct | null => {
const ref = row[0]?.trim()
if (!ref) return null
const rawPrice = (row[3] ?? '').replace(',', '.').trim()
const price = parseFloat(rawPrice || '0')
return {
ref,
handle: normalizeHandle(ref),
title: row[1]?.trim() || ref,
description: row[2]?.trim() || '',
price: isNaN(price) ? '0.00' : price.toFixed(2),
stock: parseInt(row[4] ?? '0', 10) || 0,
status: (row[5] ?? '').toLowerCase().includes('inac') ? 'draft' : 'active',
}
})
.filter((p): p is SyncProduct => p !== null)
}
/**
* Lit tous les produits depuis Google Sheets via l'API v4 (API Key publique).
* 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[]> {
const apiKey = process.env.GOOGLE_API_KEY
const sheetId = process.env.GOOGLE_SHEETS_ID
const singleTab = process.env.GOOGLE_SHEETS_TAB
if (!apiKey || !sheetId) {
throw new Error('Variables GOOGLE_API_KEY et GOOGLE_SHEETS_ID requises')
}
const sheetNames = singleTab ? [singleTab] : 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()
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}!A2:F`)
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`
const res = await fetch(url)
if (!res.ok) {
const err = await res.text()
throw new Error(`Google Sheets API error (onglet "${sheetName}"): ${res.status}${err}`)
}
const data = await res.json()
return parseSheetRows((data.values ?? []) as string[][])
}
+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 }
}
+107 -32
View File
@@ -21,34 +21,62 @@ 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 variants
const searchBySku = async (sku: string): Promise<ShopifyProductResult> => {
const res = await fetch(
`${baseUrl}/variants.json?sku=${encodeURIComponent(sku)}&fields=id,sku,product_id&limit=1`,
{ headers },
)
if (!res.ok) return { found: false }
const data = await res.json()
return (data.products ?? []) as Array<{ id: number; title: string; handle: string }>
const variants = (data.variants ?? []) as Array<{ id: number; sku: string; product_id: number }>
if (variants.length === 0) return { found: false }
// Récupère le titre du produit
const productRes = await fetch(
`${baseUrl}/products/${variants[0].product_id}.json?fields=id,title`,
{ headers },
)
if (!productRes.ok) return { found: false }
const productData = await productRes.json()
return {
found: true,
shopifyId: String(variants[0].product_id),
title: productData.product?.title ?? sku,
}
}
// 1. SKU exact (ex: "30827")
let result = await searchBySku(ref)
if (result.found) return result
// 2. SKU sans suffixe : "30827-dessus" → "30827" (retire tout après le dernier séparateur)
const withoutSuffix = ref.replace(/[-_ ][^-_ ]*$/, '')
if (withoutSuffix !== ref) {
result = await searchBySku(withoutSuffix)
if (result.found) return result
}
// 3. Fallback handle Shopify
const handle = ref.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
const tryFetch = async (params: string) => {
const res = await fetch(`${baseUrl}/products.json?${params}&fields=id,title&limit=1`, { headers })
if (!res.ok) return []
const data = await res.json()
return (data.products ?? []) as Array<{ id: number; title: string }>
}
let products = await tryFetch(`handle=${encodeURIComponent(handle)}`)
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 +85,74 @@ 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.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)')
})
})
+159
View File
@@ -0,0 +1,159 @@
import type { MetafieldDefinition, MetafieldResolution } from '@/types/creation'
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 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 { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/products/${shopifyProductId}/metafields.json`, {
method: 'POST',
headers,
body: JSON.stringify({
metafield: { namespace: resolution.namespace, key: resolution.key, value, type: resolution.type ?? 'single_line_text_field' },
}),
})
if (!res.ok) {
const err = await res.text()
throw new Error(`Shopify set metafield error: ${res.status}${err}`)
}
}
+179
View File
@@ -0,0 +1,179 @@
import type { ShopifyProductFull, SyncProduct } from '@/types/sync'
const API_VERSION = '2024-01'
function getConfig() {
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) {
throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
}
return {
baseUrl: `https://${domain}/admin/api/${API_VERSION}`,
headers: {
'X-Shopify-Access-Token': token,
'Content-Type': 'application/json',
},
}
}
interface ShopifyVariant {
id: number
price: string
sku: string
inventory_quantity: number
}
interface ShopifyAPIProduct {
id: number
title: string
handle: string
body_html: string
status: string
variants: ShopifyVariant[]
}
function toSyncProduct(p: ShopifyAPIProduct): ShopifyProductFull {
return {
shopifyId: String(p.id),
ref: p.variants[0]?.sku || p.handle,
handle: p.handle,
title: p.title,
description: p.body_html ?? '',
price: p.variants[0]?.price ?? '0.00',
stock: p.variants[0]?.inventory_quantity ?? 0,
status: p.status === 'active' ? 'active' : 'draft',
}
}
function extractNextUrl(linkHeader: string | null): string | null {
if (!linkHeader) return null
const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/)
return match ? match[1] : null
}
/**
* Récupère tous les produits Shopify (actifs + drafts), en paginant.
* 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=${status}`
while (url) {
const res = await fetch(url, { headers })
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'))
}
return all
}
/**
* Crée un nouveau produit Shopify depuis un SyncProduct.
* Retourne l'ID Shopify créé.
*/
export async function createShopifyProduct(product: SyncProduct): Promise<string> {
const { baseUrl, headers } = getConfig()
const body = {
product: {
title: product.title,
body_html: product.description,
handle: product.handle,
status: product.status,
variants: [{ price: product.price, sku: product.ref }],
},
}
const res = await fetch(`${baseUrl}/products.json`, {
method: 'POST',
headers,
body: JSON.stringify(body),
})
if (!res.ok) {
const err = await res.text()
throw new Error(`Shopify createProduct error: ${res.status}${err}`)
}
const data = await res.json()
return String(data.product.id)
}
/**
* Met à jour un produit Shopify existant (titre, description, prix, statut).
*/
export async function updateShopifyProduct(
shopifyId: string,
product: SyncProduct,
variantId: string,
): Promise<void> {
const { baseUrl, headers } = getConfig()
const body = {
product: {
id: shopifyId,
title: product.title,
body_html: product.description,
status: product.status,
variants: [{ id: variantId, price: product.price }],
},
}
const res = await fetch(`${baseUrl}/products/${shopifyId}.json`, {
method: 'PUT',
headers,
body: JSON.stringify(body),
})
if (!res.ok) {
const err = await res.text()
throw new Error(`Shopify updateProduct error: ${res.status}${err}`)
}
}
/**
* Passe un produit Shopify en draft (désactivation — jamais de suppression).
*/
export async function deactivateShopifyProduct(shopifyId: string): Promise<void> {
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/products/${shopifyId}.json`, {
method: 'PUT',
headers,
body: JSON.stringify({ product: { id: shopifyId, status: 'draft' } }),
})
if (!res.ok) {
const err = await res.text()
throw new Error(`Shopify deactivateProduct error: ${res.status}${err}`)
}
}
/**
* Récupère l'ID de la première variante d'un produit Shopify.
* Nécessaire pour mettre à jour le prix (update variant).
*/
export async function getFirstVariantId(shopifyId: string): Promise<string> {
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/products/${shopifyId}/variants.json?limit=1`, { headers })
if (!res.ok) throw new Error(`Shopify getVariants error: ${res.status}`)
const data = await res.json()
const variant = data.variants?.[0]
if (!variant) throw new Error(`Aucune variante pour le produit ${shopifyId}`)
return String(variant.id)
}
+65
View File
@@ -0,0 +1,65 @@
import type { SyncProduct, ShopifyProductFull, SyncChange, DiffResult } from '@/types/sync'
const COMPARABLE_FIELDS = ['title', 'description', 'price', 'status'] as const
function getChangedFields(
sheet: SyncProduct,
shopify: ShopifyProductFull,
): Array<{ field: string; from: string; to: string }> {
return COMPARABLE_FIELDS
.filter((field) => sheet[field] !== shopify[field])
.map((field) => ({
field,
from: String(shopify[field]),
to: String(sheet[field]),
}))
}
/**
* Calcule le diff entre les produits Sheets et Shopify.
* Matching par handle (ref normalisée).
*/
export function computeDiff(
sheetProducts: SyncProduct[],
shopifyProducts: ShopifyProductFull[],
): DiffResult {
const shopifyMap = new Map(shopifyProducts.map((p) => [p.handle, p]))
const sheetHandles = new Set(sheetProducts.map((p) => p.handle))
const changes: SyncChange[] = []
for (const sheet of sheetProducts) {
const shopify = shopifyMap.get(sheet.handle)
if (!shopify) {
changes.push({ type: 'create', ref: sheet.ref, sheetProduct: sheet })
} else {
const changedFields = getChangedFields(sheet, shopify)
if (changedFields.length > 0) {
changes.push({
type: 'update',
ref: sheet.ref,
sheetProduct: sheet,
shopifyProduct: shopify,
changedFields,
})
} else {
changes.push({ type: 'unchanged', ref: sheet.ref, sheetProduct: sheet, shopifyProduct: shopify })
}
}
}
for (const shopify of shopifyProducts) {
if (shopify.status === 'active' && !sheetHandles.has(shopify.handle)) {
changes.push({ type: 'deactivate', ref: shopify.ref, shopifyProduct: shopify })
}
}
const summary = {
create: changes.filter((c) => c.type === 'create').length,
update: changes.filter((c) => c.type === 'update').length,
deactivate: changes.filter((c) => c.type === 'deactivate').length,
unchanged: changes.filter((c) => c.type === 'unchanged').length,
}
return { changes, summary }
}
+58
View File
@@ -0,0 +1,58 @@
/** 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: 'done'; summary: { created: 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';
+1
View File
@@ -6,6 +6,7 @@ export type ImageStatus =
| 'ready' // 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
+25
View File
@@ -0,0 +1,25 @@
// src/types/products.ts
/** Produit Shopify simplifié pour affichage catalogue */
export interface CatalogProduct {
shopifyId: string
ref: string // SKU de la première variante
handle: string
title: string
price: string // "29.99"
stock: number // inventory_quantity variante 1
status: 'active' | 'draft' | 'archived'
shopifyUrl: string // https://materiaux-destock.myshopify.com/admin/products/<id>
}
/** Filtres appliqués à la liste */
export interface ProductFilters {
status: 'all' | 'active' | 'draft'
search: string // filtre titre ou ref (côté client)
}
/** Réponse de l'API /api/products/list */
export interface ProductListResponse {
products: CatalogProduct[]
total: number
}
+67
View File
@@ -0,0 +1,67 @@
export type SyncChangeType = 'create' | 'update' | 'deactivate' | 'unchanged'
/** Produit normalisé depuis Google Sheets */
export interface SyncProduct {
ref: string // ex: "REF-1042" (colonne A telle quelle)
handle: string // ref normalisée en handle Shopify: "ref-1042"
title: string // colonne B
description: string // colonne C (texte brut, stocké en body_html)
price: string // colonne D — "29.99" (2 décimales)
stock: number // colonne E — info affichage uniquement (non synchronisé)
status: 'active' | 'draft' // colonne F : "inactif"/"inactif" → draft, tout autre → active
}
/** Produit Shopify enrichi pour la comparaison */
export interface ShopifyProductFull extends SyncProduct {
shopifyId: string // ID numérique Shopify (string)
}
/** Un changement unitaire calculé par computeDiff */
export interface SyncChange {
type: SyncChangeType
ref: string
sheetProduct?: SyncProduct // présent pour create + update
shopifyProduct?: ShopifyProductFull // présent pour update + deactivate
changedFields?: Array<{ // présent pour update uniquement
field: string
from: string
to: string
}>
}
/** Résultat complet du diff */
export interface DiffResult {
changes: SyncChange[]
summary: {
create: number
update: number
deactivate: number
unchanged: number
}
}
/** Une entrée du log d'exécution */
export interface SyncLogEntry {
ref: string
action: 'create' | 'update' | 'deactivate'
status: 'success' | 'error'
message: string
}
/** Un événement NDJSON streamé par /api/sync/execute */
export type SyncStreamEvent =
| { type: 'progress'; current: number; total: number; message: string }
| { type: 'done'; summary: { created: number; updated: number; deactivated: number; errors: number; status: 'success' | 'partial' | 'error' } }
/** Une entrée d'historique (depuis SyncRun Prisma) */
export interface SyncHistoryEntry {
id: string
createdAt: string // ISO string
userName: string
status: 'success' | 'partial' | 'error'
created: number
updated: number
deactivated: number
errors: number
log: SyncLogEntry[]
}