feat: collection picker in product creation flow
- API /api/creation/collections liste toutes les collections Shopify - Détection automatique depuis la colonne ID FAMILLE du Sheets - Sélecteur avec recherche si absente ou pour modifier - Collection affichée dans l'écran de confirmation - collectionOverride passé à l'exécution comme fallback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
import { Header } from '@/components/layout/Header'
|
import { Header } from '@/components/layout/Header'
|
||||||
import { PendingRowsTable } from '@/components/creation/PendingRowsTable'
|
import { PendingRowsTable } from '@/components/creation/PendingRowsTable'
|
||||||
import { MetafieldResolver } from '@/components/creation/MetafieldResolver'
|
import { MetafieldResolver } from '@/components/creation/MetafieldResolver'
|
||||||
@@ -9,6 +9,8 @@ import type { PendingProduct, MetafieldResolution, CreationStreamEvent } from '@
|
|||||||
|
|
||||||
type Step = 'famille' | 'analyse' | 'metafields' | 'confirmation' | 'execution'
|
type Step = 'famille' | 'analyse' | 'metafields' | 'confirmation' | 'execution'
|
||||||
|
|
||||||
|
interface ShopifyCollection { id: string; title: string }
|
||||||
|
|
||||||
export default function CreationPage() {
|
export default function CreationPage() {
|
||||||
const [step, setStep] = useState<Step>('famille')
|
const [step, setStep] = useState<Step>('famille')
|
||||||
const [selectedTab, setSelectedTab] = useState<string | null>(null)
|
const [selectedTab, setSelectedTab] = useState<string | null>(null)
|
||||||
@@ -23,9 +25,28 @@ export default function CreationPage() {
|
|||||||
const [events, setEvents] = useState<CreationStreamEvent[]>([])
|
const [events, setEvents] = useState<CreationStreamEvent[]>([])
|
||||||
const [done, setDone] = useState(false)
|
const [done, setDone] = useState(false)
|
||||||
|
|
||||||
|
// Collection
|
||||||
|
const [collections, setCollections] = useState<ShopifyCollection[]>([])
|
||||||
|
const [detectedCollectionId, setDetectedCollectionId] = useState<string>('')
|
||||||
|
const [collectionOverride, setCollectionOverride] = useState<string>('')
|
||||||
|
const [collectionSearch, setCollectionSearch] = useState('')
|
||||||
|
const [loadingCollections, setLoadingCollections] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadingCollections(true)
|
||||||
|
fetch('/api/creation/collections')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setCollections(d.collections ?? []))
|
||||||
|
.finally(() => setLoadingCollections(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSelectTab = (tab: string) => {
|
const handleSelectTab = (tab: string) => {
|
||||||
setSelectedTab(tab)
|
setSelectedTab(tab)
|
||||||
setStep('analyse')
|
setStep('analyse')
|
||||||
|
setDetectedCollectionId('')
|
||||||
|
setCollectionOverride('')
|
||||||
|
setCollectionSearch('')
|
||||||
|
setClearResult(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const analyse = async () => {
|
const analyse = async () => {
|
||||||
@@ -41,6 +62,17 @@ export default function CreationPage() {
|
|||||||
setByTab(data.byTab)
|
setByTab(data.byTab)
|
||||||
setTotal(data.total)
|
setTotal(data.total)
|
||||||
setSpecificColumns(data.specificColumns)
|
setSpecificColumns(data.specificColumns)
|
||||||
|
|
||||||
|
// Détecter la collection depuis les produits analysés
|
||||||
|
const products: PendingProduct[] = Object.values(data.byTab as Record<string, PendingProduct[]>).flat()
|
||||||
|
const ids = Array.from(new Set(products.map(p => p.collectionId).filter(Boolean)))
|
||||||
|
if (ids.length === 1) {
|
||||||
|
setDetectedCollectionId(ids[0])
|
||||||
|
setCollectionOverride(ids[0])
|
||||||
|
} else {
|
||||||
|
setDetectedCollectionId('')
|
||||||
|
setCollectionOverride('')
|
||||||
|
}
|
||||||
setStep('metafields')
|
setStep('metafields')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Erreur')
|
setError(e instanceof Error ? e.message : 'Erreur')
|
||||||
@@ -58,7 +90,7 @@ export default function CreationPage() {
|
|||||||
const res = await fetch('/api/creation/execute', {
|
const res = await fetch('/api/creation/execute', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ resolutions, tab: selectedTab }),
|
body: JSON.stringify({ resolutions, tab: selectedTab, collectionOverride: collectionOverride || undefined }),
|
||||||
})
|
})
|
||||||
if (!res.body) return
|
if (!res.body) return
|
||||||
const reader = res.body.getReader()
|
const reader = res.body.getReader()
|
||||||
@@ -114,8 +146,16 @@ export default function CreationPage() {
|
|||||||
setDone(false)
|
setDone(false)
|
||||||
setByTab({})
|
setByTab({})
|
||||||
setTotal(0)
|
setTotal(0)
|
||||||
|
setDetectedCollectionId('')
|
||||||
|
setCollectionOverride('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filteredCollections = collectionSearch
|
||||||
|
? collections.filter(c => c.title.toLowerCase().includes(collectionSearch.toLowerCase()))
|
||||||
|
: collections
|
||||||
|
|
||||||
|
const selectedCollection = collections.find(c => c.id === collectionOverride)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="Création produits" />
|
<Header title="Création produits" />
|
||||||
@@ -171,6 +211,57 @@ export default function CreationPage() {
|
|||||||
<PendingRowsTable byTab={byTab} total={total} />
|
<PendingRowsTable byTab={byTab} total={total} />
|
||||||
{total > 0 && (
|
{total > 0 && (
|
||||||
<>
|
<>
|
||||||
|
<hr className="border-slate-700" />
|
||||||
|
|
||||||
|
{/* Collection Shopify */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-white mb-1">Collection Shopify</h2>
|
||||||
|
{detectedCollectionId ? (
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xs text-green-400">✓ Détectée depuis le Sheets :</span>
|
||||||
|
<span className="text-xs text-white font-medium">
|
||||||
|
{collections.find(c => c.id === detectedCollectionId)?.title ?? `ID ${detectedCollectionId}`}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={() => { setDetectedCollectionId(''); setCollectionOverride('') }}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-300">modifier</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-amber-400 mb-2">⚠ Aucune collection détectée dans le Sheets — choisissez-en une :</p>
|
||||||
|
)}
|
||||||
|
{(!detectedCollectionId || collectionOverride !== detectedCollectionId) && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selectedCollection && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-blue-300 font-medium">→ {selectedCollection.title}</span>
|
||||||
|
<button type="button" onClick={() => setCollectionOverride('')}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-300">✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={collectionSearch}
|
||||||
|
onChange={e => setCollectionSearch(e.target.value)}
|
||||||
|
placeholder={loadingCollections ? 'Chargement…' : 'Rechercher une collection Shopify…'}
|
||||||
|
className="w-full max-w-sm bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
{collectionSearch && filteredCollections.length > 0 && (
|
||||||
|
<div className="max-w-sm bg-slate-700 border border-slate-600 rounded shadow-lg max-h-48 overflow-y-auto">
|
||||||
|
{filteredCollections.map(c => (
|
||||||
|
<button key={c.id} type="button"
|
||||||
|
onClick={() => { setCollectionOverride(c.id); setCollectionSearch('') }}
|
||||||
|
className="w-full text-left px-3 py-1.5 text-sm text-white hover:bg-slate-600">
|
||||||
|
{c.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{collectionSearch && filteredCollections.length === 0 && (
|
||||||
|
<p className="text-xs text-gray-400">Aucune collection trouvée</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr className="border-slate-700" />
|
<hr className="border-slate-700" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-semibold text-white mb-3">Résolution des métachamps</h2>
|
<h2 className="text-base font-semibold text-white mb-3">Résolution des métachamps</h2>
|
||||||
@@ -189,6 +280,11 @@ export default function CreationPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2 text-sm">
|
<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">{total} produit{total > 1 ? 's' : ''}</span> à créer dans Shopify</p>
|
||||||
|
<p className="text-gray-300">
|
||||||
|
Collection : <span className="text-white font-medium">
|
||||||
|
{collections.find(c => c.id === collectionOverride)?.title ?? (collectionOverride ? `ID ${collectionOverride}` : <span className="text-amber-400">Aucune</span>)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
<p className="text-gray-300"><span className="text-white font-medium">{resolutions.filter(r => r.action !== 'skip').length}</span> type{resolutions.filter(r => r.action !== 'skip').length > 1 ? 's' : ''} de métachamps à assigner</p>
|
<p className="text-gray-300"><span className="text-white font-medium">{resolutions.filter(r => r.action !== '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-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>
|
<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>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
|
||||||
|
const API_VERSION = '2024-01'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
|
||||||
|
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||||
|
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||||
|
if (!domain || !token) return NextResponse.json({ error: 'Config Shopify manquante' }, { status: 500 })
|
||||||
|
|
||||||
|
const baseUrl = `https://${domain}/admin/api/${API_VERSION}`
|
||||||
|
const headers = { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' }
|
||||||
|
|
||||||
|
// Récupère custom collections + smart collections
|
||||||
|
const [customRes, smartRes] = await Promise.all([
|
||||||
|
fetch(`${baseUrl}/custom_collections.json?limit=250&fields=id,title`, { headers }),
|
||||||
|
fetch(`${baseUrl}/smart_collections.json?limit=250&fields=id,title`, { headers }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const custom = customRes.ok ? (await customRes.json()).custom_collections as { id: number; title: string }[] : []
|
||||||
|
const smart = smartRes.ok ? (await smartRes.json()).smart_collections as { id: number; title: string }[] : []
|
||||||
|
|
||||||
|
const collections = [...custom, ...smart]
|
||||||
|
.map(c => ({ id: String(c.id), title: c.title }))
|
||||||
|
.sort((a, b) => a.title.localeCompare(b.title))
|
||||||
|
|
||||||
|
return NextResponse.json({ collections })
|
||||||
|
}
|
||||||
@@ -20,9 +20,10 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userId = (session.user as { id: string }).id
|
const userId = (session.user as { id: string }).id
|
||||||
const body = await req.json().catch(() => ({})) as { resolutions?: MetafieldResolution[]; tab?: string }
|
const body = await req.json().catch(() => ({})) as { resolutions?: MetafieldResolution[]; tab?: string; collectionOverride?: string }
|
||||||
const resolutions: MetafieldResolution[] = body.resolutions ?? []
|
const resolutions: MetafieldResolution[] = body.resolutions ?? []
|
||||||
const tabFilter: string | undefined = body.tab ?? undefined
|
const tabFilter: string | undefined = body.tab ?? undefined
|
||||||
|
const collectionOverride: string | undefined = body.collectionOverride ?? undefined
|
||||||
const resolutionMap = new Map<string, MetafieldResolution>(resolutions.map(r => [r.columnHeader, r]))
|
const resolutionMap = new Map<string, MetafieldResolution>(resolutions.map(r => [r.columnHeader, r]))
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
@@ -83,7 +84,8 @@ export async function POST(req: NextRequest) {
|
|||||||
weightKg: product.weightKg,
|
weightKg: product.weightKg,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
})
|
})
|
||||||
if (product.collectionId) await addProductToCollection(shopifyId, product.collectionId)
|
const effectiveCollectionId = product.collectionId || collectionOverride || ''
|
||||||
|
if (effectiveCollectionId) await addProductToCollection(shopifyId, effectiveCollectionId)
|
||||||
if (product.subCollectionId) await addProductToCollection(shopifyId, product.subCollectionId)
|
if (product.subCollectionId) await addProductToCollection(shopifyId, product.subCollectionId)
|
||||||
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Métachamps…' })
|
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)) {
|
for (const [colHeader, value] of Object.entries(product.specificFields)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user