24 Commits

Author SHA1 Message Date
C_Ma_For bb596c7e32 feat(plan5): page /creation stepper 4 étapes + lien nav + fix TS shopifyMetafields
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:20:36 +02:00
C_Ma_For 1ed8c055d7 feat(plan5): composants PendingRowsTable + MetafieldResolver + CreationProgress 2026-06-09 10:14:12 +02:00
C_Ma_For 40d537efb6 feat(plan5): API POST /api/creation/execute streaming NDJSON 2026-06-09 10:09:27 +02:00
C_Ma_For 015833150a feat(plan5): API GET /api/creation/metafields (TDD) 2026-06-09 09:58:14 +02:00
C_Ma_For 04833b490c feat(plan5): API GET /api/creation/preview (TDD) 2026-06-09 09:57:29 +02:00
C_Ma_For 072110e655 feat(plan5): shopifyCollections — ajout produit à collection 2026-06-09 09:55:57 +02:00
C_Ma_For 421c275d48 feat(plan5): shopifyMetafields — matching + gestion définitions (TDD)
Implémente Task 4 du Plan 5:
- normalizeMetafieldKey: minuscules, accents retirés, espaces/parenth→_, max 30 chars
- matchMetafields: compare clé normalisée d'une colonne avec les clés existantes
- fetchMetafieldDefinitions: récupère les définitions Shopify existantes
- ensureMetafieldDefinition: crée une définition si manquante
- setProductMetafield: définit un métachamp sur un produit

7 tests passants (4 normalizeMetafieldKey + 3 matchMetafields).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 09:52:23 +02:00
C_Ma_For ed091711c8 feat(plan5): openaiClient — génération descriptions GPT-4o (TDD) 2026-06-09 09:42:26 +02:00
C_Ma_For 3aa770321c feat(plan5): creationSheets — lecture lignes en attente (TDD) 2026-06-09 09:40:23 +02:00
C_Ma_For 2e9df3659f feat(plan5): types création + modèle Prisma ProductCreation 2026-06-09 09:33:11 +02:00
C_Ma_For 1e48ca142f fix: remplacer status=any par deux appels active+draft (API REST Shopify) 2026-06-09 00:05:53 +02:00
C_Ma_For c89ab1c818 feat: lire tous les onglets Google Sheets (un onglet par famille de produits) 2026-06-08 23:56:22 +02:00
C_Ma_For 4263b8afd0 fix: onglet Google Sheets par défaut MATRICE au lieu de Produits 2026-06-08 23:52:09 +02:00
C_Ma_For 4db43f833b feat(plan4): page /parametres avec onglets Connexions + Utilisateurs 2026-06-08 23:29:32 +02:00
C_Ma_For 4e4268d678 feat(plan4): composants ConnectionStatus + UsersManager 2026-06-08 23:28:29 +02:00
C_Ma_For 1934d0b655 feat(plan4): page /produits avec catalogue Shopify + filtres 2026-06-08 23:26:41 +02:00
C_Ma_For 119123e006 feat(plan4): composants ProductFiltersBar + ProductTable 2026-06-08 23:26:01 +02:00
C_Ma_For 5825640105 feat(plan4): API GET /api/admin/connections/test 2026-06-08 23:23:53 +02:00
C_Ma_For f27b62a403 feat(plan4): API CRUD /api/admin/users (TDD) 2026-06-08 23:23:10 +02:00
C_Ma_For 9303aab78e feat(plan4): API GET /api/products/list avec filtre statut (TDD) 2026-06-08 23:21:18 +02:00
C_Ma_For 6dd6dc301e feat(plan4): types CatalogProduct, ProductFilters, ProductListResponse 2026-06-08 23:18:29 +02:00
C_Ma_For e576df334a fix: fusionner imports next/server dupliqués 2026-06-08 23:05:51 +02:00
C_Ma_For dc88e56f49 fix: supprimer API_VERSION inutilisé + eslint-disable _req 2026-06-08 22:59:30 +02:00
C_Ma_For e04e286111 fix: API Key Google Sheets (remplace service account) + signatures routes GET 2026-06-08 22:52:40 +02:00
37 changed files with 1993 additions and 41 deletions
@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "ProductCreation" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
"sheetTab" TEXT NOT NULL,
"sheetRow" INTEGER NOT NULL,
"shopifyId" TEXT NOT NULL,
"sku" TEXT NOT NULL,
"title" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'success',
"errorMessage" TEXT NOT NULL DEFAULT '',
CONSTRAINT "ProductCreation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
+22 -7
View File
@@ -7,13 +7,14 @@ datasource db {
}
model User {
id String @id @default(cuid())
email String @unique
name String
password String
role String @default("gestionnaire")
createdAt DateTime @default(now())
syncRuns SyncRun[]
id String @id @default(cuid())
email String @unique
name String
password String
role String @default("gestionnaire")
createdAt DateTime @default(now())
syncRuns SyncRun[]
productCreations ProductCreation[]
}
model SyncRun {
@@ -28,3 +29,17 @@ model SyncRun {
errors Int @default(0)
log String @default("[]")
}
model ProductCreation {
id String @id @default(cuid())
createdAt DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id])
sheetTab String
sheetRow Int
shopifyId String
sku String
title String
status String @default("success")
errorMessage String @default("")
}
+166
View File
@@ -0,0 +1,166 @@
'use client'
import { useState, useCallback } from 'react'
import { Header } from '@/components/layout/Header'
import { PendingRowsTable } from '@/components/creation/PendingRowsTable'
import { MetafieldResolver } from '@/components/creation/MetafieldResolver'
import { CreationProgress } from '@/components/creation/CreationProgress'
import type { PendingProduct, MetafieldResolution, CreationStreamEvent } from '@/types/creation'
type Step = 'analyse' | 'metafields' | 'confirmation' | 'execution'
export default function CreationPage() {
const [step, setStep] = useState<Step>('analyse')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [byTab, setByTab] = useState<Record<string, PendingProduct[]>>({})
const [total, setTotal] = useState(0)
const [specificColumns, setSpecificColumns] = useState<string[]>([])
const [resolutions, setResolutions] = useState<MetafieldResolution[]>([])
const [events, setEvents] = useState<CreationStreamEvent[]>([])
const [done, setDone] = useState(false)
const analyse = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/creation/preview')
const data = await res.json()
if (data.error) throw new Error(data.error)
setByTab(data.byTab)
setTotal(data.total)
setSpecificColumns(data.specificColumns)
setStep('metafields')
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur')
} finally {
setLoading(false)
}
}
const handleResolved = useCallback((r: MetafieldResolution[]) => { setResolutions(r) }, [])
const execute = async () => {
setStep('execution')
setEvents([])
setDone(false)
const res = await fetch('/api/creation/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resolutions }),
})
if (!res.body) return
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done: readerDone, value } = await reader.read()
if (readerDone) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.trim()) continue
try {
const event = JSON.parse(line) as CreationStreamEvent
setEvents(prev => [...prev, event])
if (event.type === 'done') setDone(true)
} catch { /* ligne incomplète */ }
}
}
setDone(true)
}
const STEPS: { id: Step; label: string }[] = [
{ id: 'analyse', label: 'Analyse' },
{ id: 'metafields', label: 'Métachamps' },
{ id: 'confirmation', label: 'Confirmation' },
{ id: 'execution', label: 'Exécution' },
]
const currentIdx = STEPS.findIndex(s => s.id === step)
return (
<>
<Header title="Création produits" />
<div className="p-6 space-y-6">
<div className="flex gap-2">
{STEPS.map((s, i) => (
<div key={s.id} className="flex items-center gap-2">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${step === s.id ? 'bg-indigo-600 text-white' : i < currentIdx ? 'bg-green-700 text-white' : 'bg-slate-700 text-gray-400'}`}>
{i + 1}. {s.label}
</span>
{i < STEPS.length - 1 && <span className="text-gray-600"></span>}
</div>
))}
</div>
<div className="bg-slate-800 rounded-xl p-6">
{error && <div className="mb-4 p-3 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">{error}</div>}
{step === 'analyse' && (
<div className="space-y-4">
<p className="text-gray-300 text-sm">
Lit tous les onglets du Google Sheets et détecte les lignes <code className="text-indigo-300">Publié=1</code> et <code className="text-indigo-300">ID vide</code>.
</p>
<button onClick={analyse} disabled={loading}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
{loading ? 'Analyse en cours…' : 'Analyser le Sheets'}
</button>
</div>
)}
{step === 'metafields' && (
<div className="space-y-6">
<PendingRowsTable byTab={byTab} total={total} />
{total > 0 && (
<>
<hr className="border-slate-700" />
<div>
<h2 className="text-base font-semibold text-white mb-3">Résolution des métachamps</h2>
<MetafieldResolver specificColumns={specificColumns} onResolved={handleResolved} />
</div>
<button onClick={() => setStep('confirmation')}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors">
Continuer
</button>
</>
)}
</div>
)}
{step === 'confirmation' && (
<div className="space-y-4">
<div className="space-y-2 text-sm">
<p className="text-gray-300"><span className="text-white font-medium">{total} produit{total > 1 ? 's' : ''}</span> à créer dans Shopify</p>
<p className="text-gray-300"><span className="text-white font-medium">{resolutions.filter(r => r.action !== 'skip').length}</span> type{resolutions.filter(r => r.action !== 'skip').length > 1 ? 's' : ''} de métachamps à assigner</p>
<p className="text-gray-300"><span className="text-white font-medium">{resolutions.filter(r => r.action === 'create').length}</span> nouvelle{resolutions.filter(r => r.action === 'create').length > 1 ? 's' : ''} définition{resolutions.filter(r => r.action === 'create').length > 1 ? 's' : ''} à créer dans Shopify</p>
<p className="text-yellow-300 text-xs mt-2"> Les descriptions seront générées par OpenAI GPT-4o (~0.01$ par produit)</p>
</div>
<div className="flex gap-3">
<button onClick={() => setStep('metafields')}
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-gray-300 rounded-lg text-sm font-medium transition-colors">
Retour
</button>
<button onClick={execute}
className="px-4 py-2 bg-green-700 hover:bg-green-600 text-white rounded-lg text-sm font-medium transition-colors">
Lancer la création ({total} produits)
</button>
</div>
</div>
)}
{step === 'execution' && (
<div className="space-y-4">
<CreationProgress events={events} done={done} />
{done && (
<button onClick={() => { setStep('analyse'); setEvents([]); setDone(false); setByTab({}); setTotal(0) }}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors">
Nouvelle session
</button>
)}
</div>
)}
</div>
</div>
</>
)
}
+34 -2
View File
@@ -1,9 +1,41 @@
'use client'
import { useState } from 'react'
import { useSession } from 'next-auth/react'
import { Header } from '@/components/layout/Header'
import { UsersManager } from '@/components/settings/UsersManager'
import { ConnectionStatus } from '@/components/settings/ConnectionStatus'
type Tab = 'connexions' | 'utilisateurs'
export default function ParametresPage() {
const { data: session } = useSession()
const isAdmin = session?.user?.role === 'admin'
const [tab, setTab] = useState<Tab>('connexions')
return (
<>
<Header title="⚙️ Paramètres" />
<div className="p-6"><p className="text-gray-400 text-sm">Module Paramètres à implémenter (Plan 4)</p></div>
<Header title="Paramètres" />
<div className="p-6 space-y-6">
{isAdmin && (
<div className="flex gap-2">
{(['connexions', 'utilisateurs'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
tab === t
? 'bg-indigo-600 text-white'
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
}`}
>
{t === 'connexions' ? 'Connexions' : 'Utilisateurs'}
</button>
))}
</div>
)}
{(!isAdmin || tab === 'connexions') && <ConnectionStatus />}
{isAdmin && tab === 'utilisateurs' && <UsersManager />}
</div>
</>
)
}
+49 -2
View File
@@ -1,9 +1,56 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { Header } from '@/components/layout/Header'
import { ProductFiltersBar } from '@/components/products/ProductFiltersBar'
import { ProductTable } from '@/components/products/ProductTable'
import type { CatalogProduct, ProductFilters } from '@/types/products'
export default function ProduitsPage() {
const [allProducts, setAllProducts] = useState<CatalogProduct[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filters, setFilters] = useState<ProductFilters>({ status: 'all', search: '' })
useEffect(() => {
setLoading(true)
setError(null)
const params = filters.status !== 'all' ? `?status=${filters.status}` : ''
fetch(`/api/products/list${params}`)
.then((r) => r.json())
.then((data) => {
if (data.error) throw new Error(data.error)
setAllProducts(data.products)
})
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false))
}, [filters.status])
const displayed = useMemo(() => {
if (!filters.search.trim()) return allProducts
const q = filters.search.toLowerCase()
return allProducts.filter(
(p) => p.title.toLowerCase().includes(q) || p.ref.toLowerCase().includes(q),
)
}, [allProducts, filters.search])
return (
<>
<Header title="📦 Produits" />
<div className="p-6"><p className="text-gray-400 text-sm">Module Produits à implémenter (Plan 4)</p></div>
<Header title="Produits" />
<div className="p-6">
<div className="bg-slate-800 rounded-xl p-6">
<ProductFiltersBar
filters={filters}
total={displayed.length}
onChange={(f) => setFilters((prev) => ({ ...prev, ...f }))}
/>
{error && (
<div className="mb-4 p-3 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">
{error}
</div>
)}
<ProductTable products={displayed} loading={loading} />
</div>
</div>
</>
)
}
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
async function testShopify(): Promise<{ ok: boolean; message: string }> {
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) return { ok: false, message: 'Variables manquantes' }
try {
const res = await fetch(`https://${domain}/admin/api/2024-01/shop.json`, {
headers: { 'X-Shopify-Access-Token': token },
})
if (!res.ok) return { ok: false, message: `HTTP ${res.status}` }
const data = await res.json()
return { ok: true, message: `Boutique : ${data.shop?.name ?? domain}` }
} catch (e) {
return { ok: false, message: e instanceof Error ? e.message : 'Erreur réseau' }
}
}
async function testSheets(): Promise<{ ok: boolean; message: string }> {
const apiKey = process.env.GOOGLE_API_KEY
const sheetId = process.env.GOOGLE_SHEETS_ID
if (!apiKey || !sheetId) return { ok: false, message: 'Variables manquantes' }
try {
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=properties.title`
const res = await fetch(url)
if (!res.ok) return { ok: false, message: `HTTP ${res.status}` }
const data = await res.json()
return { ok: true, message: `Feuille : ${data.properties?.title ?? sheetId}` }
} catch (e) {
return { ok: false, message: e instanceof Error ? e.message : 'Erreur réseau' }
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function GET(_req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session || session.user.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const [shopify, sheets] = await Promise.all([testShopify(), testSheets()])
return NextResponse.json({ shopify, sheets })
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs'
async function requireAdmin() {
const session = await getServerSession(authOptions)
if (!session || session.user.role !== 'admin') return null
return session
}
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
const session = await requireAdmin()
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const body = await req.json().catch(() => ({}))
const { name, role, password } = body as Record<string, string>
const data: Record<string, string> = {}
if (name) data.name = name
if (role) data.role = role
if (password) data.password = await bcrypt.hash(password, 12)
if (Object.keys(data).length === 0) {
return NextResponse.json({ error: 'Aucun champ à mettre à jour' }, { status: 400 })
}
const user = await prisma.user.update({
where: { id: params.id },
data,
select: { id: true, email: true, name: true, role: true, createdAt: true },
})
return NextResponse.json(user)
}
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
const session = await requireAdmin()
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
if (params.id === session.user.id) {
return NextResponse.json({ error: 'Impossible de se supprimer soi-même' }, { status: 400 })
}
await prisma.user.delete({ where: { id: params.id } })
return NextResponse.json({ ok: true })
}
+61
View File
@@ -0,0 +1,61 @@
import { GET, POST } from './route'
import { getServerSession } from 'next-auth'
import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs'
jest.mock('next-auth')
jest.mock('@/lib/prisma', () => ({ prisma: { user: { findMany: jest.fn(), create: jest.fn() } } }))
jest.mock('bcryptjs')
const mockSession = getServerSession as jest.MockedFunction<typeof getServerSession>
const mockFindMany = prisma.user.findMany as jest.MockedFunction<typeof prisma.user.findMany>
const mockCreate = prisma.user.create as jest.MockedFunction<typeof prisma.user.create>
const mockHash = bcrypt.hash as jest.MockedFunction<typeof bcrypt.hash>
const adminSession = { user: { id: 'admin-id', role: 'admin' } }
const gestSession = { user: { id: 'gest-id', role: 'gestionnaire' } }
describe('GET /api/admin/users', () => {
it('retourne 403 si non admin', async () => {
mockSession.mockResolvedValue(gestSession as never)
const res = await GET(new Request('http://localhost') as never)
expect(res.status).toBe(403)
})
it('retourne la liste des utilisateurs', async () => {
mockSession.mockResolvedValue(adminSession as never)
mockFindMany.mockResolvedValue([
{ id: '1', email: 'a@b.com', name: 'Alice', role: 'admin', createdAt: new Date() } as never,
])
const res = await GET(new Request('http://localhost') as never)
const body = await res.json()
expect(body).toHaveLength(1)
expect(body[0].email).toBe('a@b.com')
expect(body[0].password).toBeUndefined()
})
})
describe('POST /api/admin/users', () => {
it('retourne 403 si non admin', async () => {
mockSession.mockResolvedValue(gestSession as never)
const res = await POST(new Request('http://localhost', { method: 'POST', body: '{}' }) as never)
expect(res.status).toBe(403)
})
it('crée un utilisateur avec mot de passe hashé', async () => {
mockSession.mockResolvedValue(adminSession as never)
mockHash.mockResolvedValue('hashed' as never)
mockCreate.mockResolvedValue({ id: 'new', email: 'b@c.com', name: 'Bob', role: 'gestionnaire', createdAt: new Date() } as never)
const body = JSON.stringify({ email: 'b@c.com', name: 'Bob', password: 'pass123', role: 'gestionnaire' })
const res = await POST(new Request('http://localhost', { method: 'POST', body, headers: { 'Content-Type': 'application/json' } }) as never)
expect(res.status).toBe(201)
expect(mockHash).toHaveBeenCalledWith('pass123', 12)
})
it('retourne 400 si champs manquants', async () => {
mockSession.mockResolvedValue(adminSession as never)
const body = JSON.stringify({ email: 'b@c.com' })
const res = await POST(new Request('http://localhost', { method: 'POST', body, headers: { 'Content-Type': 'application/json' } }) as never)
expect(res.status).toBe(400)
})
})
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs'
async function requireAdmin() {
const session = await getServerSession(authOptions)
if (!session || session.user.role !== 'admin') return null
return session
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function GET(_req: NextRequest) {
const session = await requireAdmin()
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const users = await prisma.user.findMany({
orderBy: { createdAt: 'asc' },
select: { id: true, email: true, name: true, role: true, createdAt: true },
})
return NextResponse.json(users)
}
export async function POST(req: NextRequest) {
const session = await requireAdmin()
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const body = await req.json().catch(() => ({}))
const { email, name, password, role } = body as Record<string, string>
if (!email || !name || !password || !role) {
return NextResponse.json({ error: 'email, name, password et role requis' }, { status: 400 })
}
const hashed = await bcrypt.hash(password, 12)
const user = await prisma.user.create({
data: { email, name, password: hashed, role },
select: { id: true, email: true, name: true, role: true, createdAt: true },
})
return NextResponse.json(user, { status: 201 })
}
+107
View File
@@ -0,0 +1,107 @@
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { fetchPendingProducts } from '@/lib/creationSheets'
import { generateDescriptions } from '@/lib/openaiClient'
import { ensureMetafieldDefinition, setProductMetafield } from '@/lib/shopifyMetafields'
import { addProductToCollection } from '@/lib/shopifyCollections'
import { createShopifyProduct } from '@/lib/shopifySync'
import { prisma } from '@/lib/prisma'
import type { MetafieldResolution, CreationStreamEvent } from '@/types/creation'
function encode(event: CreationStreamEvent): string {
return JSON.stringify(event) + '\n'
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) {
return new Response(JSON.stringify({ error: 'Non authentifié' }), { status: 401 })
}
const userId = (session.user as { id: string }).id
const body = await req.json().catch(() => ({})) as { resolutions?: MetafieldResolution[] }
const resolutions: MetafieldResolution[] = body.resolutions ?? []
const resolutionMap = new Map<string, MetafieldResolution>(resolutions.map(r => [r.columnHeader, r]))
const stream = new ReadableStream({
async start(controller) {
const send = (event: CreationStreamEvent) => controller.enqueue(encode(event))
try {
const existing = await prisma.productCreation.findMany({ select: { sheetTab: true, sheetRow: true } })
const alreadyCreated = new Set(existing.map(e => `${e.sheetTab}:${e.sheetRow}`))
const pending = await fetchPendingProducts(alreadyCreated)
const total = pending.length
if (total === 0) {
send({ type: 'done', summary: { created: 0, errors: 0, status: 'success' } })
controller.close()
return
}
// Crée les définitions manquantes en amont
for (const resolution of resolutions) {
if (resolution.action === 'create') await ensureMetafieldDefinition(resolution)
}
let created = 0
let errors = 0
for (let i = 0; i < pending.length; i++) {
const product = pending[i]
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Génération descriptions…' })
try {
const { longDesc } = await generateDescriptions({
title: product.title,
rawDescription: '',
comment: product.comment,
vendor: product.vendor,
famille: product.famille,
})
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Création produit Shopify…' })
const shopifyId = await createShopifyProduct({
ref: product.sku,
handle: product.sku.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
title: product.title,
description: longDesc,
price: product.priceActual || '0.00',
stock: product.stock,
status: 'active',
})
if (product.collectionId) await addProductToCollection(shopifyId, product.collectionId)
if (product.subCollectionId) await addProductToCollection(shopifyId, product.subCollectionId)
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Métachamps…' })
for (const [colHeader, value] of Object.entries(product.specificFields)) {
const resolution = resolutionMap.get(colHeader)
if (!resolution || resolution.action === 'skip') continue
await setProductMetafield(shopifyId, resolution, value)
}
await prisma.productCreation.create({
data: { userId, sheetTab: product.tab, sheetRow: product.rowNumber, shopifyId, sku: product.sku, title: product.title, status: 'success' },
})
created++
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
send({ type: 'error', title: product.title, message })
await prisma.productCreation.create({
data: { userId, sheetTab: product.tab, sheetRow: product.rowNumber, shopifyId: '', sku: product.sku, title: product.title, status: 'error', errorMessage: message },
})
errors++
}
}
const status = errors === 0 ? 'success' : created === 0 ? 'error' : 'partial'
send({ type: 'done', summary: { created, errors, status } })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
send({ type: 'done', summary: { created: 0, errors: 1, status: 'error' } })
console.error('[creation/execute]', message)
} finally {
controller.close()
}
},
})
return new Response(stream, {
headers: { 'Content-Type': 'application/x-ndjson', 'Transfer-Encoding': 'chunked' },
})
}
@@ -0,0 +1,37 @@
import { GET } from './route'
import * as shopifyMetafields from '@/lib/shopifyMetafields'
import { getServerSession } from 'next-auth'
jest.mock('next-auth')
jest.mock('@/lib/shopifyMetafields')
const mockSession = getServerSession as jest.MockedFunction<typeof getServerSession>
const mockFetchDefs = shopifyMetafields.fetchMetafieldDefinitions as jest.MockedFunction<typeof shopifyMetafields.fetchMetafieldDefinitions>
const mockMatch = shopifyMetafields.matchMetafields as jest.MockedFunction<typeof shopifyMetafields.matchMetafields>
describe('GET /api/creation/metafields', () => {
beforeEach(() => {
mockSession.mockResolvedValue({ user: { id: 'u1', role: 'admin' } } as never)
mockFetchDefs.mockResolvedValue([
{ id: '1', namespace: 'custom', key: 'puissance', name: 'Puissance', type: 'single_line_text_field' },
])
mockMatch.mockReturnValue({
matched: { 'Puissance (AN)': { columnHeader: 'Puissance (AN)', action: 'map', namespace: 'custom', key: 'puissance', existingDefinitionId: '1' } },
unmatched: ['Batterie (AP)'],
})
})
it('retourne 401 sans session', async () => {
mockSession.mockResolvedValue(null)
const res = await GET(new Request('http://localhost/api/creation/metafields?columns=Puissance') as never)
expect(res.status).toBe(401)
})
it('retourne définitions + matching', async () => {
const res = await GET(new Request('http://localhost/api/creation/metafields?columns=Puissance+%28AN%29%2CBatterie+%28AP%29') as never)
const body = await res.json()
expect(body.definitions).toHaveLength(1)
expect(body.matched['Puissance (AN)'].key).toBe('puissance')
expect(body.unmatched).toContain('Batterie (AP)')
})
})
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { fetchMetafieldDefinitions, matchMetafields } from '@/lib/shopifyMetafields'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
try {
const { searchParams } = new URL(req.url)
const columnsParam = searchParams.get('columns') ?? ''
const columns = columnsParam ? columnsParam.split(',').map(c => c.trim()).filter(Boolean) : []
const definitions = await fetchMetafieldDefinitions()
const { matched, unmatched } = matchMetafields(columns, definitions)
return NextResponse.json({ definitions, matched, unmatched })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
@@ -0,0 +1,50 @@
import { GET } from './route'
import * as creationSheets from '@/lib/creationSheets'
import { getServerSession } from 'next-auth'
jest.mock('next-auth')
jest.mock('@/lib/creationSheets')
jest.mock('@/lib/prisma', () => ({
prisma: { productCreation: { findMany: jest.fn().mockResolvedValue([]) } },
}))
const mockSession = getServerSession as jest.MockedFunction<typeof getServerSession>
const mockFetch = creationSheets.fetchPendingProducts as jest.MockedFunction<typeof creationSheets.fetchPendingProducts>
const makePending = (tab: string, row: number, specific: Record<string, string> = {}) => ({
tab, rowNumber: row, sku: `SKU-${row}`, title: `Produit ${row}`,
priceMarket: '99', priceActual: '79', stock: 5, weightKg: 2,
vendor: 'BOSCH', famille: tab, sousFamille: '', comment: '',
collectionId: '10', subCollectionId: '', hasPhoto: false,
specificFields: specific,
})
describe('GET /api/creation/preview', () => {
beforeEach(() => {
mockSession.mockResolvedValue({ user: { id: 'u1', role: 'admin' } } as never)
mockFetch.mockResolvedValue([
makePending('OUTILLAGE', 2, { 'Puissance (AN)': '1500W' }),
makePending('CARRELAGE', 3),
])
})
it('retourne 401 sans session', async () => {
mockSession.mockResolvedValue(null)
const res = await GET(new Request('http://localhost') as never)
expect(res.status).toBe(401)
})
it('retourne les produits groupés par onglet', async () => {
const res = await GET(new Request('http://localhost') as never)
const body = await res.json()
expect(body.byTab['OUTILLAGE']).toHaveLength(1)
expect(body.byTab['CARRELAGE']).toHaveLength(1)
expect(body.total).toBe(2)
})
it('liste les colonnes spécifiques uniques', async () => {
const res = await GET(new Request('http://localhost') as never)
const body = await res.json()
expect(body.specificColumns).toContain('Puissance (AN)')
})
})
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { fetchPendingProducts } from '@/lib/creationSheets'
import { prisma } from '@/lib/prisma'
import type { PendingProduct } from '@/types/creation'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function GET(_req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
try {
const existing = await prisma.productCreation.findMany({ select: { sheetTab: true, sheetRow: true } })
const alreadyCreated = new Set(existing.map(e => `${e.sheetTab}:${e.sheetRow}`))
const pending = await fetchPendingProducts(alreadyCreated)
const byTab: Record<string, PendingProduct[]> = {}
const specificColumns = new Set<string>()
for (const p of pending) {
if (!byTab[p.tab]) byTab[p.tab] = []
byTab[p.tab].push(p)
Object.keys(p.specificFields).forEach(k => specificColumns.add(k))
}
return NextResponse.json({ byTab, total: pending.length, specificColumns: Array.from(specificColumns).sort() })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+59
View File
@@ -0,0 +1,59 @@
import { GET } from './route'
import * as shopifySync from '@/lib/shopifySync'
import type { ShopifyProductFull } from '@/types/sync'
jest.mock('@/lib/shopifySync')
const mockFetch = shopifySync.fetchAllShopifyProducts as jest.MockedFunction<typeof shopifySync.fetchAllShopifyProducts>
const PRODUCTS: ShopifyProductFull[] = [
{ shopifyId: '1', ref: 'REF-001', handle: 'ref-001', title: 'Brique rouge', description: '', price: '12.50', stock: 100, status: 'active' },
{ shopifyId: '2', ref: 'REF-002', handle: 'ref-002', title: 'Parpaing', description: '', price: '5.00', stock: 0, status: 'draft' },
{ shopifyId: '3', ref: 'REF-003', handle: 'ref-003', title: 'Ardoise', description: '', price: '80.00', stock: 20, status: 'active' },
]
describe('GET /api/products/list', () => {
beforeEach(() => {
process.env.SHOPIFY_STORE_DOMAIN = 'test.myshopify.com'
process.env.SHOPIFY_ADMIN_API_TOKEN = 'token'
mockFetch.mockResolvedValue(PRODUCTS)
})
afterEach(() => jest.clearAllMocks())
it('retourne tous les produits sans filtre', async () => {
const req = new Request('http://localhost/api/products/list')
const res = await GET(req as never)
const body = await res.json()
expect(body.total).toBe(3)
expect(body.products).toHaveLength(3)
})
it('filtre par status=active', async () => {
const req = new Request('http://localhost/api/products/list?status=active')
const res = await GET(req as never)
const body = await res.json()
expect(body.total).toBe(2)
body.products.forEach((p: { status: string }) => expect(p.status).toBe('active'))
})
it('filtre par status=draft', async () => {
const req = new Request('http://localhost/api/products/list?status=draft')
const res = await GET(req as never)
const body = await res.json()
expect(body.total).toBe(1)
expect(body.products[0].ref).toBe('REF-002')
})
it('retourne shopifyUrl pour chaque produit', async () => {
const req = new Request('http://localhost/api/products/list')
const res = await GET(req as never)
const body = await res.json()
expect(body.products[0].shopifyUrl).toContain('test.myshopify.com/admin/products/1')
})
it('retourne 500 si Shopify inaccessible', async () => {
mockFetch.mockRejectedValue(new Error('network error'))
const req = new Request('http://localhost/api/products/list')
const res = await GET(req as never)
expect(res.status).toBe(500)
})
})
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server'
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
import type { CatalogProduct, ProductListResponse } from '@/types/products'
export async function GET(req: NextRequest): Promise<NextResponse<ProductListResponse | { error: string }>> {
try {
const { searchParams } = new URL(req.url)
const statusFilter = searchParams.get('status') ?? 'all'
const domain = process.env.SHOPIFY_STORE_DOMAIN ?? ''
const shopifyProducts = await fetchAllShopifyProducts()
let products: CatalogProduct[] = shopifyProducts.map((p) => ({
shopifyId: p.shopifyId,
ref: p.ref,
handle: p.handle,
title: p.title,
price: p.price,
stock: p.stock,
status: p.status as CatalogProduct['status'],
shopifyUrl: `https://${domain}/admin/products/${p.shopifyId}`,
}))
if (statusFilter !== 'all') {
products = products.filter((p) => p.status === statusFilter)
}
return NextResponse.json({ products, total: products.length })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+3 -2
View File
@@ -1,8 +1,9 @@
import { NextResponse } from 'next/server'
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import type { SyncHistoryEntry, SyncLogEntry } from '@/types/sync'
export async function GET() {
// 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' },
+3 -2
View File
@@ -1,9 +1,10 @@
import { NextResponse } from 'next/server'
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() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function GET(_req: NextRequest) {
try {
const [sheetProducts, shopifyProducts] = await Promise.all([
readSheetProducts(),
@@ -0,0 +1,53 @@
'use client'
import type { CreationStreamEvent } from '@/types/creation'
interface Props {
events: CreationStreamEvent[]
done: boolean
}
export function CreationProgress({ events, done }: Props) {
const lastProgress = [...events].reverse().find(e => e.type === 'progress') as Extract<CreationStreamEvent, { type: 'progress' }> | undefined
const doneEvent = events.find(e => e.type === 'done') as Extract<CreationStreamEvent, { type: 'done' }> | undefined
const errors = events.filter(e => e.type === 'error') as Array<Extract<CreationStreamEvent, { type: 'error' }>>
return (
<div className="space-y-4">
{lastProgress && !done && (
<div>
<div className="flex justify-between text-sm text-gray-300 mb-1">
<span>{lastProgress.step}</span>
<span>{lastProgress.current}/{lastProgress.total}</span>
</div>
<div className="w-full bg-slate-700 rounded-full h-2">
<div className="bg-indigo-600 h-2 rounded-full transition-all"
style={{ width: `${(lastProgress.current / lastProgress.total) * 100}%` }} />
</div>
<p className="text-xs text-gray-400 mt-1 truncate">{lastProgress.tab} {lastProgress.title}</p>
</div>
)}
{errors.length > 0 && (
<div className="space-y-1">
{errors.map((e, i) => (
<div key={i} className="flex gap-2 p-2 bg-red-900/30 rounded text-xs text-red-300">
<span className="font-medium">{e.title}</span>
<span className="text-red-400"> {e.message}</span>
</div>
))}
</div>
)}
{doneEvent && (
<div className={`p-4 rounded-lg ${doneEvent.summary.status === 'success' ? 'bg-green-900/30 border border-green-700' : doneEvent.summary.status === 'partial' ? 'bg-yellow-900/30 border border-yellow-700' : 'bg-red-900/30 border border-red-700'}`}>
<p className={`font-semibold ${doneEvent.summary.status === 'success' ? 'text-green-300' : doneEvent.summary.status === 'partial' ? 'text-yellow-300' : 'text-red-300'}`}>
{doneEvent.summary.status === 'success' ? '✓ Terminé' : doneEvent.summary.status === 'partial' ? '⚠ Partiel' : '✗ Erreur'}
</p>
<p className="text-sm text-gray-300 mt-1">
{doneEvent.summary.created} créé{doneEvent.summary.created > 1 ? 's' : ''}
{doneEvent.summary.errors > 0 && `${doneEvent.summary.errors} erreur${doneEvent.summary.errors > 1 ? 's' : ''}`}
</p>
</div>
)}
{!done && !lastProgress && <p className="text-gray-400 text-sm">Démarrage</p>}
</div>
)
}
@@ -0,0 +1,117 @@
'use client'
import { useState, useEffect } from 'react'
import type { MetafieldDefinition, MetafieldResolution } from '@/types/creation'
import { normalizeMetafieldKey } from '@/lib/shopifyMetafields'
interface Props {
specificColumns: string[]
onResolved: (resolutions: MetafieldResolution[]) => void
}
export function MetafieldResolver({ specificColumns, onResolved }: Props) {
const [definitions, setDefinitions] = useState<MetafieldDefinition[]>([])
const [matched, setMatched] = useState<Record<string, MetafieldResolution>>({})
const [unmatched, setUnmatched] = useState<string[]>([])
const [resolutions, setResolutions] = useState<Record<string, MetafieldResolution>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (specificColumns.length === 0) { setLoading(false); onResolved([]); return }
const params = specificColumns.map(c => encodeURIComponent(c)).join(',')
fetch(`/api/creation/metafields?columns=${params}`)
.then(r => r.json())
.then(data => {
if (data.error) throw new Error(data.error)
setDefinitions(data.definitions)
setMatched(data.matched)
setUnmatched(data.unmatched)
const init: Record<string, MetafieldResolution> = { ...data.matched }
data.unmatched.forEach((col: string) => {
init[col] = { columnHeader: col, action: 'create', namespace: 'custom', key: normalizeMetafieldKey(col) }
})
setResolutions(init)
})
.catch(e => setError(e.message))
.finally(() => setLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [specificColumns.join(',')])
useEffect(() => {
if (!loading) onResolved(Object.values(resolutions))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resolutions, loading])
const updateResolution = (col: string, partial: Partial<MetafieldResolution>) => {
setResolutions(prev => ({ ...prev, [col]: { ...prev[col], ...partial } }))
}
if (loading) return <p className="text-gray-400 text-sm">Analyse des métachamps Shopify</p>
if (error) return <p className="text-red-400 text-sm">{error}</p>
if (specificColumns.length === 0) return <p className="text-gray-400 text-sm">Aucune colonne spécifique détectée.</p>
return (
<div className="space-y-4">
{Object.entries(matched).length > 0 && (
<div>
<h3 className="text-sm font-medium text-green-400 mb-2">
{Object.entries(matched).length} colonne{Object.entries(matched).length > 1 ? 's' : ''} avec correspondance automatique
</h3>
<div className="space-y-1">
{Object.entries(matched).map(([col, res]) => (
<div key={col} className="flex items-center gap-3 px-3 py-2 bg-slate-700/50 rounded text-sm">
<span className="text-gray-300 flex-1">{col}</span>
<span className="text-green-400 text-xs"> {res.namespace}.{res.key}</span>
</div>
))}
</div>
</div>
)}
{unmatched.length > 0 && (
<div>
<h3 className="text-sm font-medium text-yellow-400 mb-2">
{unmatched.length} colonne{unmatched.length > 1 ? 's' : ''} sans correspondance action requise
</h3>
<div className="space-y-2">
{unmatched.map(col => {
const res = resolutions[col]
if (!res) return null
return (
<div key={col} className="p-3 bg-slate-700 rounded-lg space-y-2">
<p className="text-sm text-white font-medium">{col}</p>
<div className="flex gap-2">
{(['create', 'map', 'skip'] as const).map(action => (
<button
key={action}
onClick={() => updateResolution(col, { action })}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${res.action === action ? 'bg-indigo-600 text-white' : 'bg-slate-600 text-gray-300 hover:bg-slate-500'}`}
>
{action === 'create' ? 'Créer nouveau' : action === 'map' ? 'Associer existant' : 'Ignorer'}
</button>
))}
</div>
{res.action === 'create' && (
<div className="flex gap-2">
<input value={res.key} onChange={e => updateResolution(col, { key: e.target.value })} placeholder="clé (ex: puissance)"
className="flex-1 bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white" />
<input value={res.namespace} onChange={e => updateResolution(col, { namespace: e.target.value })} placeholder="namespace"
className="w-24 bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white" />
</div>
)}
{res.action === 'map' && (
<select value={res.existingDefinitionId ?? ''}
onChange={e => { const def = definitions.find(d => d.id === e.target.value); if (def) updateResolution(col, { existingDefinitionId: def.id, key: def.key, namespace: def.namespace }) }}
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white">
<option value="">-- Choisir un métachamp existant --</option>
{definitions.map(d => <option key={d.id} value={d.id}>{d.name} ({d.namespace}.{d.key})</option>)}
</select>
)}
</div>
)
})}
</div>
</div>
)}
</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>
)
}
+1
View File
@@ -15,6 +15,7 @@ const NAV_ITEMS: NavItem[] = [
{ href: '/produits', label: 'Produits', icon: '📦' },
{ href: '/images', label: 'Images', icon: '🖼️' },
{ href: '/sync', label: 'Sync Sheets', icon: '🔄' },
{ href: '/creation', label: 'Création', icon: '✨' },
{ href: '/parametres', label: 'Paramètres', icon: '⚙️', adminOnly: true },
]
@@ -0,0 +1,38 @@
'use client'
import type { ProductFilters } from '@/types/products'
interface Props {
filters: ProductFilters
total: number
onChange: (f: Partial<ProductFilters>) => void
}
export function ProductFiltersBar({ filters, total, onChange }: Props) {
return (
<div className="flex flex-wrap gap-3 items-center mb-4">
<input
type="text"
placeholder="Rechercher ref ou titre..."
value={filters.search}
onChange={(e) => onChange({ search: e.target.value })}
className="flex-1 min-w-48 bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<div className="flex gap-2">
{(['all', 'active', 'draft'] as const).map((s) => (
<button
key={s}
onClick={() => onChange({ status: s })}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
filters.status === s
? 'bg-indigo-600 text-white'
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
}`}
>
{s === 'all' ? 'Tous' : s === 'active' ? 'Actifs' : 'Inactifs'}
</button>
))}
</div>
<span className="text-sm text-gray-400 ml-auto">{total} produit{total > 1 ? 's' : ''}</span>
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
'use client'
import type { CatalogProduct } from '@/types/products'
interface Props {
products: CatalogProduct[]
loading: boolean
}
const PAGE_SIZE = 50
export function ProductTable({ products, loading }: Props) {
if (loading) {
return (
<div className="flex items-center justify-center py-20 text-gray-400">
Chargement des produits
</div>
)
}
if (products.length === 0) {
return (
<div className="text-center py-20 text-gray-400 text-sm">
Aucun produit trouvé.
</div>
)
}
const displayed = products.slice(0, PAGE_SIZE)
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-400 border-b border-slate-700">
<th className="pb-3 pr-4 font-medium">Référence</th>
<th className="pb-3 pr-4 font-medium">Titre</th>
<th className="pb-3 pr-4 font-medium">Prix</th>
<th className="pb-3 pr-4 font-medium">Stock</th>
<th className="pb-3 pr-4 font-medium">Statut</th>
<th className="pb-3 font-medium">Lien</th>
</tr>
</thead>
<tbody>
{displayed.map((p) => (
<tr key={p.shopifyId} className="border-b border-slate-700/50 hover:bg-slate-700/30 transition-colors">
<td className="py-3 pr-4 font-mono text-indigo-300">{p.ref}</td>
<td className="py-3 pr-4 text-white">{p.title}</td>
<td className="py-3 pr-4 text-gray-300">{p.price} </td>
<td className="py-3 pr-4 text-gray-300">{p.stock}</td>
<td className="py-3 pr-4">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
p.status === 'active'
? 'bg-green-900/50 text-green-300'
: 'bg-gray-700 text-gray-400'
}`}>
{p.status === 'active' ? 'Actif' : 'Inactif'}
</span>
</td>
<td className="py-3">
<a
href={p.shopifyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-400 hover:text-indigo-300 text-xs underline"
>
Shopify
</a>
</td>
</tr>
))}
</tbody>
</table>
{products.length > PAGE_SIZE && (
<p className="text-center text-gray-400 text-xs mt-4">
Affichage des {PAGE_SIZE} premiers résultats sur {products.length}.
</p>
)}
</div>
)
}
@@ -0,0 +1,66 @@
'use client'
import { useState } from 'react'
interface TestResult {
ok: boolean
message: string
}
interface Results {
shopify: TestResult
sheets: TestResult
}
export function ConnectionStatus() {
const [results, setResults] = useState<Results | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const runTest = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/admin/connections/test')
if (!res.ok) throw new Error('Accès refusé')
setResults(await res.json())
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur')
} finally {
setLoading(false)
}
}
return (
<div className="bg-slate-800 rounded-xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Test des connexions</h2>
<button
onClick={runTest}
disabled={loading}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors mb-4"
>
{loading ? 'Test en cours…' : 'Tester les connexions'}
</button>
{error && <p className="text-red-400 text-sm mb-4">{error}</p>}
{results && (
<div className="space-y-3">
{(
[
{ key: 'shopify', label: 'Shopify Admin API' },
{ key: 'sheets', label: 'Google Sheets API' },
] as const
).map(({ key, label }) => (
<div key={key} className="flex items-center gap-3 p-3 bg-slate-700 rounded-lg">
<span className={`text-lg ${results[key].ok ? 'text-green-400' : 'text-red-400'}`}>
{results[key].ok ? '✓' : '✗'}
</span>
<div>
<p className="text-sm font-medium text-white">{label}</p>
<p className="text-xs text-gray-400">{results[key].message}</p>
</div>
</div>
))}
</div>
)}
</div>
)
}
+151
View File
@@ -0,0 +1,151 @@
'use client'
import { useState, useEffect } from 'react'
interface User {
id: string
email: string
name: string
role: string
createdAt: string
}
interface FormData {
email: string
name: string
password: string
role: 'admin' | 'gestionnaire'
}
const EMPTY_FORM: FormData = { email: '', name: '', password: '', role: 'gestionnaire' }
export function UsersManager() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [form, setForm] = useState<FormData>(EMPTY_FORM)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const load = () => {
setLoading(true)
fetch('/api/admin/users')
.then((r) => r.json())
.then(setUsers)
.catch(() => setError('Impossible de charger les utilisateurs'))
.finally(() => setLoading(false))
}
useEffect(load, [])
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
setError(null)
setSuccess(null)
try {
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
setSuccess(`Utilisateur ${data.name} créé.`)
setForm(EMPTY_FORM)
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur')
} finally {
setSubmitting(false)
}
}
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Supprimer ${name} ?`)) return
try {
const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' })
const data = await res.json()
if (!res.ok) throw new Error(data.error)
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur suppression')
}
}
return (
<div className="bg-slate-800 rounded-xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Gestion des utilisateurs</h2>
{/* Liste */}
{loading ? (
<p className="text-gray-400 text-sm mb-6">Chargement</p>
) : (
<div className="mb-6 space-y-2">
{users.map((u) => (
<div key={u.id} className="flex items-center justify-between p-3 bg-slate-700 rounded-lg">
<div>
<p className="text-sm font-medium text-white">{u.name}</p>
<p className="text-xs text-gray-400">{u.email} <span className="capitalize">{u.role}</span></p>
</div>
<button
onClick={() => handleDelete(u.id, u.name)}
className="text-red-400 hover:text-red-300 text-xs px-2 py-1 rounded hover:bg-red-900/30 transition-colors"
>
Supprimer
</button>
</div>
))}
{users.length === 0 && <p className="text-gray-400 text-sm">Aucun utilisateur.</p>}
</div>
)}
{/* Formulaire création */}
<form onSubmit={handleCreate} className="space-y-3">
<h3 className="text-sm font-medium text-gray-300">Nouvel utilisateur</h3>
<div className="grid grid-cols-2 gap-3">
<input
required
type="email"
placeholder="Email"
value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
className="bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<input
required
type="text"
placeholder="Nom"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
className="bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<input
required
type="password"
placeholder="Mot de passe"
value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
className="bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<select
value={form.role}
onChange={(e) => setForm((f) => ({ ...f, role: e.target.value as FormData['role'] }))}
className="bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="gestionnaire">Gestionnaire</option>
<option value="admin">Admin</option>
</select>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{success && <p className="text-green-400 text-sm">{success}</p>}
<button
type="submit"
disabled={submitting}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors"
>
{submitting ? 'Création…' : "Créer l'utilisateur"}
</button>
</form>
</div>
)
}
+103
View File
@@ -0,0 +1,103 @@
import { parseTabRows, findColIndex, extractSpecificFields } from './creationSheets'
const HEADERS = [
'ID', 'Type', 'UGS', 'Nom', 'Publié', 'Mis en avant ?',
'Description courte', 'Description longue',
'Date de début de promo', 'Date de fin de promo',
'État de la TVA', 'Classe de TVA', 'En stock ?',
'Stock (en pièce)', 'Vendre individuellement ?',
'Prix marché', 'Prix remisé', 'Code ecopart', 'Ecopart montant',
'Famille', 'Sous famille',
"Classe d'expédition (en retrait, livraison)",
'Conditionnement', 'Surface (m2)', 'Poids (kg) fonction unité',
'Images', 'Unité', 'Marque', 'Neuf ?',
"Commentaire\nexemple : Modèle d'expo (lègères rayures)",
'Longueur (cm)', 'Largeur (cm)', 'Hauteur (cm)', 'Épaisseur (cm)',
'Poids par carton', 'Stock conditionné (en carton)',
'Fabrication', 'Garantie', 'Coloris',
'Puissance (AN)', 'Batterie (AP)', 'Couple (AQ)',
'PHOTO ?', 'ID FAMILLE', 'ID SOUS FAMILLE',
]
const makeRow = (overrides: Record<number, string> = {}): string[] => {
const base: Record<number, string> = {
0: '', // ID vide = à créer
2: 'SKU-001', // UGS
3: 'Perceuse Test',
4: '1', // Publié
13: '5', // Stock
15: '99.99', // Prix marché
16: '79.99', // Prix remisé
19: 'OUTILLAGE',
24: '2.5', // Poids
27: 'BOSCH', // Marque
39: '1500W', // Puissance
40: 'Li-Ion', // Batterie
// Couple (index 41) laissé vide
[HEADERS.length - 2]: '10', // ID FAMILLE
[HEADERS.length - 1]: '20', // ID SOUS FAMILLE
}
return HEADERS.map((_, i) => overrides[i] ?? base[i] ?? '')
}
describe('findColIndex', () => {
it('trouve un en-tête exact', () => {
expect(findColIndex(HEADERS, 'UGS')).toBe(2)
})
it('trouve un en-tête commençant par Commentaire', () => {
const idx = findColIndex(HEADERS, 'Commentaire')
expect(idx).toBeGreaterThan(-1)
})
it('retourne -1 si non trouvé', () => {
expect(findColIndex(HEADERS, 'Inexistant')).toBe(-1)
})
})
describe('extractSpecificFields', () => {
it('exclut les colonnes communes', () => {
const row = makeRow()
const specific = extractSpecificFields(HEADERS, row)
expect(Object.keys(specific)).not.toContain('Nom')
expect(Object.keys(specific)).not.toContain('UGS')
})
it('inclut les colonnes spécifiques non vides', () => {
const row = makeRow()
const specific = extractSpecificFields(HEADERS, row)
expect(specific['Puissance (AN)']).toBe('1500W')
expect(specific['Batterie (AP)']).toBe('Li-Ion')
})
it('exclut les colonnes spécifiques vides', () => {
const row = makeRow()
const specific = extractSpecificFields(HEADERS, row)
expect(Object.keys(specific)).not.toContain('Couple (AQ)')
})
})
describe('parseTabRows', () => {
it('ignore les lignes avec ID déjà rempli', () => {
const row = makeRow({ 0: '12345' })
const result = parseTabRows('OUTILLAGE', HEADERS, [row])
expect(result).toHaveLength(0)
})
it('ignore les lignes avec Publié ≠ 1', () => {
const row = makeRow({ 4: '0' })
const result = parseTabRows('OUTILLAGE', HEADERS, [row])
expect(result).toHaveLength(0)
})
it('retourne un PendingProduct pour une ligne valide', () => {
const row = makeRow()
const result = parseTabRows('OUTILLAGE', HEADERS, [row])
expect(result).toHaveLength(1)
expect(result[0].sku).toBe('SKU-001')
expect(result[0].title).toBe('Perceuse Test')
expect(result[0].priceActual).toBe('79.99')
expect(result[0].collectionId).toBe('10')
expect(result[0].specificFields['Puissance (AN)']).toBe('1500W')
})
it('attribue le bon numéro de ligne (1-indexé depuis row 2)', () => {
const rows = [makeRow(), makeRow({ 3: 'Deuxième' })]
const result = parseTabRows('OUTILLAGE', HEADERS, rows)
expect(result[0].rowNumber).toBe(2)
expect(result[1].rowNumber).toBe(3)
})
})
+116
View File
@@ -0,0 +1,116 @@
import type { PendingProduct } from '@/types/creation'
const COMMON_HEADERS = new Set([
'ID', 'Type', 'UGS', 'Nom', 'Publié', 'Mis en avant ?',
'Description courte', 'Description longue',
'Date de début de promo', 'Date de fin de promo',
'État de la TVA', 'Classe de TVA', 'En stock ?',
'Stock (en pièce)', 'Vendre individuellement ?',
'Prix marché', 'Prix remisé', 'Code ecopart', 'Ecopart montant',
'Famille', 'Sous famille',
"Classe d'expédition (en retrait, livraison)",
'Conditionnement', 'Surface (m2)', 'Poids (kg) fonction unité',
'Images', 'Unité', 'Marque', 'Neuf ?', 'PHOTO ?',
'ID FAMILLE', 'ID SOUS FAMILLE',
])
export function findColIndex(headers: string[], search: string): number {
const exact = headers.findIndex(h => h === search)
if (exact !== -1) return exact
return headers.findIndex(h => h.startsWith(search))
}
export function extractSpecificFields(headers: string[], row: string[]): Record<string, string> {
const result: Record<string, string> = {}
headers.forEach((header, i) => {
if (!header) return
const isCommon = COMMON_HEADERS.has(header) || header.startsWith('Commentaire')
if (isCommon) return
const val = (row[i] ?? '').trim()
if (val) result[header] = val
})
return result
}
export function parseTabRows(tab: string, headers: string[], rows: string[][]): PendingProduct[] {
const idx = {
id: findColIndex(headers, 'ID'),
sku: findColIndex(headers, 'UGS'),
nom: findColIndex(headers, 'Nom'),
publie: findColIndex(headers, 'Publié'),
stock: findColIndex(headers, 'Stock (en pièce)'),
prixMarche: findColIndex(headers, 'Prix marché'),
prixRemise: findColIndex(headers, 'Prix remisé'),
poids: findColIndex(headers, 'Poids (kg) fonction unité'),
marque: findColIndex(headers, 'Marque'),
famille: findColIndex(headers, 'Famille'),
sousFamille: findColIndex(headers, 'Sous famille'),
commentaire: findColIndex(headers, 'Commentaire'),
photo: findColIndex(headers, 'PHOTO ?'),
collectionId: findColIndex(headers, 'ID FAMILLE'),
subCollectionId: findColIndex(headers, 'ID SOUS FAMILLE'),
}
const get = (row: string[], i: number) => (i >= 0 ? (row[i] ?? '').trim() : '')
return rows
.map((row, rowIdx): PendingProduct | null => {
const id = get(row, idx.id)
const publie = get(row, idx.publie)
if (id !== '' || publie !== '1') return null
return {
tab,
rowNumber: rowIdx + 2,
sku: get(row, idx.sku),
title: get(row, idx.nom),
priceMarket: get(row, idx.prixMarche).replace(',', '.'),
priceActual: get(row, idx.prixRemise).replace(',', '.'),
stock: parseInt(get(row, idx.stock) || '0', 10) || 0,
weightKg: parseFloat(get(row, idx.poids).replace(',', '.') || '0') || 0,
vendor: get(row, idx.marque),
famille: get(row, idx.famille),
sousFamille: get(row, idx.sousFamille),
comment: get(row, idx.commentaire),
collectionId: get(row, idx.collectionId),
subCollectionId: get(row, idx.subCollectionId),
hasPhoto: get(row, idx.photo) !== '',
specificFields: extractSpecificFields(headers, row),
}
})
.filter((p): p is PendingProduct => p !== null)
}
export async function fetchPendingProducts(
alreadyCreatedRows: Set<string>,
): Promise<PendingProduct[]> {
const apiKey = process.env.GOOGLE_API_KEY
const sheetId = process.env.GOOGLE_SHEETS_ID
if (!apiKey || !sheetId) throw new Error('GOOGLE_API_KEY et GOOGLE_SHEETS_ID requis')
const metaRes = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`,
)
if (!metaRes.ok) throw new Error(`Sheets metadata error: ${metaRes.status}`)
const meta = await metaRes.json()
const tabs: string[] = (meta.sheets as Array<{ properties: { title: string } }>).map(
s => s.properties.title,
)
const all: PendingProduct[] = []
for (const tab of tabs) {
const range = encodeURIComponent(`${tab}!A1:BZ`)
const res = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`,
)
if (!res.ok) continue
const data = await res.json()
const rows = (data.values ?? []) as string[][]
if (rows.length < 2) continue
const [headers, ...dataRows] = rows
const pending = parseTabRows(tab, headers, dataRows).filter(
p => !alreadyCreatedRows.has(`${p.tab}:${p.rowNumber}`),
)
all.push(...pending)
}
return all
}
+38 -23
View File
@@ -1,7 +1,5 @@
import type { SyncProduct } from '@/types/sync'
const API_VERSION = 'v4'
function normalizeHandle(ref: string): string {
return ref
.toLowerCase()
@@ -37,33 +35,50 @@ export function parseSheetRows(rows: string[][]): SyncProduct[] {
}
/**
* Lit tous les produits depuis Google Sheets via l'API v4.
* Utilise un service account (GOOGLE_SERVICE_ACCOUNT_EMAIL + GOOGLE_PRIVATE_KEY).
* Lit tous les produits depuis Google Sheets via l'API v4 (API Key publique).
* Si GOOGLE_SHEETS_TAB est défini, lit uniquement cet onglet.
* Sinon, lit tous les onglets du fichier et combine les produits.
* Le fichier Sheets doit être partagé en lecture ("Toute personne avec le lien").
*/
export async function readSheetProducts(): Promise<SyncProduct[]> {
const email = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL
const key = process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n')
const apiKey = process.env.GOOGLE_API_KEY
const sheetId = process.env.GOOGLE_SHEETS_ID
const sheetName = process.env.GOOGLE_SHEETS_TAB ?? 'Produits'
const singleTab = process.env.GOOGLE_SHEETS_TAB
if (!email || !key || !sheetId) {
throw new Error('Variables GOOGLE_SERVICE_ACCOUNT_EMAIL, GOOGLE_PRIVATE_KEY et GOOGLE_SHEETS_ID requises')
if (!apiKey || !sheetId) {
throw new Error('Variables GOOGLE_API_KEY et GOOGLE_SHEETS_ID requises')
}
const { google } = await import('googleapis')
const sheetNames = singleTab ? [singleTab] : await listSheetTabs(sheetId, apiKey)
const auth = new google.auth.GoogleAuth({
credentials: { client_email: email, private_key: key },
scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'],
})
const results = await Promise.all(
sheetNames.map((name) => readOneTab(sheetId, apiKey, name)),
)
const sheets = google.sheets({ version: API_VERSION, auth })
const response = await sheets.spreadsheets.values.get({
spreadsheetId: sheetId,
range: `${sheetName}!A2:F`, // A2 = skip header row
})
const rows = (response.data.values ?? []) as string[][]
return parseSheetRows(rows)
return results.flat()
}
async function listSheetTabs(sheetId: string, apiKey: string): Promise<string[]> {
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`
const res = await fetch(url)
if (!res.ok) {
const err = await res.text()
throw new Error(`Google Sheets API error: ${res.status}${err}`)
}
const data = await res.json()
return (data.sheets as Array<{ properties: { title: string } }>).map(
(s) => s.properties.title,
)
}
async function readOneTab(sheetId: string, apiKey: string, sheetName: string): Promise<SyncProduct[]> {
const range = encodeURIComponent(`${sheetName}!A2:F`)
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`
const res = await fetch(url)
if (!res.ok) {
const err = await res.text()
throw new Error(`Google Sheets API error (onglet "${sheetName}"): ${res.status}${err}`)
}
const data = await res.json()
return parseSheetRows((data.values ?? []) as string[][])
}
+46
View File
@@ -0,0 +1,46 @@
import { generateDescriptions } from './openaiClient'
const mockFetch = jest.fn()
global.fetch = mockFetch
const mockResponse = (content: string) => ({
ok: true,
json: async () => ({ choices: [{ message: { content } }] }),
})
describe('generateDescriptions', () => {
beforeEach(() => {
process.env.OPENAI_API_KEY = 'sk-test'
mockFetch.mockReset()
})
it('retourne shortDesc et longDesc depuis OpenAI', async () => {
mockFetch
.mockResolvedValueOnce(mockResponse('<p>Description courte</p>'))
.mockResolvedValueOnce(mockResponse('<p>Description longue</p>'))
const result = await generateDescriptions({
title: 'Perceuse', rawDescription: 'Une perceuse', comment: '', vendor: 'BOSCH', famille: 'OUTILLAGE',
})
expect(result.shortDesc).toBe('<p>Description courte</p>')
expect(result.longDesc).toBe('<p>Description longue</p>')
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('lance les deux requêtes en parallèle', async () => {
mockFetch.mockResolvedValue(mockResponse('<p>ok</p>'))
await generateDescriptions({ title: 'T', rawDescription: '', comment: '', vendor: '', famille: '' })
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('lance une erreur si OPENAI_API_KEY manquant', async () => {
delete process.env.OPENAI_API_KEY
await expect(generateDescriptions({ title: 'T', rawDescription: '', comment: '', vendor: '', famille: '' }))
.rejects.toThrow('OPENAI_API_KEY')
})
it('lance une erreur si la réponse OpenAI est non-ok', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 429, text: async () => 'rate limited' })
await expect(generateDescriptions({ title: 'T', rawDescription: '', comment: '', vendor: '', famille: '' }))
.rejects.toThrow('429')
})
})
+46
View File
@@ -0,0 +1,46 @@
interface DescriptionInput {
title: string
rawDescription: string
comment: string
vendor: string
famille: string
}
interface Descriptions {
shortDesc: string
longDesc: string
}
async function callOpenAI(prompt: string): Promise<string> {
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) throw new Error('OPENAI_API_KEY requis')
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ model: 'gpt-4o', messages: [{ role: 'user', content: prompt }], temperature: 0.7 }),
})
if (!res.ok) {
const err = await res.text()
throw new Error(`OpenAI API error: ${res.status}${err}`)
}
const data = await res.json()
return (data.choices?.[0]?.message?.content ?? '').trim()
}
function buildPrompt(input: DescriptionInput, length: number): string {
return `A partir des caractéristiques suivantes, crée un texte descriptif de ${length} caractères en faisant attention au SEO pour qu'il soit en rapport avec la catégorie ${input.famille} et le produit ${input.title} :
Nom = ${input.title}
Description : ${input.rawDescription}
Commentaire : ${input.comment}
Marque : ${input.vendor}
Formate ce texte en HTML.
Ta réponse doit commencer directement par une balise HTML (comme <p> ou <h1>), sans bloc \`\`\`html, sans aucun mot ou encadré autour.`
}
export async function generateDescriptions(input: DescriptionInput): Promise<Descriptions> {
const [shortDesc, longDesc] = await Promise.all([
callOpenAI(buildPrompt(input, 400)),
callOpenAI(buildPrompt(input, 1000)),
])
return { shortDesc, longDesc }
}
+27
View File
@@ -0,0 +1,27 @@
const API_VERSION = '2024-01'
function getConfig() {
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
return {
baseUrl: `https://${domain}/admin/api/${API_VERSION}`,
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
}
}
export async function addProductToCollection(shopifyProductId: string, collectionId: string): Promise<void> {
if (!collectionId?.trim()) return
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/collects.json`, {
method: 'POST',
headers,
body: JSON.stringify({ collect: { product_id: shopifyProductId, collection_id: collectionId } }),
})
if (!res.ok) {
const err = await res.text()
if (!err.includes('already') && !err.includes('taken')) {
throw new Error(`Shopify addToCollection error: ${res.status}${err}`)
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import { normalizeMetafieldKey, matchMetafields } from './shopifyMetafields'
import type { MetafieldDefinition } from '@/types/creation'
describe('normalizeMetafieldKey', () => {
it('minuscules + accents retirés', () => {
expect(normalizeMetafieldKey('Éclairage LED')).toBe('eclairage_led')
})
it('parenthèses et espaces → underscore', () => {
expect(normalizeMetafieldKey('Puissance (AN)')).toBe('puissance_an')
})
it('limité à 30 caractères', () => {
const long = 'a'.repeat(40)
expect(normalizeMetafieldKey(long)).toHaveLength(30)
})
it('underscores multiples réduits à un', () => {
expect(normalizeMetafieldKey('Poids par carton')).toBe('poids_par_carton')
})
})
describe('matchMetafields', () => {
const definitions: MetafieldDefinition[] = [
{ id: '1', namespace: 'custom', key: 'puissance_an', name: 'Puissance (AN)', type: 'single_line_text_field' },
{ id: '2', namespace: 'custom', key: 'coloris', name: 'Coloris', type: 'single_line_text_field' },
]
it('trouve une correspondance exacte sur la clé normalisée', () => {
const result = matchMetafields(['Puissance (AN)', 'Batterie (AP)'], definitions)
expect(result.matched['Puissance (AN)'].existingDefinitionId).toBe('1')
})
it('met les colonnes sans correspondance dans unmatched', () => {
const result = matchMetafields(['Puissance (AN)', 'Batterie (AP)'], definitions)
expect(result.unmatched).toContain('Batterie (AP)')
})
it('ne met pas en unmatched les colonnes qui ont un match', () => {
const result = matchMetafields(['Puissance (AN)'], definitions)
expect(result.unmatched).not.toContain('Puissance (AN)')
})
})
+104
View File
@@ -0,0 +1,104 @@
import type { MetafieldDefinition, MetafieldResolution } from '@/types/creation'
const API_VERSION = '2024-01'
function getConfig() {
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
return {
baseUrl: `https://${domain}/admin/api/${API_VERSION}`,
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
}
}
export function normalizeMetafieldKey(header: string): string {
return header
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '') // retire les accents
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.replace(/_+/g, '_')
.substring(0, 30)
}
export interface MatchResult {
matched: Record<string, MetafieldResolution>
unmatched: string[]
}
export function matchMetafields(columnHeaders: string[], definitions: MetafieldDefinition[]): MatchResult {
const matched: Record<string, MetafieldResolution> = {}
const unmatched: string[] = []
for (const header of columnHeaders) {
const key = normalizeMetafieldKey(header)
const def = definitions.find(d => d.key === key)
if (def) {
matched[header] = { columnHeader: header, action: 'map', namespace: def.namespace, key: def.key, existingDefinitionId: def.id }
} else {
unmatched.push(header)
}
}
return { matched, unmatched }
}
export async function fetchMetafieldDefinitions(): Promise<MetafieldDefinition[]> {
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/metafield_definitions.json?owner_type=PRODUCT&limit=250`, { headers })
if (!res.ok) throw new Error(`Shopify metafield definitions error: ${res.status}`)
const data = await res.json()
return (data.metafield_definitions ?? []).map((d: {
id: string | number; namespace: string; key: string; name: string; type: { name: string } | string
}) => ({
id: String(d.id),
namespace: d.namespace,
key: d.key,
name: d.name,
type: typeof d.type === 'string' ? d.type : d.type?.name ?? 'single_line_text_field',
}))
}
export async function ensureMetafieldDefinition(resolution: MetafieldResolution): Promise<void> {
if (resolution.action !== 'create') return
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/metafield_definitions.json`, {
method: 'POST',
headers,
body: JSON.stringify({
metafield_definition: {
namespace: resolution.namespace,
key: resolution.key,
name: resolution.columnHeader,
type: 'single_line_text_field',
owner_type: 'PRODUCT',
},
}),
})
if (!res.ok) {
const err = await res.text()
if (!err.includes('taken') && !err.includes('already')) {
throw new Error(`Shopify create metafield definition error: ${res.status}${err}`)
}
}
}
export async function setProductMetafield(
shopifyProductId: string,
resolution: MetafieldResolution,
value: string,
): Promise<void> {
if (resolution.action === 'skip' || !value.trim()) return
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/products/${shopifyProductId}/metafields.json`, {
method: 'POST',
headers,
body: JSON.stringify({
metafield: { namespace: resolution.namespace, key: resolution.key, value, type: 'single_line_text_field' },
}),
})
if (!res.ok) {
const err = await res.text()
throw new Error(`Shopify set metafield error: ${res.status}${err}`)
}
}
+11 -3
View File
@@ -54,18 +54,26 @@ function extractNextUrl(linkHeader: string | null): string | null {
/**
* Récupère tous les produits Shopify (actifs + drafts), en paginant.
* Limite : 250 par page via cursor-based pagination.
* L'API REST ne supporte pas status=any — on fait deux passes (active + draft).
*/
export async function fetchAllShopifyProducts(): Promise<ShopifyProductFull[]> {
const [active, draft] = await Promise.all([
fetchByStatus('active'),
fetchByStatus('draft'),
])
return [...active, ...draft]
}
async function fetchByStatus(status: 'active' | 'draft'): Promise<ShopifyProductFull[]> {
const { baseUrl, headers } = getConfig()
const all: ShopifyProductFull[] = []
let url: string | null =
`${baseUrl}/products.json?limit=250&fields=id,title,handle,status,body_html,variants&status=any`
`${baseUrl}/products.json?limit=250&fields=id,title,handle,status,body_html,variants&status=${status}`
while (url) {
const res = await fetch(url, { headers })
if (!res.ok) throw new Error(`Shopify fetch error: ${res.status}`)
if (!res.ok) throw new Error(`Shopify fetch error (${status}): ${res.status}`)
const data = await res.json()
all.push(...(data.products as ShopifyAPIProduct[]).map(toSyncProduct))
url = extractNextUrl(res.headers.get('Link'))
+57
View File
@@ -0,0 +1,57 @@
/** Un produit en attente de création, extrait d'un onglet Sheets */
export interface PendingProduct {
tab: string // nom de l'onglet (famille)
rowNumber: number // numéro de ligne 1-indexé dans le sheet
sku: string // UGS
title: string // Nom
priceMarket: string // Prix marché (compare_at_price), ex: "29.99"
priceActual: string // Prix remisé (price), ex: "19.99"
stock: number
weightKg: number
vendor: string // Marque
famille: string // Famille (contexte IA)
sousFamille: string
comment: string // Commentaire
collectionId: string // ID FAMILLE → collection Shopify
subCollectionId: string // ID SOUS FAMILLE
hasPhoto: boolean
specificFields: Record<string, string> // header → valeur pour les métachamps
}
/** Définition de métachamp existante dans Shopify */
export interface MetafieldDefinition {
id: string // ex: "123"
namespace: string // ex: "custom"
key: string // ex: "puissance"
name: string // ex: "Puissance"
type: string // ex: "single_line_text_field"
}
/** Résolution d'une colonne spécifique vers un métachamp */
export interface MetafieldResolution {
columnHeader: string // en-tête colonne Sheets, ex: "Puissance (AN)"
action: 'create' | 'map' | 'skip'
namespace: string // "custom" par défaut
key: string // clé normalisée
existingDefinitionId?: string // si action === 'map'
}
/** Événement streamé par /api/creation/execute */
export type CreationStreamEvent =
| { type: 'progress'; current: number; total: number; tab: string; title: string; step: string }
| { type: 'error'; title: string; message: string }
| { type: 'done'; summary: { created: number; errors: number; status: 'success' | 'partial' | 'error' } }
/** Résumé d'une création (stocké en DB) */
export interface CreationRecord {
id: string
createdAt: string
userName: string
sheetTab: string
sheetRow: number
shopifyId: string
sku: string
title: string
status: 'success' | 'error'
errorMessage?: string
}
+25
View File
@@ -0,0 +1,25 @@
// src/types/products.ts
/** Produit Shopify simplifié pour affichage catalogue */
export interface CatalogProduct {
shopifyId: string
ref: string // SKU de la première variante
handle: string
title: string
price: string // "29.99"
stock: number // inventory_quantity variante 1
status: 'active' | 'draft' | 'archived'
shopifyUrl: string // https://materiaux-destock.myshopify.com/admin/products/<id>
}
/** Filtres appliqués à la liste */
export interface ProductFilters {
status: 'all' | 'active' | 'draft'
search: string // filtre titre ou ref (côté client)
}
/** Réponse de l'API /api/products/list */
export interface ProductListResponse {
products: CatalogProduct[]
total: number
}