Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb596c7e32 | |||
| 1ed8c055d7 | |||
| 40d537efb6 | |||
| 015833150a | |||
| 04833b490c | |||
| 072110e655 | |||
| 421c275d48 | |||
| ed091711c8 | |||
| 3aa770321c | |||
| 2e9df3659f | |||
| 1e48ca142f | |||
| c89ab1c818 | |||
| 4263b8afd0 | |||
| 4db43f833b | |||
| 4e4268d678 | |||
| 1934d0b655 | |||
| 119123e006 | |||
| 5825640105 | |||
| f27b62a403 | |||
| 9303aab78e | |||
| 6dd6dc301e | |||
| e576df334a | |||
| dc88e56f49 | |||
| e04e286111 |
@@ -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
@@ -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("")
|
||||
}
|
||||
|
||||
@@ -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 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" />
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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,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)')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
// 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 })
|
||||
}
|
||||
}
|
||||
@@ -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,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 })
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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[][])
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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'))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user