Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abf5210de6 | |||
| 97f011a24b | |||
| cdbddddaec | |||
| 94993c3cbc | |||
| 9c3ec2a953 | |||
| e6a670c67b | |||
| e9d4f9dc20 | |||
| 16fece8ca9 | |||
| d4b54efa17 | |||
| 2988ecab0b | |||
| f415138103 | |||
| c4010e8c5e | |||
| 1daf9bda72 | |||
| cf5bbf91d5 | |||
| fe9c3b9ccb | |||
| 568fe0d38e | |||
| 683db58f4b | |||
| 4c1cf73435 | |||
| 901199c45d | |||
| 50469700d0 | |||
| a5ced27e93 | |||
| c9422be78e | |||
| 53008d1d43 | |||
| 9675366db9 | |||
| c97a7faaa9 | |||
| b25a712a69 | |||
| ee70ea377f | |||
| 8bd49050f6 | |||
| 79ebb6ced3 | |||
| d80e50e9de | |||
| f022238a40 | |||
| f23c954ecd | |||
| 96700ae252 | |||
| 99c3380d00 | |||
| 51f87c0672 | |||
| 8d06706a24 | |||
| 44b3b4dd8d | |||
| 38dc6efc09 | |||
| f9a14b009a | |||
| 2acf899465 | |||
| dbb155cb3a | |||
| 192def9008 | |||
| 1b60fd79f7 |
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
.next
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
.env.development
|
||||
data/
|
||||
*.db
|
||||
.git
|
||||
README.md
|
||||
@@ -0,0 +1,18 @@
|
||||
# Auth
|
||||
NEXTAUTH_URL=https://gestion.materiaux-destock.fr
|
||||
NEXTAUTH_SECRET=<générer avec: openssl rand -base64 32>
|
||||
|
||||
# Shopify (remplir lors du Plan 2)
|
||||
SHOPIFY_STORE_DOMAIN=materiaux-destock.myshopify.com
|
||||
SHOPIFY_ADMIN_API_TOKEN=shpat_...
|
||||
|
||||
# Google Sheets (remplir lors du Plan 3)
|
||||
GOOGLE_SERVICE_ACCOUNT_EMAIL=...@....iam.gserviceaccount.com
|
||||
GOOGLE_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n..."
|
||||
GOOGLE_SHEETS_ID=
|
||||
|
||||
# Base de données
|
||||
DATABASE_URL="file:./data/app.db"
|
||||
|
||||
# Seed (premier déploiement uniquement)
|
||||
SEED_ADMIN_PASSWORD=
|
||||
@@ -28,9 +28,18 @@ yarn-error.log*
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# prisma env (contains DATABASE_URL)
|
||||
.env
|
||||
|
||||
# database files
|
||||
/data/
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
.env.production
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
# Étape 1 : dépendances complètes (dev incluses pour le build)
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Étape 2 : build Next.js
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV DATABASE_URL="file:./data/app.db"
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
# Étape 3 : image de production
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Outils natifs requis pour better-sqlite3
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs
|
||||
|
||||
# Installer uniquement les dépendances de production
|
||||
# (résout automatiquement toutes les dépendances transitives : effect, dotenv, etc.)
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Ajouter le CLI Prisma (devDependency → non installé par --omit=dev)
|
||||
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
|
||||
RUN ln -sf /app/node_modules/prisma/build/index.js /app/node_modules/.bin/prisma
|
||||
|
||||
# Client Prisma généré (spécifique à la plateforme linux/alpine)
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
|
||||
# Artifacts du build Next.js
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
# Schema + migrations Prisma
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
||||
|
||||
COPY scripts/start.sh ./start.sh
|
||||
|
||||
RUN mkdir -p data && chown -R nextjs:nodejs data . && chmod +x start.sh
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["sh", "start.sh"]
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
image: gestion-materiaux-destock:latest
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
external: true
|
||||
@@ -7,6 +7,9 @@ const config: Config = {
|
||||
coverageProvider: 'v8',
|
||||
testEnvironment: 'node',
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
}
|
||||
|
||||
export default createJestConfig(config)
|
||||
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+989
-70
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -6,11 +6,20 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"db:seed": "ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"heic2any": "^0.0.4",
|
||||
"jszip": "^3.10.1",
|
||||
"next": "14.2.35",
|
||||
"next-auth": "^4.24.14",
|
||||
"react": "^18",
|
||||
@@ -32,6 +41,7 @@
|
||||
"prisma": "^7.8.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-jest": "^29.4.11",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// This file was generated by Prisma, and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"email" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'gestionnaire',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SyncRun" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"userId" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"created" INTEGER NOT NULL DEFAULT 0,
|
||||
"updated" INTEGER NOT NULL DEFAULT 0,
|
||||
"deactivated" INTEGER NOT NULL DEFAULT 0,
|
||||
"errors" INTEGER NOT NULL DEFAULT 0,
|
||||
"log" TEXT NOT NULL DEFAULT '[]',
|
||||
CONSTRAINT "SyncRun_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
@@ -0,0 +1,30 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String
|
||||
password String
|
||||
role String @default("gestionnaire")
|
||||
createdAt DateTime @default(now())
|
||||
syncRuns SyncRun[]
|
||||
}
|
||||
|
||||
model SyncRun {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
status String
|
||||
created Int @default(0)
|
||||
updated Int @default(0)
|
||||
deactivated Int @default(0)
|
||||
errors Int @default(0)
|
||||
log String @default("[]")
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
|
||||
import { hash } from 'bcryptjs'
|
||||
import path from 'path'
|
||||
|
||||
const password = process.env.SEED_ADMIN_PASSWORD
|
||||
if (!password) {
|
||||
console.error('SEED_ADMIN_PASSWORD requis')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const dbUrl = process.env.DATABASE_URL ?? 'file:./data/app.db'
|
||||
const dbPath = dbUrl.startsWith('file:')
|
||||
? path.resolve(process.cwd(), dbUrl.slice('file:'.length))
|
||||
: dbUrl
|
||||
|
||||
const adapter = new PrismaBetterSqlite3({ url: dbPath })
|
||||
const prisma = new PrismaClient({ adapter })
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { email: 'mathieu.fort@gmail.com' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'mathieu.fort@gmail.com',
|
||||
name: 'Mathieu',
|
||||
password: await hash(password, 12),
|
||||
role: 'admin',
|
||||
},
|
||||
})
|
||||
console.log('✅ Compte admin créé : mathieu.fort@gmail.com')
|
||||
await prisma.$disconnect()
|
||||
@@ -0,0 +1,33 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
|
||||
import { hash } from 'bcryptjs'
|
||||
import path from 'path'
|
||||
|
||||
const dbUrl = process.env.DATABASE_URL ?? 'file:./data/dev.db'
|
||||
const dbPath = dbUrl.startsWith('file:')
|
||||
? path.resolve(process.cwd(), dbUrl.slice('file:'.length))
|
||||
: dbUrl
|
||||
|
||||
const adapter = new PrismaBetterSqlite3({ url: dbPath })
|
||||
const prisma = new PrismaClient({ adapter } as any)
|
||||
|
||||
async function main() {
|
||||
const password = process.env.SEED_ADMIN_PASSWORD
|
||||
if (!password) throw new Error('SEED_ADMIN_PASSWORD env var requis')
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { email: 'mathieu.fort@gmail.com' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'mathieu.fort@gmail.com',
|
||||
name: 'Mathieu',
|
||||
password: await hash(password, 12),
|
||||
role: 'admin',
|
||||
},
|
||||
})
|
||||
console.log('✅ Compte admin créé : mathieu.fort@gmail.com')
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect())
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
echo "▶ Migration base de données…"
|
||||
node_modules/.bin/prisma migrate deploy
|
||||
echo "▶ Démarrage du serveur…"
|
||||
exec node server.js
|
||||
@@ -0,0 +1,75 @@
|
||||
import { POST } from '@/app/api/images/upload/route'
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
jest.mock('@/lib/shopify', () => ({
|
||||
uploadProductImage: jest.fn(),
|
||||
}))
|
||||
|
||||
import { uploadProductImage } from '@/lib/shopify'
|
||||
const mockUpload = uploadProductImage as jest.Mock
|
||||
|
||||
describe('POST /api/images/upload', () => {
|
||||
beforeEach(() => jest.clearAllMocks())
|
||||
|
||||
it('retourne 400 si shopifyProductId manquant', async () => {
|
||||
const req = new NextRequest('http://localhost/api/images/upload', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ imageBase64: 'abc', filename: 'test.jpg' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('retourne 400 si imageBase64 manquant', async () => {
|
||||
const req = new NextRequest('http://localhost/api/images/upload', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ shopifyProductId: '123', filename: 'test.jpg' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('retourne 400 si filename manquant', async () => {
|
||||
const req = new NextRequest('http://localhost/api/images/upload', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ shopifyProductId: '123', imageBase64: 'abc' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('retourne l\'URL après upload réussi', async () => {
|
||||
mockUpload.mockResolvedValue({ url: 'https://cdn.shopify.com/image.jpg' })
|
||||
const req = new NextRequest('http://localhost/api/images/upload', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
shopifyProductId: '123456',
|
||||
imageBase64: 'base64data==',
|
||||
filename: 'REF-1042.jpg',
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ url: 'https://cdn.shopify.com/image.jpg' })
|
||||
expect(mockUpload).toHaveBeenCalledWith('123456', 'base64data==', 'REF-1042.jpg')
|
||||
})
|
||||
|
||||
it('retourne 500 en cas d\'erreur Shopify', async () => {
|
||||
mockUpload.mockRejectedValue(new Error('Shopify upload error: 422'))
|
||||
const req = new NextRequest('http://localhost/api/images/upload', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
shopifyProductId: '123',
|
||||
imageBase64: 'abc',
|
||||
filename: 'test.jpg',
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(500)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { GET } from '@/app/api/products/search/route'
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
jest.mock('@/lib/shopify', () => ({
|
||||
searchProductByRef: jest.fn(),
|
||||
}))
|
||||
|
||||
import { searchProductByRef } from '@/lib/shopify'
|
||||
const mockSearch = searchProductByRef as jest.Mock
|
||||
|
||||
describe('GET /api/products/search', () => {
|
||||
beforeEach(() => jest.clearAllMocks())
|
||||
|
||||
it('retourne 400 si le paramètre ref est absent', async () => {
|
||||
const req = new NextRequest('http://localhost/api/products/search')
|
||||
const res = await GET(req)
|
||||
expect(res.status).toBe(400)
|
||||
const data = await res.json()
|
||||
expect(data.error).toMatch(/ref/)
|
||||
})
|
||||
|
||||
it('retourne 400 si ref est une chaîne vide', async () => {
|
||||
const req = new NextRequest('http://localhost/api/products/search?ref=')
|
||||
const res = await GET(req)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('retourne { found: false } si produit introuvable', async () => {
|
||||
mockSearch.mockResolvedValue({ found: false })
|
||||
const req = new NextRequest('http://localhost/api/products/search?ref=REF-9999')
|
||||
const res = await GET(req)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ found: false })
|
||||
expect(mockSearch).toHaveBeenCalledWith('REF-9999')
|
||||
})
|
||||
|
||||
it('retourne le produit trouvé', async () => {
|
||||
mockSearch.mockResolvedValue({ found: true, shopifyId: '123456', title: 'Carrelage REF-1042' })
|
||||
const req = new NextRequest('http://localhost/api/products/search?ref=REF-1042')
|
||||
const res = await GET(req)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ found: true, shopifyId: '123456', title: 'Carrelage REF-1042' })
|
||||
})
|
||||
|
||||
it('retourne 500 en cas d\'erreur Shopify', async () => {
|
||||
mockSearch.mockRejectedValue(new Error('Shopify API error: 503'))
|
||||
const req = new NextRequest('http://localhost/api/products/search?ref=REF-1042')
|
||||
const res = await GET(req)
|
||||
expect(res.status).toBe(500)
|
||||
const data = await res.json()
|
||||
expect(data.error).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { authOptions } from '@/lib/auth'
|
||||
|
||||
// Mock Prisma
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock rate-limit
|
||||
jest.mock('@/lib/rate-limit', () => ({
|
||||
checkRateLimit: jest.fn(() => true),
|
||||
recordFailedAttempt: jest.fn(),
|
||||
clearAttempts: jest.fn(),
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { checkRateLimit, recordFailedAttempt, clearAttempts } from '@/lib/rate-limit'
|
||||
|
||||
const mockFindUnique = prisma.user.findUnique as jest.Mock
|
||||
|
||||
describe('NextAuth authorize', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const authorize = (authOptions.providers[0] as any).options.authorize
|
||||
|
||||
beforeEach(() => jest.clearAllMocks())
|
||||
|
||||
it('retourne null si credentials manquants', async () => {
|
||||
const result = await authorize({ email: '', password: '' }, {})
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('retourne null si utilisateur introuvable', async () => {
|
||||
mockFindUnique.mockResolvedValue(null)
|
||||
const result = await authorize(
|
||||
{ email: 'a@b.com', password: 'wrong' },
|
||||
{ headers: {} }
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
expect(recordFailedAttempt).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retourne l\'utilisateur si mot de passe correct', async () => {
|
||||
const { hash } = await import('bcryptjs')
|
||||
const hashedPwd = await hash('correct', 12)
|
||||
mockFindUnique.mockResolvedValue({
|
||||
id: 'user1',
|
||||
email: 'a@b.com',
|
||||
name: 'Alice',
|
||||
role: 'gestionnaire',
|
||||
password: hashedPwd,
|
||||
})
|
||||
const result = await authorize(
|
||||
{ email: 'a@b.com', password: 'correct' },
|
||||
{ headers: {} }
|
||||
)
|
||||
expect(result).toEqual({ id: 'user1', email: 'a@b.com', name: 'Alice', role: 'gestionnaire' })
|
||||
expect(clearAttempts).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lève RATE_LIMITED si bloqué', async () => {
|
||||
;(checkRateLimit as jest.Mock).mockReturnValue(false)
|
||||
await expect(
|
||||
authorize({ email: 'a@b.com', password: 'x' }, { headers: {} })
|
||||
).rejects.toThrow('RATE_LIMITED')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
checkRateLimit,
|
||||
recordFailedAttempt,
|
||||
clearAttempts,
|
||||
_resetForTests,
|
||||
} from '@/lib/rate-limit'
|
||||
|
||||
beforeEach(() => _resetForTests())
|
||||
|
||||
describe('checkRateLimit', () => {
|
||||
it('autorise une nouvelle clé', () => {
|
||||
expect(checkRateLimit('ip:user@test.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('autorise après moins de 5 tentatives', () => {
|
||||
const key = 'ip:user@test.com'
|
||||
recordFailedAttempt(key)
|
||||
recordFailedAttempt(key)
|
||||
recordFailedAttempt(key)
|
||||
recordFailedAttempt(key)
|
||||
expect(checkRateLimit(key)).toBe(true)
|
||||
})
|
||||
|
||||
it('bloque après 5 tentatives', () => {
|
||||
const key = 'ip:user@test.com'
|
||||
for (let i = 0; i < 5; i++) recordFailedAttempt(key)
|
||||
expect(checkRateLimit(key)).toBe(false)
|
||||
})
|
||||
|
||||
it('débloque après clearAttempts', () => {
|
||||
const key = 'ip:user@test.com'
|
||||
for (let i = 0; i < 5; i++) recordFailedAttempt(key)
|
||||
clearAttempts(key)
|
||||
expect(checkRateLimit(key)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
import { signIn } from 'next-auth/react'
|
||||
import { useState, FormEvent } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
const result = await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
setLoading(false)
|
||||
|
||||
if (result?.error === 'RATE_LIMITED') {
|
||||
setError('Trop de tentatives. Réessayez dans 15 minutes.')
|
||||
} else if (result?.error) {
|
||||
setError('Email ou mot de passe incorrect.')
|
||||
} else {
|
||||
router.push('/')
|
||||
router.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-extrabold text-white">
|
||||
Matériaux<span className="text-indigo-400">Destock</span>
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm mt-1">Interface de gestion</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-slate-900 rounded-xl p-6 space-y-4 border border-slate-800"
|
||||
>
|
||||
{error && (
|
||||
<div className="bg-red-950 border border-red-800 text-red-300 text-sm rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-400 mb-1 uppercase tracking-wide">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
placeholder="vous@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-400 mb-1 uppercase tracking-wide">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white font-semibold py-2.5 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
{loading ? 'Connexion…' : 'Se connecter'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { DropZone } from '@/components/images/DropZone'
|
||||
import { ImageCard } from '@/components/images/ImageCard'
|
||||
import { Lightbox } from '@/components/images/Lightbox'
|
||||
import { GlobalActions } from '@/components/images/GlobalActions'
|
||||
import { useImages } from '@/hooks/useImages'
|
||||
import type { ImageItem } from '@/types/images'
|
||||
|
||||
export default function ImagesPage() {
|
||||
const { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem } = useImages()
|
||||
const [lightboxItem, setLightboxItem] = useState<ImageItem | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
title="🖼️ Images"
|
||||
actions={
|
||||
<span className="text-xs text-slate-500">
|
||||
{items.length > 0 ? `${items.length} image${items.length > 1 ? 's' : ''}` : ''}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="p-6 flex flex-col gap-4 flex-1 overflow-y-auto">
|
||||
<DropZone onFiles={addFiles} />
|
||||
|
||||
{items.length > 0 && (
|
||||
<GlobalActions items={items} onUploadAll={uploadAll} />
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{items.map((item) => (
|
||||
<ImageCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onFeatherChange={setFeather}
|
||||
onRotate={rotate}
|
||||
onUpload={uploadOne}
|
||||
onRemove={removeItem}
|
||||
onOpenLightbox={setLightboxItem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-600">
|
||||
<p className="text-4xl mb-3">📂</p>
|
||||
<p className="text-sm">Déposez des images pour commencer</p>
|
||||
<p className="text-xs mt-1">Le nom du fichier (sans extension) doit correspondre à une référence produit Shopify</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Lightbox item={lightboxItem} onClose={() => setLightboxItem(null)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Sidebar } from '@/components/layout/Sidebar'
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-50">
|
||||
<Sidebar />
|
||||
<main className="flex-1 flex flex-col overflow-hidden">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Header } from '@/components/layout/Header'
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<>
|
||||
<Header title="📊 Dashboard" />
|
||||
<div className="p-6"><p className="text-gray-400 text-sm">Dashboard — à implémenter (Plan 4)</p></div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Header } from '@/components/layout/Header'
|
||||
export default function ParametresPage() {
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Header } from '@/components/layout/Header'
|
||||
export default function ProduitsPage() {
|
||||
return (
|
||||
<>
|
||||
<Header title="📦 Produits" />
|
||||
<div className="p-6"><p className="text-gray-400 text-sm">Module Produits — à implémenter (Plan 4)</p></div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +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 flex flex-col gap-6 flex-1 overflow-y-auto">
|
||||
{/* Stepper */}
|
||||
<SyncStepper currentStep={step} />
|
||||
|
||||
{/* Étape 1 — Analyser */}
|
||||
{step === 1 && (
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<p className="text-slate-400 text-sm text-center max-w-md">
|
||||
Lit Google Sheets et compare avec le catalogue Shopify actuel pour générer l'aperçu des changements.
|
||||
</p>
|
||||
{error && (
|
||||
<div className="bg-red-950 border border-red-800 text-red-300 text-sm rounded-lg px-4 py-2 max-w-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={loading}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-semibold px-6 py-3 rounded-xl text-sm transition-colors"
|
||||
>
|
||||
{loading ? '⏳ Analyse en cours…' : '🔍 Analyser le Sheets'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Étape 2 — Aperçu diff */}
|
||||
{step === 2 && diff && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<DiffTable diff={diff} />
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => { setStep(1); setDiff(null) }}
|
||||
className="px-4 py-2 text-sm bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg"
|
||||
>
|
||||
← Relancer l'analyse
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStep(3)}
|
||||
disabled={actionableCount === 0}
|
||||
className="px-4 py-2 text-sm bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white font-semibold rounded-lg"
|
||||
>
|
||||
Confirmer →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Étape 3 — Confirmation */}
|
||||
{step === 3 && diff && (
|
||||
<div className="flex flex-col items-center gap-6 py-8">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl p-6 text-center max-w-sm">
|
||||
<p className="text-lg font-semibold text-white mb-2">
|
||||
{actionableCount} changement{actionableCount > 1 ? 's' : ''} vont être appliqués à Shopify
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
{diff.summary.create > 0 && `${diff.summary.create} créations · `}
|
||||
{diff.summary.update > 0 && `${diff.summary.update} mises à jour · `}
|
||||
{diff.summary.deactivate > 0 && `${diff.summary.deactivate} désactivations`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setStep(2)}
|
||||
className="px-4 py-2 text-sm bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
className="px-6 py-2 text-sm bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded-lg"
|
||||
>
|
||||
✅ Confirmer et synchroniser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Étape 4 — Progression */}
|
||||
{step === 4 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SyncProgress log={log} progress={progress} summary={summary} />
|
||||
{summary && (
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => { setStep(1); setDiff(null); setSummary(null); setLog([]) }}
|
||||
className="px-4 py-2 text-sm bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded-lg"
|
||||
>
|
||||
↺ Nouvelle sync
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Historique */}
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={loadHistory}
|
||||
className="text-sm text-slate-500 hover:text-slate-300 mb-3"
|
||||
>
|
||||
{historyLoaded ? '📋 Historique des syncs' : '📋 Charger l\'historique'}
|
||||
</button>
|
||||
{historyLoaded && <SyncHistory entries={history} />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import NextAuth from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
|
||||
const handler = NextAuth(authOptions)
|
||||
export { handler as GET, handler as POST }
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { uploadProductImage } from '@/lib/shopify'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Corps JSON invalide' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { shopifyProductId, imageBase64, filename } = body
|
||||
|
||||
if (!shopifyProductId || typeof shopifyProductId !== 'string') {
|
||||
return NextResponse.json({ error: 'shopifyProductId requis' }, { status: 400 })
|
||||
}
|
||||
if (!imageBase64 || typeof imageBase64 !== 'string') {
|
||||
return NextResponse.json({ error: 'imageBase64 requis' }, { status: 400 })
|
||||
}
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
return NextResponse.json({ error: 'filename requis' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await uploadProductImage(shopifyProductId, imageBase64, filename)
|
||||
return NextResponse.json(result)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { searchProductByRef } from '@/lib/shopify'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const ref = req.nextUrl.searchParams.get('ref')
|
||||
|
||||
if (!ref || ref.trim() === '') {
|
||||
return NextResponse.json({ error: 'Paramètre ref requis' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await searchProductByRef(ref.trim())
|
||||
return NextResponse.json(result)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { SyncHistoryEntry, SyncLogEntry } from '@/types/sync'
|
||||
|
||||
export async function GET() {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { readSheetProducts } from '@/lib/googleSheets'
|
||||
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
|
||||
import { computeDiff } from '@/lib/syncDiff'
|
||||
|
||||
export async function GET() {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { SessionProvider } from '@/components/providers/SessionProvider'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
@@ -12,7 +13,9 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="fr">
|
||||
<body className={inter.className}>{children}</body>
|
||||
<body className={inter.className}>
|
||||
<SessionProvider>{children}</SessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="https://nextjs.org/icons/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li>Save and see your changes instantly.</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="https://nextjs.org/icons/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="https://nextjs.org/icons/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="https://nextjs.org/icons/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="https://nextjs.org/icons/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
|
||||
interface DropZoneProps {
|
||||
onFiles: (files: File[]) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ACCEPTED_TYPES = ['.heic', '.heif', '.jpg', '.jpeg', '.png', '.zip']
|
||||
const ACCEPT_ATTR = ACCEPTED_TYPES.join(',')
|
||||
|
||||
export function DropZone({ onFiles, disabled = false }: DropZoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(fileList: FileList | null) => {
|
||||
if (!fileList) return
|
||||
const files = Array.from(fileList).filter((f) => {
|
||||
const ext = '.' + f.name.split('.').pop()?.toLowerCase()
|
||||
return ACCEPTED_TYPES.includes(ext)
|
||||
})
|
||||
if (files.length > 0) onFiles(files)
|
||||
},
|
||||
[onFiles],
|
||||
)
|
||||
|
||||
const onDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
if (!disabled) setIsDragging(true)
|
||||
}
|
||||
|
||||
const onDragLeave = () => setIsDragging(false)
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
if (!disabled) handleFiles(e.dataTransfer.files)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onClick={() => !disabled && inputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
|
||||
isDragging
|
||||
? 'border-indigo-400 bg-indigo-950/30'
|
||||
: disabled
|
||||
? 'border-slate-700 bg-slate-800/20 cursor-not-allowed'
|
||||
: 'border-slate-600 bg-slate-800/10 hover:border-indigo-500 hover:bg-indigo-950/20'
|
||||
}`}
|
||||
>
|
||||
<div className="text-3xl mb-2">🖼️</div>
|
||||
<p className="text-sm font-semibold text-slate-300">
|
||||
Déposez vos images ici ou cliquez pour parcourir
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
HEIC, JPG, PNG, ZIP — le nom du fichier = référence produit
|
||||
</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPT_ATTR}
|
||||
className="hidden"
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
import type { ImageItem } from '@/types/images'
|
||||
|
||||
interface GlobalActionsProps {
|
||||
items: ImageItem[]
|
||||
onUploadAll: () => void
|
||||
}
|
||||
|
||||
export function GlobalActions({ items, onUploadAll }: GlobalActionsProps) {
|
||||
if (items.length === 0) return null
|
||||
|
||||
const ready = items.filter((i) => i.status === 'ready').length
|
||||
const processing = items.filter((i) => ['pending', 'converting', 'processing', 'searching', 'uploading'].includes(i.status)).length
|
||||
const notFound = items.filter((i) => i.status === 'not_found').length
|
||||
const uploaded = items.filter((i) => i.status === 'uploaded').length
|
||||
const errors = items.filter((i) => i.status === 'error').length
|
||||
|
||||
return (
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 flex items-center gap-6 flex-wrap">
|
||||
<div className="flex gap-4 text-xs text-slate-400 flex-1 flex-wrap">
|
||||
{processing > 0 && <span>⏳ {processing} en traitement</span>}
|
||||
{ready > 0 && <span className="text-green-400 font-semibold">✓ {ready} prêtes</span>}
|
||||
{notFound > 0 && <span className="text-red-400">❌ {notFound} introuvables</span>}
|
||||
{errors > 0 && <span className="text-red-400">⚠️ {errors} erreurs</span>}
|
||||
{uploaded > 0 && <span className="text-slate-500">✅ {uploaded} uploadées</span>}
|
||||
</div>
|
||||
{ready > 0 && (
|
||||
<button
|
||||
onClick={onUploadAll}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
⬆️ Tout uploader ({ready})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ImageItem } from '@/types/images'
|
||||
|
||||
interface ImageCardProps {
|
||||
item: ImageItem
|
||||
onFeatherChange: (id: string, value: number) => void
|
||||
onRotate: (id: string, direction: 'cw' | 'ccw') => void
|
||||
onUpload: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
onOpenLightbox: (item: ImageItem) => void
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: '⏳ En attente', color: 'text-slate-400' },
|
||||
converting: { label: '🔄 Conversion HEIC', color: 'text-yellow-400' },
|
||||
processing: { label: '🤖 Détourage IA', color: 'text-blue-400' },
|
||||
searching: { label: '🔍 Recherche…', color: 'text-purple-400' },
|
||||
ready: { label: '✓ Prêt', color: 'text-green-400' },
|
||||
uploading: { label: '⬆️ Upload…', color: 'text-blue-400' },
|
||||
uploaded: { label: '✅ Uploadé', color: 'text-green-500' },
|
||||
not_found: { label: '❌ Réf. introuvable', color: 'text-red-400' },
|
||||
error: { label: '❌ Erreur', color: 'text-red-400' },
|
||||
}
|
||||
|
||||
function BlobThumbnail({ blob, alt }: { blob: Blob; alt: string }) {
|
||||
const [url, setUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
setUrl(objectUrl)
|
||||
return () => URL.revokeObjectURL(objectUrl)
|
||||
}, [blob])
|
||||
|
||||
if (!url) return <div className="w-full h-full bg-slate-700 animate-pulse" />
|
||||
return <img src={url} alt={alt} className="w-full h-full object-contain" />
|
||||
}
|
||||
|
||||
export function ImageCard({
|
||||
item,
|
||||
onFeatherChange,
|
||||
onRotate,
|
||||
onUpload,
|
||||
onRemove,
|
||||
onOpenLightbox,
|
||||
}: ImageCardProps) {
|
||||
const statusInfo = STATUS_LABELS[item.status] ?? { label: item.status, color: 'text-slate-400' }
|
||||
const isProcessing = ['pending', 'converting', 'processing', 'searching', 'uploading'].includes(item.status)
|
||||
const canAdjust = !!item.rawPngBlob && !isProcessing
|
||||
|
||||
return (
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden flex flex-col">
|
||||
{/* Miniatures avant / après */}
|
||||
<div
|
||||
className="relative h-40 bg-slate-800 cursor-pointer"
|
||||
onClick={() => onOpenLightbox(item)}
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<BlobThumbnail blob={item.originalBlob} alt="Original" />
|
||||
</div>
|
||||
{item.processedBlob && (
|
||||
<div className="absolute inset-0 border-l-2 border-indigo-500 left-1/2 w-1/2">
|
||||
<BlobThumbnail blob={item.processedBlob} alt="Traité" />
|
||||
</div>
|
||||
)}
|
||||
{/* Barre de progression pendant l'IA */}
|
||||
{item.status === 'processing' && item.progress && (
|
||||
<div className="absolute bottom-0 inset-x-0 bg-black/60 px-2 py-1">
|
||||
<div className="h-1 bg-slate-700 rounded">
|
||||
<div
|
||||
className="h-1 bg-indigo-500 rounded transition-all"
|
||||
style={{ width: `${item.progress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{item.progress.key} — {item.progress.percent}%</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Infos */}
|
||||
<div className="p-3 flex flex-col gap-2 flex-1">
|
||||
<div>
|
||||
<p className="text-xs font-mono text-slate-300 truncate">{item.ref}</p>
|
||||
{item.shopifyProductTitle && (
|
||||
<p className="text-xs text-slate-500 truncate">{item.shopifyProductTitle}</p>
|
||||
)}
|
||||
<p className={`text-xs font-semibold mt-0.5 ${statusInfo.color}`}>{statusInfo.label}</p>
|
||||
{item.error && <p className="text-xs text-red-400 mt-0.5 truncate" title={item.error}>{item.error}</p>}
|
||||
</div>
|
||||
|
||||
{/* Contrôles feather + rotation */}
|
||||
<div className={canAdjust ? '' : 'opacity-40 pointer-events-none'}>
|
||||
<label className="block text-xs text-slate-500 mb-1">
|
||||
Bords : {item.feather} px
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={5}
|
||||
step={1}
|
||||
value={item.feather}
|
||||
onChange={(e) => onFeatherChange(item.id, Number(e.target.value))}
|
||||
className="w-full accent-indigo-500"
|
||||
/>
|
||||
|
||||
<div className="flex gap-1 mt-2">
|
||||
<button
|
||||
onClick={() => onRotate(item.id, 'ccw')}
|
||||
className="flex-1 py-1 text-xs bg-slate-800 hover:bg-slate-700 rounded text-slate-300"
|
||||
>↺ -90°</button>
|
||||
<button
|
||||
onClick={() => onRotate(item.id, 'cw')}
|
||||
className="flex-1 py-1 text-xs bg-slate-800 hover:bg-slate-700 rounded text-slate-300"
|
||||
>↻ +90°</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Boutons d'action */}
|
||||
<div className="flex gap-2 mt-auto pt-1">
|
||||
{item.status === 'ready' && (
|
||||
<button
|
||||
onClick={() => onUpload(item.id)}
|
||||
className="flex-1 py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded"
|
||||
>
|
||||
⬆️ Uploader
|
||||
</button>
|
||||
)}
|
||||
{item.status === 'uploaded' && item.uploadedUrl && (
|
||||
<a
|
||||
href={item.uploadedUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 py-1.5 text-xs bg-green-800 hover:bg-green-700 text-white font-semibold rounded text-center"
|
||||
>
|
||||
Voir sur Shopify →
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onRemove(item.id)}
|
||||
className="py-1.5 px-2 text-xs bg-slate-800 hover:bg-red-900 text-slate-400 hover:text-red-300 rounded"
|
||||
>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ImageItem } from '@/types/images'
|
||||
|
||||
interface LightboxProps {
|
||||
item: ImageItem | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function BlobImg({ blob, alt }: { blob: Blob; alt: string }) {
|
||||
const [url, setUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const u = URL.createObjectURL(blob)
|
||||
setUrl(u)
|
||||
return () => URL.revokeObjectURL(u)
|
||||
}, [blob])
|
||||
|
||||
if (!url) return null
|
||||
return <img src={url} alt={alt} className="max-h-full max-w-full object-contain" />
|
||||
}
|
||||
|
||||
export function Lightbox({ item, onClose }: LightboxProps) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
if (!item) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-slate-900 rounded-xl max-w-5xl w-full max-h-[90vh] overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700">
|
||||
<div>
|
||||
<span className="text-sm font-mono text-slate-300">{item.ref}</span>
|
||||
{item.shopifyProductTitle && (
|
||||
<span className="text-xs text-slate-500 ml-2">{item.shopifyProductTitle}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-white text-xl leading-none"
|
||||
>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden min-h-0">
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-4 bg-slate-950 border-r border-slate-700">
|
||||
<p className="text-xs text-slate-500 mb-2">Avant</p>
|
||||
<BlobImg blob={item.originalBlob} alt="Avant" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-4 bg-white/5">
|
||||
<p className="text-xs text-slate-500 mb-2">Après</p>
|
||||
{item.processedBlob
|
||||
? <BlobImg blob={item.processedBlob} alt="Après" />
|
||||
: <p className="text-slate-600 text-sm">Traitement en cours…</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
interface HeaderProps {
|
||||
title: string
|
||||
actions?: React.ReactNode
|
||||
}
|
||||
|
||||
export function Header({ title, actions }: HeaderProps) {
|
||||
return (
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
|
||||
<h1 className="text-base font-bold text-gray-900">{title}</h1>
|
||||
{actions && <div className="flex items-center gap-3">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { signOut, useSession } from 'next-auth/react'
|
||||
|
||||
type NavItem = {
|
||||
href: string
|
||||
label: string
|
||||
icon: string
|
||||
adminOnly?: boolean
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ href: '/', label: 'Dashboard', icon: '📊' },
|
||||
{ href: '/produits', label: 'Produits', icon: '📦' },
|
||||
{ href: '/images', label: 'Images', icon: '🖼️' },
|
||||
{ href: '/sync', label: 'Sync Sheets', icon: '🔄' },
|
||||
{ href: '/parametres', label: 'Paramètres', icon: '⚙️', adminOnly: true },
|
||||
]
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname()
|
||||
const { data: session } = useSession()
|
||||
const isAdmin = session?.user?.role === 'admin'
|
||||
|
||||
const visible = NAV_ITEMS.filter(item => !item.adminOnly || isAdmin)
|
||||
|
||||
return (
|
||||
<aside className="w-48 bg-slate-900 flex flex-col h-screen sticky top-0 border-r border-slate-800">
|
||||
{/* Logo */}
|
||||
<div className="px-4 py-4 border-b border-slate-800">
|
||||
<span className="text-sm font-extrabold text-white tracking-tight">
|
||||
Matériaux<span className="text-indigo-400">Destock</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 py-3 overflow-y-auto">
|
||||
{visible.map(item => {
|
||||
const isActive =
|
||||
item.href === '/' ? pathname === '/' : pathname.startsWith(item.href)
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-2.5 px-4 py-2 text-xs border-l-2 transition-colors ${
|
||||
isActive
|
||||
? 'bg-slate-800 text-white border-indigo-500 font-semibold'
|
||||
: 'text-slate-400 border-transparent hover:text-slate-200 hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm">{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User */}
|
||||
<div className="px-4 py-3 border-t border-slate-800">
|
||||
<p className="text-xs text-slate-400 truncate mb-1">{session?.user?.name}</p>
|
||||
<p className="text-xs text-slate-600 truncate mb-2">{session?.user?.email}</p>
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: '/login' })}
|
||||
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
Déconnexion →
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
'use client'
|
||||
import { SessionProvider as NextAuthSessionProvider } from 'next-auth/react'
|
||||
|
||||
export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 // 1–4
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
'use client'
|
||||
import { useReducer, useCallback } from 'react'
|
||||
import type { ImageItem, ImageStatus } from '@/types/images'
|
||||
|
||||
// ── Reducer ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Action =
|
||||
| { type: 'ADD'; items: ImageItem[] }
|
||||
| { type: 'UPDATE'; id: string; patch: Partial<ImageItem> }
|
||||
| { type: 'REMOVE'; id: string }
|
||||
|
||||
function reducer(state: ImageItem[], action: Action): ImageItem[] {
|
||||
switch (action.type) {
|
||||
case 'ADD':
|
||||
return [...state, ...action.items]
|
||||
case 'UPDATE':
|
||||
return state.map((item) =>
|
||||
item.id === action.id ? { ...item, ...action.patch } : item,
|
||||
)
|
||||
case 'REMOVE':
|
||||
return state.filter((item) => item.id !== action.id)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function refFromFilename(filename: string): string {
|
||||
const dot = filename.lastIndexOf('.')
|
||||
return dot === -1 ? filename : filename.slice(0, dot)
|
||||
}
|
||||
|
||||
function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string
|
||||
// Supprimer le préfixe "data:image/jpeg;base64,"
|
||||
const base64 = result.split(',')[1]
|
||||
resolve(base64)
|
||||
}
|
||||
reader.onerror = () => reject(new Error('FileReader error'))
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Hook ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useImages() {
|
||||
const [items, dispatch] = useReducer(reducer, [])
|
||||
|
||||
const update = useCallback((id: string, patch: Partial<ImageItem>) => {
|
||||
dispatch({ type: 'UPDATE', id, patch })
|
||||
}, [])
|
||||
|
||||
// Recherche produit Shopify
|
||||
const searchProduct = useCallback(
|
||||
async (id: string, ref: string) => {
|
||||
update(id, { status: 'searching' })
|
||||
try {
|
||||
const res = await fetch(`/api/products/search?ref=${encodeURIComponent(ref)}`)
|
||||
const data = await res.json()
|
||||
if (data.found) {
|
||||
update(id, {
|
||||
status: 'ready',
|
||||
shopifyProductId: data.shopifyId,
|
||||
shopifyProductTitle: data.title,
|
||||
})
|
||||
} else {
|
||||
update(id, { status: 'not_found' })
|
||||
}
|
||||
} catch (err) {
|
||||
update(id, { status: 'error', error: `Recherche produit : ${(err as Error).message}` })
|
||||
}
|
||||
},
|
||||
[update],
|
||||
)
|
||||
|
||||
// Pipeline complet pour un item (avec ref)
|
||||
const runPipeline = useCallback(
|
||||
async (id: string, blob: Blob, ref: string, feather: number, rotation: number) => {
|
||||
const { convertHeicToJpeg } = await import('@/lib/client/heicConverter')
|
||||
const { removeBackgroundRaw, compositeOnWhite } = await import('@/lib/client/bgRemover')
|
||||
const { rotateBlob } = await import('@/lib/client/imageRotator')
|
||||
|
||||
let jpegBlob = blob
|
||||
|
||||
if (blob.type === 'image/heic' || blob.type === 'image/heif' || blob.type === '') {
|
||||
try {
|
||||
update(id, { status: 'converting' })
|
||||
jpegBlob = await convertHeicToJpeg(blob)
|
||||
} catch (err) {
|
||||
update(id, { status: 'error', error: `Conversion HEIC : ${(err as Error).message}` })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let rawPng: Blob
|
||||
try {
|
||||
update(id, { status: 'processing', progress: null })
|
||||
rawPng = await removeBackgroundRaw(jpegBlob, (p) => update(id, { progress: p }))
|
||||
} catch (err) {
|
||||
update(id, { status: 'error', error: `Détourage IA : ${(err as Error).message}` })
|
||||
return
|
||||
}
|
||||
|
||||
let finalBlob: Blob
|
||||
try {
|
||||
const composited = await compositeOnWhite(rawPng, feather)
|
||||
finalBlob = await rotateBlob(composited, rotation)
|
||||
} catch (err) {
|
||||
update(id, { status: 'error', error: `Post-traitement : ${(err as Error).message}` })
|
||||
return
|
||||
}
|
||||
|
||||
update(id, { rawPngBlob: rawPng, processedBlob: finalBlob })
|
||||
await searchProduct(id, ref)
|
||||
},
|
||||
[update, searchProduct],
|
||||
)
|
||||
|
||||
// Recomposite rapide (feather ou rotation changés) — ne relance pas l'IA
|
||||
const recomposite = useCallback(
|
||||
async (id: string, rawPngBlob: Blob, feather: number, rotation: number) => {
|
||||
const { compositeOnWhite } = await import('@/lib/client/bgRemover')
|
||||
const { rotateBlob } = await import('@/lib/client/imageRotator')
|
||||
try {
|
||||
const composited = await compositeOnWhite(rawPngBlob, feather)
|
||||
const finalBlob = await rotateBlob(composited, rotation)
|
||||
update(id, { processedBlob: finalBlob, feather, rotation })
|
||||
} catch (err) {
|
||||
update(id, { status: 'error', error: `Retraitement : ${(err as Error).message}` })
|
||||
}
|
||||
},
|
||||
[update],
|
||||
)
|
||||
|
||||
// ── Actions exposées ───────────────────────────────────────────────────────
|
||||
|
||||
const addFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
const { readImagesFromZip } = await import('@/lib/client/zipImporter')
|
||||
|
||||
for (const file of files) {
|
||||
if (file.name.toLowerCase().endsWith('.zip')) {
|
||||
const entries = await readImagesFromZip(file)
|
||||
const newItems: ImageItem[] = entries.map((entry) => ({
|
||||
id: crypto.randomUUID(),
|
||||
filename: `${entry.folder}/${entry.filename}.${entry.ext}`,
|
||||
ref: entry.folder,
|
||||
originalBlob: entry.blob,
|
||||
rawPngBlob: null,
|
||||
processedBlob: null,
|
||||
rotation: 0,
|
||||
feather: 2,
|
||||
shopifyProductId: null,
|
||||
shopifyProductTitle: null,
|
||||
uploadedUrl: null,
|
||||
status: 'pending' as ImageStatus,
|
||||
error: null,
|
||||
progress: null,
|
||||
}))
|
||||
dispatch({ type: 'ADD', items: newItems })
|
||||
for (const item of newItems) {
|
||||
runPipeline(item.id, item.originalBlob, item.ref, item.feather, item.rotation)
|
||||
}
|
||||
} else {
|
||||
const ref = refFromFilename(file.name)
|
||||
const newItem: ImageItem = {
|
||||
id: crypto.randomUUID(),
|
||||
filename: file.name,
|
||||
ref,
|
||||
originalBlob: file,
|
||||
rawPngBlob: null,
|
||||
processedBlob: null,
|
||||
rotation: 0,
|
||||
feather: 2,
|
||||
shopifyProductId: null,
|
||||
shopifyProductTitle: null,
|
||||
uploadedUrl: null,
|
||||
status: 'pending',
|
||||
error: null,
|
||||
progress: null,
|
||||
}
|
||||
dispatch({ type: 'ADD', items: [newItem] })
|
||||
runPipeline(newItem.id, newItem.originalBlob, newItem.ref, newItem.feather, newItem.rotation)
|
||||
}
|
||||
}
|
||||
},
|
||||
[runPipeline],
|
||||
)
|
||||
|
||||
const setFeather = useCallback(
|
||||
(id: string, value: number) => {
|
||||
const item = items.find((i) => i.id === id)
|
||||
if (!item || !item.rawPngBlob) return
|
||||
recomposite(id, item.rawPngBlob, value, item.rotation)
|
||||
},
|
||||
[items, recomposite],
|
||||
)
|
||||
|
||||
const rotate = useCallback(
|
||||
(id: string, direction: 'cw' | 'ccw') => {
|
||||
const item = items.find((i) => i.id === id)
|
||||
if (!item || !item.rawPngBlob) return
|
||||
const delta = direction === 'cw' ? 90 : -90
|
||||
const newRotation = ((item.rotation + delta) % 360 + 360) % 360
|
||||
recomposite(id, item.rawPngBlob, item.feather, newRotation)
|
||||
},
|
||||
[items, recomposite],
|
||||
)
|
||||
|
||||
const uploadOne = useCallback(
|
||||
async (id: string) => {
|
||||
const item = items.find((i) => i.id === id)
|
||||
if (!item || item.status !== 'ready' || !item.processedBlob || !item.shopifyProductId) return
|
||||
|
||||
update(id, { status: 'uploading' })
|
||||
|
||||
try {
|
||||
const base64 = await blobToBase64(item.processedBlob)
|
||||
const filename = `${item.ref}.jpg`
|
||||
const res = await fetch('/api/images/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ shopifyProductId: item.shopifyProductId, imageBase64: base64, filename }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||
update(id, { status: 'uploaded', uploadedUrl: data.url })
|
||||
} catch (err) {
|
||||
update(id, { status: 'error', error: `Upload : ${(err as Error).message}` })
|
||||
}
|
||||
},
|
||||
[items, update],
|
||||
)
|
||||
|
||||
const uploadAll = useCallback(() => {
|
||||
items.filter((i) => i.status === 'ready').forEach((i) => uploadOne(i.id))
|
||||
}, [items, uploadOne])
|
||||
|
||||
const removeItem = useCallback((id: string) => {
|
||||
dispatch({ type: 'REMOVE', id })
|
||||
}, [])
|
||||
|
||||
return { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem }
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { NextAuthOptions } from 'next-auth'
|
||||
import CredentialsProvider from 'next-auth/providers/credentials'
|
||||
import { compare } from 'bcryptjs'
|
||||
import { prisma } from './prisma'
|
||||
import { checkRateLimit, recordFailedAttempt, clearAttempts } from './rate-limit'
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
session: {
|
||||
strategy: 'jwt',
|
||||
maxAge: 8 * 60 * 60, // 8 heures
|
||||
},
|
||||
pages: {
|
||||
signIn: '/login',
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
// NextAuth v4 : les types de base n'incluent pas les champs custom.
|
||||
// Le module augmentation dans next-auth.d.ts couvre le type Session/JWT
|
||||
// mais pas le paramètre `user` du callback jwt. Cast nécessaire.
|
||||
const u = user as typeof user & { role: string }
|
||||
token.role = u.role
|
||||
token.id = u.id
|
||||
}
|
||||
return token
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user) {
|
||||
// session.user est déjà augmenté via next-auth.d.ts (id + role)
|
||||
session.user.id = token.id as string
|
||||
session.user.role = token.role as string
|
||||
}
|
||||
return session
|
||||
},
|
||||
},
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: 'Credentials',
|
||||
credentials: {
|
||||
email: { label: 'Email', type: 'email' },
|
||||
password: { label: 'Mot de passe', type: 'password' },
|
||||
},
|
||||
async authorize(credentials, req) {
|
||||
if (!credentials?.email || !credentials?.password) return null
|
||||
|
||||
// NextAuth v4 : req est un RequestInternal partiel sans typings complets.
|
||||
// Le cast est nécessaire pour accéder à x-forwarded-for.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ip = (req as any)?.headers?.['x-forwarded-for']?.split(',')[0]?.trim() ?? 'unknown'
|
||||
const key = `${ip}:${credentials.email}`
|
||||
|
||||
if (!checkRateLimit(key)) {
|
||||
throw new Error('RATE_LIMITED')
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: credentials.email },
|
||||
})
|
||||
|
||||
if (!user || !(await compare(credentials.password, user.password))) {
|
||||
recordFailedAttempt(key)
|
||||
return null
|
||||
}
|
||||
|
||||
clearAttempts(key)
|
||||
return { id: user.id, email: user.email, name: user.name, role: user.role }
|
||||
},
|
||||
}),
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
const CDN_VERSION = '1.7.0'
|
||||
const CDN_URL = `https://cdn.jsdelivr.net/npm/@imgly/background-removal@${CDN_VERSION}/dist/index.mjs`
|
||||
|
||||
let _removeBg: ((blob: Blob, opts: Record<string, unknown>) => Promise<Blob>) | null = null
|
||||
|
||||
async function loadLib() {
|
||||
if (_removeBg) return _removeBg
|
||||
const mod = await import(/* webpackIgnore: true */ CDN_URL)
|
||||
_removeBg = mod.removeBackground
|
||||
return _removeBg!
|
||||
}
|
||||
|
||||
export interface ProgressEvent {
|
||||
key: string
|
||||
percent: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime le fond d'un Blob image via @imgly IA.
|
||||
* Retourne un PNG avec fond transparent (à combiner avec compositeOnWhite).
|
||||
*/
|
||||
export async function removeBackgroundRaw(
|
||||
blob: Blob,
|
||||
onProgress?: (e: ProgressEvent) => void,
|
||||
): Promise<Blob> {
|
||||
const removeBg = await loadLib()
|
||||
|
||||
return removeBg(blob, {
|
||||
output: { format: 'image/png', type: 'foreground', quality: 1 },
|
||||
progress: (key: string, current: number, total: number) => {
|
||||
if (onProgress && total > 0) {
|
||||
onProgress({ key, percent: Math.round((current / total) * 100) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite un PNG transparent sur fond blanc avec feather optionnel.
|
||||
* @param pngBlob — PNG avec transparence (résultat de removeBackgroundRaw)
|
||||
* @param feather — récupération des bords en px (0 = strict, 5 = doux)
|
||||
*/
|
||||
export function compositeOnWhite(pngBlob: Blob, feather: number): Promise<Blob> {
|
||||
const objectUrl = URL.createObjectURL(pngBlob)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
|
||||
img.onload = () => {
|
||||
const { width, height } = img
|
||||
|
||||
const fgCanvas = document.createElement('canvas')
|
||||
fgCanvas.width = width
|
||||
fgCanvas.height = height
|
||||
const fgCtx = fgCanvas.getContext('2d')!
|
||||
|
||||
fgCtx.drawImage(img, 0, 0)
|
||||
|
||||
if (feather > 0) {
|
||||
fgCtx.globalCompositeOperation = 'destination-over'
|
||||
fgCtx.filter = `blur(${feather}px)`
|
||||
fgCtx.drawImage(img, 0, 0)
|
||||
fgCtx.filter = 'none'
|
||||
fgCtx.globalCompositeOperation = 'source-over'
|
||||
}
|
||||
|
||||
const out = document.createElement('canvas')
|
||||
out.width = width
|
||||
out.height = height
|
||||
const ctx = out.getContext('2d')!
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
ctx.drawImage(fgCanvas, 0, 0)
|
||||
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
out.toBlob(
|
||||
(b) => (b ? resolve(b) : reject(new Error('canvas.toBlob a retourné null'))),
|
||||
'image/jpeg',
|
||||
0.92,
|
||||
)
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
reject(new Error('Impossible de charger le PNG'))
|
||||
}
|
||||
img.src = objectUrl
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Convertit un Blob HEIC/HEIF en Blob JPEG via heic2any.
|
||||
* À utiliser uniquement côté client (navigateur).
|
||||
*/
|
||||
export async function convertHeicToJpeg(blob: Blob): Promise<Blob> {
|
||||
if (!(blob instanceof Blob)) {
|
||||
throw new TypeError('L\'entrée doit être un Blob')
|
||||
}
|
||||
|
||||
const type = blob.type.toLowerCase()
|
||||
if (type !== '' && !['image/heic', 'image/heif'].includes(type)) {
|
||||
throw new TypeError(`Format attendu : HEIC/HEIF. Reçu : ${blob.type || '(vide)'}`)
|
||||
}
|
||||
|
||||
const heic2any = (await import('heic2any')).default
|
||||
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout conversion HEIC (120s)')), 120_000),
|
||||
)
|
||||
|
||||
const conversion = heic2any({ blob, toType: 'image/jpeg', quality: 0.92 })
|
||||
|
||||
const result = await Promise.race([conversion, timeout])
|
||||
const output = Array.isArray(result) ? result[0] : result
|
||||
|
||||
if (!(output instanceof Blob)) {
|
||||
throw new Error("heic2any n'a pas retourné un Blob")
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Pivote un Blob JPEG via Canvas.
|
||||
* @param blob — image source (JPEG)
|
||||
* @param degrees — angle en degrés (0, 90, 180, 270)
|
||||
* @returns Blob JPEG pivoté, ou le blob original si degrees === 0
|
||||
*/
|
||||
export function rotateBlob(blob: Blob, degrees: number): Promise<Blob> {
|
||||
const norm = ((degrees % 360) + 360) % 360
|
||||
if (norm === 0) return Promise.resolve(blob)
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
|
||||
img.onload = () => {
|
||||
const swap = norm === 90 || norm === 270
|
||||
const w = swap ? img.naturalHeight : img.naturalWidth
|
||||
const h = swap ? img.naturalWidth : img.naturalHeight
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
ctx.translate(w / 2, h / 2)
|
||||
ctx.rotate((norm * Math.PI) / 180)
|
||||
ctx.drawImage(img, -img.naturalWidth / 2, -img.naturalHeight / 2)
|
||||
|
||||
URL.revokeObjectURL(url)
|
||||
canvas.toBlob(
|
||||
(b) => (b ? resolve(b) : reject(new Error('rotateBlob : toBlob a retourné null'))),
|
||||
'image/jpeg',
|
||||
0.92,
|
||||
)
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
reject(new Error('rotateBlob : impossible de charger le blob'))
|
||||
}
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import JSZip from 'jszip'
|
||||
import type { ZipEntry } from '@/types/images'
|
||||
|
||||
const IMAGE_EXTS = new Set(['heic', 'heif', 'jpg', 'jpeg', 'png'])
|
||||
|
||||
/**
|
||||
* Extrait toutes les images d'un fichier ZIP.
|
||||
* Structure attendue : dossier-ref/image.ext (le nom du dossier = référence produit).
|
||||
*/
|
||||
export async function readImagesFromZip(file: File): Promise<ZipEntry[]> {
|
||||
const zip = await JSZip.loadAsync(file)
|
||||
|
||||
const entries: Array<{ relativePath: string; entry: JSZip.JSZipObject }> = []
|
||||
zip.forEach((relativePath, entry) => {
|
||||
if (!entry.dir) entries.push({ relativePath, entry })
|
||||
})
|
||||
|
||||
const results: ZipEntry[] = []
|
||||
|
||||
for (const { relativePath, entry } of entries) {
|
||||
if (relativePath.startsWith('__MACOSX/')) continue
|
||||
|
||||
const parts = relativePath.split('/').filter((p) => p.length > 0)
|
||||
const filename = parts[parts.length - 1]
|
||||
if (filename.startsWith('.')) continue
|
||||
|
||||
const dotIdx = filename.lastIndexOf('.')
|
||||
if (dotIdx === -1) continue
|
||||
const ext = filename.slice(dotIdx + 1).toLowerCase()
|
||||
if (!IMAGE_EXTS.has(ext)) continue
|
||||
|
||||
const folder = parts.length > 1 ? parts[0] : filename.slice(0, dotIdx)
|
||||
const isHeic = ext === 'heic' || ext === 'heif'
|
||||
const mimeType = isHeic
|
||||
? 'image/heic'
|
||||
: ext === 'jpg' || ext === 'jpeg'
|
||||
? 'image/jpeg'
|
||||
: 'image/png'
|
||||
|
||||
const rawBlob = await entry.async('blob')
|
||||
const blob = new Blob([rawBlob], { type: mimeType })
|
||||
|
||||
results.push({ folder, filename: filename.slice(0, dotIdx), ext, blob, isHeic })
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { SyncProduct } from '@/types/sync'
|
||||
|
||||
const API_VERSION = 'v4'
|
||||
|
||||
function normalizeHandle(ref: string): string {
|
||||
return ref
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse des lignes brutes Google Sheets en SyncProduct[].
|
||||
* Colonnes : A=Référence, B=Nom, C=Description, D=Prix, E=Stock, F=Statut
|
||||
* Exporté pour les tests unitaires.
|
||||
*/
|
||||
export function parseSheetRows(rows: string[][]): SyncProduct[] {
|
||||
return rows
|
||||
.map((row): SyncProduct | null => {
|
||||
const ref = row[0]?.trim()
|
||||
if (!ref) return null
|
||||
|
||||
const rawPrice = (row[3] ?? '').replace(',', '.').trim()
|
||||
const price = parseFloat(rawPrice || '0')
|
||||
|
||||
return {
|
||||
ref,
|
||||
handle: normalizeHandle(ref),
|
||||
title: row[1]?.trim() || ref,
|
||||
description: row[2]?.trim() || '',
|
||||
price: isNaN(price) ? '0.00' : price.toFixed(2),
|
||||
stock: parseInt(row[4] ?? '0', 10) || 0,
|
||||
status: (row[5] ?? '').toLowerCase().includes('inac') ? 'draft' : 'active',
|
||||
}
|
||||
})
|
||||
.filter((p): p is SyncProduct => p !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit tous les produits depuis Google Sheets via l'API v4.
|
||||
* Utilise un service account (GOOGLE_SERVICE_ACCOUNT_EMAIL + GOOGLE_PRIVATE_KEY).
|
||||
*/
|
||||
export async function readSheetProducts(): Promise<SyncProduct[]> {
|
||||
const email = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL
|
||||
const key = process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n')
|
||||
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||
const sheetName = process.env.GOOGLE_SHEETS_TAB ?? 'Produits'
|
||||
|
||||
if (!email || !key || !sheetId) {
|
||||
throw new Error('Variables GOOGLE_SERVICE_ACCOUNT_EMAIL, GOOGLE_PRIVATE_KEY et GOOGLE_SHEETS_ID requises')
|
||||
}
|
||||
|
||||
const { google } = await import('googleapis')
|
||||
|
||||
const auth = new google.auth.GoogleAuth({
|
||||
credentials: { client_email: email, private_key: key },
|
||||
scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'],
|
||||
})
|
||||
|
||||
const sheets = google.sheets({ version: API_VERSION, auth })
|
||||
|
||||
const response = await sheets.spreadsheets.values.get({
|
||||
spreadsheetId: sheetId,
|
||||
range: `${sheetName}!A2:F`, // A2 = skip header row
|
||||
})
|
||||
|
||||
const rows = (response.data.values ?? []) as string[][]
|
||||
return parseSheetRows(rows)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
|
||||
import path from 'path'
|
||||
|
||||
function createPrismaClient() {
|
||||
const dbUrl = process.env.DATABASE_URL ?? 'file:./data/dev.db'
|
||||
// Parse the file path from "file:./data/dev.db" format
|
||||
const dbPath = dbUrl.startsWith('file:')
|
||||
? path.resolve(process.cwd(), dbUrl.slice('file:'.length))
|
||||
: dbUrl
|
||||
|
||||
const adapter = new PrismaBetterSqlite3({ url: dbPath })
|
||||
return new PrismaClient({
|
||||
adapter,
|
||||
log: process.env.NODE_ENV === 'development' ? ['error'] : [],
|
||||
})
|
||||
}
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? createPrismaClient()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
@@ -0,0 +1,32 @@
|
||||
const MAX_ATTEMPTS = 5
|
||||
const BLOCK_DURATION_MS = 15 * 60 * 1000 // 15 minutes
|
||||
|
||||
type Entry = { count: number; blockedAt: number | null }
|
||||
let attempts = new Map<string, Entry>()
|
||||
|
||||
export function checkRateLimit(key: string): boolean {
|
||||
const now = Date.now()
|
||||
const entry = attempts.get(key)
|
||||
if (!entry) return true
|
||||
if (entry.blockedAt !== null) {
|
||||
if (now - entry.blockedAt < BLOCK_DURATION_MS) return false
|
||||
attempts.delete(key)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function recordFailedAttempt(key: string): void {
|
||||
const entry = attempts.get(key) ?? { count: 0, blockedAt: null }
|
||||
entry.count += 1
|
||||
if (entry.count >= MAX_ATTEMPTS) entry.blockedAt = Date.now()
|
||||
attempts.set(key, entry)
|
||||
}
|
||||
|
||||
export function clearAttempts(key: string): void {
|
||||
attempts.delete(key)
|
||||
}
|
||||
|
||||
/** Réinitialisation pour les tests uniquement */
|
||||
export function _resetForTests(): void {
|
||||
attempts = new Map()
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
const API_VERSION = '2024-01'
|
||||
|
||||
function getConfig() {
|
||||
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||
if (!domain || !token) {
|
||||
throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
|
||||
}
|
||||
return {
|
||||
baseUrl: `https://${domain}/admin/api/${API_VERSION}`,
|
||||
headers: {
|
||||
'X-Shopify-Access-Token': token,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type ShopifyProductResult =
|
||||
| { found: false }
|
||||
| { found: true; shopifyId: string; title: string }
|
||||
|
||||
/**
|
||||
* Recherche un produit Shopify par sa référence.
|
||||
* Tente d'abord par handle (ex: "ref-1042"), puis par titre exact.
|
||||
*/
|
||||
export async function searchProductByRef(ref: string): Promise<ShopifyProductResult> {
|
||||
const { baseUrl, headers } = getConfig()
|
||||
|
||||
// Normalise la référence en handle Shopify (minuscules, tirets)
|
||||
const handle = ref.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
|
||||
const tryFetch = async (params: string) => {
|
||||
const res = await fetch(`${baseUrl}/products.json?${params}&fields=id,title,handle&limit=1`, { headers })
|
||||
if (!res.ok) throw new Error(`Shopify API error: ${res.status}`)
|
||||
const data = await res.json()
|
||||
return (data.products ?? []) as Array<{ id: number; title: string; handle: string }>
|
||||
}
|
||||
|
||||
let products = await tryFetch(`handle=${encodeURIComponent(handle)}`)
|
||||
|
||||
if (products.length === 0) {
|
||||
products = await tryFetch(`title=${encodeURIComponent(ref)}`)
|
||||
}
|
||||
|
||||
if (products.length === 0) return { found: false }
|
||||
|
||||
return {
|
||||
found: true,
|
||||
shopifyId: String(products[0].id),
|
||||
title: products[0].title,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload une image (base64) vers un produit Shopify via l'API REST.
|
||||
* @param shopifyProductId — ID numérique du produit (string)
|
||||
* @param imageBase64 — contenu JPEG encodé en base64 (sans le préfixe data:)
|
||||
* @param filename — nom de fichier avec extension (ex: "REF-1042.jpg")
|
||||
*/
|
||||
export async function uploadProductImage(
|
||||
shopifyProductId: string,
|
||||
imageBase64: string,
|
||||
filename: string,
|
||||
): Promise<{ url: string }> {
|
||||
const { baseUrl, headers } = getConfig()
|
||||
|
||||
const res = await fetch(`${baseUrl}/products/${shopifyProductId}/images.json`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ image: { attachment: imageBase64, filename } }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
throw new Error(`Shopify upload error: ${res.status} — ${body}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
if (!data.image?.src) {
|
||||
throw new Error('Shopify response invalide : champ image.src manquant')
|
||||
}
|
||||
return { url: data.image.src as string }
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { ShopifyProductFull, SyncProduct } from '@/types/sync'
|
||||
|
||||
const API_VERSION = '2024-01'
|
||||
|
||||
function getConfig() {
|
||||
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||
if (!domain || !token) {
|
||||
throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
|
||||
}
|
||||
return {
|
||||
baseUrl: `https://${domain}/admin/api/${API_VERSION}`,
|
||||
headers: {
|
||||
'X-Shopify-Access-Token': token,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface ShopifyVariant {
|
||||
id: number
|
||||
price: string
|
||||
sku: string
|
||||
inventory_quantity: number
|
||||
}
|
||||
|
||||
interface ShopifyAPIProduct {
|
||||
id: number
|
||||
title: string
|
||||
handle: string
|
||||
body_html: string
|
||||
status: string
|
||||
variants: ShopifyVariant[]
|
||||
}
|
||||
|
||||
function toSyncProduct(p: ShopifyAPIProduct): ShopifyProductFull {
|
||||
return {
|
||||
shopifyId: String(p.id),
|
||||
ref: p.variants[0]?.sku || p.handle,
|
||||
handle: p.handle,
|
||||
title: p.title,
|
||||
description: p.body_html ?? '',
|
||||
price: p.variants[0]?.price ?? '0.00',
|
||||
stock: p.variants[0]?.inventory_quantity ?? 0,
|
||||
status: p.status === 'active' ? 'active' : 'draft',
|
||||
}
|
||||
}
|
||||
|
||||
function extractNextUrl(linkHeader: string | null): string | null {
|
||||
if (!linkHeader) return null
|
||||
const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère tous les produits Shopify (actifs + drafts), en paginant.
|
||||
* Limite : 250 par page via cursor-based pagination.
|
||||
*/
|
||||
export async function fetchAllShopifyProducts(): Promise<ShopifyProductFull[]> {
|
||||
const { baseUrl, headers } = getConfig()
|
||||
const all: ShopifyProductFull[] = []
|
||||
|
||||
let url: string | null =
|
||||
`${baseUrl}/products.json?limit=250&fields=id,title,handle,status,body_html,variants&status=any`
|
||||
|
||||
while (url) {
|
||||
const res = await fetch(url, { headers })
|
||||
if (!res.ok) throw new Error(`Shopify fetch error: ${res.status}`)
|
||||
const data = await res.json()
|
||||
all.push(...(data.products as ShopifyAPIProduct[]).map(toSyncProduct))
|
||||
url = extractNextUrl(res.headers.get('Link'))
|
||||
}
|
||||
|
||||
return all
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un nouveau produit Shopify depuis un SyncProduct.
|
||||
* Retourne l'ID Shopify créé.
|
||||
*/
|
||||
export async function createShopifyProduct(product: SyncProduct): Promise<string> {
|
||||
const { baseUrl, headers } = getConfig()
|
||||
|
||||
const body = {
|
||||
product: {
|
||||
title: product.title,
|
||||
body_html: product.description,
|
||||
handle: product.handle,
|
||||
status: product.status,
|
||||
variants: [{ price: product.price, sku: product.ref }],
|
||||
},
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseUrl}/products.json`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
throw new Error(`Shopify createProduct error: ${res.status} — ${err}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
return String(data.product.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour un produit Shopify existant (titre, description, prix, statut).
|
||||
*/
|
||||
export async function updateShopifyProduct(
|
||||
shopifyId: string,
|
||||
product: SyncProduct,
|
||||
variantId: string,
|
||||
): Promise<void> {
|
||||
const { baseUrl, headers } = getConfig()
|
||||
|
||||
const body = {
|
||||
product: {
|
||||
id: shopifyId,
|
||||
title: product.title,
|
||||
body_html: product.description,
|
||||
status: product.status,
|
||||
variants: [{ id: variantId, price: product.price }],
|
||||
},
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseUrl}/products/${shopifyId}.json`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
throw new Error(`Shopify updateProduct error: ${res.status} — ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Passe un produit Shopify en draft (désactivation — jamais de suppression).
|
||||
*/
|
||||
export async function deactivateShopifyProduct(shopifyId: string): Promise<void> {
|
||||
const { baseUrl, headers } = getConfig()
|
||||
|
||||
const res = await fetch(`${baseUrl}/products/${shopifyId}.json`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify({ product: { id: shopifyId, status: 'draft' } }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
throw new Error(`Shopify deactivateProduct error: ${res.status} — ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'ID de la première variante d'un produit Shopify.
|
||||
* Nécessaire pour mettre à jour le prix (update variant).
|
||||
*/
|
||||
export async function getFirstVariantId(shopifyId: string): Promise<string> {
|
||||
const { baseUrl, headers } = getConfig()
|
||||
const res = await fetch(`${baseUrl}/products/${shopifyId}/variants.json?limit=1`, { headers })
|
||||
if (!res.ok) throw new Error(`Shopify getVariants error: ${res.status}`)
|
||||
const data = await res.json()
|
||||
const variant = data.variants?.[0]
|
||||
if (!variant) throw new Error(`Aucune variante pour le produit ${shopifyId}`)
|
||||
return String(variant.id)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { withAuth } from 'next-auth/middleware'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export default withAuth(
|
||||
function middleware(req) {
|
||||
const { token } = req.nextauth
|
||||
const { pathname } = req.nextUrl
|
||||
|
||||
// Seuls les admins accèdent à /parametres
|
||||
if (pathname.startsWith('/parametres') && token?.role !== 'admin') {
|
||||
return NextResponse.redirect(new URL('/', req.url))
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
},
|
||||
{
|
||||
callbacks: {
|
||||
authorized: ({ token }) => !!token,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!login|api/auth|_next/static|_next/image|favicon\\.ico).*)',
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export type ImageStatus =
|
||||
| 'pending' // ajouté, pas encore traité
|
||||
| 'converting' // HEIC → JPEG en cours
|
||||
| 'processing' // détourage IA en cours
|
||||
| 'searching' // recherche produit Shopify en cours
|
||||
| 'ready' // prêt à uploader
|
||||
| 'uploading' // upload Shopify en cours
|
||||
| 'uploaded' // uploadé avec succès
|
||||
| 'not_found' // référence produit introuvable dans Shopify
|
||||
| 'error' // erreur à n'importe quelle étape
|
||||
|
||||
export interface ImageProgress {
|
||||
key: string
|
||||
percent: number
|
||||
}
|
||||
|
||||
export interface ImageItem {
|
||||
id: string
|
||||
filename: string // nom de fichier original (ex: "REF-1042.heic")
|
||||
ref: string // référence produit extraite (ex: "REF-1042")
|
||||
originalBlob: Blob // image originale (pour la vue "avant")
|
||||
rawPngBlob: Blob | null // PNG transparent issu du détourage IA (cache pour feather)
|
||||
processedBlob: Blob | null // JPEG final sur fond blanc, pivoté (pour upload + vue "après")
|
||||
rotation: number // 0, 90, 180, 270
|
||||
feather: number // 0–5, défaut 2
|
||||
shopifyProductId: string | null
|
||||
shopifyProductTitle: string | null
|
||||
uploadedUrl: string | null
|
||||
status: ImageStatus
|
||||
error: string | null
|
||||
progress: ImageProgress | null
|
||||
}
|
||||
|
||||
export interface ZipEntry {
|
||||
folder: string // nom du dossier = référence produit
|
||||
filename: string // nom de fichier dans le dossier
|
||||
ext: string // extension sans point, en minuscules
|
||||
blob: Blob // blob avec type MIME correct
|
||||
isHeic: boolean
|
||||
}
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
import type { DefaultSession, DefaultJWT } from 'next-auth'
|
||||
|
||||
declare module 'next-auth' {
|
||||
interface Session {
|
||||
user: DefaultSession['user'] & {
|
||||
id: string
|
||||
role: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'next-auth/jwt' {
|
||||
interface JWT extends DefaultJWT {
|
||||
id: string
|
||||
role: string
|
||||
}
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
Reference in New Issue
Block a user