Compare commits
159 Commits
f435b1f6cc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ac05d2ac30 | |||
| 60dc9cc0ec | |||
| eb5f605175 | |||
| ac58289bb7 | |||
| 4f617b15b2 | |||
| 401b43cc04 | |||
| d276f6224c | |||
| a9850c8efd | |||
| 747f8a47b8 | |||
| a334987b7c | |||
| 06587c1ccd | |||
| 99de0da823 | |||
| 3ec0b9fa3c | |||
| 461667c506 | |||
| 34ec818ad3 | |||
| f650225c98 | |||
| 7ebb7e899d | |||
| 4ce62c1473 | |||
| fcadea6758 | |||
| 0a3f5042fc | |||
| d616f1d74c | |||
| eec9bd5dce | |||
| c95093a81f | |||
| 03770d1a76 | |||
| 5e1b864ce7 | |||
| cc0d57bf2b | |||
| 6ca2509ca6 | |||
| 42989a5ef3 | |||
| f56e52f801 | |||
| 949d7c60b9 | |||
| 36447a5c8c | |||
| 920ec50402 | |||
| 2f6eea1122 | |||
| 42ef490196 | |||
| 31b764f458 | |||
| 8c9bce7aa5 | |||
| 9e7423eee3 | |||
| 6d8be0645d | |||
| 114e20c9fb | |||
| caef95bd55 | |||
| 34ade6beb2 | |||
| cdde567558 | |||
| e85314eb77 | |||
| 5a10fd4221 | |||
| 93a616e997 | |||
| 41f5c245d4 | |||
| f6b0ba9685 | |||
| 4811b73c69 | |||
| 921d9d6a77 | |||
| d138577a9d | |||
| 1ef69dc205 | |||
| 01e2fcd5e5 | |||
| 07432bbd95 | |||
| 731d0dbde6 | |||
| 42d0e2f855 | |||
| 15f647f58a | |||
| cb227943f3 | |||
| d45c9490c0 | |||
| c1fad5d0ed | |||
| d8a3f26d0b | |||
| 297d47bd19 | |||
| a2c0789625 | |||
| 23aa78d3da | |||
| 1b07359415 | |||
| 6f4affcd66 | |||
| 211d8c5c01 | |||
| d16cd1fbf5 | |||
| fecf56ff09 | |||
| 85b0590d85 | |||
| 725a7508b0 | |||
| 4df074f02d | |||
| 06cda68929 | |||
| c19510d413 | |||
| 2de5c6670d | |||
| 6a08fccb7a | |||
| da52080e3e | |||
| f0b6ddf145 | |||
| d1e78e91e4 | |||
| 071f126244 | |||
| 415b7db643 | |||
| 8ede847ba0 | |||
| 193edc80aa | |||
| 1f8db29b15 | |||
| 4b9db5bebf | |||
| a77f44b800 | |||
| 0b26a61f90 | |||
| 2efe339576 | |||
| c4edcb568c | |||
| e3246d022e | |||
| 9ab8b3ef60 | |||
| 7da453606e | |||
| 8d591bb769 | |||
| bb596c7e32 | |||
| 1ed8c055d7 | |||
| 40d537efb6 | |||
| 015833150a | |||
| 04833b490c | |||
| 072110e655 | |||
| 421c275d48 | |||
| ed091711c8 | |||
| 3aa770321c | |||
| 2e9df3659f | |||
| 1e48ca142f | |||
| c89ab1c818 | |||
| 4263b8afd0 | |||
| 4db43f833b | |||
| 4e4268d678 | |||
| 1934d0b655 | |||
| 119123e006 | |||
| 5825640105 | |||
| f27b62a403 | |||
| 9303aab78e | |||
| 6dd6dc301e | |||
| e576df334a | |||
| dc88e56f49 | |||
| e04e286111 | |||
| 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,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -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
+1788
-75
File diff suppressed because it is too large
Load Diff
+17
-2
@@ -6,15 +6,28 @@
|
||||
"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": {
|
||||
"@imgly/background-removal": "^1.7.0",
|
||||
"@prisma/adapter-better-sqlite3": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"googleapis": "^173.0.0",
|
||||
"heic-convert": "^2.1.0",
|
||||
"heic2any": "^0.0.4",
|
||||
"jszip": "^3.10.1",
|
||||
"next": "14.2.35",
|
||||
"next-auth": "^4.24.14",
|
||||
"onnxruntime-web": "^1.21.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18"
|
||||
"react-dom": "^18",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -24,6 +37,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.35",
|
||||
"jest": "^30.4.2",
|
||||
@@ -32,6 +46,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,14 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProductCreation" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"userId" TEXT NOT NULL,
|
||||
"sheetTab" TEXT NOT NULL,
|
||||
"sheetRow" INTEGER NOT NULL,
|
||||
"shopifyId" TEXT NOT NULL,
|
||||
"sku" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'success',
|
||||
"errorMessage" TEXT NOT NULL DEFAULT '',
|
||||
CONSTRAINT "ProductCreation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
@@ -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,45 @@
|
||||
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[]
|
||||
productCreations ProductCreation[]
|
||||
}
|
||||
|
||||
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("[]")
|
||||
}
|
||||
|
||||
model ProductCreation {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
sheetTab String
|
||||
sheetRow Int
|
||||
shopifyId String
|
||||
sku String
|
||||
title String
|
||||
status String @default("success")
|
||||
errorMessage String @default("")
|
||||
}
|
||||
@@ -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,76 @@
|
||||
/**
|
||||
* Met à jour compare_at_price sur tous les produits Shopify existants
|
||||
* en lisant Prix marché / Prix remisé depuis Google Sheets.
|
||||
*
|
||||
* Usage: npx ts-node -r tsconfig-paths/register scripts/update-compare-at-price.ts
|
||||
*/
|
||||
|
||||
import * as dotenv from 'dotenv'
|
||||
dotenv.config({ path: '.env.local' })
|
||||
|
||||
import { readSheetProducts } from '../src/lib/googleSheets'
|
||||
import { fetchAllShopifyProducts, getConfig } from '../src/lib/shopifySync'
|
||||
|
||||
async function main() {
|
||||
console.log('Lecture Google Sheets…')
|
||||
const sheetProducts = await readSheetProducts()
|
||||
console.log(`${sheetProducts.length} produits dans Sheets`)
|
||||
|
||||
console.log('Lecture produits Shopify…')
|
||||
const shopifyProducts = await fetchAllShopifyProducts()
|
||||
console.log(`${shopifyProducts.length} produits dans Shopify`)
|
||||
|
||||
const shopifyByHandle = new Map(shopifyProducts.map(p => [p.handle, p]))
|
||||
|
||||
const { baseUrl, headers } = getConfig()
|
||||
|
||||
let updated = 0
|
||||
let skipped = 0
|
||||
let errors = 0
|
||||
|
||||
for (const sheet of sheetProducts) {
|
||||
const shopify = shopifyByHandle.get(sheet.handle)
|
||||
if (!shopify) { skipped++; continue }
|
||||
|
||||
const newCompareAt = sheet.compareAtPrice ?? null
|
||||
const currentCompareAt = (shopify as { compareAtPrice?: string }).compareAtPrice ?? null
|
||||
|
||||
// Normalise pour comparer (null vs undefined vs "0.00" vs null)
|
||||
const norm = (v: string | null) => (!v || v === '0.00') ? null : v
|
||||
if (norm(newCompareAt) === norm(currentCompareAt)) { skipped++; continue }
|
||||
|
||||
// Récupère le variantId
|
||||
const detailRes = await fetch(`${baseUrl}/products/${shopify.shopifyId}.json?fields=variants`, { headers })
|
||||
if (!detailRes.ok) { console.error(`Erreur lecture variant ${sheet.ref}`); errors++; continue }
|
||||
const detail = await detailRes.json() as { product: { variants: Array<{ id: number }> } }
|
||||
const variantId = detail.product.variants[0]?.id
|
||||
if (!variantId) { errors++; continue }
|
||||
|
||||
const res = await fetch(`${baseUrl}/products/${shopify.shopifyId}.json`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
product: {
|
||||
id: shopify.shopifyId,
|
||||
variants: [{ id: variantId, price: sheet.price, compare_at_price: newCompareAt }],
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
console.log(`✓ ${sheet.ref} — price=${sheet.price} compare_at=${newCompareAt ?? 'null'}`)
|
||||
updated++
|
||||
} else {
|
||||
const err = await res.text()
|
||||
console.error(`✗ ${sheet.ref} — ${err}`)
|
||||
errors++
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
}
|
||||
|
||||
console.log(`\nTerminé : ${updated} mis à jour, ${skipped} inchangés, ${errors} erreurs`)
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1) })
|
||||
@@ -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,297 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { BannerBuilder } from '@/components/bannieres/BannerBuilder'
|
||||
import type { SlideshowSection, Slide } from '@/lib/shopifyTheme'
|
||||
|
||||
// Convertit "shopify://shop_images/foo.jpg" → URL CDN prévisualisable via proxy
|
||||
function shopifyRefToProxyUrl(ref: string): string {
|
||||
if (!ref) return ''
|
||||
const filename = ref.replace('shopify://shop_images/', '')
|
||||
return `/api/images/proxy?url=${encodeURIComponent(`https://cdn.shopify.com/s/files/1/0897/7373/6263/files/${filename}`)}`
|
||||
}
|
||||
|
||||
function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve((reader.result as string).split(',')[1])
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
interface SlideCardProps {
|
||||
slide: Slide
|
||||
index: number
|
||||
total: number
|
||||
onMoveUp: () => void
|
||||
onMoveDown: () => void
|
||||
onRemove: () => void
|
||||
onChangeImage: (shopifyRef: string) => void
|
||||
onChangeLink: (link: string) => void
|
||||
onOpenBuilder: () => void
|
||||
}
|
||||
|
||||
function SlideCard({ slide, index, total, onMoveUp, onMoveDown, onRemove, onChangeImage, onChangeLink, onOpenBuilder }: SlideCardProps) {
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const base64 = await blobToBase64(file)
|
||||
const res = await fetch('/api/theme/upload-file', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ imageBase64: base64, filename: file.name }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error)
|
||||
onChangeImage(data.shopifyRef)
|
||||
} catch (err) {
|
||||
setError((err as Error).message)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const previewUrl = shopifyRefToProxyUrl(slide.settings.image)
|
||||
|
||||
return (
|
||||
<div className="bg-slate-800 border border-slate-700 rounded-xl overflow-hidden">
|
||||
{/* Image preview */}
|
||||
<div className="relative h-44 bg-slate-900">
|
||||
{previewUrl ? (
|
||||
<img src={previewUrl} alt={`Slide ${index + 1}`} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-600 text-sm">
|
||||
Aucune image
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-2 left-2 bg-black/60 text-white text-xs px-2 py-0.5 rounded-full">
|
||||
{index + 1} / {total}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="p-3 space-y-2">
|
||||
{/* Link */}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">Lien (optionnel)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slide.settings.link ?? ''}
|
||||
onChange={e => onChangeLink(e.target.value)}
|
||||
placeholder="https://…"
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={onOpenBuilder}
|
||||
className="flex-1 py-1.5 text-xs bg-purple-700 hover:bg-purple-600 text-white rounded font-medium"
|
||||
>
|
||||
🎨 Créer une bannière
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="py-1.5 px-2 text-xs bg-indigo-600 hover:bg-indigo-500 text-white rounded font-medium disabled:opacity-50"
|
||||
title="Uploader une image"
|
||||
>
|
||||
{uploading ? '…' : '🖼️'}
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleFile} />
|
||||
<button
|
||||
onClick={onMoveUp}
|
||||
disabled={index === 0}
|
||||
className="py-1.5 px-2 text-xs bg-slate-700 hover:bg-slate-600 text-slate-300 rounded disabled:opacity-30"
|
||||
title="Monter"
|
||||
>↑</button>
|
||||
<button
|
||||
onClick={onMoveDown}
|
||||
disabled={index === total - 1}
|
||||
className="py-1.5 px-2 text-xs bg-slate-700 hover:bg-slate-600 text-slate-300 rounded disabled:opacity-30"
|
||||
title="Descendre"
|
||||
>↓</button>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="py-1.5 px-2 text-xs bg-slate-700 hover:bg-red-900 text-slate-400 hover:text-red-300 rounded"
|
||||
title="Supprimer"
|
||||
>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BannièresPage() {
|
||||
const [slideshow, setSlideshow] = useState<SlideshowSection | null>(null)
|
||||
const [themeId, setThemeId] = useState<string>('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Index de la slide cible du builder (-1 = nouvelle slide)
|
||||
const [builderTargetIndex, setBuilderTargetIndex] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/theme/slideshow')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.error) throw new Error(d.error)
|
||||
setSlideshow(d.slideshow)
|
||||
setThemeId(d.themeId)
|
||||
})
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const update = useCallback((fn: (s: SlideshowSection) => SlideshowSection) => {
|
||||
setSlideshow(prev => prev ? fn(prev) : prev)
|
||||
setSaved(false)
|
||||
}, [])
|
||||
|
||||
const moveSlide = (index: number, dir: -1 | 1) => {
|
||||
update(s => {
|
||||
const slides = [...s.slides]
|
||||
const tmp = slides[index]
|
||||
slides[index] = slides[index + dir]
|
||||
slides[index + dir] = tmp
|
||||
return { ...s, slides }
|
||||
})
|
||||
}
|
||||
|
||||
const removeSlide = (index: number) => {
|
||||
update(s => ({ ...s, slides: s.slides.filter((_, i) => i !== index) }))
|
||||
}
|
||||
|
||||
const addSlide = () => {
|
||||
update(s => ({
|
||||
...s,
|
||||
slides: [...s.slides, {
|
||||
blockId: `slide_${Math.random().toString(36).slice(2, 8)}`,
|
||||
settings: { image: '', link: '' },
|
||||
}],
|
||||
}))
|
||||
}
|
||||
|
||||
const changeImage = (index: number, shopifyRef: string) => {
|
||||
update(s => {
|
||||
const slides = s.slides.map((sl, i) =>
|
||||
i === index ? { ...sl, settings: { ...sl.settings, image: shopifyRef } } : sl
|
||||
)
|
||||
return { ...s, slides }
|
||||
})
|
||||
}
|
||||
|
||||
const changeLink = (index: number, link: string) => {
|
||||
update(s => {
|
||||
const slides = s.slides.map((sl, i) =>
|
||||
i === index ? { ...sl, settings: { ...sl.settings, link } } : sl
|
||||
)
|
||||
return { ...s, slides }
|
||||
})
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!slideshow) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/theme/slideshow', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slideshow, themeId }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error)
|
||||
setSaved(true)
|
||||
} catch (err) {
|
||||
setError((err as Error).message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
title="Bannières homepage"
|
||||
actions={
|
||||
<div className="flex items-center gap-3">
|
||||
{saved && <span className="text-xs text-green-400">✓ Publié sur Shopify</span>}
|
||||
{error && <span className="text-xs text-red-400">{error}</span>}
|
||||
<button
|
||||
onClick={addSlide}
|
||||
disabled={!slideshow}
|
||||
className="px-3 py-1.5 text-xs bg-slate-700 hover:bg-slate-600 text-white rounded font-medium disabled:opacity-40"
|
||||
>
|
||||
+ Ajouter une slide
|
||||
</button>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={!slideshow || saving}
|
||||
className="px-4 py-1.5 text-xs bg-green-700 hover:bg-green-600 text-white rounded font-medium disabled:opacity-40"
|
||||
>
|
||||
{saving ? 'Publication…' : '🚀 Publier sur Shopify'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="p-6">
|
||||
{loading && (
|
||||
<div className="text-center py-12 text-slate-500 text-sm">Chargement du thème…</div>
|
||||
)}
|
||||
{!loading && !slideshow && (
|
||||
<div className="text-center py-12 text-red-400 text-sm">
|
||||
Section slideshow-custom introuvable dans le thème actif.
|
||||
</div>
|
||||
)}
|
||||
{slideshow && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-slate-500">
|
||||
{slideshow.slides.length} slide{slideshow.slides.length > 1 ? 's' : ''} · Thème actif (Meka)
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{slideshow.slides.map((slide, i) => (
|
||||
<SlideCard
|
||||
key={slide.blockId}
|
||||
slide={slide}
|
||||
index={i}
|
||||
total={slideshow.slides.length}
|
||||
onMoveUp={() => moveSlide(i, -1)}
|
||||
onMoveDown={() => moveSlide(i, 1)}
|
||||
onRemove={() => removeSlide(i)}
|
||||
onChangeImage={ref => changeImage(i, ref)}
|
||||
onChangeLink={link => changeLink(i, link)}
|
||||
onOpenBuilder={() => setBuilderTargetIndex(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{builderTargetIndex !== null && (
|
||||
<BannerBuilder
|
||||
onApply={shopifyRef => {
|
||||
if (builderTargetIndex !== null) changeImage(builderTargetIndex, shopifyRef)
|
||||
setBuilderTargetIndex(null)
|
||||
setSaved(false)
|
||||
}}
|
||||
onClose={() => setBuilderTargetIndex(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
'use client'
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { PendingRowsTable } from '@/components/creation/PendingRowsTable'
|
||||
import { MetafieldResolver } from '@/components/creation/MetafieldResolver'
|
||||
import { CreationProgress } from '@/components/creation/CreationProgress'
|
||||
import { FamilySelector } from '@/components/common/FamilySelector'
|
||||
import type { PendingProduct, MetafieldResolution, CreationStreamEvent } from '@/types/creation'
|
||||
|
||||
type Step = 'famille' | 'analyse' | 'metafields' | 'confirmation' | 'execution'
|
||||
|
||||
interface ShopifyCollection { id: string; title: string }
|
||||
|
||||
export default function CreationPage() {
|
||||
const [step, setStep] = useState<Step>('famille')
|
||||
const [selectedTab, setSelectedTab] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [byTab, setByTab] = useState<Record<string, PendingProduct[]>>({})
|
||||
const [total, setTotal] = useState(0)
|
||||
const [specificColumns, setSpecificColumns] = useState<string[]>([])
|
||||
const [resolutions, setResolutions] = useState<MetafieldResolution[]>([])
|
||||
const [events, setEvents] = useState<CreationStreamEvent[]>([])
|
||||
const [done, setDone] = useState(false)
|
||||
const [stopped, setStopped] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
// Sync stock
|
||||
const [stockSyncing, setStockSyncing] = useState(false)
|
||||
const [stockResult, setStockResult] = useState<{ updated: number; errors: number; fatalError?: string } | null>(null)
|
||||
const [stockProgress, setStockProgress] = useState<{ current: number; total: number; title?: string } | null>(null)
|
||||
|
||||
const runStockStream = async (url: string, setter: typeof setStockResult) => {
|
||||
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: '{}' })
|
||||
if (!res.body) return
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
try {
|
||||
const evt = JSON.parse(line)
|
||||
if (evt.type === 'progress') setStockProgress({ current: evt.current, total: evt.total, title: evt.title })
|
||||
if (evt.type === 'done') setter({ updated: evt.updated, errors: evt.errors, fatalError: evt.fatalError })
|
||||
} catch { /* */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const syncStock = async () => {
|
||||
setStockSyncing(true)
|
||||
setStockResult(null)
|
||||
setStockProgress(null)
|
||||
try { await runStockStream('/api/creation/sync-stock', setStockResult) }
|
||||
finally { setStockSyncing(false); setStockProgress(null) }
|
||||
}
|
||||
|
||||
const [stockReverseSyncing, setStockReverseSyncing] = useState(false)
|
||||
const [stockReverseResult, setStockReverseResult] = useState<{ updated: number; errors: number; fatalError?: string } | null>(null)
|
||||
|
||||
const syncStockReverse = async () => {
|
||||
setStockReverseSyncing(true)
|
||||
setStockReverseResult(null)
|
||||
setStockProgress(null)
|
||||
try { await runStockStream('/api/creation/sync-stock-reverse', setStockReverseResult) }
|
||||
finally { setStockReverseSyncing(false); setStockProgress(null) }
|
||||
}
|
||||
|
||||
// Collection
|
||||
const [collections, setCollections] = useState<ShopifyCollection[]>([])
|
||||
const [detectedCollectionId, setDetectedCollectionId] = useState<string>('')
|
||||
const [collectionOverride, setCollectionOverride] = useState<string>('')
|
||||
const [collectionSearch, setCollectionSearch] = useState('')
|
||||
const [loadingCollections, setLoadingCollections] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingCollections(true)
|
||||
fetch('/api/creation/collections')
|
||||
.then(r => r.json())
|
||||
.then(d => setCollections(d.collections ?? []))
|
||||
.finally(() => setLoadingCollections(false))
|
||||
}, [])
|
||||
|
||||
const handleSelectTab = (tab: string) => {
|
||||
setSelectedTab(tab)
|
||||
setStep('analyse')
|
||||
setDetectedCollectionId('')
|
||||
setCollectionOverride('')
|
||||
setCollectionSearch('')
|
||||
}
|
||||
|
||||
const analyse = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const url = selectedTab
|
||||
? `/api/creation/preview?tab=${encodeURIComponent(selectedTab)}`
|
||||
: '/api/creation/preview'
|
||||
const res = await fetch(url)
|
||||
const data = await res.json()
|
||||
if (data.error) throw new Error(data.error)
|
||||
setByTab(data.byTab)
|
||||
setTotal(data.total)
|
||||
setSpecificColumns(data.specificColumns)
|
||||
|
||||
// Détecter la collection depuis les produits analysés
|
||||
const products: PendingProduct[] = Object.values(data.byTab as Record<string, PendingProduct[]>).flat()
|
||||
const ids = Array.from(new Set(products.map(p => p.collectionId).filter(Boolean)))
|
||||
if (ids.length === 1) {
|
||||
setDetectedCollectionId(ids[0])
|
||||
setCollectionOverride(ids[0])
|
||||
} else {
|
||||
setDetectedCollectionId('')
|
||||
setCollectionOverride('')
|
||||
}
|
||||
setStep('metafields')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erreur')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResolved = useCallback((r: MetafieldResolution[]) => { setResolutions(r) }, [])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
abortRef.current?.abort()
|
||||
setStopped(true)
|
||||
setDone(true)
|
||||
}, [])
|
||||
|
||||
const execute = async () => {
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
setStep('execution')
|
||||
setEvents([])
|
||||
setDone(false)
|
||||
setStopped(false)
|
||||
try {
|
||||
const res = await fetch('/api/creation/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ resolutions, tab: selectedTab, collectionOverride: collectionOverride || undefined }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.body) return
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
while (true) {
|
||||
const { done: readerDone, value } = await reader.read()
|
||||
if (readerDone) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
try {
|
||||
const event = JSON.parse(line) as CreationStreamEvent
|
||||
setEvents(prev => [...prev, event])
|
||||
if (event.type === 'done') setDone(true)
|
||||
} catch { /* ligne incomplète */ }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if ((e as Error).name !== 'AbortError') {
|
||||
setEvents(prev => [...prev, { type: 'error', title: '', message: (e as Error).message }])
|
||||
}
|
||||
}
|
||||
setDone(true)
|
||||
}
|
||||
|
||||
const STEPS: { id: Step; label: string }[] = [
|
||||
{ id: 'analyse', label: 'Analyse' },
|
||||
{ id: 'metafields', label: 'Métachamps' },
|
||||
{ id: 'confirmation', label: 'Confirmation' },
|
||||
{ id: 'execution', label: 'Exécution' },
|
||||
]
|
||||
const currentIdx = STEPS.findIndex(s => s.id === step)
|
||||
|
||||
const reset = () => {
|
||||
abortRef.current?.abort()
|
||||
abortRef.current = null
|
||||
setStep('famille')
|
||||
setSelectedTab(null)
|
||||
setEvents([])
|
||||
setDone(false)
|
||||
setStopped(false)
|
||||
setByTab({})
|
||||
setTotal(0)
|
||||
setDetectedCollectionId('')
|
||||
setCollectionOverride('')
|
||||
}
|
||||
|
||||
const filteredCollections = collectionSearch
|
||||
? collections.filter(c => c.title.toLowerCase().includes(collectionSearch.toLowerCase()))
|
||||
: collections
|
||||
|
||||
const selectedCollection = collections.find(c => c.id === collectionOverride)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Création produits" />
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex gap-2">
|
||||
{STEPS.map((s, i) => (
|
||||
<div key={s.id} className="flex items-center gap-2">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${step === s.id ? 'bg-indigo-600 text-white' : i < currentIdx ? 'bg-green-700 text-white' : 'bg-slate-700 text-gray-400'}`}>
|
||||
{i + 1}. {s.label}
|
||||
</span>
|
||||
{i < STEPS.length - 1 && <span className="text-gray-600">→</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Étape 0 — Sélection famille */}
|
||||
{step === 'famille' && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-slate-800 rounded-xl p-6">
|
||||
<h2 className="text-sm font-semibold text-white mb-4">Choisir une famille</h2>
|
||||
<FamilySelector onSelect={handleSelectTab} />
|
||||
</div>
|
||||
<div className="bg-slate-800 rounded-xl p-6 space-y-6">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white mb-1">Sheets → Shopify</h2>
|
||||
<p className="text-xs text-gray-400 mb-3">Met à jour le stock Shopify depuis les valeurs du Sheets.</p>
|
||||
<button onClick={syncStock} disabled={stockSyncing || stockReverseSyncing}
|
||||
className="px-4 py-2 bg-amber-700 hover:bg-amber-600 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
{stockSyncing ? (stockProgress ? `Mise à jour… ${stockProgress.current}/${stockProgress.total}` : 'Chargement…') : 'Synchroniser Sheets → Shopify'}
|
||||
</button>
|
||||
{stockSyncing && stockProgress?.title && (
|
||||
<p className="mt-2 text-xs text-gray-400 truncate">→ {stockProgress.title}</p>
|
||||
)}
|
||||
{stockResult && (
|
||||
<div className="mt-2 text-sm">
|
||||
{stockResult.fatalError
|
||||
? <p className="text-red-400">✕ {stockResult.fatalError}</p>
|
||||
: stockResult.errors === 0
|
||||
? <p className="text-green-400">✓ {stockResult.updated} produit{stockResult.updated > 1 ? 's' : ''} mis à jour</p>
|
||||
: <p className="text-amber-400">✓ {stockResult.updated} mis à jour — ⚠ {stockResult.errors} erreur{stockResult.errors > 1 ? 's' : ''}</p>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<hr className="border-slate-700" />
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white mb-1">Shopify → Sheets</h2>
|
||||
<p className="text-xs text-gray-400 mb-3">Écrase les stocks du Sheets avec les valeurs actuelles de Shopify.</p>
|
||||
<button onClick={syncStockReverse} disabled={stockSyncing || stockReverseSyncing}
|
||||
className="px-4 py-2 bg-blue-700 hover:bg-blue-600 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
{stockReverseSyncing ? (stockProgress ? `Lecture… ${stockProgress.current}/${stockProgress.total}` : 'Chargement…') : 'Synchroniser Shopify → Sheets'}
|
||||
</button>
|
||||
{stockReverseSyncing && stockProgress && (
|
||||
<p className="mt-2 text-xs text-gray-400">→ {stockProgress.current}/{stockProgress.total}</p>
|
||||
)}
|
||||
{stockReverseResult && (
|
||||
<div className="mt-2 text-sm">
|
||||
{stockReverseResult.fatalError
|
||||
? <p className="text-red-400">✕ {stockReverseResult.fatalError}</p>
|
||||
: stockReverseResult.errors === 0
|
||||
? <p className="text-green-400">✓ {stockReverseResult.updated} produit{stockReverseResult.updated > 1 ? 's' : ''} mis à jour dans le Sheets</p>
|
||||
: <p className="text-amber-400">✓ {stockReverseResult.updated} mis à jour — ⚠ {stockReverseResult.errors} erreur{stockReverseResult.errors > 1 ? 's' : ''}</p>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-slate-800 rounded-xl p-6" style={{ display: step === 'famille' ? 'none' : undefined }}>
|
||||
{error && <div className="mb-4 p-3 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">{error}</div>}
|
||||
|
||||
{step === 'analyse' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 bg-indigo-950/50 border border-indigo-800/50 rounded-lg px-3 py-2 w-fit">
|
||||
<span className="text-indigo-400 text-sm">📂 Famille :</span>
|
||||
<span className="text-white text-sm font-semibold">{selectedTab}</span>
|
||||
<button onClick={() => { setStep('famille'); setSelectedTab(null) }} className="ml-2 text-xs text-slate-400 hover:text-slate-200">changer</button>
|
||||
</div>
|
||||
<p className="text-gray-300 text-sm">
|
||||
Détecte les lignes de <strong className="text-white">{selectedTab}</strong> où <code className="text-indigo-300">Publié=1</code> et <code className="text-indigo-300">ID vide</code>.
|
||||
</p>
|
||||
<button onClick={analyse} disabled={loading}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
{loading ? 'Analyse en cours…' : 'Analyser le Sheets'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'metafields' && (
|
||||
<div className="space-y-6">
|
||||
<PendingRowsTable byTab={byTab} total={total} />
|
||||
{total > 0 && (
|
||||
<>
|
||||
<hr className="border-slate-700" />
|
||||
|
||||
{/* Collection Shopify */}
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white mb-1">Collection Shopify</h2>
|
||||
{detectedCollectionId ? (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-green-400">✓ Détectée depuis le Sheets :</span>
|
||||
<span className="text-xs text-white font-medium">
|
||||
{collections.find(c => c.id === detectedCollectionId)?.title ?? `ID ${detectedCollectionId}`}
|
||||
</span>
|
||||
<button type="button" onClick={() => { setDetectedCollectionId(''); setCollectionOverride('') }}
|
||||
className="text-xs text-gray-500 hover:text-gray-300">modifier</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-amber-400 mb-2">⚠ Aucune collection détectée dans le Sheets — choisissez-en une :</p>
|
||||
)}
|
||||
{(!detectedCollectionId || collectionOverride !== detectedCollectionId) && (
|
||||
<div className="space-y-2">
|
||||
{selectedCollection && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-blue-300 font-medium">→ {selectedCollection.title}</span>
|
||||
<button type="button" onClick={() => setCollectionOverride('')}
|
||||
className="text-xs text-gray-500 hover:text-gray-300">✕</button>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={collectionSearch}
|
||||
onChange={e => setCollectionSearch(e.target.value)}
|
||||
placeholder={loadingCollections ? 'Chargement…' : 'Rechercher une collection Shopify…'}
|
||||
className="w-full max-w-sm bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
{collectionSearch && filteredCollections.length > 0 && (
|
||||
<div className="max-w-sm bg-slate-700 border border-slate-600 rounded shadow-lg max-h-48 overflow-y-auto">
|
||||
{filteredCollections.map(c => (
|
||||
<button key={c.id} type="button"
|
||||
onClick={() => { setCollectionOverride(c.id); setCollectionSearch('') }}
|
||||
className="w-full text-left px-3 py-1.5 text-sm text-white hover:bg-slate-600">
|
||||
{c.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{collectionSearch && filteredCollections.length === 0 && (
|
||||
<p className="text-xs text-gray-400">Aucune collection trouvée</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr className="border-slate-700" />
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white mb-3">Résolution des métachamps</h2>
|
||||
<MetafieldResolver specificColumns={specificColumns} onResolved={handleResolved} />
|
||||
</div>
|
||||
<button onClick={() => setStep('confirmation')}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
Continuer →
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'confirmation' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="text-gray-300"><span className="text-white font-medium">{total} produit{total > 1 ? 's' : ''}</span> à créer dans Shopify</p>
|
||||
<p className="text-gray-300">
|
||||
Collection : <span className="text-white font-medium">
|
||||
{collections.find(c => c.id === collectionOverride)?.title ?? (collectionOverride ? `ID ${collectionOverride}` : <span className="text-amber-400">Aucune</span>)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-gray-300"><span className="text-white font-medium">{resolutions.filter(r => r.action !== 'skip').length}</span> type{resolutions.filter(r => r.action !== 'skip').length > 1 ? 's' : ''} de métachamps à assigner</p>
|
||||
<p className="text-gray-300"><span className="text-white font-medium">{resolutions.filter(r => r.action === 'create').length}</span> nouvelle{resolutions.filter(r => r.action === 'create').length > 1 ? 's' : ''} définition{resolutions.filter(r => r.action === 'create').length > 1 ? 's' : ''} à créer dans Shopify</p>
|
||||
<p className="text-yellow-300 text-xs mt-2">⚠ Les descriptions seront générées par OpenAI GPT-4o (~0.01$ par produit)</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => setStep('metafields')}
|
||||
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-gray-300 rounded-lg text-sm font-medium transition-colors">
|
||||
← Retour
|
||||
</button>
|
||||
<button onClick={execute}
|
||||
className="px-4 py-2 bg-green-700 hover:bg-green-600 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
Lancer la création ({total} produits)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'execution' && (
|
||||
<div className="space-y-4">
|
||||
<CreationProgress events={events} done={done} />
|
||||
<div className="flex gap-3">
|
||||
{!done && (
|
||||
<button onClick={stop}
|
||||
className="px-4 py-2 bg-red-700 hover:bg-red-600 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
⏹ Arrêter
|
||||
</button>
|
||||
)}
|
||||
{done && (
|
||||
<>
|
||||
{stopped && <p className="text-amber-400 text-sm self-center">⚠ Création interrompue — les produits déjà créés sont conservés dans Shopify.</p>}
|
||||
<button onClick={reset}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
Nouvelle session
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
'use client'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { DropZone } from '@/components/images/DropZone'
|
||||
import { ImageCard } from '@/components/images/ImageCard'
|
||||
import { Lightbox } from '@/components/images/Lightbox'
|
||||
import { GlobalActions } from '@/components/images/GlobalActions'
|
||||
import { MockupModalFromImage } from '@/components/mockup/MockupModalFromImage'
|
||||
import { ErrorBoundary } from '@/components/common/ErrorBoundary'
|
||||
import { useImages } from '@/hooks/useImages'
|
||||
import type { ImageItem } from '@/types/images'
|
||||
|
||||
function ImagesPageInner() {
|
||||
const { items, addFiles, setFeather, setAlphaThreshold, rotate, uploadOne, uploadAll, uploadSelected, removeItem, assignProduct, reprocess, detour } = useImages()
|
||||
const [lightboxItem, setLightboxItem] = useState<ImageItem | null>(null)
|
||||
const [mockupItem, setMockupItem] = useState<ImageItem | null>(null)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [uploadingSelected, setUploadingSelected] = useState(false)
|
||||
|
||||
const openMockup = useCallback((id: string) => {
|
||||
const item = items.find(i => i.id === id)
|
||||
if (item) setMockupItem(item)
|
||||
}, [items])
|
||||
|
||||
const toggleSelect = useCallback((id: string, value: boolean) => {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
if (value) next.add(id)
|
||||
else next.delete(id)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearSelection = useCallback(() => setSelected(new Set()), [])
|
||||
|
||||
const selectAllDetourable = useCallback(() => {
|
||||
const ids = items
|
||||
.filter(i => i.status === 'converted' || i.status === 'not_found' || i.status === 'error')
|
||||
.map(i => i.id) as string[]
|
||||
setSelected(new Set(ids))
|
||||
}, [items])
|
||||
|
||||
const selectAllUploadable = useCallback(() => {
|
||||
const ids = items
|
||||
.filter(i => (i.status === 'ready' || i.status === 'converted') && !!i.shopifyProductId)
|
||||
.map(i => i.id) as string[]
|
||||
setSelected(new Set(ids))
|
||||
}, [items])
|
||||
|
||||
const detourSelected = useCallback(() => {
|
||||
Array.from(selected).forEach(id => detour(id))
|
||||
setSelected(new Set())
|
||||
}, [selected, detour])
|
||||
|
||||
const handleUploadSelected = useCallback(async () => {
|
||||
setUploadingSelected(true)
|
||||
await uploadSelected(selected)
|
||||
setUploadingSelected(false)
|
||||
setSelected(new Set())
|
||||
}, [selected, uploadSelected])
|
||||
|
||||
const selectedDetourable = Array.from(selected).filter(id => {
|
||||
const item = items.find(i => i.id === id)
|
||||
return item && (item.status === 'converted' || item.status === 'not_found' || item.status === 'error')
|
||||
})
|
||||
const selectedUploadable = Array.from(selected).filter(id => {
|
||||
const item = items.find(i => i.id === id)
|
||||
return item && (item.status === 'ready' || item.status === 'converted') && !!item.shopifyProductId
|
||||
})
|
||||
|
||||
const detourable = items.filter(i =>
|
||||
i.status === 'converted' || i.status === 'not_found' || i.status === 'error'
|
||||
)
|
||||
const uploadable = items.filter(i =>
|
||||
(i.status === 'ready' || i.status === 'converted') && !!i.shopifyProductId
|
||||
)
|
||||
|
||||
const showSelectionBar = detourable.length > 0 || uploadable.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<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} />
|
||||
)}
|
||||
|
||||
{/* Barre de sélection multiple */}
|
||||
{showSelectionBar && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-slate-800 rounded-lg border border-slate-700 flex-wrap">
|
||||
{/* Compteurs et raccourcis de sélection */}
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||
{detourable.length > 0 && (
|
||||
<button onClick={selectAllDetourable} className="hover:text-purple-300 transition-colors">
|
||||
Sélectionner à détourer ({detourable.length})
|
||||
</button>
|
||||
)}
|
||||
{detourable.length > 0 && uploadable.length > 0 && (
|
||||
<span className="text-slate-600">·</span>
|
||||
)}
|
||||
{uploadable.length > 0 && (
|
||||
<button onClick={selectAllUploadable} className="hover:text-indigo-300 transition-colors">
|
||||
Sélectionner à uploader ({uploadable.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions sur la sélection */}
|
||||
{selected.size > 0 && (
|
||||
<>
|
||||
<span className="text-slate-600 text-xs">|</span>
|
||||
<span className="text-xs text-white font-medium">
|
||||
{selected.size} sélectionnée{selected.size > 1 ? 's' : ''}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{selectedDetourable.length > 0 && (
|
||||
<button
|
||||
onClick={detourSelected}
|
||||
className="px-3 py-1 text-xs bg-purple-700 hover:bg-purple-600 text-white font-semibold rounded"
|
||||
>
|
||||
✂️ Détourer ({selectedDetourable.length})
|
||||
</button>
|
||||
)}
|
||||
{selectedUploadable.length > 0 && (
|
||||
<button
|
||||
onClick={handleUploadSelected}
|
||||
disabled={uploadingSelected}
|
||||
className="px-3 py-1 text-xs bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded disabled:opacity-50"
|
||||
>
|
||||
{uploadingSelected
|
||||
? `⬆️ Upload en cours…`
|
||||
: `⬆️ Uploader (${selectedUploadable.length})`}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={clearSelection} className="text-xs text-slate-500 hover:text-slate-300">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{items.map((item) => (
|
||||
<ImageCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
selected={selected.has(item.id)}
|
||||
onSelect={toggleSelect}
|
||||
onFeatherChange={setFeather}
|
||||
onAlphaThresholdChange={setAlphaThreshold}
|
||||
onRotate={rotate}
|
||||
onUpload={uploadOne}
|
||||
onRemove={removeItem}
|
||||
onOpenLightbox={setLightboxItem}
|
||||
onAssignProduct={assignProduct}
|
||||
onReprocess={reprocess}
|
||||
onDetour={detour}
|
||||
onMockup={openMockup}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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)} />
|
||||
{mockupItem && (
|
||||
<MockupModalFromImage item={mockupItem} onClose={() => setMockupItem(null)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ImagesPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ImagesPageInner />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -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,449 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { TemplateLibrary } from '@/components/mockup/TemplateLibrary'
|
||||
import type { MockupTemplate } from '@/types/mockup'
|
||||
|
||||
type Tab = 'generate' | 'library'
|
||||
type Step = 'product' | 'image' | 'template'
|
||||
|
||||
interface Product {
|
||||
shopifyId: string
|
||||
title: string
|
||||
ref: string
|
||||
images: string[]
|
||||
}
|
||||
|
||||
function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve((reader.result as string).split(',')[1])
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
export default function MockupPage() {
|
||||
const [tab, setTab] = useState<Tab>('generate')
|
||||
const [step, setStep] = useState<Step>('product')
|
||||
|
||||
// Step 1 — product
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null)
|
||||
const [family, setFamily] = useState('')
|
||||
const [placement, setPlacement] = useState('')
|
||||
const [dimensions, setDimensions] = useState('')
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Step 2 — image
|
||||
const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>(null)
|
||||
const [cutoutB64, setCutoutB64] = useState<string | null>(null)
|
||||
const [loadingImage, setLoadingImage] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Step 3 — template + generation
|
||||
const [templates, setTemplates] = useState<MockupTemplate[]>([])
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null)
|
||||
const [instruction, setInstruction] = useState('')
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [resultB64, setResultB64] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadedUrl, setUploadedUrl] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const templateFileRef = useRef<HTMLInputElement>(null)
|
||||
const [uploadingTemplate, setUploadingTemplate] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/products/list')
|
||||
.then(r => r.json())
|
||||
.then(data => setProducts(data.products ?? []))
|
||||
fetch('/api/mockup/templates')
|
||||
.then(r => r.json())
|
||||
.then(data => setTemplates(data as MockupTemplate[]))
|
||||
}, [])
|
||||
|
||||
const filtered = search.length < 2 ? [] : products.filter(p =>
|
||||
p.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.ref.toLowerCase().includes(search.toLowerCase())
|
||||
).slice(0, 20)
|
||||
|
||||
const isCarrelage = family.toLowerCase().includes('carrelage')
|
||||
|
||||
const filteredTemplates = templates.filter(t => {
|
||||
if (t.family !== family && t.family !== 'Tous') return false
|
||||
if (isCarrelage && placement && t.placement && t.placement !== placement) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const selectProduct = (p: Product) => {
|
||||
setSelectedProduct(p)
|
||||
setSearch('')
|
||||
setShowDropdown(false)
|
||||
setSelectedImageUrl(null)
|
||||
setCutoutB64(null)
|
||||
setResultB64(null)
|
||||
setUploadedUrl(null)
|
||||
setStep('image')
|
||||
fetch(`/api/products/sheet-info?ref=${encodeURIComponent(p.ref)}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (!data?.product) return
|
||||
const { famille, longueur, largeur } = data.product
|
||||
if (famille) setFamily(famille)
|
||||
if (longueur && largeur) setDimensions(`${longueur}x${largeur}cm`)
|
||||
else if (longueur) setDimensions(`${longueur}cm`)
|
||||
})
|
||||
}
|
||||
|
||||
const selectImage = async (url: string) => {
|
||||
setSelectedImageUrl(url)
|
||||
setCutoutB64(null)
|
||||
setLoadingImage(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/images/proxy?url=${encodeURIComponent(url)}`)
|
||||
if (!res.ok) throw new Error('Impossible de charger l\'image')
|
||||
const blob = await res.blob()
|
||||
const b64 = await blobToBase64(blob)
|
||||
setCutoutB64(b64)
|
||||
} catch {
|
||||
setError('Impossible de charger cette image')
|
||||
setSelectedImageUrl(null)
|
||||
} finally {
|
||||
setLoadingImage(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setSelectedImageUrl(null)
|
||||
const reader = new FileReader()
|
||||
reader.onload = ev => {
|
||||
setCutoutB64((ev.target?.result as string).split(',')[1])
|
||||
setStep('template')
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const uploadTemplate = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setUploadingTemplate(true)
|
||||
const form = new FormData()
|
||||
form.append('name', file.name.replace(/\.[^.]+$/, ''))
|
||||
form.append('family', family || 'Tous')
|
||||
form.append('description', '')
|
||||
form.append('image', file)
|
||||
const res = await fetch('/api/mockup/templates', { method: 'POST', body: form })
|
||||
const tpl = await res.json() as MockupTemplate
|
||||
setTemplates(prev => [...prev, tpl])
|
||||
setSelectedTemplate(tpl.id)
|
||||
setUploadingTemplate(false)
|
||||
}
|
||||
|
||||
const generate = async () => {
|
||||
if (!selectedTemplate || !cutoutB64) return
|
||||
setGenerating(true)
|
||||
setError(null)
|
||||
setResultB64(null)
|
||||
setUploadedUrl(null)
|
||||
try {
|
||||
const res = await fetch('/api/mockup/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
templateId: selectedTemplate,
|
||||
productImageBase64: cutoutB64,
|
||||
productName: selectedProduct!.title,
|
||||
family: family || 'Tous',
|
||||
placement: placement || undefined,
|
||||
dimensions: dimensions || undefined,
|
||||
instruction: instruction || undefined,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error)
|
||||
setResultB64(data.imageBase64)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erreur')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const upload = async () => {
|
||||
if (!resultB64 || !selectedProduct) return
|
||||
setUploading(true)
|
||||
try {
|
||||
const res = await fetch('/api/images/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
shopifyProductId: selectedProduct.shopifyId,
|
||||
imageBase64: resultB64,
|
||||
filename: `${selectedProduct.ref}-mockup.jpg`,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error)
|
||||
setUploadedUrl(data.url)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erreur upload')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const StepIndicator = () => (
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
{(['product', 'image', 'template'] as Step[]).map((s, i) => {
|
||||
const labels = ['Produit', 'Image', 'Template']
|
||||
const active = step === s
|
||||
const done = (step === 'image' && s === 'product') ||
|
||||
(step === 'template' && (s === 'product' || s === 'image'))
|
||||
return (
|
||||
<div key={s} className="flex items-center gap-2">
|
||||
{i > 0 && <div className={`h-px w-6 ${done ? 'bg-indigo-500' : 'bg-slate-600'}`} />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => done || active ? setStep(s) : undefined}
|
||||
className={`flex items-center gap-1.5 text-xs font-medium transition-colors ${
|
||||
active ? 'text-white' : done ? 'text-indigo-400 hover:text-indigo-300' : 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
|
||||
active ? 'bg-indigo-600 text-white' : done ? 'bg-indigo-500/30 text-indigo-400' : 'bg-slate-700 text-slate-500'
|
||||
}`}>
|
||||
{done ? '✓' : i + 1}
|
||||
</span>
|
||||
{labels[i]}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="🖼️ Mockups" actions={
|
||||
<div className="flex gap-2">
|
||||
{(['generate', 'library'] as Tab[]).map(t => (
|
||||
<button key={t} type="button" onClick={() => setTab(t)}
|
||||
className={`px-3 py-1 text-xs rounded-lg transition-colors ${tab === t ? 'bg-indigo-600 text-white' : 'text-gray-400 hover:text-white'}`}>
|
||||
{t === 'generate' ? 'Générer' : 'Bibliothèque templates'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
} />
|
||||
|
||||
<div className="p-6 max-w-2xl mx-auto">
|
||||
{tab === 'library' ? (
|
||||
<TemplateLibrary />
|
||||
) : (
|
||||
<div>
|
||||
<StepIndicator />
|
||||
|
||||
{/* ── STEP 1 : Produit ── */}
|
||||
{step === 'product' && (
|
||||
<div className="bg-slate-800 rounded-xl p-5 border border-slate-700 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-white">Rechercher un produit</h2>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setShowDropdown(true) }}
|
||||
onFocus={() => setShowDropdown(true)}
|
||||
placeholder="Nom ou référence du produit…"
|
||||
autoFocus
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
{showDropdown && filtered.length > 0 && (
|
||||
<div className="absolute z-10 mt-1 w-full bg-slate-700 border border-slate-600 rounded-lg shadow-xl max-h-72 overflow-y-auto">
|
||||
{filtered.map(p => (
|
||||
<button key={p.shopifyId} type="button"
|
||||
onMouseDown={() => selectProduct(p)}
|
||||
className="w-full text-left px-3 py-2.5 text-sm text-white hover:bg-slate-600 flex justify-between gap-2 border-b border-slate-600/50 last:border-0">
|
||||
<span className="truncate">{p.title}</span>
|
||||
<span className="text-xs text-gray-400 shrink-0 self-center">{p.ref}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showDropdown && search.length >= 2 && filtered.length === 0 && (
|
||||
<div className="absolute z-10 mt-1 w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2">
|
||||
<p className="text-xs text-gray-400">Aucun résultat</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── STEP 2 : Image ── */}
|
||||
{step === 'image' && selectedProduct && (
|
||||
<div className="bg-slate-800 rounded-xl p-5 border border-slate-700 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white">Choisir une image</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{selectedProduct.title} · <span className="font-mono text-indigo-300">{selectedProduct.ref}</span></p>
|
||||
</div>
|
||||
<button type="button" onClick={() => { setSelectedProduct(null); setStep('product') }}
|
||||
className="text-xs text-gray-400 hover:text-white">← Changer</button>
|
||||
</div>
|
||||
|
||||
{selectedProduct.images.length > 0 ? (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-2">Images Shopify</p>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{selectedProduct.images.map((url, i) => (
|
||||
<button key={i} type="button" onClick={() => { selectImage(url); setStep('template') }}
|
||||
className={`relative rounded-lg overflow-hidden border-2 transition-all ${selectedImageUrl === url ? 'border-indigo-500' : 'border-slate-600 hover:border-slate-400'}`}>
|
||||
<img src={url} alt={`Image ${i + 1}`} className="w-full h-20 object-cover" />
|
||||
{loadingImage && selectedImageUrl === url && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||
<span className="text-xs text-white">…</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500">Aucune image disponible sur Shopify pour ce produit.</p>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-700 pt-3">
|
||||
<p className="text-xs text-gray-400 mb-2">Ou uploader une image (PNG découpée)</p>
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={handleFileUpload} className="text-xs text-gray-300" />
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── STEP 3 : Template + génération ── */}
|
||||
{step === 'template' && selectedProduct && cutoutB64 && (
|
||||
<div className="space-y-4">
|
||||
{/* Recap produit + image */}
|
||||
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 flex items-center gap-3">
|
||||
{selectedImageUrl && (
|
||||
<img src={selectedImageUrl} alt="" className="w-12 h-12 object-cover rounded-lg border border-slate-600 shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white truncate">{selectedProduct.title}</p>
|
||||
<p className="text-xs text-gray-400">{selectedProduct.ref}
|
||||
{dimensions && <span className="ml-2 text-green-400">· {dimensions}</span>}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setStep('image')} className="text-xs text-gray-400 hover:text-white shrink-0">← Image</button>
|
||||
</div>
|
||||
|
||||
{/* Famille + placement carrelage */}
|
||||
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Famille</label>
|
||||
<select value={family} onChange={e => { setFamily(e.target.value); setPlacement('') }}
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
{['Menuiserie', 'Baies vitrées', 'Carrelage', 'Tous'].map(f => <option key={f}>{f}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Dimensions</label>
|
||||
<input value={dimensions} onChange={e => setDimensions(e.target.value)}
|
||||
placeholder="ex: 60x60cm"
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||
</div>
|
||||
</div>
|
||||
{isCarrelage && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1.5">Placement</p>
|
||||
<div className="flex gap-2">
|
||||
{[{ val: 'sol', label: 'Sol' }, { val: 'mur', label: 'Mur (faïence)' }].map(({ val, label }) => (
|
||||
<button key={val} type="button" onClick={() => setPlacement(p => p === val ? '' : val)}
|
||||
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors ${placement === val ? 'bg-indigo-600 text-white' : 'bg-slate-700 text-gray-300 hover:bg-slate-600'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Templates */}
|
||||
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide">Template</h3>
|
||||
<button type="button" onClick={() => templateFileRef.current?.click()}
|
||||
disabled={uploadingTemplate}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300 disabled:opacity-40">
|
||||
{uploadingTemplate ? 'Upload…' : '+ Nouveau template'}
|
||||
</button>
|
||||
<input ref={templateFileRef} type="file" accept="image/*" className="hidden" onChange={uploadTemplate} />
|
||||
</div>
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<p className="text-xs text-gray-500">Aucun template pour cette famille. Uploadez-en un.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{filteredTemplates.map(t => (
|
||||
<button key={t.id} type="button" onClick={() => setSelectedTemplate(t.id)}
|
||||
className={`relative rounded-lg overflow-hidden border-2 transition-all ${selectedTemplate === t.id ? 'border-indigo-500' : 'border-slate-600 hover:border-slate-400'}`}>
|
||||
<img src={`/api/mockup/templates/image/${t.filename}`} alt={t.name} className="w-full h-16 object-cover" />
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5">
|
||||
<p className="text-xs text-white truncate">{t.name}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Instruction + génération */}
|
||||
<div className="space-y-2">
|
||||
<input type="text" value={instruction} onChange={e => setInstruction(e.target.value)}
|
||||
placeholder="Instruction personnalisée (optionnel)…"
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||
<button type="button" onClick={generate}
|
||||
disabled={!selectedTemplate || generating}
|
||||
className="w-full py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium disabled:opacity-40 hover:bg-indigo-500 transition-colors">
|
||||
{generating ? 'Génération en cours…' : 'Générer le mockup'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
|
||||
{/* Résultat */}
|
||||
{resultB64 && (
|
||||
<div className="space-y-3">
|
||||
<img src={`data:image/jpeg;base64,${resultB64}`} alt="Mockup"
|
||||
className="w-full rounded-xl border border-slate-600" />
|
||||
<div className="flex gap-2">
|
||||
<input type="text" value={instruction} onChange={e => setInstruction(e.target.value)}
|
||||
placeholder="Modifier et regénérer…"
|
||||
className="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||
<button type="button" onClick={generate} disabled={generating}
|
||||
className="px-3 py-1.5 rounded bg-slate-700 text-xs text-white hover:bg-slate-600 disabled:opacity-40">
|
||||
{generating ? '…' : '↺'}
|
||||
</button>
|
||||
</div>
|
||||
{uploadedUrl ? (
|
||||
<p className="text-xs text-green-400 text-center">✓ Uploadé sur Shopify</p>
|
||||
) : (
|
||||
<button type="button" onClick={upload} disabled={uploading}
|
||||
className="w-full py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium disabled:opacity-40 hover:bg-emerald-500 transition-colors">
|
||||
{uploading ? 'Upload…' : '↑ Uploader sur Shopify'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
'use client'
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
|
||||
interface ProductStats {
|
||||
active: number
|
||||
draft: number
|
||||
total: number
|
||||
withoutImages: number
|
||||
}
|
||||
|
||||
interface LastSync {
|
||||
createdAt: string
|
||||
userName: string
|
||||
status: string
|
||||
created: number
|
||||
updated: number
|
||||
deactivated: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
interface DashboardStats {
|
||||
products: ProductStats
|
||||
lastSync: LastSync | null
|
||||
sheetDrift: boolean
|
||||
sheetModifiedAt: string | null
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
href,
|
||||
loading,
|
||||
error,
|
||||
accent,
|
||||
}: {
|
||||
icon: string
|
||||
label: string
|
||||
value: string | number | null
|
||||
sub?: string
|
||||
href?: string
|
||||
loading?: boolean
|
||||
error?: boolean
|
||||
accent?: 'indigo' | 'amber' | 'green' | 'red'
|
||||
}) {
|
||||
const accentClass = {
|
||||
indigo: 'border-indigo-500/30 bg-indigo-500/5',
|
||||
amber: 'border-amber-500/30 bg-amber-500/5',
|
||||
green: 'border-green-500/30 bg-green-500/5',
|
||||
red: 'border-red-500/30 bg-red-500/5',
|
||||
}[accent ?? 'indigo']
|
||||
|
||||
const content = (
|
||||
<div className={`rounded-xl border p-5 flex flex-col gap-1 ${accentClass} ${href ? 'hover:brightness-110 transition-all cursor-pointer' : ''}`}>
|
||||
<span className="text-xl">{icon}</span>
|
||||
<p className="text-xs text-slate-400 mt-1">{label}</p>
|
||||
{loading ? (
|
||||
<p className="text-2xl font-bold text-slate-600 animate-pulse">—</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-red-400">Erreur</p>
|
||||
) : (
|
||||
<p className="text-2xl font-bold text-white">{value ?? '—'}</p>
|
||||
)}
|
||||
{sub && <p className="text-xs text-slate-500">{sub}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
return href ? <Link href={href}>{content}</Link> : content
|
||||
}
|
||||
|
||||
function ActionCard({ href, icon, label, description }: { href: string; icon: string; label: string; description: string }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="flex items-start gap-3 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl p-4 transition-colors group"
|
||||
>
|
||||
<span className="text-2xl mt-0.5">{icon}</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white group-hover:text-indigo-300 transition-colors">{label}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{description}</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
day: '2-digit', month: 'short', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
}).format(new Date(iso))
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [statsError, setStatsError] = useState(false)
|
||||
const [statsLoading, setStatsLoading] = useState(true)
|
||||
|
||||
const [pendingCount, setPendingCount] = useState<number | null>(null)
|
||||
const [pendingLoading, setPendingLoading] = useState(false)
|
||||
const [pendingError, setPendingError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/dashboard/stats')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.error) throw new Error(data.error)
|
||||
setStats(data)
|
||||
})
|
||||
.catch(() => setStatsError(true))
|
||||
.finally(() => setStatsLoading(false))
|
||||
}, [])
|
||||
|
||||
const loadPending = useCallback(async () => {
|
||||
setPendingLoading(true)
|
||||
setPendingError(false)
|
||||
try {
|
||||
const r = await fetch('/api/dashboard/pending')
|
||||
const data = await r.json()
|
||||
if (data.error) throw new Error(data.error)
|
||||
setPendingCount(data.count)
|
||||
} catch {
|
||||
setPendingError(true)
|
||||
} finally {
|
||||
setPendingLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const lastSync = stats?.lastSync
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="📊 Dashboard" />
|
||||
|
||||
<div className="p-6 flex flex-col gap-8 flex-1 overflow-y-auto">
|
||||
|
||||
{/* ── Alerte drift Sheets ───────────────────────────────── */}
|
||||
{!statsLoading && stats?.sheetDrift && (
|
||||
<Link href="/sync" className="flex items-center gap-3 bg-amber-950/40 border border-amber-600/50 rounded-xl px-4 py-3 hover:bg-amber-950/60 transition-colors group">
|
||||
<span className="text-xl">⚠️</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-amber-300">Google Sheets modifié depuis la dernière sync</p>
|
||||
<p className="text-xs text-amber-500 mt-0.5">
|
||||
{stats.sheetModifiedAt
|
||||
? `Dernière modification : ${formatDate(stats.sheetModifiedAt)}`
|
||||
: 'Des changements sont peut-être en attente de synchronisation'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-amber-400 group-hover:text-amber-300 whitespace-nowrap">Lancer la sync →</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* ── KPIs ─────────────────────────────────────────────── */}
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Vue d'ensemble</h2>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard
|
||||
icon="📦"
|
||||
label="Produits actifs"
|
||||
value={stats?.products.active ?? null}
|
||||
sub={stats ? `${stats.products.draft} en brouillon` : undefined}
|
||||
loading={statsLoading}
|
||||
error={statsError}
|
||||
accent="green"
|
||||
href="/produits"
|
||||
/>
|
||||
<StatCard
|
||||
icon="🖼️"
|
||||
label="Sans image"
|
||||
value={stats?.products.withoutImages ?? null}
|
||||
sub={stats ? `sur ${stats.products.total} produits` : undefined}
|
||||
loading={statsLoading}
|
||||
error={statsError}
|
||||
accent={stats && stats.products.withoutImages > 0 ? 'amber' : 'green'}
|
||||
href="/images"
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔄"
|
||||
label="Dernière sync"
|
||||
value={lastSync ? formatDate(lastSync.createdAt) : 'Aucune'}
|
||||
sub={lastSync ? `par ${lastSync.userName}` : undefined}
|
||||
loading={statsLoading}
|
||||
error={statsError}
|
||||
accent="indigo"
|
||||
href="/sync"
|
||||
/>
|
||||
|
||||
{/* Pending — chargement lazy */}
|
||||
<div
|
||||
className="rounded-xl border border-indigo-500/30 bg-indigo-500/5 p-5 flex flex-col gap-1 cursor-pointer hover:brightness-110 transition-all"
|
||||
onClick={pendingCount === null && !pendingLoading ? loadPending : undefined}
|
||||
>
|
||||
<span className="text-xl">✨</span>
|
||||
<p className="text-xs text-slate-400 mt-1">En attente de création</p>
|
||||
{pendingLoading ? (
|
||||
<p className="text-2xl font-bold text-slate-600 animate-pulse">—</p>
|
||||
) : pendingError ? (
|
||||
<p className="text-sm text-red-400">Erreur — réessayer</p>
|
||||
) : pendingCount !== null ? (
|
||||
<p className="text-2xl font-bold text-white">{pendingCount}</p>
|
||||
) : (
|
||||
<button className="text-xs text-indigo-400 hover:text-indigo-300 text-left mt-1">
|
||||
Calculer →
|
||||
</button>
|
||||
)}
|
||||
{pendingCount !== null && (
|
||||
<p className="text-xs text-slate-500">depuis Google Sheets</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Dernière sync détail ──────────────────────────────── */}
|
||||
{!statsLoading && lastSync && (
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Dernière synchronisation</h2>
|
||||
<div className="bg-slate-800 border border-slate-700 rounded-xl p-5 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{formatDate(lastSync.createdAt)}</p>
|
||||
<p className="text-xs text-slate-400">par {lastSync.userName}</p>
|
||||
</div>
|
||||
<span className={`text-xs font-medium px-2 py-1 rounded-full border ${
|
||||
lastSync.status === 'success'
|
||||
? 'bg-green-900/30 border-green-700 text-green-400'
|
||||
: 'bg-red-900/30 border-red-700 text-red-400'
|
||||
}`}>
|
||||
{lastSync.status === 'success' ? '✅ Succès' : '❌ Erreur'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3 pt-2 border-t border-slate-700">
|
||||
{[
|
||||
{ label: 'Créations', value: lastSync.created, color: 'text-green-400' },
|
||||
{ label: 'Mises à jour', value: lastSync.updated, color: 'text-blue-400' },
|
||||
{ label: 'Désactivés', value: lastSync.deactivated, color: 'text-amber-400' },
|
||||
{ label: 'Erreurs', value: lastSync.errors, color: 'text-red-400' },
|
||||
].map(({ label, value, color }) => (
|
||||
<div key={label} className="text-center">
|
||||
<p className={`text-xl font-bold ${color}`}>{value}</p>
|
||||
<p className="text-xs text-slate-500">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Link href="/sync" className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors">
|
||||
Nouvelle sync →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Actions rapides ───────────────────────────────────── */}
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Actions rapides</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<ActionCard
|
||||
href="/images"
|
||||
icon="🖼️"
|
||||
label="Uploader des images"
|
||||
description="Convertir HEIC, détourer et envoyer sur Shopify"
|
||||
/>
|
||||
<ActionCard
|
||||
href="/sync"
|
||||
icon="🔄"
|
||||
label="Synchroniser le catalogue"
|
||||
description="Comparer Google Sheets avec Shopify et appliquer les changements"
|
||||
/>
|
||||
<ActionCard
|
||||
href="/creation"
|
||||
icon="✨"
|
||||
label="Créer des produits"
|
||||
description="Ajouter les produits en attente depuis Google Sheets"
|
||||
/>
|
||||
<ActionCard
|
||||
href="/produits"
|
||||
icon="📦"
|
||||
label="Voir le catalogue"
|
||||
description="Parcourir et filtrer tous les produits Shopify"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { UsersManager } from '@/components/settings/UsersManager'
|
||||
import { ConnectionStatus } from '@/components/settings/ConnectionStatus'
|
||||
import { TemplateLibrary } from '@/components/mockup/TemplateLibrary'
|
||||
|
||||
type Tab = 'connexions' | 'utilisateurs' | 'mockup-templates'
|
||||
|
||||
export default function ParametresPage() {
|
||||
const { data: session } = useSession()
|
||||
const isAdmin = session?.user?.role === 'admin'
|
||||
const [tab, setTab] = useState<Tab>('connexions')
|
||||
|
||||
const tabs: { id: Tab; label: string; adminOnly?: boolean }[] = [
|
||||
{ id: 'connexions', label: 'Connexions' },
|
||||
{ id: 'mockup-templates', label: 'Templates mockup' },
|
||||
{ id: 'utilisateurs', label: 'Utilisateurs', adminOnly: true },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Paramètres" />
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex gap-2">
|
||||
{tabs.filter(t => !t.adminOnly || isAdmin).map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
tab === t.id
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === 'connexions' && <ConnectionStatus />}
|
||||
{tab === 'mockup-templates' && <TemplateLibrary />}
|
||||
{isAdmin && tab === 'utilisateurs' && <UsersManager />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
'use client'
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import type { DuplicateGroup } from '@/app/api/products/duplicates/route'
|
||||
|
||||
type DeleteState = 'idle' | 'confirm' | 'deleting' | 'done'
|
||||
|
||||
interface GroupState {
|
||||
group: DuplicateGroup
|
||||
keepId: string
|
||||
deleteState: DeleteState
|
||||
deleted: string[] // IDs supprimés
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export default function DoublonsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [groupStates, setGroupStates] = useState<GroupState[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/products/duplicates')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.error) throw new Error(data.error)
|
||||
setGroupStates(
|
||||
(data.groups as DuplicateGroup[]).map((group) => ({
|
||||
group,
|
||||
keepId: group.products[0].shopifyId, // garder le plus ancien par défaut
|
||||
deleteState: 'idle',
|
||||
deleted: [],
|
||||
error: null,
|
||||
}))
|
||||
)
|
||||
})
|
||||
.catch((e: Error) => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const setKeep = useCallback((groupIdx: number, id: string) => {
|
||||
setGroupStates((prev) =>
|
||||
prev.map((gs, i) => i === groupIdx ? { ...gs, keepId: id } : gs)
|
||||
)
|
||||
}, [])
|
||||
|
||||
const askConfirm = useCallback((groupIdx: number) => {
|
||||
setGroupStates((prev) =>
|
||||
prev.map((gs, i) => i === groupIdx ? { ...gs, deleteState: 'confirm', error: null } : gs)
|
||||
)
|
||||
}, [])
|
||||
|
||||
const cancelConfirm = useCallback((groupIdx: number) => {
|
||||
setGroupStates((prev) =>
|
||||
prev.map((gs, i) => i === groupIdx ? { ...gs, deleteState: 'idle' } : gs)
|
||||
)
|
||||
}, [])
|
||||
|
||||
const deleteGroup = useCallback(async (groupIdx: number) => {
|
||||
const gs = groupStates[groupIdx]
|
||||
const toDelete = gs.group.products
|
||||
.map((p) => p.shopifyId)
|
||||
.filter((id) => id !== gs.keepId && !gs.deleted.includes(id))
|
||||
|
||||
setGroupStates((prev) =>
|
||||
prev.map((s, i) => i === groupIdx ? { ...s, deleteState: 'deleting', error: null } : s)
|
||||
)
|
||||
|
||||
const newDeleted = [...gs.deleted]
|
||||
let lastError: string | null = null
|
||||
|
||||
for (const id of toDelete) {
|
||||
const res = await fetch(`/api/products/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
newDeleted.push(id)
|
||||
} else {
|
||||
const data = await res.json()
|
||||
lastError = data.error ?? `Erreur HTTP ${res.status}`
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
setGroupStates((prev) =>
|
||||
prev.map((s, i) =>
|
||||
i === groupIdx
|
||||
? { ...s, deleted: newDeleted, deleteState: lastError ? 'idle' : 'done', error: lastError }
|
||||
: s
|
||||
)
|
||||
)
|
||||
}, [groupStates])
|
||||
|
||||
const remaining = groupStates.filter((gs) => gs.deleteState !== 'done').length
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
title="🔍 Doublons produits"
|
||||
actions={
|
||||
<Link href="/produits" className="text-xs text-slate-400 hover:text-slate-200">
|
||||
← Retour au catalogue
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="p-6 flex flex-col gap-6 flex-1 overflow-y-auto">
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-slate-400 text-sm py-12 justify-center">
|
||||
<span className="animate-spin">⏳</span> Analyse du catalogue Shopify…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && groupStates.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-4xl mb-3">✅</p>
|
||||
<p className="text-white font-semibold">Aucun doublon détecté</p>
|
||||
<p className="text-slate-400 text-sm mt-1">Tous les produits ont une référence unique.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && groupStates.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-slate-400">
|
||||
<span className="text-white font-semibold">{groupStates.length}</span> groupe{groupStates.length > 1 ? 's' : ''} de doublons détectés
|
||||
{remaining < groupStates.length && (
|
||||
<span className="ml-2 text-green-400">· {groupStates.length - remaining} résolu{groupStates.length - remaining > 1 ? 's' : ''}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{groupStates.map((gs, groupIdx) => {
|
||||
const { group, keepId, deleteState, deleted, error: groupError } = gs
|
||||
const isDone = deleteState === 'done'
|
||||
const activeProducts = group.products.filter((p) => !deleted.includes(p.shopifyId))
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group.ref}
|
||||
className={`border rounded-xl overflow-hidden transition-all ${
|
||||
isDone
|
||||
? 'border-green-700/50 bg-green-950/20 opacity-60'
|
||||
: 'border-slate-700 bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
{/* En-tête groupe */}
|
||||
<div className="px-4 py-3 border-b border-slate-700 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-mono text-sm text-indigo-300 font-semibold">{group.ref}</span>
|
||||
<span className="ml-3 text-xs text-slate-500">
|
||||
{isDone ? '✅ Résolu' : `${activeProducts.length} produits`}
|
||||
</span>
|
||||
</div>
|
||||
{!isDone && deleteState === 'idle' && (
|
||||
<button
|
||||
onClick={() => askConfirm(groupIdx)}
|
||||
className="text-xs px-3 py-1.5 bg-red-900/40 hover:bg-red-800/60 border border-red-700/50 text-red-300 rounded-lg transition-colors"
|
||||
>
|
||||
Supprimer les doublons
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Liste des produits */}
|
||||
{!isDone && (
|
||||
<div className="divide-y divide-slate-700/50">
|
||||
{activeProducts.map((product) => {
|
||||
const isKeep = product.shopifyId === keepId
|
||||
return (
|
||||
<div
|
||||
key={product.shopifyId}
|
||||
className={`px-4 py-3 flex items-center gap-3 ${isKeep ? 'bg-green-950/20' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`keep-${groupIdx}`}
|
||||
checked={isKeep}
|
||||
onChange={() => setKeep(groupIdx, product.shopifyId)}
|
||||
className="accent-green-500 cursor-pointer"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white truncate">{product.title}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
ID {product.shopifyId} ·{' '}
|
||||
<span className={product.status === 'active' ? 'text-green-400' : 'text-slate-400'}>
|
||||
{product.status}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isKeep && (
|
||||
<span className="text-xs text-green-400 font-medium">À garder</span>
|
||||
)}
|
||||
<a
|
||||
href={product.shopifyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-slate-500 hover:text-slate-300"
|
||||
>
|
||||
Shopify ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zone de confirmation */}
|
||||
{deleteState === 'confirm' && (
|
||||
<div className="px-4 py-3 bg-red-950/30 border-t border-red-800/40 flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-red-300">
|
||||
Supprimer définitivement{' '}
|
||||
<strong>{activeProducts.length - 1} produit{activeProducts.length - 1 > 1 ? 's' : ''}</strong>{' '}
|
||||
de Shopify. Cette action est irréversible.
|
||||
</p>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => cancelConfirm(groupIdx)}
|
||||
className="text-xs px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded-lg"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteGroup(groupIdx)}
|
||||
className="text-xs px-3 py-1.5 bg-red-700 hover:bg-red-600 text-white font-semibold rounded-lg"
|
||||
>
|
||||
Confirmer la suppression
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteState === 'deleting' && (
|
||||
<div className="px-4 py-3 border-t border-slate-700 text-sm text-slate-400 flex items-center gap-2">
|
||||
<span className="animate-spin">⏳</span> Suppression en cours…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupError && (
|
||||
<div className="px-4 py-2 border-t border-red-800/40 text-xs text-red-400">
|
||||
Erreur : {groupError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { ProductFiltersBar } from '@/components/products/ProductFiltersBar'
|
||||
import { ProductTable } from '@/components/products/ProductTable'
|
||||
import type { CatalogProduct, ProductFilters } from '@/types/products'
|
||||
|
||||
export default function ProduitsPage() {
|
||||
const [allProducts, setAllProducts] = useState<CatalogProduct[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filters, setFilters] = useState<ProductFilters>({ status: 'all', search: '' })
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const params = filters.status !== 'all' ? `?status=${filters.status}` : ''
|
||||
fetch(`/api/products/list${params}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.error) throw new Error(data.error)
|
||||
setAllProducts(data.products)
|
||||
})
|
||||
.catch((e: Error) => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [filters.status])
|
||||
|
||||
const displayed = useMemo(() => {
|
||||
if (!filters.search.trim()) return allProducts
|
||||
const q = filters.search.toLowerCase()
|
||||
return allProducts.filter(
|
||||
(p) => p.title.toLowerCase().includes(q) || p.ref.toLowerCase().includes(q),
|
||||
)
|
||||
}, [allProducts, filters.search])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
title="Produits"
|
||||
actions={
|
||||
<Link href="/produits/doublons" className="text-xs px-3 py-1.5 bg-slate-700 hover:bg-slate-600 border border-slate-600 text-slate-300 rounded-lg transition-colors">
|
||||
🔍 Vérifier les doublons
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<div className="p-6">
|
||||
<div className="bg-slate-800 rounded-xl p-6">
|
||||
<ProductFiltersBar
|
||||
filters={filters}
|
||||
total={displayed.length}
|
||||
onChange={(f) => setFilters((prev) => ({ ...prev, ...f }))}
|
||||
/>
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<ProductTable products={displayed} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
'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 { FamilySelector } from '@/components/common/FamilySelector'
|
||||
import type { DiffResult, SyncChange, SyncHistoryEntry } from '@/types/sync'
|
||||
|
||||
type Step = 0 | 1 | 2 | 3 | 4
|
||||
|
||||
export default function SyncPage() {
|
||||
const [step, setStep] = useState<Step>(0)
|
||||
const [selectedTab, setSelectedTab] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [diff, setDiff] = useState<DiffResult | null>(null)
|
||||
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 0 → 1 : sélection famille
|
||||
const handleSelectTab = useCallback((tab: string) => {
|
||||
setSelectedTab(tab)
|
||||
setStep(1)
|
||||
}, [])
|
||||
|
||||
// Étape 1 → 2 : lancer le preview
|
||||
const handleAnalyze = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const url = selectedTab ? `/api/sync/preview?tab=${encodeURIComponent(selectedTab)}` : '/api/sync/preview'
|
||||
const res = await fetch(url)
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||
setDiff(data)
|
||||
setStep(2)
|
||||
} catch (err) {
|
||||
setError((err as Error).message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [selectedTab])
|
||||
|
||||
// É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 > 0 ? step : 1} />
|
||||
|
||||
{/* Étape 0 — Sélection famille */}
|
||||
{step === 0 && (
|
||||
<div className="bg-slate-800/50 border border-slate-700 rounded-xl p-6">
|
||||
<h2 className="text-sm font-semibold text-white mb-4">Choisir une famille</h2>
|
||||
<FamilySelector onSelect={handleSelectTab} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Étape 1 — Analyser */}
|
||||
{step === 1 && (
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<div className="flex items-center gap-2 bg-indigo-950/50 border border-indigo-800/50 rounded-lg px-4 py-2">
|
||||
<span className="text-indigo-400 text-sm">📂 Famille :</span>
|
||||
<span className="text-white text-sm font-semibold">{selectedTab}</span>
|
||||
<button onClick={() => { setStep(0); setSelectedTab(null) }} className="ml-2 text-xs text-slate-400 hover:text-slate-200">changer</button>
|
||||
</div>
|
||||
<p className="text-slate-400 text-sm text-center max-w-md">
|
||||
Compare la famille <strong className="text-white">{selectedTab}</strong> avec le catalogue Shopify.
|
||||
</p>
|
||||
{error && (
|
||||
<div className="bg-red-950 border border-red-800 text-red-300 text-sm rounded-lg px-4 py-2 max-w-md">
|
||||
{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(0); setSelectedTab(null); setDiff(null); setSummary(null); setLog([]) }}
|
||||
className="px-4 py-2 text-sm bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded-lg"
|
||||
>
|
||||
↺ Nouvelle sync
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Historique */}
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={loadHistory}
|
||||
className="text-sm text-slate-500 hover:text-slate-300 mb-3"
|
||||
>
|
||||
{historyLoaded ? '📋 Historique des syncs' : '📋 Charger l\'historique'}
|
||||
</button>
|
||||
{historyLoaded && <SyncHistory entries={history} />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
|
||||
async function testShopify(): Promise<{ ok: boolean; message: string }> {
|
||||
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||
if (!domain || !token) return { ok: false, message: 'Variables manquantes' }
|
||||
try {
|
||||
const res = await fetch(`https://${domain}/admin/api/2024-01/shop.json`, {
|
||||
headers: { 'X-Shopify-Access-Token': token },
|
||||
})
|
||||
if (!res.ok) return { ok: false, message: `HTTP ${res.status}` }
|
||||
const data = await res.json()
|
||||
return { ok: true, message: `Boutique : ${data.shop?.name ?? domain}` }
|
||||
} catch (e) {
|
||||
return { ok: false, message: e instanceof Error ? e.message : 'Erreur réseau' }
|
||||
}
|
||||
}
|
||||
|
||||
async function testSheets(): Promise<{ ok: boolean; message: string }> {
|
||||
const apiKey = process.env.GOOGLE_API_KEY
|
||||
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||
if (!apiKey || !sheetId) return { ok: false, message: 'Variables manquantes' }
|
||||
try {
|
||||
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=properties.title`
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) return { ok: false, message: `HTTP ${res.status}` }
|
||||
const data = await res.json()
|
||||
return { ok: true, message: `Feuille : ${data.properties?.title ?? sheetId}` }
|
||||
} catch (e) {
|
||||
return { ok: false, message: e instanceof Error ? e.message : 'Erreur réseau' }
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export async function GET(_req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || session.user.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const [shopify, sheets] = await Promise.all([testShopify(), testSheets()])
|
||||
return NextResponse.json({ shopify, sheets })
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { readSheetProducts } from '@/lib/googleSheets'
|
||||
import { fetchAllShopifyProducts, getConfig } from '@/lib/shopifySync'
|
||||
|
||||
export const maxDuration = 300
|
||||
|
||||
export async function POST() {
|
||||
const sheetProducts = await readSheetProducts()
|
||||
const shopifyProducts = await fetchAllShopifyProducts()
|
||||
const shopifyByHandle = new Map(shopifyProducts.map(p => [p.handle, p]))
|
||||
const { baseUrl, headers } = getConfig()
|
||||
|
||||
const norm = (v: string | null | undefined) => (!v || v === '0.00') ? null : v
|
||||
|
||||
let updated = 0, skipped = 0, errors = 0
|
||||
const log: string[] = []
|
||||
|
||||
for (const sheet of sheetProducts) {
|
||||
const shopify = shopifyByHandle.get(sheet.handle)
|
||||
if (!shopify) { skipped++; continue }
|
||||
|
||||
const newCompareAt = sheet.compareAtPrice ?? null
|
||||
const currentCompareAt = (shopify as { compareAtPrice?: string }).compareAtPrice ?? null
|
||||
|
||||
if (norm(newCompareAt) === norm(currentCompareAt) && sheet.price === shopify.price) {
|
||||
skipped++; continue
|
||||
}
|
||||
|
||||
const detailRes = await fetch(`${baseUrl}/products/${shopify.shopifyId}.json?fields=variants`, { headers })
|
||||
if (!detailRes.ok) { errors++; log.push(`✗ ${sheet.ref} — lecture variant`); continue }
|
||||
const detail = await detailRes.json() as { product: { variants: Array<{ id: number }> } }
|
||||
const variantId = detail.product.variants[0]?.id
|
||||
if (!variantId) { errors++; continue }
|
||||
|
||||
const res = await fetch(`${baseUrl}/products/${shopify.shopifyId}.json`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
product: {
|
||||
id: shopify.shopifyId,
|
||||
variants: [{ id: variantId, price: sheet.price, compare_at_price: newCompareAt }],
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
updated++
|
||||
log.push(`✓ ${sheet.ref} price=${sheet.price} compare_at=${newCompareAt ?? 'null'}`)
|
||||
} else {
|
||||
errors++
|
||||
log.push(`✗ ${sheet.ref} — ${res.status}`)
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
}
|
||||
|
||||
return NextResponse.json({ updated, skipped, errors, log })
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || session.user.role !== 'admin') return null
|
||||
return session
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
const session = await requireAdmin()
|
||||
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const { name, role, password } = body as Record<string, string>
|
||||
|
||||
const data: Record<string, string> = {}
|
||||
if (name) data.name = name
|
||||
if (role) data.role = role
|
||||
if (password) data.password = await bcrypt.hash(password, 12)
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return NextResponse.json({ error: 'Aucun champ à mettre à jour' }, { status: 400 })
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: params.id },
|
||||
data,
|
||||
select: { id: true, email: true, name: true, role: true, createdAt: true },
|
||||
})
|
||||
return NextResponse.json(user)
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
const session = await requireAdmin()
|
||||
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
if (params.id === session.user.id) {
|
||||
return NextResponse.json({ error: 'Impossible de se supprimer soi-même' }, { status: 400 })
|
||||
}
|
||||
|
||||
await prisma.user.delete({ where: { id: params.id } })
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { GET, POST } from './route'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
jest.mock('next-auth')
|
||||
jest.mock('@/lib/prisma', () => ({ prisma: { user: { findMany: jest.fn(), create: jest.fn() } } }))
|
||||
jest.mock('bcryptjs')
|
||||
|
||||
const mockSession = getServerSession as jest.MockedFunction<typeof getServerSession>
|
||||
const mockFindMany = prisma.user.findMany as jest.MockedFunction<typeof prisma.user.findMany>
|
||||
const mockCreate = prisma.user.create as jest.MockedFunction<typeof prisma.user.create>
|
||||
const mockHash = bcrypt.hash as jest.MockedFunction<typeof bcrypt.hash>
|
||||
|
||||
const adminSession = { user: { id: 'admin-id', role: 'admin' } }
|
||||
const gestSession = { user: { id: 'gest-id', role: 'gestionnaire' } }
|
||||
|
||||
describe('GET /api/admin/users', () => {
|
||||
it('retourne 403 si non admin', async () => {
|
||||
mockSession.mockResolvedValue(gestSession as never)
|
||||
const res = await GET(new Request('http://localhost') as never)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('retourne la liste des utilisateurs', async () => {
|
||||
mockSession.mockResolvedValue(adminSession as never)
|
||||
mockFindMany.mockResolvedValue([
|
||||
{ id: '1', email: 'a@b.com', name: 'Alice', role: 'admin', createdAt: new Date() } as never,
|
||||
])
|
||||
const res = await GET(new Request('http://localhost') as never)
|
||||
const body = await res.json()
|
||||
expect(body).toHaveLength(1)
|
||||
expect(body[0].email).toBe('a@b.com')
|
||||
expect(body[0].password).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/admin/users', () => {
|
||||
it('retourne 403 si non admin', async () => {
|
||||
mockSession.mockResolvedValue(gestSession as never)
|
||||
const res = await POST(new Request('http://localhost', { method: 'POST', body: '{}' }) as never)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('crée un utilisateur avec mot de passe hashé', async () => {
|
||||
mockSession.mockResolvedValue(adminSession as never)
|
||||
mockHash.mockResolvedValue('hashed' as never)
|
||||
mockCreate.mockResolvedValue({ id: 'new', email: 'b@c.com', name: 'Bob', role: 'gestionnaire', createdAt: new Date() } as never)
|
||||
const body = JSON.stringify({ email: 'b@c.com', name: 'Bob', password: 'pass123', role: 'gestionnaire' })
|
||||
const res = await POST(new Request('http://localhost', { method: 'POST', body, headers: { 'Content-Type': 'application/json' } }) as never)
|
||||
expect(res.status).toBe(201)
|
||||
expect(mockHash).toHaveBeenCalledWith('pass123', 12)
|
||||
})
|
||||
|
||||
it('retourne 400 si champs manquants', async () => {
|
||||
mockSession.mockResolvedValue(adminSession as never)
|
||||
const body = JSON.stringify({ email: 'b@c.com' })
|
||||
const res = await POST(new Request('http://localhost', { method: 'POST', body, headers: { 'Content-Type': 'application/json' } }) as never)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || session.user.role !== 'admin') return null
|
||||
return session
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export async function GET(_req: NextRequest) {
|
||||
const session = await requireAdmin()
|
||||
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { id: true, email: true, name: true, role: true, createdAt: true },
|
||||
})
|
||||
return NextResponse.json(users)
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await requireAdmin()
|
||||
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const { email, name, password, role } = body as Record<string, string>
|
||||
|
||||
if (!email || !name || !password || !role) {
|
||||
return NextResponse.json({ error: 'email, name, password et role requis' }, { status: 400 })
|
||||
}
|
||||
|
||||
const hashed = await bcrypt.hash(password, 12)
|
||||
const user = await prisma.user.create({
|
||||
data: { email, name, password: hashed, role },
|
||||
select: { id: true, email: true, name: true, role: true, createdAt: true },
|
||||
})
|
||||
return NextResponse.json(user, { status: 201 })
|
||||
}
|
||||
@@ -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,93 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
|
||||
async function getAccessToken(): Promise<string | null> {
|
||||
const email = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL
|
||||
const rawKey = process.env.GOOGLE_SERVICE_ACCOUNT_KEY
|
||||
if (!email || !rawKey) return null
|
||||
|
||||
const privateKey = rawKey.replace(/\\n/g, '\n')
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url')
|
||||
const payload = Buffer.from(JSON.stringify({
|
||||
iss: email,
|
||||
scope: 'https://www.googleapis.com/auth/spreadsheets',
|
||||
aud: 'https://oauth2.googleapis.com/token',
|
||||
iat: now,
|
||||
exp: now + 3600,
|
||||
})).toString('base64url')
|
||||
|
||||
const { createSign } = await import('crypto')
|
||||
const sign = createSign('RSA-SHA256')
|
||||
sign.update(`${header}.${payload}`)
|
||||
const signature = sign.sign(privateKey, 'base64url')
|
||||
const jwt = `${header}.${payload}.${signature}`
|
||||
|
||||
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt }),
|
||||
})
|
||||
if (!tokenRes.ok) return null
|
||||
const { access_token } = await tokenRes.json() as { access_token: string }
|
||||
return access_token
|
||||
}
|
||||
|
||||
// POST /api/creation/clear-ids?tab=SOL CARRELAGE
|
||||
// Efface la colonne A (ID Shopify) de toutes les lignes produit d'un onglet
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
|
||||
const tab = new URL(req.url).searchParams.get('tab')
|
||||
if (!tab) return NextResponse.json({ error: 'tab requis' }, { status: 400 })
|
||||
|
||||
const apiKey = process.env.GOOGLE_API_KEY
|
||||
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||
if (!apiKey || !sheetId) return NextResponse.json({ error: 'Config Google manquante' }, { status: 500 })
|
||||
|
||||
// Lire les données de l'onglet
|
||||
const range = encodeURIComponent(`${tab}!A1:A500`)
|
||||
const res = await fetch(
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`,
|
||||
)
|
||||
if (!res.ok) return NextResponse.json({ error: `Sheets read error: ${res.status}` }, { status: 502 })
|
||||
|
||||
const data = await res.json() as { values?: string[][] }
|
||||
const rows = data.values ?? []
|
||||
|
||||
// Trouver la ligne d'en-tête (contient "ID" en col A)
|
||||
const headerRowIdx = rows.findIndex(r => (r[0] ?? '').trim() === 'ID')
|
||||
if (headerRowIdx === -1) return NextResponse.json({ error: 'Onglet non reconnu (pas de colonne ID)' }, { status: 400 })
|
||||
|
||||
// Identifier les lignes produit qui ont un ID Shopify (numérique)
|
||||
const clearValues: string[][] = []
|
||||
const startRow = headerRowIdx + 2 // 1-indexed, skip header row
|
||||
for (let i = headerRowIdx + 1; i < rows.length; i++) {
|
||||
const cell = (rows[i]?.[0] ?? '').trim()
|
||||
clearValues.push([cell && /^\d+$/.test(cell) ? '' : cell])
|
||||
}
|
||||
|
||||
if (clearValues.length === 0) return NextResponse.json({ cleared: 0 })
|
||||
|
||||
const accessToken = await getAccessToken()
|
||||
if (!accessToken) return NextResponse.json({ error: 'Impossible d\'obtenir le token Google' }, { status: 500 })
|
||||
|
||||
const writeRange = `${tab}!A${startRow}:A${startRow + clearValues.length - 1}`
|
||||
const writeRes = await fetch(
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${encodeURIComponent(writeRange)}?valueInputOption=RAW`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ range: writeRange, majorDimension: 'ROWS', values: clearValues }),
|
||||
},
|
||||
)
|
||||
if (!writeRes.ok) {
|
||||
const err = await writeRes.text()
|
||||
return NextResponse.json({ error: `Sheets write error: ${err}` }, { status: 502 })
|
||||
}
|
||||
|
||||
const cleared = clearValues.filter(r => r[0] === '').length
|
||||
return NextResponse.json({ cleared, tab })
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
|
||||
const API_VERSION = '2024-01'
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
|
||||
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||
if (!domain || !token) return NextResponse.json({ error: 'Config Shopify manquante' }, { status: 500 })
|
||||
|
||||
const baseUrl = `https://${domain}/admin/api/${API_VERSION}`
|
||||
const headers = { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' }
|
||||
|
||||
// Récupère custom collections + smart collections
|
||||
const [customRes, smartRes] = await Promise.all([
|
||||
fetch(`${baseUrl}/custom_collections.json?limit=250&fields=id,title`, { headers }),
|
||||
fetch(`${baseUrl}/smart_collections.json?limit=250&fields=id,title`, { headers }),
|
||||
])
|
||||
|
||||
const custom = customRes.ok ? (await customRes.json()).custom_collections as { id: number; title: string }[] : []
|
||||
const smart = smartRes.ok ? (await smartRes.json()).smart_collections as { id: number; title: string }[] : []
|
||||
|
||||
const collections = [...custom, ...smart]
|
||||
.map(c => ({ id: String(c.id), title: c.title }))
|
||||
.sort((a, b) => a.title.localeCompare(b.title))
|
||||
|
||||
return NextResponse.json({ collections })
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { fetchPendingProducts, writeShopifyIdToSheet } from '@/lib/creationSheets'
|
||||
import { generateDescriptions } from '@/lib/openaiClient'
|
||||
import { ensureMetafieldDefinition, setProductMetafield } from '@/lib/shopifyMetafields'
|
||||
import { addProductToCollection } from '@/lib/shopifyCollections'
|
||||
import { createShopifyProduct, fetchAllShopifyProducts } from '@/lib/shopifySync'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { MetafieldResolution, CreationStreamEvent } from '@/types/creation'
|
||||
|
||||
function encode(event: CreationStreamEvent): string {
|
||||
return JSON.stringify(event) + '\n'
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({ error: 'Non authentifié' }), { status: 401 })
|
||||
}
|
||||
|
||||
const userId = (session.user as { id: string }).id
|
||||
const body = await req.json().catch(() => ({})) as { resolutions?: MetafieldResolution[]; tab?: string; collectionOverride?: string }
|
||||
const resolutions: MetafieldResolution[] = body.resolutions ?? []
|
||||
const tabFilter: string | undefined = body.tab ?? undefined
|
||||
const collectionOverride: string | undefined = body.collectionOverride ?? undefined
|
||||
const resolutionMap = new Map<string, MetafieldResolution>(resolutions.map(r => [r.columnHeader, r]))
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (event: CreationStreamEvent) => controller.enqueue(encode(event))
|
||||
try {
|
||||
const [existingRows, shopifyProducts] = await Promise.all([
|
||||
prisma.productCreation.findMany({ select: { sheetTab: true, sheetRow: true } }),
|
||||
fetchAllShopifyProducts(),
|
||||
])
|
||||
const alreadyCreated = new Set(existingRows.map(e => `${e.sheetTab}:${e.sheetRow}`))
|
||||
const shopifyRefs = new Set(shopifyProducts.map(p => p.ref.trim().toLowerCase()))
|
||||
const pending = await fetchPendingProducts(alreadyCreated, tabFilter)
|
||||
const total = pending.length
|
||||
|
||||
if (total === 0) {
|
||||
send({ type: 'done', summary: { created: 0, skipped: 0, errors: 0, status: 'success' } })
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
|
||||
// Crée les définitions manquantes en amont
|
||||
for (const resolution of resolutions) {
|
||||
if (resolution.action === 'create') await ensureMetafieldDefinition(resolution)
|
||||
}
|
||||
|
||||
let created = 0
|
||||
let skipped = 0
|
||||
let errors = 0
|
||||
|
||||
for (let i = 0; i < pending.length; i++) {
|
||||
const product = pending[i]
|
||||
|
||||
// Vérifier si le produit existe déjà dans Shopify par SKU
|
||||
if (shopifyRefs.has(product.sku.trim().toLowerCase())) {
|
||||
send({ type: 'skipped', title: product.title, sku: product.sku })
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Génération descriptions…' })
|
||||
try {
|
||||
const { longDesc } = await generateDescriptions({
|
||||
title: product.title,
|
||||
rawDescription: '',
|
||||
comment: product.comment,
|
||||
vendor: product.vendor,
|
||||
famille: product.famille,
|
||||
})
|
||||
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Création produit Shopify…' })
|
||||
console.log(`[creation] ${product.sku} stock=${product.stock}`)
|
||||
const shopifyId = await createShopifyProduct({
|
||||
ref: product.sku,
|
||||
handle: product.sku.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
title: product.title,
|
||||
description: longDesc,
|
||||
price: product.priceActual || '0.00',
|
||||
stock: product.stock,
|
||||
weightKg: product.weightKg,
|
||||
status: 'active',
|
||||
})
|
||||
const effectiveCollectionId = product.collectionId || collectionOverride || ''
|
||||
if (effectiveCollectionId) await addProductToCollection(shopifyId, effectiveCollectionId)
|
||||
if (product.subCollectionId) await addProductToCollection(shopifyId, product.subCollectionId)
|
||||
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Métachamps…' })
|
||||
for (const [colHeader, value] of Object.entries(product.specificFields)) {
|
||||
const resolution = resolutionMap.get(colHeader)
|
||||
if (!resolution || resolution.action === 'skip') continue
|
||||
await setProductMetafield(shopifyId, resolution, value)
|
||||
}
|
||||
await prisma.productCreation.create({
|
||||
data: { userId, sheetTab: product.tab, sheetRow: product.rowNumber, shopifyId, sku: product.sku, title: product.title, status: 'success' },
|
||||
})
|
||||
await writeShopifyIdToSheet(product.tab, product.rowNumber, shopifyId)
|
||||
created++
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
send({ type: 'error', title: product.title, message })
|
||||
await prisma.productCreation.create({
|
||||
data: { userId, sheetTab: product.tab, sheetRow: product.rowNumber, shopifyId: '', sku: product.sku, title: product.title, status: 'error', errorMessage: message },
|
||||
})
|
||||
errors++
|
||||
}
|
||||
}
|
||||
const status = errors === 0 ? 'success' : created === 0 ? 'error' : 'partial'
|
||||
send({ type: 'done', summary: { created, skipped, errors, status } })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
send({ type: 'done', summary: { created: 0, skipped: 0, errors: 1, status: 'error' } })
|
||||
console.error('[creation/execute]', message)
|
||||
} finally {
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: { 'Content-Type': 'application/x-ndjson', 'Transfer-Encoding': 'chunked' },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { GET } from './route'
|
||||
import * as shopifyMetafields from '@/lib/shopifyMetafields'
|
||||
import { getServerSession } from 'next-auth'
|
||||
|
||||
jest.mock('next-auth')
|
||||
jest.mock('@/lib/shopifyMetafields')
|
||||
|
||||
const mockSession = getServerSession as jest.MockedFunction<typeof getServerSession>
|
||||
const mockFetchDefs = shopifyMetafields.fetchMetafieldDefinitions as jest.MockedFunction<typeof shopifyMetafields.fetchMetafieldDefinitions>
|
||||
const mockMatch = shopifyMetafields.matchMetafields as jest.MockedFunction<typeof shopifyMetafields.matchMetafields>
|
||||
|
||||
describe('GET /api/creation/metafields', () => {
|
||||
beforeEach(() => {
|
||||
mockSession.mockResolvedValue({ user: { id: 'u1', role: 'admin' } } as never)
|
||||
mockFetchDefs.mockResolvedValue([
|
||||
{ id: '1', namespace: 'custom', key: 'puissance', name: 'Puissance', type: 'single_line_text_field' },
|
||||
])
|
||||
mockMatch.mockReturnValue({
|
||||
matched: { 'Puissance (AN)': { columnHeader: 'Puissance (AN)', action: 'map', namespace: 'custom', key: 'puissance', existingDefinitionId: '1' } },
|
||||
unmatched: ['Batterie (AP)'],
|
||||
})
|
||||
})
|
||||
|
||||
it('retourne 401 sans session', async () => {
|
||||
mockSession.mockResolvedValue(null)
|
||||
const res = await GET(new Request('http://localhost/api/creation/metafields?columns=Puissance') as never)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('retourne définitions + matching', async () => {
|
||||
const res = await GET(new Request('http://localhost/api/creation/metafields?columns=Puissance+%28AN%29%2CBatterie+%28AP%29') as never)
|
||||
const body = await res.json()
|
||||
expect(body.definitions).toHaveLength(1)
|
||||
expect(body.matched['Puissance (AN)'].key).toBe('puissance')
|
||||
expect(body.unmatched).toContain('Batterie (AP)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { fetchMetafieldDefinitions, matchMetafields } from '@/lib/shopifyMetafields'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const columnsParam = searchParams.get('columns') ?? ''
|
||||
const columns = columnsParam ? columnsParam.split(',').map(c => c.trim()).filter(Boolean) : []
|
||||
const definitions = await fetchMetafieldDefinitions()
|
||||
const { matched, unmatched } = matchMetafields(columns, definitions)
|
||||
return NextResponse.json({ definitions, matched, unmatched })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { GET } from './route'
|
||||
import * as creationSheets from '@/lib/creationSheets'
|
||||
import { getServerSession } from 'next-auth'
|
||||
|
||||
jest.mock('next-auth')
|
||||
jest.mock('@/lib/creationSheets')
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: { productCreation: { findMany: jest.fn().mockResolvedValue([]) } },
|
||||
}))
|
||||
|
||||
const mockSession = getServerSession as jest.MockedFunction<typeof getServerSession>
|
||||
const mockFetch = creationSheets.fetchPendingProducts as jest.MockedFunction<typeof creationSheets.fetchPendingProducts>
|
||||
|
||||
const makePending = (tab: string, row: number, specific: Record<string, string> = {}) => ({
|
||||
tab, rowNumber: row, sku: `SKU-${row}`, title: `Produit ${row}`,
|
||||
priceMarket: '99', priceActual: '79', stock: 5, weightKg: 2,
|
||||
vendor: 'BOSCH', famille: tab, sousFamille: '', comment: '',
|
||||
collectionId: '10', subCollectionId: '', hasPhoto: false,
|
||||
specificFields: specific,
|
||||
})
|
||||
|
||||
describe('GET /api/creation/preview', () => {
|
||||
beforeEach(() => {
|
||||
mockSession.mockResolvedValue({ user: { id: 'u1', role: 'admin' } } as never)
|
||||
mockFetch.mockResolvedValue([
|
||||
makePending('OUTILLAGE', 2, { 'Puissance (AN)': '1500W' }),
|
||||
makePending('CARRELAGE', 3),
|
||||
])
|
||||
})
|
||||
|
||||
it('retourne 401 sans session', async () => {
|
||||
mockSession.mockResolvedValue(null)
|
||||
const res = await GET(new Request('http://localhost') as never)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('retourne les produits groupés par onglet', async () => {
|
||||
const res = await GET(new Request('http://localhost') as never)
|
||||
const body = await res.json()
|
||||
expect(body.byTab['OUTILLAGE']).toHaveLength(1)
|
||||
expect(body.byTab['CARRELAGE']).toHaveLength(1)
|
||||
expect(body.total).toBe(2)
|
||||
})
|
||||
|
||||
it('liste les colonnes spécifiques uniques', async () => {
|
||||
const res = await GET(new Request('http://localhost') as never)
|
||||
const body = await res.json()
|
||||
expect(body.specificColumns).toContain('Puissance (AN)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { fetchPendingProducts } from '@/lib/creationSheets'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { PendingProduct } from '@/types/creation'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
const tab = new URL(req.url).searchParams.get('tab') ?? undefined
|
||||
try {
|
||||
const existing = await prisma.productCreation.findMany({ select: { sheetTab: true, sheetRow: true } })
|
||||
const alreadyCreated = new Set(existing.map(e => `${e.sheetTab}:${e.sheetRow}`))
|
||||
const pending = await fetchPendingProducts(alreadyCreated, tab)
|
||||
const byTab: Record<string, PendingProduct[]> = {}
|
||||
const specificColumns = new Set<string>()
|
||||
for (const p of pending) {
|
||||
if (!byTab[p.tab]) byTab[p.tab] = []
|
||||
byTab[p.tab].push(p)
|
||||
Object.keys(p.specificFields).forEach(k => specificColumns.add(k))
|
||||
}
|
||||
return NextResponse.json({ byTab, total: pending.length, specificColumns: Array.from(specificColumns).sort() })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { loadResolutionCache, saveResolutionCache } from '@/lib/resolutionCache'
|
||||
import type { MetafieldResolution } from '@/types/creation'
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
const cache = await loadResolutionCache()
|
||||
return NextResponse.json(cache)
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
const body = await req.json() as Record<string, MetafieldResolution>
|
||||
await saveResolutionCache(body)
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { findColIndex, writeShopifyIdToSheet } from '@/lib/creationSheets'
|
||||
import { getConfig } from '@/lib/shopifySync'
|
||||
|
||||
void writeShopifyIdToSheet // réutilise le même mécanisme d'auth Google
|
||||
|
||||
export async function POST() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (obj: object) => controller.enqueue(new TextEncoder().encode(JSON.stringify(obj) + '\n'))
|
||||
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
const shopifyFetch = async (url: string, opts: RequestInit = {}, retries = 3): Promise<Response> => {
|
||||
const res = await fetch(url, opts)
|
||||
if (res.status === 429 && retries > 0) {
|
||||
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '2', 10)
|
||||
await sleep(retryAfter * 1000)
|
||||
return shopifyFetch(url, opts, retries - 1)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
try {
|
||||
const { baseUrl, headers: shopifyHeaders } = getConfig()
|
||||
|
||||
// Récupérer le location_id
|
||||
const locRes = await shopifyFetch(`${baseUrl}/locations.json`, { headers: shopifyHeaders })
|
||||
if (!locRes.ok) throw new Error(`Shopify locations error: ${locRes.status}`)
|
||||
const locData = await locRes.json() as { locations: Array<{ id: number }> }
|
||||
const locationId = locData.locations[0]?.id
|
||||
if (!locationId) throw new Error('Aucune location Shopify trouvée')
|
||||
|
||||
// Auth Google Sheets
|
||||
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||
const email = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL
|
||||
const rawKey = process.env.GOOGLE_SERVICE_ACCOUNT_KEY
|
||||
if (!sheetId || !email || !rawKey) throw new Error('Credentials Google manquants')
|
||||
const apiKey = process.env.GOOGLE_API_KEY
|
||||
if (!apiKey) throw new Error('GOOGLE_API_KEY manquant')
|
||||
|
||||
const privateKey = rawKey.replace(/\\n/g, '\n')
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url')
|
||||
const payload = Buffer.from(JSON.stringify({
|
||||
iss: email,
|
||||
scope: 'https://www.googleapis.com/auth/spreadsheets',
|
||||
aud: 'https://oauth2.googleapis.com/token',
|
||||
iat: now, exp: now + 3600,
|
||||
})).toString('base64url')
|
||||
const { createSign } = await import('crypto')
|
||||
const sign = createSign('RSA-SHA256')
|
||||
sign.update(`${header}.${payload}`)
|
||||
const signature = sign.sign(privateKey, 'base64url')
|
||||
const jwt = `${header}.${payload}.${signature}`
|
||||
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt }),
|
||||
})
|
||||
if (!tokenRes.ok) throw new Error(`Google token error: ${tokenRes.status}`)
|
||||
const { access_token } = await tokenRes.json() as { access_token: string }
|
||||
|
||||
// Lire tous les onglets
|
||||
const metaRes = await fetch(
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`,
|
||||
)
|
||||
if (!metaRes.ok) throw new Error(`Sheets metadata error: ${metaRes.status}`)
|
||||
const meta = await metaRes.json()
|
||||
const tabs = (meta.sheets as Array<{ properties: { title: string } }>).map(s => s.properties.title)
|
||||
|
||||
type Entry = { shopifyId: string; tab: string; row: number; stockColLetter: string }
|
||||
const entries: Entry[] = []
|
||||
|
||||
for (const tab of tabs) {
|
||||
const range = encodeURIComponent(`${tab}!A1:BZ`)
|
||||
const res = await fetch(`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`)
|
||||
if (!res.ok) continue
|
||||
const data = await res.json()
|
||||
const rows = (data.values ?? []) as string[][]
|
||||
if (rows.length < 2) continue
|
||||
const headerRowIdx = rows.findIndex(r => (r[0] ?? '').trim() === 'ID')
|
||||
if (headerRowIdx === -1) continue
|
||||
const hdrs = rows[headerRowIdx]
|
||||
if (findColIndex(hdrs, 'Publié') === -1) continue
|
||||
|
||||
const stockCdtIdx = findColIndex(hdrs, 'Stock conditionné')
|
||||
const stockIdx = findColIndex(hdrs, 'Stock (en pièce)') !== -1
|
||||
? findColIndex(hdrs, 'Stock (en pièce)')
|
||||
: findColIndex(hdrs, 'Stock (pièce)')
|
||||
|
||||
// Choisir la colonne cible (Stock conditionné en priorité)
|
||||
const targetColIdx = stockCdtIdx !== -1 ? stockCdtIdx : stockIdx
|
||||
if (targetColIdx === -1) continue
|
||||
|
||||
const colLetter = targetColIdx < 26
|
||||
? String.fromCharCode(65 + targetColIdx)
|
||||
: String.fromCharCode(64 + Math.floor(targetColIdx / 26)) + String.fromCharCode(65 + (targetColIdx % 26))
|
||||
|
||||
const get = (row: string[], i: number) => (i >= 0 ? (row[i] ?? '').trim() : '')
|
||||
|
||||
for (let ri = 0; ri < rows.slice(headerRowIdx + 1).length; ri++) {
|
||||
const row = rows[headerRowIdx + 1 + ri]
|
||||
const shopifyId = get(row, 0)
|
||||
if (!shopifyId || !/^\d+$/.test(shopifyId)) continue
|
||||
entries.push({ shopifyId, tab, row: headerRowIdx + 2 + ri, stockColLetter: colLetter })
|
||||
}
|
||||
}
|
||||
|
||||
send({ type: 'total', total: entries.length })
|
||||
|
||||
let updated = 0
|
||||
let errors = 0
|
||||
const batchByTab: Record<string, Array<{ range: string; values: string[][] }>> = {}
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const { shopifyId, tab, row, stockColLetter } = entries[i]
|
||||
send({ type: 'progress', current: i + 1, total: entries.length })
|
||||
try {
|
||||
await sleep(300)
|
||||
const pRes = await shopifyFetch(`${baseUrl}/products/${shopifyId}.json?fields=variants`, { headers: shopifyHeaders })
|
||||
if (pRes.status === 404) continue
|
||||
if (!pRes.ok) { errors++; continue }
|
||||
|
||||
const pData = await pRes.json() as { product: { variants: Array<{ inventory_item_id: number }> } }
|
||||
const inventoryItemId = pData.product.variants[0]?.inventory_item_id
|
||||
if (!inventoryItemId) { errors++; continue }
|
||||
|
||||
await sleep(300)
|
||||
const invRes = await shopifyFetch(
|
||||
`${baseUrl}/inventory_levels.json?inventory_item_ids=${inventoryItemId}&location_ids=${locationId}`,
|
||||
{ headers: shopifyHeaders }
|
||||
)
|
||||
if (!invRes.ok) { errors++; continue }
|
||||
|
||||
const invData = await invRes.json() as { inventory_levels: Array<{ available: number }> }
|
||||
const available = invData.inventory_levels[0]?.available ?? 0
|
||||
|
||||
if (!batchByTab[tab]) batchByTab[tab] = []
|
||||
batchByTab[tab].push({ range: `${tab}!${stockColLetter}${row}`, values: [[String(available)]] })
|
||||
updated++
|
||||
} catch {
|
||||
errors++
|
||||
}
|
||||
}
|
||||
|
||||
// Écrire tous les stocks dans le Sheet en batch par tab
|
||||
const allData = Object.values(batchByTab).flat()
|
||||
if (allData.length > 0) {
|
||||
const batchRes = await fetch(
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values:batchUpdate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${access_token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ valueInputOption: 'RAW', data: allData }),
|
||||
}
|
||||
)
|
||||
if (!batchRes.ok) {
|
||||
const txt = await batchRes.text()
|
||||
throw new Error(`Sheets batchUpdate error: ${batchRes.status} — ${txt}`)
|
||||
}
|
||||
}
|
||||
|
||||
send({ type: 'done', updated, errors })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
console.error('[sync-stock-reverse] fatal:', message)
|
||||
send({ type: 'done', updated: 0, errors: 1, fatalError: message })
|
||||
} finally {
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: { 'Content-Type': 'application/x-ndjson', 'Transfer-Encoding': 'chunked' },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { findColIndex } from '@/lib/creationSheets'
|
||||
import { getConfig } from '@/lib/shopifySync'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
|
||||
const { tab } = await req.json().catch(() => ({})) as { tab?: string }
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (obj: object) => controller.enqueue(new TextEncoder().encode(JSON.stringify(obj) + '\n'))
|
||||
|
||||
try {
|
||||
const { baseUrl, headers } = getConfig()
|
||||
|
||||
// Récupérer le location_id une seule fois
|
||||
const locRes = await fetch(`${baseUrl}/locations.json`, { headers })
|
||||
if (!locRes.ok) throw new Error(`Shopify locations error: ${locRes.status}`)
|
||||
const locData = await locRes.json() as { locations: Array<{ id: number }> }
|
||||
const locationId = locData.locations[0]?.id
|
||||
if (!locationId) throw new Error('Aucune location Shopify trouvée')
|
||||
|
||||
// Lire tous les onglets avec un ID Shopify en colonne A
|
||||
const apiKey = process.env.GOOGLE_API_KEY
|
||||
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||
if (!apiKey || !sheetId) throw new Error('GOOGLE_API_KEY et GOOGLE_SHEETS_ID requis')
|
||||
|
||||
let tabs: string[]
|
||||
if (tab) {
|
||||
tabs = [tab]
|
||||
} else {
|
||||
const metaRes = await fetch(
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`,
|
||||
)
|
||||
if (!metaRes.ok) throw new Error(`Sheets metadata error: ${metaRes.status}`)
|
||||
const meta = await metaRes.json()
|
||||
tabs = (meta.sheets as Array<{ properties: { title: string } }>).map(s => s.properties.title)
|
||||
}
|
||||
|
||||
type StockEntry = { shopifyId: string; stock: number; title: string; tab: string }
|
||||
const entries: StockEntry[] = []
|
||||
|
||||
for (const t of tabs) {
|
||||
const range = encodeURIComponent(`${t}!A1:BZ`)
|
||||
const res = await fetch(`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`)
|
||||
if (!res.ok) continue
|
||||
const data = await res.json()
|
||||
const rows = (data.values ?? []) as string[][]
|
||||
if (rows.length < 2) continue
|
||||
|
||||
const headerRowIdx = rows.findIndex(r => (r[0] ?? '').trim() === 'ID')
|
||||
if (headerRowIdx === -1) continue
|
||||
const hdrs = rows[headerRowIdx]
|
||||
if (findColIndex(hdrs, 'Publié') === -1) continue
|
||||
|
||||
const idIdx = 0
|
||||
const nomIdx = findColIndex(hdrs, 'Nom')
|
||||
const stockIdx = findColIndex(hdrs, 'Stock (en pièce)') !== -1
|
||||
? findColIndex(hdrs, 'Stock (en pièce)')
|
||||
: findColIndex(hdrs, 'Stock (pièce)')
|
||||
const stockCdtIdx = findColIndex(hdrs, 'Stock conditionné')
|
||||
|
||||
const get = (row: string[], i: number) => (i >= 0 ? (row[i] ?? '').trim() : '')
|
||||
|
||||
for (const row of rows.slice(headerRowIdx + 1)) {
|
||||
const shopifyId = get(row, idIdx)
|
||||
if (!shopifyId || !/^\d+$/.test(shopifyId)) continue
|
||||
const rawStock = get(row, stockCdtIdx) || get(row, stockIdx)
|
||||
const stock = parseInt(rawStock || '0', 10) || 0
|
||||
entries.push({ shopifyId, stock, title: get(row, nomIdx), tab: t })
|
||||
}
|
||||
}
|
||||
|
||||
send({ type: 'total', total: entries.length })
|
||||
|
||||
let updated = 0
|
||||
let errors = 0
|
||||
|
||||
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
const shopifyFetch = async (url: string, opts: RequestInit, retries = 3): Promise<Response> => {
|
||||
const res = await fetch(url, opts)
|
||||
if (res.status === 429 && retries > 0) {
|
||||
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '2', 10)
|
||||
await sleep(retryAfter * 1000)
|
||||
return shopifyFetch(url, opts, retries - 1)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const { shopifyId, stock, title } = entries[i]
|
||||
send({ type: 'progress', current: i + 1, total: entries.length, title })
|
||||
try {
|
||||
await sleep(300) // éviter le rate limiting (2 req/s max)
|
||||
|
||||
const pRes = await shopifyFetch(`${baseUrl}/products/${shopifyId}.json?fields=variants`, { headers })
|
||||
if (pRes.status === 404) continue
|
||||
if (!pRes.ok) {
|
||||
send({ type: 'error', title, message: `Produit ${shopifyId} erreur (${pRes.status})` })
|
||||
errors++
|
||||
continue
|
||||
}
|
||||
const pData = await pRes.json() as { product: { variants: Array<{ inventory_item_id: number }> } }
|
||||
const inventoryItemId = pData.product.variants[0]?.inventory_item_id
|
||||
if (!inventoryItemId) { errors++; continue }
|
||||
|
||||
await sleep(300)
|
||||
await shopifyFetch(`${baseUrl}/inventory_levels/connect.json`, {
|
||||
method: 'POST', headers,
|
||||
body: JSON.stringify({ location_id: locationId, inventory_item_id: inventoryItemId }),
|
||||
})
|
||||
|
||||
await sleep(300)
|
||||
const setRes = await shopifyFetch(`${baseUrl}/inventory_levels/set.json`, {
|
||||
method: 'POST', headers,
|
||||
body: JSON.stringify({ location_id: locationId, inventory_item_id: inventoryItemId, available: stock }),
|
||||
})
|
||||
if (!setRes.ok) {
|
||||
const txt = await setRes.text()
|
||||
send({ type: 'error', title, message: `stock set failed: ${setRes.status} — ${txt}` })
|
||||
errors++
|
||||
} else {
|
||||
updated++
|
||||
}
|
||||
} catch (err) {
|
||||
send({ type: 'error', title, message: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
errors++
|
||||
}
|
||||
}
|
||||
|
||||
send({ type: 'done', updated, errors })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
console.error('[sync-stock] fatal:', message)
|
||||
send({ type: 'done', updated: 0, errors: 1, fatalError: message })
|
||||
} finally {
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: { 'Content-Type': 'application/x-ndjson', 'Transfer-Encoding': 'chunked' },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { fetchPendingProducts } from '@/lib/creationSheets'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
|
||||
try {
|
||||
const existing = await prisma.productCreation.findMany({ select: { sheetTab: true, sheetRow: true } })
|
||||
const alreadyCreated = new Set(existing.map((e) => `${e.sheetTab}:${e.sheetRow}`))
|
||||
const pending = await fetchPendingProducts(alreadyCreated)
|
||||
return NextResponse.json({ count: pending.length })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
const API_VERSION = '2024-01'
|
||||
|
||||
function getShopifyConfig() {
|
||||
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||
if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
|
||||
return {
|
||||
baseUrl: `https://${domain}/admin/api/${API_VERSION}`,
|
||||
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
|
||||
}
|
||||
}
|
||||
|
||||
function extractNextUrl(linkHeader: string | null): string | null {
|
||||
if (!linkHeader) return null
|
||||
const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
interface LightProduct {
|
||||
id: number
|
||||
status: string
|
||||
images: Array<{ id: number }>
|
||||
}
|
||||
|
||||
async function fetchProductsLight(status: 'active' | 'draft'): Promise<LightProduct[]> {
|
||||
const { baseUrl, headers } = getShopifyConfig()
|
||||
const all: LightProduct[] = []
|
||||
let url: string | null =
|
||||
`${baseUrl}/products.json?limit=250&fields=id,status,images&status=${status}`
|
||||
|
||||
while (url) {
|
||||
const res = await fetch(url, { headers, cache: 'no-store' })
|
||||
if (!res.ok) throw new Error(`Shopify fetch error (${status}): ${res.status}`)
|
||||
const data = await res.json()
|
||||
all.push(...(data.products as LightProduct[]))
|
||||
url = extractNextUrl(res.headers.get('Link'))
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
async function fetchSheetModifiedTime(): Promise<string | null> {
|
||||
const apiKey = process.env.GOOGLE_API_KEY
|
||||
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||
if (!apiKey || !sheetId) return null
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files/${sheetId}?fields=modifiedTime&key=${apiKey}`,
|
||||
{ cache: 'no-store' },
|
||||
)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return (data.modifiedTime as string) ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
|
||||
try {
|
||||
const [activeProducts, draftProducts, lastSyncRun, sheetModifiedTime] = await Promise.all([
|
||||
fetchProductsLight('active'),
|
||||
fetchProductsLight('draft'),
|
||||
prisma.syncRun.findFirst({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { user: { select: { name: true } } },
|
||||
}),
|
||||
fetchSheetModifiedTime(),
|
||||
])
|
||||
|
||||
const allProducts = [...activeProducts, ...draftProducts]
|
||||
const withoutImages = allProducts.filter((p) => p.images.length === 0).length
|
||||
|
||||
const lastSync = lastSyncRun
|
||||
? {
|
||||
createdAt: lastSyncRun.createdAt.toISOString(),
|
||||
userName: lastSyncRun.user.name,
|
||||
status: lastSyncRun.status,
|
||||
created: lastSyncRun.created,
|
||||
updated: lastSyncRun.updated,
|
||||
deactivated: lastSyncRun.deactivated,
|
||||
errors: lastSyncRun.errors,
|
||||
}
|
||||
: null
|
||||
|
||||
// Drift : Sheets modifié après la dernière sync réussie
|
||||
const sheetDrift =
|
||||
sheetModifiedTime !== null &&
|
||||
lastSyncRun !== null &&
|
||||
new Date(sheetModifiedTime) > lastSyncRun.createdAt
|
||||
|
||||
return NextResponse.json({
|
||||
products: {
|
||||
active: activeProducts.length,
|
||||
draft: draftProducts.length,
|
||||
total: allProducts.length,
|
||||
withoutImages,
|
||||
},
|
||||
lastSync,
|
||||
sheetDrift,
|
||||
sheetModifiedAt: sheetModifiedTime,
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import convert from 'heic-convert'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
|
||||
try {
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
if (!file) return NextResponse.json({ error: 'Fichier manquant' }, { status: 400 })
|
||||
|
||||
const inputBuffer = Buffer.from(await file.arrayBuffer())
|
||||
// heic-convert utilise libheif WASM — supporte HEIC/HEIF sur tous les environnements
|
||||
const outputBuffer = await convert({
|
||||
buffer: inputBuffer,
|
||||
format: 'JPEG',
|
||||
quality: 0.92,
|
||||
})
|
||||
|
||||
const jpeg = Buffer.from(outputBuffer)
|
||||
return new NextResponse(jpeg.buffer as ArrayBuffer, {
|
||||
headers: {
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Content-Length': String(jpeg.length),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur conversion'
|
||||
console.error('[convert-heic API]', message)
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return new Response('Non authentifié', { status: 401 })
|
||||
|
||||
const url = req.nextUrl.searchParams.get('url')
|
||||
if (!url) return new Response('url requis', { status: 400 })
|
||||
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) return new Response('Fetch failed', { status: 502 })
|
||||
|
||||
const buffer = await res.arrayBuffer()
|
||||
const contentType = res.headers.get('content-type') ?? 'image/jpeg'
|
||||
return new Response(buffer, { headers: { 'Content-Type': contentType } })
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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'
|
||||
const status = (err as { status?: number }).status === 404 ? 404 : 500
|
||||
return NextResponse.json({ error: message }, { status })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { getTemplateImagePath, listTemplates } from '@/lib/mockupTemplates'
|
||||
import type { MockupGenerateRequest } from '@/types/mockup'
|
||||
|
||||
function buildPrompt(req: MockupGenerateRequest): string {
|
||||
if (req.family === 'Carrelage') {
|
||||
let surface = 'floor or wall'
|
||||
let restriction = ''
|
||||
if (req.placement === 'mur') {
|
||||
surface = 'wall'
|
||||
restriction = ' Apply tiles only to the wall surface, not the floor.'
|
||||
} else if (req.placement === 'sol') {
|
||||
surface = 'floor'
|
||||
restriction = ' Apply tiles only to the floor surface, not the walls.'
|
||||
}
|
||||
const dimClause = req.dimensions
|
||||
? ` The real-world tile size is ${req.dimensions}.`
|
||||
: ''
|
||||
const instructionClause = req.instruction ? ` Additional instruction: ${req.instruction}.` : ''
|
||||
|
||||
return `Replace the ${surface} covering in the reference scene with the provided tile image (${req.productName}).${dimClause}${restriction} CRITICAL RULES — you MUST follow all of them:
|
||||
1. Every single tile in the entire image must be EXACTLY the same size — no exceptions anywhere in the scene.
|
||||
2. The tile pattern must be a perfectly uniform grid: identical tile dimensions repeated consistently across the entire ${surface}.
|
||||
3. Tiles may be partially cut at edges, corners, or where the ${surface} meets other surfaces — but each visible portion must match the same grid unit size.
|
||||
4. NEVER vary the tile scale or apparent size in different parts of the image, even for artistic or perspective reasons.
|
||||
5. Copy the tile design, texture, color, and proportions from the provided image exactly — do not alter them.
|
||||
6. Maintain realistic lighting and perspective consistent with the reference scene.${instructionClause}`
|
||||
}
|
||||
|
||||
const familyContext: Record<string, string> = {
|
||||
'Menuiserie': 'door installed in a residential entrance or hallway, visible wall and floor context',
|
||||
'Baies vitrées': 'bay window or sliding door installed in a living room with garden view',
|
||||
}
|
||||
const context = familyContext[req.family] ?? 'product installed in an appropriate room setting'
|
||||
const dimClause = req.dimensions
|
||||
? ` The product dimensions are exactly ${req.dimensions} — preserve these exact proportions, do NOT resize or distort the product to fit the scene.`
|
||||
: ''
|
||||
const instructionClause = req.instruction ? ` Additional instruction: ${req.instruction}.` : ''
|
||||
|
||||
return `Replace the product in the reference scene with the provided product image (${req.productName}). Show the ${context}.${dimClause} Keep the product's exact design, texture, colors and proportions identical to the provided image. Maintain realistic perspective and lighting consistent with the scene.${instructionClause}`
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return Response.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
if (!apiKey) return Response.json({ error: 'OPENAI_API_KEY manquant' }, { status: 500 })
|
||||
|
||||
const body = await req.json() as MockupGenerateRequest
|
||||
|
||||
const templates = await listTemplates()
|
||||
const template = templates.find(t => t.id === body.templateId)
|
||||
if (!template) return Response.json({ error: 'Template introuvable' }, { status: 404 })
|
||||
|
||||
const templateBuffer = await readFile(getTemplateImagePath(template.filename))
|
||||
const templateExt = template.filename.split('.').pop() ?? 'jpg'
|
||||
const templateMime = templateExt === 'png' ? 'image/png' : 'image/jpeg'
|
||||
|
||||
const productBuffer = Buffer.from(body.productImageBase64, 'base64')
|
||||
|
||||
const form = new FormData()
|
||||
form.append('model', 'gpt-image-1')
|
||||
form.append('prompt', buildPrompt(body))
|
||||
form.append('n', '1')
|
||||
form.append('size', '1024x1024')
|
||||
form.append('image[]', new Blob([templateBuffer], { type: templateMime }), `template.${templateExt}`)
|
||||
form.append('image[]', new Blob([productBuffer], { type: 'image/png' }), 'product.png')
|
||||
|
||||
const openaiRes = await fetch('https://api.openai.com/v1/images/edits', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
body: form,
|
||||
})
|
||||
|
||||
if (!openaiRes.ok) {
|
||||
const err = await openaiRes.text()
|
||||
return Response.json({ error: `OpenAI error: ${err}` }, { status: 502 })
|
||||
}
|
||||
|
||||
const data = await openaiRes.json() as { data: Array<{ b64_json?: string }> }
|
||||
const b64 = data.data?.[0]?.b64_json
|
||||
if (!b64) return Response.json({ error: 'Pas de résultat OpenAI' }, { status: 502 })
|
||||
|
||||
return Response.json({ imageBase64: b64 })
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
|
||||
const API_VERSION = '2024-01'
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: { params: { shopifyId: string } }) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return Response.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
|
||||
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||
if (!domain || !token) return Response.json({ error: 'Config Shopify manquante' }, { status: 500 })
|
||||
|
||||
const res = await fetch(
|
||||
`https://${domain}/admin/api/${API_VERSION}/products/${params.shopifyId}/metafields.json`,
|
||||
{ headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' } },
|
||||
)
|
||||
if (!res.ok) return Response.json({ error: `Shopify error: ${res.status}` }, { status: 502 })
|
||||
|
||||
const data = await res.json()
|
||||
const metafields = (data.metafields ?? []) as Array<{
|
||||
namespace: string
|
||||
key: string
|
||||
value: string
|
||||
type: string
|
||||
}>
|
||||
|
||||
return Response.json({ metafields })
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { deleteTemplate } from '@/lib/mockupTemplates'
|
||||
|
||||
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return Response.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
await deleteTemplate(params.id)
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { readFile } from 'fs/promises'
|
||||
import { getTemplateImagePath } from '@/lib/mockupTemplates'
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: { filename: string } }) {
|
||||
try {
|
||||
const filePath = getTemplateImagePath(params.filename)
|
||||
const buffer = await readFile(filePath)
|
||||
const ext = params.filename.split('.').pop() ?? 'jpg'
|
||||
const contentType = ext === 'png' ? 'image/png' : 'image/jpeg'
|
||||
return new Response(buffer, { headers: { 'Content-Type': contentType } })
|
||||
} catch {
|
||||
return new Response('Not found', { status: 404 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { listTemplates, saveTemplate } from '@/lib/mockupTemplates'
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return Response.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
const templates = await listTemplates()
|
||||
return Response.json(templates)
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return Response.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
|
||||
const form = await req.formData()
|
||||
const name = form.get('name') as string
|
||||
const family = form.get('family') as string
|
||||
const placement = (form.get('placement') as string) || undefined
|
||||
const description = (form.get('description') as string) ?? ''
|
||||
const imageFile = form.get('image') as File
|
||||
|
||||
if (!name || !family || !imageFile) {
|
||||
return Response.json({ error: 'name, family et image requis' }, { status: 400 })
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await imageFile.arrayBuffer())
|
||||
const ext = imageFile.name.split('.').pop() ?? 'jpg'
|
||||
const template = await saveTemplate({ name, family, placement, description, filename: '' }, buffer, ext)
|
||||
return Response.json(template)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { deleteShopifyProduct } from '@/lib/shopifySync'
|
||||
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: { id: string } },
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
|
||||
try {
|
||||
await deleteShopifyProduct(params.id)
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
|
||||
import type { ShopifyProductFull } from '@/types/sync'
|
||||
|
||||
export interface DuplicateGroup {
|
||||
ref: string
|
||||
products: Array<{
|
||||
shopifyId: string
|
||||
title: string
|
||||
status: string
|
||||
handle: string
|
||||
shopifyUrl: string
|
||||
}>
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
|
||||
try {
|
||||
const domain = process.env.SHOPIFY_STORE_DOMAIN ?? ''
|
||||
const all = await fetchAllShopifyProducts()
|
||||
|
||||
// Grouper par ref (SKU)
|
||||
const byRef = new Map<string, ShopifyProductFull[]>()
|
||||
for (const p of all) {
|
||||
const key = p.ref.trim().toLowerCase()
|
||||
if (!byRef.has(key)) byRef.set(key, [])
|
||||
byRef.get(key)!.push(p)
|
||||
}
|
||||
|
||||
const groups: DuplicateGroup[] = []
|
||||
for (const products of Array.from(byRef.values())) {
|
||||
if (products.length < 2) continue
|
||||
// Trier : le plus ancien (ID numérique le plus petit) en premier
|
||||
products.sort((a: ShopifyProductFull, b: ShopifyProductFull) => Number(a.shopifyId) - Number(b.shopifyId))
|
||||
groups.push({
|
||||
ref: products[0].ref,
|
||||
products: products.map((p: ShopifyProductFull) => ({
|
||||
shopifyId: p.shopifyId,
|
||||
title: p.title,
|
||||
status: p.status,
|
||||
handle: p.handle,
|
||||
shopifyUrl: `https://${domain}/admin/products/${p.shopifyId}`,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// Trier les groupes par ref
|
||||
groups.sort((a, b) => a.ref.localeCompare(b.ref))
|
||||
|
||||
return NextResponse.json({ groups, total: groups.length })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { GET } from './route'
|
||||
import * as shopifySync from '@/lib/shopifySync'
|
||||
import type { ShopifyProductFull } from '@/types/sync'
|
||||
|
||||
jest.mock('@/lib/shopifySync')
|
||||
const mockFetch = shopifySync.fetchAllShopifyProducts as jest.MockedFunction<typeof shopifySync.fetchAllShopifyProducts>
|
||||
|
||||
const PRODUCTS: ShopifyProductFull[] = [
|
||||
{ shopifyId: '1', ref: 'REF-001', handle: 'ref-001', title: 'Brique rouge', description: '', price: '12.50', stock: 100, status: 'active' },
|
||||
{ shopifyId: '2', ref: 'REF-002', handle: 'ref-002', title: 'Parpaing', description: '', price: '5.00', stock: 0, status: 'draft' },
|
||||
{ shopifyId: '3', ref: 'REF-003', handle: 'ref-003', title: 'Ardoise', description: '', price: '80.00', stock: 20, status: 'active' },
|
||||
]
|
||||
|
||||
describe('GET /api/products/list', () => {
|
||||
beforeEach(() => {
|
||||
process.env.SHOPIFY_STORE_DOMAIN = 'test.myshopify.com'
|
||||
process.env.SHOPIFY_ADMIN_API_TOKEN = 'token'
|
||||
mockFetch.mockResolvedValue(PRODUCTS)
|
||||
})
|
||||
afterEach(() => jest.clearAllMocks())
|
||||
|
||||
it('retourne tous les produits sans filtre', async () => {
|
||||
const req = new Request('http://localhost/api/products/list')
|
||||
const res = await GET(req as never)
|
||||
const body = await res.json()
|
||||
expect(body.total).toBe(3)
|
||||
expect(body.products).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('filtre par status=active', async () => {
|
||||
const req = new Request('http://localhost/api/products/list?status=active')
|
||||
const res = await GET(req as never)
|
||||
const body = await res.json()
|
||||
expect(body.total).toBe(2)
|
||||
body.products.forEach((p: { status: string }) => expect(p.status).toBe('active'))
|
||||
})
|
||||
|
||||
it('filtre par status=draft', async () => {
|
||||
const req = new Request('http://localhost/api/products/list?status=draft')
|
||||
const res = await GET(req as never)
|
||||
const body = await res.json()
|
||||
expect(body.total).toBe(1)
|
||||
expect(body.products[0].ref).toBe('REF-002')
|
||||
})
|
||||
|
||||
it('retourne shopifyUrl pour chaque produit', async () => {
|
||||
const req = new Request('http://localhost/api/products/list')
|
||||
const res = await GET(req as never)
|
||||
const body = await res.json()
|
||||
expect(body.products[0].shopifyUrl).toContain('test.myshopify.com/admin/products/1')
|
||||
})
|
||||
|
||||
it('retourne 500 si Shopify inaccessible', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('network error'))
|
||||
const req = new Request('http://localhost/api/products/list')
|
||||
const res = await GET(req as never)
|
||||
expect(res.status).toBe(500)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
|
||||
import type { CatalogProduct, ProductListResponse } from '@/types/products'
|
||||
|
||||
export async function GET(req: NextRequest): Promise<NextResponse<ProductListResponse | { error: string }>> {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const statusFilter = searchParams.get('status') ?? 'all'
|
||||
|
||||
const domain = process.env.SHOPIFY_STORE_DOMAIN ?? ''
|
||||
const shopifyProducts = await fetchAllShopifyProducts()
|
||||
|
||||
let products: CatalogProduct[] = shopifyProducts.map((p) => ({
|
||||
shopifyId: p.shopifyId,
|
||||
ref: p.ref,
|
||||
handle: p.handle,
|
||||
title: p.title,
|
||||
price: p.price,
|
||||
stock: p.stock,
|
||||
status: p.status as CatalogProduct['status'],
|
||||
shopifyUrl: `https://${domain}/admin/products/${p.shopifyId}`,
|
||||
images: p.images ?? [],
|
||||
}))
|
||||
|
||||
if (statusFilter !== 'all') {
|
||||
products = products.filter((p) => p.status === statusFilter)
|
||||
}
|
||||
|
||||
return NextResponse.json({ products, total: products.length })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const API_VERSION = '2024-01'
|
||||
|
||||
// GET /api/products/search-list?q=peinture
|
||||
// Retourne jusqu'à 10 produits correspondant à la recherche (titre ou SKU)
|
||||
export async function GET(req: NextRequest) {
|
||||
const q = new URL(req.url).searchParams.get('q')?.trim()
|
||||
if (!q || q.length < 2) return NextResponse.json([])
|
||||
|
||||
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||
if (!domain || !token) return NextResponse.json([], { status: 500 })
|
||||
|
||||
const headers = { 'X-Shopify-Access-Token': token }
|
||||
const baseUrl = `https://${domain}/admin/api/${API_VERSION}`
|
||||
|
||||
try {
|
||||
// Recherche par titre
|
||||
const res = await fetch(
|
||||
`${baseUrl}/products.json?title=${encodeURIComponent(q)}&limit=10&fields=id,title,variants`,
|
||||
{ headers },
|
||||
)
|
||||
if (!res.ok) return NextResponse.json([])
|
||||
const data = await res.json()
|
||||
const products = (data.products ?? []) as Array<{
|
||||
id: number
|
||||
title: string
|
||||
variants: Array<{ sku: string }>
|
||||
}>
|
||||
|
||||
const results = products.map(p => ({
|
||||
shopifyId: String(p.id),
|
||||
title: p.title,
|
||||
sku: p.variants[0]?.sku ?? '',
|
||||
}))
|
||||
|
||||
return NextResponse.json(results)
|
||||
} catch {
|
||||
return NextResponse.json([])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
|
||||
/**
|
||||
* Cherche un produit dans Google Sheets par sa référence (UGS, col C).
|
||||
* Retourne le Shopify ID (col A) et le titre (col D) si trouvé.
|
||||
* Parcourt tous les onglets.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ found: false }, { status: 401 })
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const ref = searchParams.get('ref')?.trim()
|
||||
if (!ref) return NextResponse.json({ found: false }, { status: 400 })
|
||||
|
||||
const apiKey = process.env.GOOGLE_API_KEY
|
||||
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||
if (!apiKey || !sheetId) {
|
||||
return NextResponse.json({ found: false, error: 'GOOGLE_API_KEY et GOOGLE_SHEETS_ID requis' }, { status: 500 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Récupère la liste des onglets
|
||||
const metaRes = await fetch(
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`,
|
||||
)
|
||||
if (!metaRes.ok) throw new Error(`Sheets metadata error: ${metaRes.status}`)
|
||||
const meta = await metaRes.json()
|
||||
const tabs: string[] = (meta.sheets as Array<{ properties: { title: string } }>).map(
|
||||
s => s.properties.title,
|
||||
)
|
||||
|
||||
let bestMatch: { shopifyId: string; title: string; ugsLength: number } | null = null
|
||||
|
||||
for (const tab of tabs) {
|
||||
const range = encodeURIComponent(`${tab}!A2:D`)
|
||||
const res = await fetch(
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`,
|
||||
)
|
||||
if (!res.ok) continue
|
||||
const data = await res.json()
|
||||
const rows = (data.values ?? []) as string[][]
|
||||
|
||||
for (const row of rows) {
|
||||
const shopifyId = (row[0] ?? '').trim()
|
||||
const ugs = (row[2] ?? '').trim()
|
||||
|
||||
const refLower = ref.toLowerCase()
|
||||
const ugsLower = ugs.toLowerCase()
|
||||
const wordBoundary = new RegExp(`(^|[^a-z0-9])${ugsLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`)
|
||||
// Ne requiert pas shopifyId pour le match UGS — ID peut être absent si produit externe
|
||||
const isMatch = ugs && wordBoundary.test(refLower)
|
||||
|
||||
if (isMatch && shopifyId) {
|
||||
const title = (row[3] ?? '').trim()
|
||||
if (!bestMatch || ugs.length > bestMatch.ugsLength) {
|
||||
bestMatch = { shopifyId, title, ugsLength: ugs.length }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch) {
|
||||
return NextResponse.json({ found: true, shopifyId: bestMatch.shopifyId, title: bestMatch.title })
|
||||
}
|
||||
return NextResponse.json({ found: false })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur'
|
||||
return NextResponse.json({ found: false, error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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())
|
||||
console.log(`[search] ref="${ref.trim()}" → found=${result.found} id=${(result as {shopifyId?: string}).shopifyId ?? '-'}`)
|
||||
return NextResponse.json(result)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
console.error(`[search] ref="${ref.trim()}" → ERROR: ${message}`)
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { readSheetProducts } from '@/lib/googleSheets'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
|
||||
const ref = new URL(req.url).searchParams.get('ref')
|
||||
if (!ref) return NextResponse.json({ error: 'Paramètre ref manquant' }, { status: 400 })
|
||||
|
||||
const products = await readSheetProducts()
|
||||
const product = products.find(p => p.ref.toLowerCase() === ref.toLowerCase())
|
||||
|
||||
if (!product) return NextResponse.json({ error: 'Produit non trouvé' }, { status: 404 })
|
||||
|
||||
return NextResponse.json({ product })
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
|
||||
const apiKey = process.env.GOOGLE_API_KEY
|
||||
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||
if (!apiKey || !sheetId) {
|
||||
return NextResponse.json({ error: 'GOOGLE_API_KEY et GOOGLE_SHEETS_ID requis' }, { status: 500 })
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`
|
||||
const res = await fetch(url, { cache: 'no-store' })
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
throw new Error(`Google Sheets API error: ${res.status} — ${err}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const tabs: string[] = (data.sheets as Array<{ properties: { title: string } }>).map(
|
||||
(s) => s.properties.title,
|
||||
)
|
||||
return NextResponse.json({ tabs })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -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,33 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { SyncHistoryEntry, SyncLogEntry } from '@/types/sync'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export async function GET(_req: NextRequest) {
|
||||
try {
|
||||
const runs = await prisma.syncRun.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
include: { user: { select: { name: true } } },
|
||||
})
|
||||
|
||||
const entries: SyncHistoryEntry[] = runs.map((run) => ({
|
||||
id: run.id,
|
||||
createdAt: run.createdAt.toISOString(),
|
||||
userName: run.user.name,
|
||||
status: run.status as SyncHistoryEntry['status'],
|
||||
created: run.created,
|
||||
updated: run.updated,
|
||||
deactivated: run.deactivated,
|
||||
errors: run.errors,
|
||||
log: (() => {
|
||||
try { return JSON.parse(run.log) as SyncLogEntry[] } catch { return [] }
|
||||
})(),
|
||||
}))
|
||||
|
||||
return NextResponse.json(entries)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { readSheetProducts } from '@/lib/googleSheets'
|
||||
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
|
||||
import { computeDiff } from '@/lib/syncDiff'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const tab = new URL(req.url).searchParams.get('tab') ?? undefined
|
||||
try {
|
||||
const [sheetProducts, shopifyProducts] = await Promise.all([
|
||||
readSheetProducts(tab),
|
||||
fetchAllShopifyProducts(),
|
||||
])
|
||||
|
||||
const diff = computeDiff(sheetProducts, shopifyProducts, tab)
|
||||
return NextResponse.json(diff)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const API_VERSION = '2024-01'
|
||||
|
||||
function getShopifyConfig() {
|
||||
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||
if (!domain || !token) throw new Error('Config Shopify manquante')
|
||||
return {
|
||||
baseUrl: `https://${domain}/admin/api/${API_VERSION}`,
|
||||
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
|
||||
}
|
||||
}
|
||||
|
||||
export interface BannerProductData {
|
||||
id: string
|
||||
title: string
|
||||
price: string
|
||||
compareAtPrice: string | null
|
||||
sku: string
|
||||
imageUrl: string | null
|
||||
allImages: string[]
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const ref = searchParams.get('ref')
|
||||
const id = searchParams.get('id')
|
||||
|
||||
try {
|
||||
const { baseUrl, headers } = getShopifyConfig()
|
||||
|
||||
let product: Record<string, unknown> | null = null
|
||||
|
||||
if (id) {
|
||||
const res = await fetch(`${baseUrl}/products/${id}.json?fields=id,title,variants,images`, { headers })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
product = data.product
|
||||
}
|
||||
} else if (ref) {
|
||||
// Recherche par SKU via GraphQL
|
||||
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||
const gqlRes = await fetch(`https://${domain}/admin/api/${API_VERSION}/graphql.json`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
query: `{ productVariants(first: 5, query: "sku:${ref.replace(/"/g, '')}") {
|
||||
edges { node { sku product { id legacyResourceId title } } }
|
||||
} }`,
|
||||
}),
|
||||
})
|
||||
const gqlData = await gqlRes.json()
|
||||
const edges = gqlData?.data?.productVariants?.edges ?? []
|
||||
const match = edges.find((e: { node: { sku: string } }) =>
|
||||
e.node.sku.toLowerCase() === ref.toLowerCase()
|
||||
)
|
||||
if (match) {
|
||||
const legacyId = match.node.product.legacyResourceId
|
||||
const res = await fetch(`${baseUrl}/products/${legacyId}.json?fields=id,title,variants,images`, { headers })
|
||||
if (res.ok) product = (await res.json()).product
|
||||
}
|
||||
}
|
||||
|
||||
if (!product) return NextResponse.json({ error: 'Produit introuvable' }, { status: 404 })
|
||||
|
||||
const variants = product.variants as Array<{ price: string; compare_at_price: string | null; sku: string }>
|
||||
const images = product.images as Array<{ src: string }>
|
||||
const variant = variants[0]
|
||||
|
||||
const result: BannerProductData = {
|
||||
id: String(product.id),
|
||||
title: product.title as string,
|
||||
price: variant?.price ?? '',
|
||||
compareAtPrice: variant?.compare_at_price ?? null,
|
||||
sku: variant?.sku ?? '',
|
||||
imageUrl: images[0]?.src ?? null,
|
||||
allImages: images.map(i => i.src),
|
||||
}
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: (err as Error).message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
getActiveThemeId,
|
||||
getHomepageTemplate,
|
||||
extractSlideshow,
|
||||
applySlideshow,
|
||||
saveHomepageTemplate,
|
||||
} from '@/lib/shopifyTheme'
|
||||
import type { SlideshowSection } from '@/lib/shopifyTheme'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const themeId = await getActiveThemeId()
|
||||
const template = await getHomepageTemplate(themeId)
|
||||
const slideshow = extractSlideshow(template)
|
||||
if (!slideshow) return NextResponse.json({ error: 'Section slideshow-custom introuvable' }, { status: 404 })
|
||||
return NextResponse.json({ slideshow, themeId })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: (err as Error).message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as { slideshow: SlideshowSection; themeId: string }
|
||||
const template = await getHomepageTemplate(body.themeId)
|
||||
const updated = applySlideshow(template, body.slideshow)
|
||||
await saveHomepageTemplate(body.themeId, updated)
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: (err as Error).message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { uploadFileToShopify } from '@/lib/shopifyTheme'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { imageBase64, filename } = await req.json() as { imageBase64: string; filename: string }
|
||||
if (!imageBase64 || !filename) {
|
||||
return NextResponse.json({ error: 'imageBase64 et filename requis' }, { status: 400 })
|
||||
}
|
||||
const shopifyRef = await uploadFileToShopify(imageBase64, filename)
|
||||
return NextResponse.json({ shopifyRef })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: (err as Error).message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
+22
-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,27 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="fr">
|
||||
<body className={inter.className}>{children}</body>
|
||||
<head>
|
||||
{/* Import map requis par @imgly/background-removal chargé depuis CDN.
|
||||
Doit être placé avant tout script module — Next.js le respecte car
|
||||
le layout est rendu côté serveur avant injection des chunks. */}
|
||||
<script
|
||||
type="importmap"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
imports: {
|
||||
'onnxruntime-web': 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.21.0/dist/ort.all.min.mjs',
|
||||
'onnxruntime-web/webgpu': 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.21.0/dist/ort.webgpu.min.mjs',
|
||||
'onnxruntime-web/wasm': 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.21.0/dist/ort.wasm.min.mjs',
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className={inter.className}>
|
||||
<SessionProvider>{children}</SessionProvider>
|
||||
</body>
|
||||
</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,417 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { COLOR_SCHEMES, renderBanner } from '@/lib/client/bannerRenderer'
|
||||
import type { BannerConfig, ColorScheme } from '@/lib/client/bannerRenderer'
|
||||
import type { BannerProductData } from '@/app/api/theme/product-banner-data/route'
|
||||
|
||||
interface Props {
|
||||
onApply: (shopifyRef: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface SearchSuggestion {
|
||||
shopifyId: string
|
||||
title: string
|
||||
sku: string
|
||||
}
|
||||
|
||||
function formatPrice(price: string): string {
|
||||
if (!price) return ''
|
||||
const n = parseFloat(price)
|
||||
return isNaN(n) ? price : `${Math.round(n)} €`
|
||||
}
|
||||
|
||||
function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve((reader.result as string).split(',')[1])
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
export function BannerBuilder({ onApply, onClose }: Props) {
|
||||
// Combobox produit
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([])
|
||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
|
||||
const [loadingProduct, setLoadingProduct] = useState(false)
|
||||
const [product, setProduct] = useState<BannerProductData | null>(null)
|
||||
const [searchError, setSearchError] = useState<string | null>(null)
|
||||
const comboRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Banner config
|
||||
const [scheme, setScheme] = useState<ColorScheme>('dark-blue')
|
||||
const [promoLabel, setPromoLabel] = useState('Destockage - Durée limitée')
|
||||
const [title, setTitle] = useState('')
|
||||
const [price, setPrice] = useState('')
|
||||
const [comparePrice, setComparePrice] = useState('')
|
||||
const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>(null)
|
||||
const [backgroundBlob, setBackgroundBlob] = useState<Blob | null>(null)
|
||||
const [backgroundPreview, setBackgroundPreview] = useState<string | null>(null)
|
||||
const bgFileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Generation
|
||||
const [rendering, setRendering] = useState(false)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const previewRef = useRef<string | null>(null)
|
||||
|
||||
// Ferme les suggestions si clic extérieur
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (comboRef.current && !comboRef.current.contains(e.target as Node)) {
|
||||
setShowSuggestions(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
// Recherche des suggestions (debounce 300ms)
|
||||
useEffect(() => {
|
||||
const q = inputValue.trim()
|
||||
if (q.length < 2) { setSuggestions([]); setShowSuggestions(false); return }
|
||||
const t = setTimeout(async () => {
|
||||
setLoadingSuggestions(true)
|
||||
try {
|
||||
const res = await fetch(`/api/products/search-list?q=${encodeURIComponent(q)}`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json() as SearchSuggestion[]
|
||||
setSuggestions(data)
|
||||
setShowSuggestions(data.length > 0)
|
||||
} catch { /* silencieux */ } finally {
|
||||
setLoadingSuggestions(false)
|
||||
}
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [inputValue])
|
||||
|
||||
const selectSuggestion = async (s: SearchSuggestion) => {
|
||||
setInputValue(s.title)
|
||||
setShowSuggestions(false)
|
||||
setSuggestions([])
|
||||
setLoadingProduct(true)
|
||||
setSearchError(null)
|
||||
setProduct(null)
|
||||
setPreviewUrl(null)
|
||||
try {
|
||||
const res = await fetch(`/api/theme/product-banner-data?id=${s.shopifyId}`)
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error)
|
||||
const p = data as BannerProductData
|
||||
setProduct(p)
|
||||
setTitle(p.title.toUpperCase())
|
||||
setPrice(formatPrice(p.price))
|
||||
setComparePrice(p.compareAtPrice ? formatPrice(p.compareAtPrice) : '')
|
||||
setSelectedImageUrl(p.imageUrl)
|
||||
} catch (e) {
|
||||
setSearchError((e as Error).message)
|
||||
} finally {
|
||||
setLoadingProduct(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBgFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setBackgroundBlob(file)
|
||||
const url = URL.createObjectURL(file)
|
||||
if (backgroundPreview) URL.revokeObjectURL(backgroundPreview)
|
||||
setBackgroundPreview(url)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const removeBg = () => {
|
||||
if (backgroundPreview) URL.revokeObjectURL(backgroundPreview)
|
||||
setBackgroundBlob(null)
|
||||
setBackgroundPreview(null)
|
||||
}
|
||||
|
||||
const getConfig = useCallback((): BannerConfig => ({
|
||||
scheme,
|
||||
promoLabel,
|
||||
title,
|
||||
price,
|
||||
comparePrice,
|
||||
productImageUrl: selectedImageUrl,
|
||||
backgroundImageBlob: backgroundBlob,
|
||||
}), [scheme, promoLabel, title, price, comparePrice, selectedImageUrl, backgroundBlob])
|
||||
|
||||
const generate = useCallback(async () => {
|
||||
setRendering(true)
|
||||
setError(null)
|
||||
try {
|
||||
const blob = await renderBanner(getConfig())
|
||||
const url = URL.createObjectURL(blob)
|
||||
if (previewRef.current) URL.revokeObjectURL(previewRef.current)
|
||||
previewRef.current = url
|
||||
setPreviewUrl(url)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setRendering(false)
|
||||
}
|
||||
}, [getConfig])
|
||||
|
||||
// Régénère automatiquement quand les paramètres changent (debounce 600ms)
|
||||
useEffect(() => {
|
||||
if (!title && !product) return
|
||||
const t = setTimeout(() => { generate() }, 600)
|
||||
return () => clearTimeout(t)
|
||||
}, [scheme, promoLabel, title, price, comparePrice, selectedImageUrl, backgroundBlob]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const applyToSlide = async () => {
|
||||
if (!previewRef.current) return
|
||||
setUploading(true)
|
||||
setError(null)
|
||||
try {
|
||||
// Convertit l'objectURL en blob
|
||||
const res = await fetch(previewRef.current)
|
||||
const blob = await res.blob()
|
||||
const base64 = await blobToBase64(blob)
|
||||
const filename = `banniere-${(product?.sku ?? 'custom').toLowerCase().replace(/[^a-z0-9]/g, '-')}-${Date.now()}.jpg`
|
||||
|
||||
const uploadRes = await fetch('/api/theme/upload-file', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ imageBase64: base64, filename }),
|
||||
})
|
||||
const uploadData = await uploadRes.json()
|
||||
if (!uploadRes.ok) throw new Error(uploadData.error)
|
||||
onApply(uploadData.shopifyRef)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const schemeEntries = Object.entries(COLOR_SCHEMES) as [ColorScheme, typeof COLOR_SCHEMES[ColorScheme]][]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div className="bg-slate-800 border border-slate-700 rounded-xl w-full max-w-5xl max-h-[95vh] flex flex-col">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-700">
|
||||
<h2 className="text-white font-semibold text-sm">Créer une bannière</h2>
|
||||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-white text-lg leading-none">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* ── Panneau gauche : configuration ─────────────────────────────── */}
|
||||
<div className="w-80 flex-shrink-0 overflow-y-auto border-r border-slate-700 p-4 space-y-5">
|
||||
|
||||
{/* Recherche produit — combobox */}
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">Produit</h3>
|
||||
<div className="relative" ref={comboRef}>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={e => { setInputValue(e.target.value); setProduct(null) }}
|
||||
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
|
||||
placeholder="Rechercher par nom ou référence…"
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 pr-7 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 text-xs pointer-events-none">
|
||||
{loadingSuggestions || loadingProduct ? '⏳' : '🔍'}
|
||||
</span>
|
||||
</div>
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<div className="absolute z-20 w-full mt-1 bg-slate-700 border border-slate-600 rounded shadow-xl max-h-52 overflow-y-auto">
|
||||
{suggestions.map(s => (
|
||||
<button
|
||||
key={s.shopifyId}
|
||||
type="button"
|
||||
onMouseDown={e => { e.preventDefault(); selectSuggestion(s) }}
|
||||
className="w-full text-left px-3 py-2 hover:bg-slate-600 border-b border-slate-600 last:border-0"
|
||||
>
|
||||
<p className="text-xs text-white truncate">{s.title}</p>
|
||||
<p className="text-xs text-slate-400 font-mono">{s.sku}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{searchError && <p className="text-xs text-red-400 mt-1">{searchError}</p>}
|
||||
{product && (
|
||||
<div className="mt-2 p-2 bg-slate-700 rounded text-xs">
|
||||
<p className="text-white font-medium truncate">{product.title}</p>
|
||||
<p className="text-slate-400">{product.sku} · {formatPrice(product.price)}</p>
|
||||
{product.allImages.length > 1 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-slate-400 mb-1">Choisir l'image :</p>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{product.allImages.map((url, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setSelectedImageUrl(url)}
|
||||
className={`rounded overflow-hidden border-2 transition-all ${selectedImageUrl === url ? 'border-indigo-500' : 'border-slate-600'}`}
|
||||
>
|
||||
<img src={url} alt={`img ${i}`} className="w-12 h-12 object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Image de fond */}
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">Image de fond</h3>
|
||||
{backgroundPreview ? (
|
||||
<div className="relative rounded overflow-hidden border border-slate-600">
|
||||
<img src={backgroundPreview} alt="Fond" className="w-full h-24 object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeBg}
|
||||
className="absolute top-1 right-1 bg-black/70 hover:bg-red-900 text-white text-xs px-1.5 py-0.5 rounded"
|
||||
>
|
||||
✕ Retirer
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => bgFileRef.current?.click()}
|
||||
className="w-full py-3 border-2 border-dashed border-slate-600 hover:border-indigo-500 rounded text-xs text-slate-400 hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
+ Uploader une image de fond
|
||||
</button>
|
||||
)}
|
||||
<input ref={bgFileRef} type="file" accept="image/*" className="hidden" onChange={handleBgFile} />
|
||||
{backgroundPreview && (
|
||||
<p className="text-xs text-slate-500 mt-1">Un overlay sombre est appliqué automatiquement pour la lisibilité du texte.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Thème couleur */}
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">
|
||||
{backgroundPreview ? 'Couleur du texte' : 'Thème couleur'}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{schemeEntries.map(([key, s]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setScheme(key)}
|
||||
style={{ background: s.bg, borderColor: scheme === key ? '#6366f1' : '#475569' }}
|
||||
className={`px-3 py-2 rounded text-xs border-2 font-medium transition-all`}
|
||||
>
|
||||
<span style={{ color: s.titleColor }}>{s.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Textes */}
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">Textes</h3>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-0.5">Badge promotionnel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={promoLabel}
|
||||
onChange={e => setPromoLabel(e.target.value)}
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-0.5">Titre produit</label>
|
||||
<textarea
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-slate-400 mb-0.5">Prix affiché</label>
|
||||
<input
|
||||
type="text"
|
||||
value={price}
|
||||
onChange={e => setPrice(e.target.value)}
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-slate-400 mb-0.5">Prix barré</label>
|
||||
<input
|
||||
type="text"
|
||||
value={comparePrice}
|
||||
onChange={e => setComparePrice(e.target.value)}
|
||||
placeholder="optionnel"
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* ── Panneau droit : aperçu ──────────────────────────────────────── */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 flex items-center justify-center bg-slate-900 p-4 overflow-auto">
|
||||
{rendering && (
|
||||
<p className="text-slate-500 text-sm">Génération en cours…</p>
|
||||
)}
|
||||
{!rendering && previewUrl && (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Aperçu bannière"
|
||||
className="max-w-full rounded shadow-lg"
|
||||
style={{ maxHeight: '60vh' }}
|
||||
/>
|
||||
)}
|
||||
{!rendering && !previewUrl && (
|
||||
<div className="text-center text-slate-600">
|
||||
<p className="text-4xl mb-2">🎨</p>
|
||||
<p className="text-sm">Recherchez un produit pour commencer</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="p-4 border-t border-slate-700 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
<span className="text-xs text-slate-500">Format : 1500 × 500 px</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={rendering || (!title && !product)}
|
||||
className="px-4 py-2 text-xs bg-slate-700 hover:bg-slate-600 text-white rounded font-medium disabled:opacity-40"
|
||||
>
|
||||
{rendering ? '⏳ Rendu…' : '↺ Régénérer'}
|
||||
</button>
|
||||
<button
|
||||
onClick={applyToSlide}
|
||||
disabled={!previewUrl || uploading || rendering}
|
||||
className="px-4 py-2 text-xs bg-green-700 hover:bg-green-600 text-white rounded font-medium disabled:opacity-40"
|
||||
>
|
||||
{uploading ? '⬆️ Upload…' : '✓ Appliquer à la slide'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
import { Component, type ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: (error: Error, reset: () => void) => ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error }
|
||||
}
|
||||
|
||||
reset = () => this.setState({ error: null })
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
if (this.props.fallback) return this.props.fallback(this.state.error, this.reset)
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-900/30 border border-red-700 rounded-xl p-6 text-center space-y-3">
|
||||
<p className="text-red-300 font-semibold">Une erreur est survenue</p>
|
||||
<p className="text-red-400 text-sm font-mono">{this.state.error.message}</p>
|
||||
<button
|
||||
onClick={this.reset}
|
||||
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg text-sm"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
onSelect: (tab: string) => void
|
||||
}
|
||||
|
||||
export function FamilySelector({ onSelect }: Props) {
|
||||
const [tabs, setTabs] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/sheets/tabs')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.error) throw new Error(data.error)
|
||||
setTabs(data.tabs)
|
||||
})
|
||||
.catch((e: Error) => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-slate-400 text-sm py-8 justify-center">
|
||||
<span className="animate-spin">⏳</span> Chargement des familles…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">
|
||||
Impossible de charger les familles : {error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-slate-300 text-sm">
|
||||
Choisissez une famille (onglet Google Sheets) pour travailler uniquement sur celle-ci.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => onSelect(tab)}
|
||||
className="flex items-center gap-2 px-4 py-3 bg-slate-700 hover:bg-indigo-700 border border-slate-600 hover:border-indigo-500 rounded-xl text-sm text-slate-200 hover:text-white font-medium transition-all text-left group"
|
||||
>
|
||||
<span className="text-base group-hover:scale-110 transition-transform">📂</span>
|
||||
<span className="truncate">{tab}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
import type { CreationStreamEvent } from '@/types/creation'
|
||||
|
||||
interface Props {
|
||||
events: CreationStreamEvent[]
|
||||
done: boolean
|
||||
}
|
||||
|
||||
export function CreationProgress({ events, done }: Props) {
|
||||
const lastProgress = [...events].reverse().find(e => e.type === 'progress') as Extract<CreationStreamEvent, { type: 'progress' }> | undefined
|
||||
const doneEvent = events.find(e => e.type === 'done') as Extract<CreationStreamEvent, { type: 'done' }> | undefined
|
||||
const errors = events.filter(e => e.type === 'error') as Array<Extract<CreationStreamEvent, { type: 'error' }>>
|
||||
const skipped = events.filter(e => e.type === 'skipped') as Array<Extract<CreationStreamEvent, { type: 'skipped' }>>
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{lastProgress && !done && (
|
||||
<div>
|
||||
<div className="flex justify-between text-sm text-gray-300 mb-1">
|
||||
<span>{lastProgress.step}</span>
|
||||
<span>{lastProgress.current}/{lastProgress.total}</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-700 rounded-full h-2">
|
||||
<div className="bg-indigo-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${(lastProgress.current / lastProgress.total) * 100}%` }} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1 truncate">{lastProgress.tab} — {lastProgress.title}</p>
|
||||
</div>
|
||||
)}
|
||||
{skipped.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{skipped.map((e, i) => (
|
||||
<div key={i} className="flex gap-2 p-2 bg-amber-900/20 border border-amber-800/40 rounded text-xs text-amber-300">
|
||||
<span>⚠️</span>
|
||||
<span className="font-medium">{e.title}</span>
|
||||
<span className="text-amber-500">— SKU {e.sku} déjà présent dans Shopify, ignoré</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{errors.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{errors.map((e, i) => (
|
||||
<div key={i} className="flex gap-2 p-2 bg-red-900/30 rounded text-xs text-red-300">
|
||||
<span className="font-medium">{e.title}</span>
|
||||
<span className="text-red-400">— {e.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{doneEvent && (
|
||||
<div className={`p-4 rounded-lg ${doneEvent.summary.status === 'success' ? 'bg-green-900/30 border border-green-700' : doneEvent.summary.status === 'partial' ? 'bg-yellow-900/30 border border-yellow-700' : 'bg-red-900/30 border border-red-700'}`}>
|
||||
<p className={`font-semibold ${doneEvent.summary.status === 'success' ? 'text-green-300' : doneEvent.summary.status === 'partial' ? 'text-yellow-300' : 'text-red-300'}`}>
|
||||
{doneEvent.summary.status === 'success' ? '✓ Terminé' : doneEvent.summary.status === 'partial' ? '⚠ Partiel' : '✗ Erreur'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-300 mt-1">
|
||||
{doneEvent.summary.created} créé{doneEvent.summary.created > 1 ? 's' : ''}
|
||||
{doneEvent.summary.skipped > 0 && ` — ${doneEvent.summary.skipped} déjà existant${doneEvent.summary.skipped > 1 ? 's' : ''} (ignorés)`}
|
||||
{doneEvent.summary.errors > 0 && ` — ${doneEvent.summary.errors} erreur${doneEvent.summary.errors > 1 ? 's' : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!done && !lastProgress && <p className="text-gray-400 text-sm">Démarrage…</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import type { MetafieldDefinition, MetafieldResolution } from '@/types/creation'
|
||||
import { normalizeMetafieldKey } from '@/lib/shopifyMetafields'
|
||||
|
||||
interface Props {
|
||||
specificColumns: string[]
|
||||
onResolved: (resolutions: MetafieldResolution[]) => void
|
||||
}
|
||||
|
||||
export function MetafieldResolver({ specificColumns, onResolved }: Props) {
|
||||
const [definitions, setDefinitions] = useState<MetafieldDefinition[]>([])
|
||||
const [matched, setMatched] = useState<Record<string, MetafieldResolution>>({})
|
||||
const [unmatched, setUnmatched] = useState<string[]>([])
|
||||
const [resolutions, setResolutions] = useState<Record<string, MetafieldResolution>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searches, setSearches] = useState<Record<string, string>>({})
|
||||
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (specificColumns.length === 0) { setLoading(false); onResolved([]); return }
|
||||
const params = specificColumns.map(c => encodeURIComponent(c)).join(',')
|
||||
Promise.all([
|
||||
fetch(`/api/creation/metafields?columns=${params}`).then(r => r.json()),
|
||||
fetch('/api/creation/resolutions').then(r => r.json()),
|
||||
])
|
||||
.then(([data, cache]: [{ error?: string; definitions: MetafieldDefinition[]; matched: Record<string, MetafieldResolution>; unmatched: string[] }, Record<string, MetafieldResolution>]) => {
|
||||
if (data.error) throw new Error(data.error)
|
||||
setDefinitions(data.definitions)
|
||||
setMatched(data.matched)
|
||||
setUnmatched(data.unmatched)
|
||||
const init: Record<string, MetafieldResolution> = { ...data.matched }
|
||||
data.unmatched.forEach((col: string) => {
|
||||
// Utilise le cache persisté si disponible, sinon 'skip' par défaut
|
||||
init[col] = cache[col] ?? { columnHeader: col, action: 'skip', namespace: 'custom', key: normalizeMetafieldKey(col) }
|
||||
})
|
||||
setResolutions(init)
|
||||
})
|
||||
.catch(e => setError((e as Error).message))
|
||||
.finally(() => setLoading(false))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [specificColumns.join(',')])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) onResolved(Object.values(resolutions))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resolutions, loading])
|
||||
|
||||
const updateResolution = (col: string, partial: Partial<MetafieldResolution>) => {
|
||||
setResolutions(prev => {
|
||||
const next = { ...prev, [col]: { ...prev[col], ...partial } }
|
||||
// Sauvegarde en différé pour éviter un appel par frappe
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
fetch('/api/creation/resolutions', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(next),
|
||||
}).catch(() => null)
|
||||
}, 800)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const filteredDefs = (col: string) => {
|
||||
const q = (searches[col] ?? '').toLowerCase()
|
||||
if (!q) return definitions
|
||||
return definitions.filter(d =>
|
||||
d.name.toLowerCase().includes(q) ||
|
||||
d.key.toLowerCase().includes(q) ||
|
||||
d.namespace.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) return <p className="text-gray-400 text-sm">Analyse des métachamps Shopify…</p>
|
||||
if (error) return <p className="text-red-400 text-sm">{error}</p>
|
||||
if (specificColumns.length === 0) return <p className="text-gray-400 text-sm">Aucune colonne spécifique détectée.</p>
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(matched).length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-green-400 mb-2">
|
||||
✓ {Object.entries(matched).length} colonne{Object.entries(matched).length > 1 ? 's' : ''} avec correspondance automatique
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(matched).map(([col, res]) => (
|
||||
<div key={col} className="flex items-center gap-3 px-3 py-2 bg-slate-700/50 rounded text-sm">
|
||||
<span className="text-gray-300 flex-1">{col}</span>
|
||||
<span className="text-green-400 text-xs">→ {res.namespace}.{res.key}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{unmatched.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-3 p-3 bg-slate-700/50 rounded-lg border border-slate-600">
|
||||
<p className="text-xs text-gray-400">
|
||||
Ces colonnes n'ont pas de métachamp correspondant dans Shopify.
|
||||
Choisissez pour chacune : <span className="text-indigo-300 font-medium">Créer nouveau</span> (nouveau métachamp Shopify),
|
||||
<span className="text-blue-300 font-medium"> Associer</span> (utiliser un existant), ou
|
||||
<span className="text-gray-300 font-medium"> Ignorer</span> (ne pas synchroniser).
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{unmatched.map(col => {
|
||||
const res = resolutions[col]
|
||||
if (!res) return null
|
||||
return (
|
||||
<div key={col} className={`p-3 rounded-lg space-y-2 border ${res.action === 'skip' ? 'bg-slate-700/40 border-slate-700' : res.action === 'create' ? 'bg-indigo-900/20 border-indigo-700/50' : 'bg-blue-900/20 border-blue-700/50'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-white font-medium">{col}</p>
|
||||
{res.action === 'create' && <span className="text-xs text-indigo-300">✓ Sera créé dans Shopify</span>}
|
||||
{res.action === 'map' && <span className="text-xs text-blue-300">✓ Associé à un existant</span>}
|
||||
{res.action === 'skip' && <span className="text-xs text-gray-500">— Ignoré</span>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{(['create', 'map', 'skip'] as const).map(action => (
|
||||
<button
|
||||
key={action}
|
||||
type="button"
|
||||
onClick={() => updateResolution(col, { action })}
|
||||
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||
res.action === action
|
||||
? action === 'create' ? 'bg-indigo-600 text-white'
|
||||
: action === 'map' ? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-500 text-white'
|
||||
: 'bg-slate-600 text-gray-400 hover:bg-slate-500 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{action === 'create' ? '+ Créer nouveau' : action === 'map' ? '↔ Associer existant' : '✕ Ignorer'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{res.action === 'create' && (
|
||||
<div className="flex gap-2 mt-1">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-gray-400 mb-1">Clé Shopify</label>
|
||||
<input
|
||||
type="text"
|
||||
value={res.key}
|
||||
onChange={e => updateResolution(col, { key: e.target.value })}
|
||||
placeholder="ex: puissance"
|
||||
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-28">
|
||||
<label className="block text-xs text-gray-400 mb-1">Namespace</label>
|
||||
<input
|
||||
type="text"
|
||||
value={res.namespace}
|
||||
onChange={e => updateResolution(col, { namespace: e.target.value })}
|
||||
placeholder="custom"
|
||||
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{res.action === 'map' && (
|
||||
<div className="relative" ref={openDropdown === col ? dropdownRef : undefined}>
|
||||
<label className="block text-xs text-gray-400 mb-1">Métachamp existant</label>
|
||||
{res.existingDefinitionId ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-1 text-xs text-blue-300 bg-slate-600 border border-slate-500 rounded px-2 py-1 truncate">
|
||||
{definitions.find(d => d.id === res.existingDefinitionId)?.name ?? res.key} ({res.namespace}.{res.key})
|
||||
</span>
|
||||
<button type="button" onClick={() => { updateResolution(col, { existingDefinitionId: undefined, key: normalizeMetafieldKey(col), namespace: 'custom' }); setSearches(p => ({ ...p, [col]: '' })); setOpenDropdown(col) }} className="text-xs text-gray-400 hover:text-white px-1">✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={searches[col] ?? ''}
|
||||
onChange={e => { setSearches(p => ({ ...p, [col]: e.target.value })); setOpenDropdown(col) }}
|
||||
onFocus={() => setOpenDropdown(col)}
|
||||
placeholder="Rechercher un métachamp…"
|
||||
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
{openDropdown === col && (
|
||||
<div className="absolute z-10 mt-1 w-full bg-slate-700 border border-slate-600 rounded shadow-lg max-h-48 overflow-y-auto">
|
||||
{filteredDefs(col).length === 0 ? (
|
||||
<p className="text-xs text-gray-400 px-3 py-2">Aucun résultat</p>
|
||||
) : filteredDefs(col).map(d => (
|
||||
<button
|
||||
key={d.id}
|
||||
type="button"
|
||||
onMouseDown={() => { updateResolution(col, { existingDefinitionId: d.id, key: d.key, namespace: d.namespace, type: d.type }); setOpenDropdown(null) }}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-white hover:bg-slate-600 flex justify-between gap-2"
|
||||
>
|
||||
<span>{d.name}</span>
|
||||
<span className="text-gray-400 shrink-0">{d.namespace}.{d.key}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Résumé des actions */}
|
||||
<div className="mt-3 flex gap-4 text-xs text-gray-400">
|
||||
<span className="text-indigo-300 font-medium">{Object.values(resolutions).filter(r => r.action === 'create').length} à créer</span>
|
||||
<span className="text-blue-300 font-medium">{Object.values(resolutions).filter(r => r.action === 'map').length} à associer</span>
|
||||
<span>{Object.values(resolutions).filter(r => r.action === 'skip').length} ignorés</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
import type { PendingProduct } from '@/types/creation'
|
||||
|
||||
interface Props {
|
||||
byTab: Record<string, PendingProduct[]>
|
||||
total: number
|
||||
}
|
||||
|
||||
export function PendingRowsTable({ byTab, total }: Props) {
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
Aucun produit en attente (Publié=1 et ID vide).
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(byTab).map(([tab, products]) => (
|
||||
<div key={tab}>
|
||||
<h3 className="text-sm font-semibold text-indigo-300 mb-2 uppercase tracking-wide">
|
||||
{tab} — {products.length} produit{products.length > 1 ? 's' : ''}
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-400 border-b border-slate-700">
|
||||
<th className="pb-2 pr-4 font-medium">SKU</th>
|
||||
<th className="pb-2 pr-4 font-medium">Titre</th>
|
||||
<th className="pb-2 pr-4 font-medium">Prix</th>
|
||||
<th className="pb-2 pr-4 font-medium">Stock</th>
|
||||
<th className="pb-2 font-medium">Métachamps</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map((p) => (
|
||||
<tr key={`${p.tab}-${p.rowNumber}`} className="border-b border-slate-700/50">
|
||||
<td className="py-2 pr-4 font-mono text-indigo-300 text-xs">{p.sku}</td>
|
||||
<td className="py-2 pr-4 text-white">{p.title}</td>
|
||||
<td className="py-2 pr-4 text-gray-300">{p.priceActual} €</td>
|
||||
<td className="py-2 pr-4 text-gray-300">{p.stock}</td>
|
||||
<td className="py-2 text-gray-400 text-xs">
|
||||
{Object.keys(p.specificFields).length} champ{Object.keys(p.specificFields).length > 1 ? 's' : ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,272 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ImageItem } from '@/types/images'
|
||||
|
||||
interface ImageCardProps {
|
||||
item: ImageItem
|
||||
selected?: boolean
|
||||
onSelect?: (id: string, selected: boolean) => void
|
||||
onFeatherChange: (id: string, value: number) => void
|
||||
onAlphaThresholdChange: (id: string, value: number) => void
|
||||
onRotate: (id: string, direction: 'cw' | 'ccw') => void
|
||||
onUpload: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
onOpenLightbox: (item: ImageItem) => void
|
||||
onAssignProduct: (id: string, shopifyProductId: string, shopifyProductTitle: string) => void
|
||||
onReprocess: (id: string) => void
|
||||
onDetour: (id: string) => void
|
||||
onMockup: (id: string) => void
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: '⏳ En attente', color: 'text-slate-400' },
|
||||
converting: { label: '🔄 Conversion HEIC', color: 'text-yellow-400' },
|
||||
searching: { label: '🔍 Recherche…', color: 'text-purple-400' },
|
||||
converted: { label: '✓ Prêt (sans détourage)', color: 'text-amber-400' },
|
||||
processing: { label: '🤖 Détourage IA', color: 'text-blue-400' },
|
||||
ready: { label: '✓ Détouré', color: 'text-green-400' },
|
||||
uploading: { label: '⬆️ Upload…', color: 'text-blue-400' },
|
||||
uploaded: { label: '✅ Uploadé', color: 'text-green-500' },
|
||||
skipped: { label: '⏭️ Déjà sur Shopify', color: 'text-slate-400' },
|
||||
not_found: { label: '❌ Réf. introuvable', color: 'text-red-400' },
|
||||
error: { label: '❌ Erreur', color: 'text-red-400' },
|
||||
}
|
||||
|
||||
function BlobThumbnail({ blob, alt }: { blob: Blob; alt: string }) {
|
||||
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" />
|
||||
}
|
||||
|
||||
function ProductSearch({ itemId, onAssign }: { itemId: string; onAssign: (id: string, shopifyProductId: string, title: string) => void }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<{ shopifyId: string; title: string }[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function search() {
|
||||
if (!query.trim()) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/products/search?ref=${encodeURIComponent(query.trim())}`)
|
||||
const data = await res.json()
|
||||
if (data.found) setResults([{ shopifyId: data.shopifyId, title: data.title }])
|
||||
else setResults([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 mt-1">
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && search()}
|
||||
placeholder="Rechercher un produit…"
|
||||
className="flex-1 px-2 py-1 text-xs bg-slate-800 border border-slate-600 rounded text-slate-200 placeholder-slate-500 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
<button
|
||||
onClick={search}
|
||||
disabled={loading}
|
||||
className="px-2 py-1 text-xs bg-slate-700 hover:bg-slate-600 rounded text-slate-300 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '…' : '🔍'}
|
||||
</button>
|
||||
</div>
|
||||
{results.length === 0 && !loading && query && (
|
||||
<p className="text-xs text-slate-500">Aucun résultat</p>
|
||||
)}
|
||||
{results.map((r) => (
|
||||
<button
|
||||
key={r.shopifyId}
|
||||
onClick={() => onAssign(itemId, r.shopifyId, r.title)}
|
||||
className="text-left px-2 py-1.5 text-xs bg-slate-800 hover:bg-indigo-900 border border-slate-600 hover:border-indigo-500 rounded text-slate-200 truncate"
|
||||
>
|
||||
{r.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ImageCard({
|
||||
item,
|
||||
selected = false,
|
||||
onSelect,
|
||||
onFeatherChange,
|
||||
onAlphaThresholdChange,
|
||||
onRotate,
|
||||
onUpload,
|
||||
onRemove,
|
||||
onOpenLightbox,
|
||||
onAssignProduct,
|
||||
onReprocess,
|
||||
onDetour,
|
||||
onMockup,
|
||||
}: ImageCardProps) {
|
||||
const statusInfo = STATUS_LABELS[item.status] ?? { label: item.status, color: 'text-slate-400' }
|
||||
const isProcessing = ['pending', 'converting', 'processing', 'searching', 'uploading'].includes(item.status)
|
||||
const canAdjust = !!item.rawPngBlob && !isProcessing
|
||||
const canUpload = (item.status === 'ready' || item.status === 'converted') && !!item.shopifyProductId
|
||||
const canDetour = (item.status === 'converted' || item.status === 'error' || item.status === 'not_found') && !isProcessing
|
||||
|
||||
return (
|
||||
<div className={`bg-slate-900 border rounded-xl overflow-hidden flex flex-col ${selected ? 'border-indigo-500 ring-1 ring-indigo-500' : 'border-slate-700'}`}>
|
||||
{/* Miniatures avant / après */}
|
||||
<div
|
||||
className="relative h-40 bg-slate-800 cursor-pointer"
|
||||
onClick={() => onOpenLightbox(item)}
|
||||
>
|
||||
{onSelect && (
|
||||
<div className="absolute top-2 left-2 z-10" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={e => onSelect(item.id, e.target.checked)}
|
||||
className="w-4 h-4 accent-indigo-500 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={`absolute top-0 bottom-0 left-0 overflow-hidden ${item.processedBlob ? 'w-1/2' : 'w-full'}`}>
|
||||
<BlobThumbnail blob={item.originalBlob} alt="Original" />
|
||||
</div>
|
||||
{item.processedBlob && (
|
||||
<div className="absolute top-0 bottom-0 right-0 w-1/2 overflow-hidden border-l-2 border-indigo-500">
|
||||
<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" title={item.filename}>{item.filename}</p>
|
||||
<p className="text-xs text-slate-500 truncate">Réf : {item.ref}</p>
|
||||
{item.shopifyProductTitle && (
|
||||
<p className="text-xs text-slate-500 truncate">{item.shopifyProductTitle}</p>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{item.status === 'not_found' && (
|
||||
<ProductSearch itemId={item.id} onAssign={onAssignProduct} />
|
||||
)}
|
||||
|
||||
{/* Contrôles feather + tolérance + rotation */}
|
||||
<div className={canAdjust ? '' : 'opacity-40 pointer-events-none'}>
|
||||
<label className="block text-xs text-slate-500 mb-1">
|
||||
Bords : {item.feather} px
|
||||
</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"
|
||||
/>
|
||||
|
||||
<label className="block text-xs text-slate-500 mb-1 mt-2">
|
||||
Tolérance verre : {item.alphaThreshold}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={item.alphaThreshold}
|
||||
onChange={(e) => onAlphaThresholdChange(item.id, Number(e.target.value))}
|
||||
className="w-full accent-amber-500"
|
||||
/>
|
||||
|
||||
<div className="flex gap-1 mt-2">
|
||||
<button
|
||||
onClick={() => onRotate(item.id, 'ccw')}
|
||||
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">
|
||||
{canUpload && (
|
||||
<button
|
||||
onClick={() => onUpload(item.id)}
|
||||
className="flex-1 py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded"
|
||||
>
|
||||
⬆️ Uploader
|
||||
</button>
|
||||
)}
|
||||
{canDetour && (
|
||||
<button
|
||||
onClick={() => onDetour(item.id)}
|
||||
title="Lancer le détourage IA"
|
||||
className="flex-1 py-1.5 text-xs bg-purple-700 hover:bg-purple-600 text-white font-semibold rounded"
|
||||
>
|
||||
✂️ Détourer
|
||||
</button>
|
||||
)}
|
||||
{(item.status === 'ready' || item.status === 'uploaded' || item.status === 'skipped' || item.status === 'converted') && (
|
||||
<button
|
||||
onClick={() => onMockup(item.id)}
|
||||
title="Créer un mockup"
|
||||
className="py-1.5 px-2 text-xs bg-slate-800 hover:bg-teal-900 text-slate-400 hover:text-teal-300 rounded"
|
||||
>
|
||||
🖼️
|
||||
</button>
|
||||
)}
|
||||
{(item.status === 'ready' || item.status === 'uploaded' || item.status === 'skipped' || item.status === 'error') && item.rawPngBlob && (
|
||||
<button
|
||||
onClick={() => onReprocess(item.id)}
|
||||
title="Relancer le détourage IA"
|
||||
className="py-1.5 px-2 text-xs bg-slate-800 hover:bg-amber-900 text-slate-400 hover:text-amber-300 rounded"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
)}
|
||||
{item.status === 'uploaded' && item.uploadedUrl && (
|
||||
<a
|
||||
href={item.uploadedUrl}
|
||||
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,75 @@
|
||||
'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: '/creation', label: 'Création', icon: '✨' },
|
||||
{ href: '/mockup', label: 'Mockups', icon: '🖼️' },
|
||||
{ href: '/bannieres', label: 'Bannières', 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,170 @@
|
||||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { MockupTemplate } from '@/types/mockup'
|
||||
|
||||
interface Props {
|
||||
productCutoutBase64: string
|
||||
productName: string
|
||||
shopifyId?: string
|
||||
family: string
|
||||
dimensions?: string
|
||||
onUploaded?: (url: string) => void
|
||||
}
|
||||
|
||||
export function MockupGenerator({ productCutoutBase64, productName, shopifyId, family, dimensions, onUploaded }: Props) {
|
||||
const [templates, setTemplates] = useState<MockupTemplate[]>([])
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null)
|
||||
const [instruction, setInstruction] = useState('')
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [resultB64, setResultB64] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [uploadedUrl, setUploadedUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/mockup/templates')
|
||||
.then(r => r.json())
|
||||
.then((data: MockupTemplate[]) => {
|
||||
const filtered = data.filter(t => !family || t.family === family || t.family === 'Tous')
|
||||
setTemplates(filtered.length > 0 ? filtered : data)
|
||||
})
|
||||
}, [family])
|
||||
|
||||
const generate = async () => {
|
||||
if (!selectedTemplate) return
|
||||
setGenerating(true)
|
||||
setError(null)
|
||||
setResultB64(null)
|
||||
try {
|
||||
const res = await fetch('/api/mockup/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
templateId: selectedTemplate,
|
||||
productImageBase64: productCutoutBase64,
|
||||
productName,
|
||||
family,
|
||||
dimensions: dimensions || undefined,
|
||||
instruction: instruction || undefined,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error)
|
||||
setResultB64(data.imageBase64)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erreur')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const upload = async () => {
|
||||
if (!resultB64 || !shopifyId) return
|
||||
setUploading(true)
|
||||
try {
|
||||
const filename = `${productName.toLowerCase().replace(/\s+/g, '-')}-mockup.jpg`
|
||||
const res = await fetch('/api/images/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ shopifyProductId: shopifyId, imageBase64: resultB64, filename }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error)
|
||||
setUploadedUrl(data.url)
|
||||
onUploaded?.(data.url)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erreur upload')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-2">Template de mise en ambiance</label>
|
||||
{templates.length === 0 ? (
|
||||
<p className="text-xs text-gray-500">Aucun template disponible — ajoutez-en dans la bibliothèque.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{templates.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedTemplate(t.id)}
|
||||
className={`relative rounded-lg overflow-hidden border-2 transition-all ${selectedTemplate === t.id ? 'border-indigo-500' : 'border-slate-600 hover:border-slate-400'}`}
|
||||
>
|
||||
<img
|
||||
src={`/api/mockup/templates/image/${t.filename}`}
|
||||
alt={t.name}
|
||||
className="w-full h-24 object-cover"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-2 py-1">
|
||||
<p className="text-xs text-white truncate">{t.name}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dimensions ? (
|
||||
<p className="text-xs text-green-400">Dimensions : {dimensions}</p>
|
||||
) : (
|
||||
<p className="text-xs text-amber-400">Dimensions non renseignées — le mockup peut ne pas être à l'échelle.</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={generate}
|
||||
disabled={!selectedTemplate || generating}
|
||||
className="w-full py-2 px-4 rounded-lg bg-indigo-600 text-white text-sm font-medium disabled:opacity-40 hover:bg-indigo-500 transition-colors"
|
||||
>
|
||||
{generating ? 'Génération en cours…' : 'Générer le mockup'}
|
||||
</button>
|
||||
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
|
||||
{resultB64 && (
|
||||
<div className="space-y-3">
|
||||
<img
|
||||
src={`data:image/jpeg;base64,${resultB64}`}
|
||||
alt="Mockup généré"
|
||||
className="w-full rounded-lg border border-slate-600"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={instruction}
|
||||
onChange={e => setInstruction(e.target.value)}
|
||||
placeholder="Instruction supplémentaire (optionnel)…"
|
||||
className="flex-1 bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={generate}
|
||||
disabled={generating}
|
||||
className="px-3 py-1.5 rounded bg-slate-600 text-xs text-white hover:bg-slate-500 disabled:opacity-40"
|
||||
>
|
||||
{generating ? '…' : '↺ Regénérer'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{uploadedUrl ? (
|
||||
<p className="text-xs text-green-400">✓ Uploadé sur Shopify</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={upload}
|
||||
disabled={uploading || !shopifyId}
|
||||
className="w-full py-2 px-4 rounded-lg bg-emerald-600 text-white text-sm font-medium disabled:opacity-40 hover:bg-emerald-500 transition-colors"
|
||||
>
|
||||
{uploading ? 'Upload…' : shopifyId ? '↑ Uploader sur Shopify' : 'Produit Shopify requis pour uploader'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import type { CatalogProduct } from '@/types/products'
|
||||
import type { MockupTemplate } from '@/types/mockup'
|
||||
|
||||
interface Props {
|
||||
product: CatalogProduct
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface Metafield {
|
||||
namespace: string
|
||||
key: string
|
||||
value: string
|
||||
type: string
|
||||
}
|
||||
|
||||
async function urlToBase64(url: string): Promise<string> {
|
||||
const res = await fetch(`/api/images/proxy?url=${encodeURIComponent(url)}`)
|
||||
if (!res.ok) throw new Error('Impossible de charger l\'image')
|
||||
const blob = await res.blob()
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve((reader.result as string).split(',')[1])
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
export function MockupModal({ product, onClose }: Props) {
|
||||
const [templates, setTemplates] = useState<MockupTemplate[]>([])
|
||||
const [families, setFamilies] = useState<string[]>([])
|
||||
const [metafields, setMetafields] = useState<Metafield[]>([])
|
||||
const [loadingMeta, setLoadingMeta] = useState(true)
|
||||
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null)
|
||||
const [family, setFamily] = useState('')
|
||||
const [placement, setPlacement] = useState<string>('')
|
||||
const [selectedMeta, setSelectedMeta] = useState<string[]>([])
|
||||
const [cutoutB64, setCutoutB64] = useState<string | null>(null)
|
||||
const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>(null)
|
||||
const [loadingImage, setLoadingImage] = useState(false)
|
||||
|
||||
const [instruction, setInstruction] = useState('')
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [resultB64, setResultB64] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadedUrl, setUploadedUrl] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const templateFileRef = useRef<HTMLInputElement>(null)
|
||||
const [uploadingTemplate, setUploadingTemplate] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch('/api/mockup/templates').then(r => r.json()),
|
||||
fetch('/api/sheets/tabs').then(r => r.json()),
|
||||
fetch(`/api/mockup/product-meta/${product.shopifyId}`).then(r => r.json()),
|
||||
product.ref ? fetch(`/api/products/sheet-info?ref=${encodeURIComponent(product.ref)}`).then(r => r.ok ? r.json() : null) : Promise.resolve(null),
|
||||
]).then(([tplData, tabsData, metaData, sheetData]) => {
|
||||
setTemplates(tplData as MockupTemplate[])
|
||||
const tabs: string[] = (tabsData.tabs ?? []).filter((t: string) =>
|
||||
!['COLLECTIONS', 'LIVRAISONS', 'ECOPART', 'RECAP'].some(x => t.toUpperCase().includes(x))
|
||||
)
|
||||
setFamilies(tabs)
|
||||
const sheetFamille = sheetData?.product?.famille
|
||||
setFamily(sheetFamille && tabs.includes(sheetFamille) ? sheetFamille : tabs[0] ?? '')
|
||||
setMetafields(metaData.metafields ?? [])
|
||||
}).finally(() => setLoadingMeta(false))
|
||||
}, [product.shopifyId, product.ref])
|
||||
|
||||
const isCarrelage = family.toLowerCase().includes('carrelage')
|
||||
|
||||
const filteredTemplates = templates.filter(t => {
|
||||
if (t.family !== family && t.family !== 'Tous') return false
|
||||
if (isCarrelage && placement && t.placement && t.placement !== placement) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const dimensionStr = selectedMeta.length > 0
|
||||
? selectedMeta.map(key => {
|
||||
const mf = metafields.find(m => `${m.namespace}.${m.key}` === key)
|
||||
return mf ? `${mf.key}: ${mf.value}` : ''
|
||||
}).filter(Boolean).join(', ')
|
||||
: undefined
|
||||
|
||||
const selectExistingImage = async (url: string) => {
|
||||
setSelectedImageUrl(url)
|
||||
setCutoutB64(null)
|
||||
setLoadingImage(true)
|
||||
setError(null)
|
||||
try {
|
||||
const b64 = await urlToBase64(url)
|
||||
setCutoutB64(b64)
|
||||
} catch {
|
||||
setError('Impossible de charger cette image')
|
||||
setSelectedImageUrl(null)
|
||||
} finally {
|
||||
setLoadingImage(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setSelectedImageUrl(null)
|
||||
const reader = new FileReader()
|
||||
reader.onload = ev => setCutoutB64((ev.target?.result as string).split(',')[1])
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const uploadTemplate = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setUploadingTemplate(true)
|
||||
const form = new FormData()
|
||||
form.append('name', file.name.replace(/\.[^.]+$/, ''))
|
||||
form.append('family', family || 'Tous')
|
||||
form.append('description', '')
|
||||
form.append('image', file)
|
||||
const res = await fetch('/api/mockup/templates', { method: 'POST', body: form })
|
||||
const tpl = await res.json() as MockupTemplate
|
||||
setTemplates(prev => [...prev, tpl])
|
||||
setSelectedTemplate(tpl.id)
|
||||
setUploadingTemplate(false)
|
||||
}
|
||||
|
||||
const toggleMeta = (key: string) => {
|
||||
setSelectedMeta(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key])
|
||||
}
|
||||
|
||||
const generate = async () => {
|
||||
if (!selectedTemplate || !cutoutB64) return
|
||||
setGenerating(true)
|
||||
setError(null)
|
||||
setResultB64(null)
|
||||
try {
|
||||
const res = await fetch('/api/mockup/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
templateId: selectedTemplate,
|
||||
productImageBase64: cutoutB64,
|
||||
productName: product.title,
|
||||
family: family || 'Tous',
|
||||
placement: placement || undefined,
|
||||
dimensions: dimensionStr,
|
||||
instruction: instruction || undefined,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error)
|
||||
setResultB64(data.imageBase64)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erreur')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const upload = async () => {
|
||||
if (!resultB64) return
|
||||
setUploading(true)
|
||||
try {
|
||||
const res = await fetch('/api/images/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ shopifyProductId: product.shopifyId, imageBase64: resultB64, filename: `${product.ref}-mockup.jpg` }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error)
|
||||
setUploadedUrl(data.url)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erreur upload')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="bg-slate-800 border border-slate-700 rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-700 sticky top-0 bg-slate-800 z-10">
|
||||
<div>
|
||||
<h2 className="text-white font-semibold text-sm">Créer un mockup</h2>
|
||||
<p className="text-gray-400 text-xs mt-0.5">
|
||||
{product.title} <span className="font-mono text-indigo-300">{product.ref}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-white text-lg leading-none">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-5">
|
||||
|
||||
{/* 1. Image de base */}
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">1. Image du produit</h3>
|
||||
{product.images.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<p className="text-xs text-gray-400 mb-1.5">Images existantes</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{product.images.map((url, i) => (
|
||||
<button key={i} type="button" onClick={() => selectExistingImage(url)}
|
||||
className={`relative rounded-lg overflow-hidden border-2 transition-all ${selectedImageUrl === url ? 'border-indigo-500' : 'border-slate-600 hover:border-slate-400'}`}>
|
||||
<img src={url} alt={`Image ${i + 1}`} className="w-16 h-16 object-cover" />
|
||||
{loadingImage && selectedImageUrl === url && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||
<span className="text-xs text-white">…</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1">Ou uploader une image découpée (PNG)</p>
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={handleFileUpload} className="text-xs text-gray-300" />
|
||||
</div>
|
||||
{cutoutB64 && !loadingImage && (
|
||||
<p className="text-xs text-green-400 mt-1">✓ Image prête</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 2. Famille + placement */}
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">2. Famille</h3>
|
||||
{families.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<select value={family} onChange={e => { setFamily(e.target.value); setPlacement('') }}
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
{families.map(f => <option key={f}>{f}</option>)}
|
||||
</select>
|
||||
{isCarrelage && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1.5">Placement</p>
|
||||
<div className="flex gap-2">
|
||||
{[{ val: 'sol', label: 'Sol' }, { val: 'mur', label: 'Mur (faïence)' }].map(({ val, label }) => (
|
||||
<button key={val} type="button" onClick={() => setPlacement(p => p === val ? '' : val)}
|
||||
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors ${placement === val ? 'bg-indigo-600 text-white' : 'bg-slate-700 text-gray-300 hover:bg-slate-600'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500">{loadingMeta ? 'Chargement…' : 'Aucune famille disponible'}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 3. Caractéristiques (metafields) */}
|
||||
{metafields.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">3. Caractéristiques à inclure dans le prompt</h3>
|
||||
<div className="space-y-1 max-h-36 overflow-y-auto">
|
||||
{metafields.map(mf => {
|
||||
const key = `${mf.namespace}.${mf.key}`
|
||||
return (
|
||||
<label key={key} className="flex items-center gap-2 cursor-pointer group">
|
||||
<input type="checkbox" checked={selectedMeta.includes(key)} onChange={() => toggleMeta(key)}
|
||||
className="rounded border-slate-600 bg-slate-700 text-indigo-500 focus:ring-indigo-500" />
|
||||
<span className="text-xs text-gray-300 group-hover:text-white">
|
||||
<span className="font-medium">{mf.key}</span>
|
||||
<span className="text-gray-500 ml-1">{mf.value}</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{dimensionStr && (
|
||||
<p className="text-xs text-indigo-300 mt-1.5">Inclus dans le prompt : {dimensionStr}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 4. Template */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide">
|
||||
{metafields.length > 0 ? '4.' : '3.'} Image de référence (template)
|
||||
</h3>
|
||||
<button type="button" onClick={() => templateFileRef.current?.click()}
|
||||
disabled={uploadingTemplate}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300 disabled:opacity-40">
|
||||
{uploadingTemplate ? 'Upload…' : '+ Uploader une nouvelle'}
|
||||
</button>
|
||||
<input ref={templateFileRef} type="file" accept="image/*" className="hidden" onChange={uploadTemplate} />
|
||||
</div>
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<p className="text-xs text-gray-500">Aucun template pour cette famille. Uploadez-en un ci-dessus.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{filteredTemplates.map(t => (
|
||||
<button key={t.id} type="button" onClick={() => setSelectedTemplate(t.id)}
|
||||
className={`relative rounded-lg overflow-hidden border-2 transition-all ${selectedTemplate === t.id ? 'border-indigo-500' : 'border-slate-600 hover:border-slate-400'}`}>
|
||||
<img src={`/api/mockup/templates/image/${t.filename}`} alt={t.name} className="w-full h-16 object-cover" />
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5">
|
||||
<p className="text-xs text-white truncate">{t.name}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Generate */}
|
||||
<button type="button" onClick={generate}
|
||||
disabled={!selectedTemplate || !cutoutB64 || generating || loadingImage}
|
||||
className="w-full py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium disabled:opacity-40 hover:bg-indigo-500 transition-colors">
|
||||
{generating ? 'Génération en cours…' : 'Générer le mockup'}
|
||||
</button>
|
||||
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
|
||||
{/* Result */}
|
||||
{resultB64 && (
|
||||
<div className="space-y-3">
|
||||
<img src={`data:image/jpeg;base64,${resultB64}`} alt="Mockup" className="w-full rounded-lg border border-slate-600" />
|
||||
<div className="flex gap-2">
|
||||
<input type="text" value={instruction} onChange={e => setInstruction(e.target.value)}
|
||||
placeholder="Instruction pour regénérer (optionnel)…"
|
||||
className="flex-1 bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||
<button type="button" onClick={generate} disabled={generating}
|
||||
className="px-3 py-1.5 rounded bg-slate-600 text-xs text-white hover:bg-slate-500 disabled:opacity-40">
|
||||
{generating ? '…' : '↺ Regénérer'}
|
||||
</button>
|
||||
</div>
|
||||
{uploadedUrl ? (
|
||||
<p className="text-xs text-green-400">✓ Uploadé sur Shopify</p>
|
||||
) : (
|
||||
<button type="button" onClick={upload} disabled={uploading}
|
||||
className="w-full py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium disabled:opacity-40 hover:bg-emerald-500 transition-colors">
|
||||
{uploading ? 'Upload…' : '↑ Uploader sur Shopify'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import type { ImageItem } from '@/types/images'
|
||||
import type { MockupTemplate } from '@/types/mockup'
|
||||
|
||||
interface Props {
|
||||
item: ImageItem
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve((reader.result as string).split(',')[1])
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
export function MockupModalFromImage({ item, onClose }: Props) {
|
||||
const [templates, setTemplates] = useState<MockupTemplate[]>([])
|
||||
const [families, setFamilies] = useState<string[]>([])
|
||||
const [family, setFamily] = useState('')
|
||||
const [dimensions, setDimensions] = useState('')
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null)
|
||||
const [instruction, setInstruction] = useState('')
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [resultB64, setResultB64] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadedUrl, setUploadedUrl] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const templateFileRef = useRef<HTMLInputElement>(null)
|
||||
const [uploadingTemplate, setUploadingTemplate] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch('/api/mockup/templates').then(r => r.json()),
|
||||
fetch('/api/sheets/tabs').then(r => r.json()),
|
||||
item.ref ? fetch(`/api/products/sheet-info?ref=${encodeURIComponent(item.ref)}`).then(r => r.ok ? r.json() : null) : Promise.resolve(null),
|
||||
]).then(([tplData, tabsData, sheetData]) => {
|
||||
setTemplates(tplData as MockupTemplate[])
|
||||
const tabs: string[] = (tabsData.tabs ?? []).filter((t: string) =>
|
||||
!['COLLECTIONS', 'LIVRAISONS', 'ECOPART', 'RECAP'].some(x => t.toUpperCase().includes(x))
|
||||
)
|
||||
setFamilies(tabs)
|
||||
const sheetFamille = sheetData?.product?.famille
|
||||
setFamily(sheetFamille && tabs.includes(sheetFamille) ? sheetFamille : tabs[0] ?? '')
|
||||
const { longueur, largeur } = sheetData?.product ?? {}
|
||||
if (longueur && largeur) setDimensions(`${longueur}x${largeur}cm`)
|
||||
else if (longueur) setDimensions(`${longueur}cm`)
|
||||
})
|
||||
}, [item.ref])
|
||||
|
||||
const filteredTemplates = templates.filter(t =>
|
||||
t.family === family || t.family === 'Tous'
|
||||
)
|
||||
|
||||
const uploadTemplate = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setUploadingTemplate(true)
|
||||
const form = new FormData()
|
||||
form.append('name', file.name.replace(/\.[^.]+$/, ''))
|
||||
form.append('family', family || 'Tous')
|
||||
form.append('description', '')
|
||||
form.append('image', file)
|
||||
const res = await fetch('/api/mockup/templates', { method: 'POST', body: form })
|
||||
const tpl = await res.json() as MockupTemplate
|
||||
setTemplates(prev => [...prev, tpl])
|
||||
setSelectedTemplate(tpl.id)
|
||||
setUploadingTemplate(false)
|
||||
}
|
||||
|
||||
const generate = async () => {
|
||||
if (!selectedTemplate) return
|
||||
setGenerating(true)
|
||||
setError(null)
|
||||
setResultB64(null)
|
||||
try {
|
||||
// Utilise l'image détourée si disponible, sinon l'originale
|
||||
const sourceBlob = item.processedBlob ?? item.originalBlob
|
||||
const productImageBase64 = await blobToBase64(sourceBlob)
|
||||
const res = await fetch('/api/mockup/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
templateId: selectedTemplate,
|
||||
productImageBase64,
|
||||
productName: item.shopifyProductTitle ?? item.ref,
|
||||
family: family || 'Tous',
|
||||
dimensions: dimensions || undefined,
|
||||
instruction: instruction || undefined,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error)
|
||||
setResultB64(data.imageBase64)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erreur')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const upload = async () => {
|
||||
if (!resultB64 || !item.shopifyProductId) return
|
||||
setUploading(true)
|
||||
try {
|
||||
const res = await fetch('/api/images/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
shopifyProductId: item.shopifyProductId,
|
||||
imageBase64: resultB64,
|
||||
filename: `${item.ref}-mockup.jpg`,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error)
|
||||
setUploadedUrl(data.url)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erreur upload')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadResult = () => {
|
||||
if (!resultB64) return
|
||||
const link = document.createElement('a')
|
||||
link.href = `data:image/jpeg;base64,${resultB64}`
|
||||
link.download = `${item.ref}-mockup.jpg`
|
||||
link.click()
|
||||
}
|
||||
|
||||
const sourceLabel = item.processedBlob ? 'image détourée' : 'image originale'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div className="bg-slate-800 border border-slate-700 rounded-xl w-full max-w-xl max-h-[90vh] overflow-y-auto">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-700 sticky top-0 bg-slate-800 z-10">
|
||||
<div>
|
||||
<h2 className="text-white font-semibold text-sm">Créer un mockup</h2>
|
||||
<p className="text-gray-400 text-xs mt-0.5">
|
||||
{item.shopifyProductTitle ?? item.ref}
|
||||
<span className="ml-2 text-indigo-300 font-mono">{item.ref}</span>
|
||||
<span className="ml-2 text-slate-500">· {sourceLabel}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-white text-lg leading-none">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-5">
|
||||
|
||||
{/* Famille */}
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">Famille</h3>
|
||||
<select
|
||||
value={family}
|
||||
onChange={e => setFamily(e.target.value)}
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
>
|
||||
{families.map(f => <option key={f}>{f}</option>)}
|
||||
</select>
|
||||
</section>
|
||||
|
||||
{/* Template */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide">Template</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => templateFileRef.current?.click()}
|
||||
disabled={uploadingTemplate}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300 disabled:opacity-40"
|
||||
>
|
||||
{uploadingTemplate ? 'Upload…' : '+ Nouveau'}
|
||||
</button>
|
||||
<input ref={templateFileRef} type="file" accept="image/*" className="hidden" onChange={uploadTemplate} />
|
||||
</div>
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<p className="text-xs text-gray-500">Aucun template pour cette famille. Uploadez-en un.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{filteredTemplates.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedTemplate(t.id)}
|
||||
className={`relative rounded-lg overflow-hidden border-2 transition-all ${selectedTemplate === t.id ? 'border-indigo-500' : 'border-slate-600 hover:border-slate-400'}`}
|
||||
>
|
||||
<img src={`/api/mockup/templates/image/${t.filename}`} alt={t.name} className="w-full h-16 object-cover" />
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5">
|
||||
<p className="text-xs text-white truncate">{t.name}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Instruction libre */}
|
||||
<section>
|
||||
<label className="block text-xs font-semibold text-gray-300 uppercase tracking-wide mb-1">
|
||||
Instruction personnalisée <span className="text-gray-500 font-normal normal-case">(optionnel)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={instruction}
|
||||
onChange={e => setInstruction(e.target.value)}
|
||||
placeholder="Ex: fond beige, lumière naturelle, ambiance chaleureuse…"
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={generate}
|
||||
disabled={!selectedTemplate || generating}
|
||||
className="w-full py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium disabled:opacity-40 hover:bg-indigo-500 transition-colors"
|
||||
>
|
||||
{generating ? 'Génération en cours…' : 'Générer le mockup'}
|
||||
</button>
|
||||
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
|
||||
{/* Résultat */}
|
||||
{resultB64 && (
|
||||
<div className="space-y-3">
|
||||
<img
|
||||
src={`data:image/jpeg;base64,${resultB64}`}
|
||||
alt="Mockup"
|
||||
className="w-full rounded-lg border border-slate-600"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={instruction}
|
||||
onChange={e => setInstruction(e.target.value)}
|
||||
placeholder="Modifier l'instruction et regénérer…"
|
||||
className="flex-1 bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={generate}
|
||||
disabled={generating}
|
||||
className="px-3 py-1.5 rounded bg-slate-600 text-xs text-white hover:bg-slate-500 disabled:opacity-40"
|
||||
>
|
||||
{generating ? '…' : '↺'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadResult}
|
||||
className="flex-1 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
↓ Télécharger
|
||||
</button>
|
||||
{item.shopifyProductId ? (
|
||||
uploadedUrl ? (
|
||||
<p className="flex-1 py-2 text-center text-xs text-green-400 self-center">✓ Uploadé sur Shopify</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={upload}
|
||||
disabled={uploading}
|
||||
className="flex-1 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{uploading ? 'Upload…' : '↑ Uploader sur Shopify'}
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<p className="flex-1 py-2 text-center text-xs text-slate-500 self-center">Pas de produit associé</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import type { MockupTemplate } from '@/types/mockup'
|
||||
|
||||
const CARRELAGE_PLACEMENTS = ['sol', 'mur']
|
||||
|
||||
export function TemplateLibrary() {
|
||||
const [templates, setTemplates] = useState<MockupTemplate[]>([])
|
||||
const [families, setFamilies] = useState<string[]>(['Tous'])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [family, setFamily] = useState('Tous')
|
||||
const [placement, setPlacement] = useState<string>('')
|
||||
const [description, setDescription] = useState('')
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const load = () => {
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
fetch('/api/mockup/templates').then(r => r.json()),
|
||||
fetch('/api/sheets/tabs').then(r => r.json()),
|
||||
]).then(([tplData, tabsData]) => {
|
||||
setTemplates(tplData as MockupTemplate[])
|
||||
const tabs: string[] = (tabsData.tabs ?? []).filter((t: string) =>
|
||||
!['COLLECTIONS', 'LIVRAISONS', 'ECOPART', 'RECAP'].some(x => t.toUpperCase().includes(x))
|
||||
)
|
||||
const allFamilies = ['Tous', ...tabs]
|
||||
setFamilies(allFamilies)
|
||||
if (!allFamilies.includes(family)) setFamily(allFamilies[0])
|
||||
}).finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
const isCarrelage = family.toLowerCase().includes('carrelage')
|
||||
|
||||
const upload = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const file = fileRef.current?.files?.[0]
|
||||
if (!file || !name || !family) return
|
||||
if (isCarrelage && !placement) return
|
||||
setUploading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('name', name)
|
||||
form.append('family', family)
|
||||
form.append('description', description)
|
||||
if (isCarrelage && placement) form.append('placement', placement)
|
||||
form.append('image', file)
|
||||
const res = await fetch('/api/mockup/templates', { method: 'POST', body: form })
|
||||
if (!res.ok) throw new Error((await res.json()).error)
|
||||
setName(''); setDescription(''); setPlacement('')
|
||||
if (fileRef.current) fileRef.current.value = ''
|
||||
load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erreur')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (id: string) => {
|
||||
await fetch(`/api/mockup/templates/${id}`, { method: 'DELETE' })
|
||||
load()
|
||||
}
|
||||
|
||||
const allUsedFamilies = Array.from(new Set(templates.map(t => t.family)))
|
||||
const displayFamilies = families.filter(f => allUsedFamilies.includes(f) || f === 'Tous')
|
||||
|
||||
const groupedTemplates = (familyName: string) => {
|
||||
const byFamily = templates.filter(t => t.family === familyName)
|
||||
const isCarr = familyName.toLowerCase().includes('carrelage')
|
||||
if (!isCarr) return [{ label: null, items: byFamily }]
|
||||
return CARRELAGE_PLACEMENTS.map(p => ({
|
||||
label: p === 'mur' ? 'Mur (faïence)' : 'Sol',
|
||||
items: byFamily.filter(t => t.placement === p),
|
||||
})).filter(g => g.items.length > 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Upload form */}
|
||||
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700">
|
||||
<h2 className="text-sm font-semibold text-white mb-3">Ajouter un template</h2>
|
||||
<form onSubmit={upload} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Nom</label>
|
||||
<input value={name} onChange={e => setName(e.target.value)}
|
||||
placeholder="ex: Salle de bain moderne"
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Famille</label>
|
||||
<select value={family} onChange={e => { setFamily(e.target.value); setPlacement('') }}
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
{families.map(f => <option key={f}>{f}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCarrelage && (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Placement <span className="text-red-400">*</span></label>
|
||||
<div className="flex gap-2">
|
||||
{CARRELAGE_PLACEMENTS.map(p => (
|
||||
<button key={p} type="button" onClick={() => setPlacement(p)}
|
||||
className={`px-4 py-1.5 rounded text-sm font-medium transition-colors ${placement === p ? 'bg-indigo-600 text-white' : 'bg-slate-700 text-gray-300 hover:bg-slate-600'}`}>
|
||||
{p === 'mur' ? 'Mur (faïence)' : 'Sol'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Description (optionnel)</label>
|
||||
<input value={description} onChange={e => setDescription(e.target.value)}
|
||||
placeholder="ex: Salle de bain lumineuse"
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Image</label>
|
||||
<input ref={fileRef} type="file" accept="image/*" className="text-sm text-gray-300" />
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
<button type="submit" disabled={uploading || !name || !family || (isCarrelage && !placement)}
|
||||
className="px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg disabled:opacity-40 hover:bg-indigo-500">
|
||||
{uploading ? 'Ajout…' : '+ Ajouter'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Templates grouped by family */}
|
||||
{loading ? (
|
||||
<p className="text-sm text-gray-400">Chargement…</p>
|
||||
) : templates.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Aucun template. Ajoutez-en un ci-dessus.</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{displayFamilies.map(f => {
|
||||
const groups = groupedTemplates(f)
|
||||
const total = groups.reduce((n, g) => n + g.items.length, 0)
|
||||
if (total === 0) return null
|
||||
return (
|
||||
<div key={f}>
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3 flex items-center gap-2">
|
||||
{f}
|
||||
<span className="text-xs text-gray-500">{total} template{total > 1 ? 's' : ''}</span>
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{groups.map((group, gi) => (
|
||||
<div key={gi}>
|
||||
{group.label && (
|
||||
<p className="text-xs text-gray-400 font-medium mb-2 uppercase tracking-wide">{group.label}</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{group.items.map(t => (
|
||||
<div key={t.id} className="relative rounded-lg overflow-hidden border border-slate-600 group">
|
||||
<img src={`/api/mockup/templates/image/${t.filename}`} alt={t.name}
|
||||
className="w-full h-32 object-cover" />
|
||||
<div className="p-2 bg-slate-800">
|
||||
<p className="text-xs text-white font-medium truncate">{t.name}</p>
|
||||
{t.description && <p className="text-xs text-gray-400 truncate">{t.description}</p>}
|
||||
</div>
|
||||
<button type="button" onClick={() => remove(t.id)}
|
||||
className="absolute top-1 right-1 bg-red-600/80 hover:bg-red-500 text-white text-xs rounded px-1.5 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
import type { ProductFilters } from '@/types/products'
|
||||
|
||||
interface Props {
|
||||
filters: ProductFilters
|
||||
total: number
|
||||
onChange: (f: Partial<ProductFilters>) => void
|
||||
}
|
||||
|
||||
export function ProductFiltersBar({ filters, total, onChange }: Props) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3 items-center mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher ref ou titre..."
|
||||
value={filters.search}
|
||||
onChange={(e) => onChange({ search: e.target.value })}
|
||||
className="flex-1 min-w-48 bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
{(['all', 'active', 'draft'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => onChange({ status: s })}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
filters.status === s
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
{s === 'all' ? 'Tous' : s === 'active' ? 'Actifs' : 'Inactifs'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-gray-400 ml-auto">{total} produit{total > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import type { CatalogProduct } from '@/types/products'
|
||||
import { MockupModal } from '@/components/mockup/MockupModal'
|
||||
|
||||
interface Props {
|
||||
products: CatalogProduct[]
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export function ProductTable({ products, loading }: Props) {
|
||||
const [mockupProduct, setMockupProduct] = useState<CatalogProduct | null>(null)
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20 text-gray-400">
|
||||
Chargement des produits…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (products.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-20 text-gray-400 text-sm">
|
||||
Aucun produit trouvé.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const displayed = products.slice(0, PAGE_SIZE)
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-400 border-b border-slate-700">
|
||||
<th className="pb-3 pr-4 font-medium">Référence</th>
|
||||
<th className="pb-3 pr-4 font-medium">Images</th>
|
||||
<th className="pb-3 pr-4 font-medium">Titre</th>
|
||||
<th className="pb-3 pr-4 font-medium">Prix</th>
|
||||
<th className="pb-3 pr-4 font-medium">Stock</th>
|
||||
<th className="pb-3 pr-4 font-medium">Statut</th>
|
||||
<th className="pb-3 pr-4 font-medium">Lien</th>
|
||||
<th className="pb-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayed.map((p) => (
|
||||
<tr key={p.shopifyId} className="border-b border-slate-700/50 hover:bg-slate-700/30 transition-colors">
|
||||
<td className="py-3 pr-4 font-mono text-indigo-300">{p.ref}</td>
|
||||
<td className="py-3 pr-4">
|
||||
{p.images.length > 0 ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<img src={p.images[0]} alt="" className="w-8 h-8 object-cover rounded border border-slate-600" />
|
||||
{p.images.length > 1 && (
|
||||
<span className="text-xs text-gray-400">+{p.images.length - 1}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-gray-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-white">{p.title}</td>
|
||||
<td className="py-3 pr-4 text-gray-300">{p.price} €</td>
|
||||
<td className="py-3 pr-4 text-gray-300">{p.stock}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
p.status === 'active'
|
||||
? 'bg-green-900/50 text-green-300'
|
||||
: 'bg-gray-700 text-gray-400'
|
||||
}`}>
|
||||
{p.status === 'active' ? 'Actif' : 'Inactif'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<a
|
||||
href={p.shopifyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-400 hover:text-indigo-300 text-xs underline"
|
||||
>
|
||||
Shopify ↗
|
||||
</a>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMockupProduct(p)}
|
||||
className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-indigo-600 text-gray-300 hover:text-white transition-colors"
|
||||
>
|
||||
Mockup
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{mockupProduct && (
|
||||
<MockupModal product={mockupProduct} onClose={() => setMockupProduct(null)} />
|
||||
)}
|
||||
{products.length > PAGE_SIZE && (
|
||||
<p className="text-center text-gray-400 text-xs mt-4">
|
||||
Affichage des {PAGE_SIZE} premiers résultats sur {products.length}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user