a9850c8efd
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
383 lines
18 KiB
TypeScript
383 lines
18 KiB
TypeScript
'use client'
|
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
|
import { Header } from '@/components/layout/Header'
|
|
import { PendingRowsTable } from '@/components/creation/PendingRowsTable'
|
|
import { MetafieldResolver } from '@/components/creation/MetafieldResolver'
|
|
import { CreationProgress } from '@/components/creation/CreationProgress'
|
|
import { FamilySelector } from '@/components/common/FamilySelector'
|
|
import type { PendingProduct, MetafieldResolution, CreationStreamEvent } from '@/types/creation'
|
|
|
|
type Step = 'famille' | 'analyse' | 'metafields' | 'confirmation' | 'execution'
|
|
|
|
interface ShopifyCollection { id: string; title: string }
|
|
|
|
export default function CreationPage() {
|
|
const [step, setStep] = useState<Step>('famille')
|
|
const [selectedTab, setSelectedTab] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [byTab, setByTab] = useState<Record<string, PendingProduct[]>>({})
|
|
const [total, setTotal] = useState(0)
|
|
const [specificColumns, setSpecificColumns] = useState<string[]>([])
|
|
const [resolutions, setResolutions] = useState<MetafieldResolution[]>([])
|
|
const [events, setEvents] = useState<CreationStreamEvent[]>([])
|
|
const [done, setDone] = useState(false)
|
|
const [stopped, setStopped] = useState(false)
|
|
const abortRef = useRef<AbortController | null>(null)
|
|
|
|
// Sync stock
|
|
const [stockSyncing, setStockSyncing] = useState(false)
|
|
const [stockResult, setStockResult] = useState<{ updated: number; errors: number } | null>(null)
|
|
const [stockProgress, setStockProgress] = useState<{ current: number; total: number; title: string } | null>(null)
|
|
|
|
const syncStock = async () => {
|
|
setStockSyncing(true)
|
|
setStockResult(null)
|
|
setStockProgress(null)
|
|
try {
|
|
const res = await fetch('/api/creation/sync-stock', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
|
|
if (!res.body) return
|
|
const reader = res.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buffer = ''
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
buffer += decoder.decode(value, { stream: true })
|
|
const lines = buffer.split('\n')
|
|
buffer = lines.pop() ?? ''
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue
|
|
try {
|
|
const evt = JSON.parse(line)
|
|
if (evt.type === 'progress') setStockProgress({ current: evt.current, total: evt.total, title: evt.title })
|
|
if (evt.type === 'done') setStockResult({ updated: evt.updated, errors: evt.errors })
|
|
} catch { /* */ }
|
|
}
|
|
}
|
|
} finally {
|
|
setStockSyncing(false)
|
|
setStockProgress(null)
|
|
}
|
|
}
|
|
|
|
// Collection
|
|
const [collections, setCollections] = useState<ShopifyCollection[]>([])
|
|
const [detectedCollectionId, setDetectedCollectionId] = useState<string>('')
|
|
const [collectionOverride, setCollectionOverride] = useState<string>('')
|
|
const [collectionSearch, setCollectionSearch] = useState('')
|
|
const [loadingCollections, setLoadingCollections] = useState(false)
|
|
|
|
useEffect(() => {
|
|
setLoadingCollections(true)
|
|
fetch('/api/creation/collections')
|
|
.then(r => r.json())
|
|
.then(d => setCollections(d.collections ?? []))
|
|
.finally(() => setLoadingCollections(false))
|
|
}, [])
|
|
|
|
const handleSelectTab = (tab: string) => {
|
|
setSelectedTab(tab)
|
|
setStep('analyse')
|
|
setDetectedCollectionId('')
|
|
setCollectionOverride('')
|
|
setCollectionSearch('')
|
|
}
|
|
|
|
const analyse = async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const url = selectedTab
|
|
? `/api/creation/preview?tab=${encodeURIComponent(selectedTab)}`
|
|
: '/api/creation/preview'
|
|
const res = await fetch(url)
|
|
const data = await res.json()
|
|
if (data.error) throw new Error(data.error)
|
|
setByTab(data.byTab)
|
|
setTotal(data.total)
|
|
setSpecificColumns(data.specificColumns)
|
|
|
|
// Détecter la collection depuis les produits analysés
|
|
const products: PendingProduct[] = Object.values(data.byTab as Record<string, PendingProduct[]>).flat()
|
|
const ids = Array.from(new Set(products.map(p => p.collectionId).filter(Boolean)))
|
|
if (ids.length === 1) {
|
|
setDetectedCollectionId(ids[0])
|
|
setCollectionOverride(ids[0])
|
|
} else {
|
|
setDetectedCollectionId('')
|
|
setCollectionOverride('')
|
|
}
|
|
setStep('metafields')
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Erreur')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleResolved = useCallback((r: MetafieldResolution[]) => { setResolutions(r) }, [])
|
|
|
|
const stop = useCallback(() => {
|
|
abortRef.current?.abort()
|
|
setStopped(true)
|
|
setDone(true)
|
|
}, [])
|
|
|
|
const execute = async () => {
|
|
const controller = new AbortController()
|
|
abortRef.current = controller
|
|
setStep('execution')
|
|
setEvents([])
|
|
setDone(false)
|
|
setStopped(false)
|
|
try {
|
|
const res = await fetch('/api/creation/execute', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ resolutions, tab: selectedTab, collectionOverride: collectionOverride || undefined }),
|
|
signal: controller.signal,
|
|
})
|
|
if (!res.body) return
|
|
const reader = res.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buffer = ''
|
|
while (true) {
|
|
const { done: readerDone, value } = await reader.read()
|
|
if (readerDone) break
|
|
buffer += decoder.decode(value, { stream: true })
|
|
const lines = buffer.split('\n')
|
|
buffer = lines.pop() ?? ''
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue
|
|
try {
|
|
const event = JSON.parse(line) as CreationStreamEvent
|
|
setEvents(prev => [...prev, event])
|
|
if (event.type === 'done') setDone(true)
|
|
} catch { /* ligne incomplète */ }
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if ((e as Error).name !== 'AbortError') {
|
|
setEvents(prev => [...prev, { type: 'error', title: '', message: (e as Error).message }])
|
|
}
|
|
}
|
|
setDone(true)
|
|
}
|
|
|
|
const STEPS: { id: Step; label: string }[] = [
|
|
{ id: 'analyse', label: 'Analyse' },
|
|
{ id: 'metafields', label: 'Métachamps' },
|
|
{ id: 'confirmation', label: 'Confirmation' },
|
|
{ id: 'execution', label: 'Exécution' },
|
|
]
|
|
const currentIdx = STEPS.findIndex(s => s.id === step)
|
|
|
|
const reset = () => {
|
|
abortRef.current?.abort()
|
|
abortRef.current = null
|
|
setStep('famille')
|
|
setSelectedTab(null)
|
|
setEvents([])
|
|
setDone(false)
|
|
setStopped(false)
|
|
setByTab({})
|
|
setTotal(0)
|
|
setDetectedCollectionId('')
|
|
setCollectionOverride('')
|
|
}
|
|
|
|
const filteredCollections = collectionSearch
|
|
? collections.filter(c => c.title.toLowerCase().includes(collectionSearch.toLowerCase()))
|
|
: collections
|
|
|
|
const selectedCollection = collections.find(c => c.id === collectionOverride)
|
|
|
|
return (
|
|
<>
|
|
<Header title="Création produits" />
|
|
<div className="p-6 space-y-6">
|
|
<div className="flex gap-2">
|
|
{STEPS.map((s, i) => (
|
|
<div key={s.id} className="flex items-center gap-2">
|
|
<span className={`px-3 py-1 rounded-full text-xs font-medium ${step === s.id ? 'bg-indigo-600 text-white' : i < currentIdx ? 'bg-green-700 text-white' : 'bg-slate-700 text-gray-400'}`}>
|
|
{i + 1}. {s.label}
|
|
</span>
|
|
{i < STEPS.length - 1 && <span className="text-gray-600">→</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Étape 0 — Sélection famille */}
|
|
{step === 'famille' && (
|
|
<div className="space-y-4">
|
|
<div className="bg-slate-800 rounded-xl p-6">
|
|
<h2 className="text-sm font-semibold text-white mb-4">Choisir une famille</h2>
|
|
<FamilySelector onSelect={handleSelectTab} />
|
|
</div>
|
|
<div className="bg-slate-800 rounded-xl p-6">
|
|
<h2 className="text-sm font-semibold text-white mb-1">Synchroniser le stock</h2>
|
|
<p className="text-xs text-gray-400 mb-4">Met à jour le stock Shopify pour tous les produits existants (ceux avec un ID Shopify dans le Sheets).</p>
|
|
<button onClick={syncStock} disabled={stockSyncing}
|
|
className="px-4 py-2 bg-amber-700 hover:bg-amber-600 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
|
|
{stockSyncing ? (stockProgress ? `Mise à jour… ${stockProgress.current}/${stockProgress.total}` : 'Chargement…') : 'Synchroniser le stock depuis Sheets'}
|
|
</button>
|
|
{stockSyncing && stockProgress && (
|
|
<p className="mt-2 text-xs text-gray-400 truncate">→ {stockProgress.title}</p>
|
|
)}
|
|
{stockResult && (
|
|
<p className="mt-3 text-sm">
|
|
{stockResult.errors === 0
|
|
? <span className="text-green-400">✓ {stockResult.updated} produit{stockResult.updated > 1 ? 's' : ''} mis à jour</span>
|
|
: <span className="text-amber-400">✓ {stockResult.updated} mis à jour — ⚠ {stockResult.errors} erreur{stockResult.errors > 1 ? 's' : ''}</span>
|
|
}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="bg-slate-800 rounded-xl p-6" style={{ display: step === 'famille' ? 'none' : undefined }}>
|
|
{error && <div className="mb-4 p-3 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">{error}</div>}
|
|
|
|
{step === 'analyse' && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-2 bg-indigo-950/50 border border-indigo-800/50 rounded-lg px-3 py-2 w-fit">
|
|
<span className="text-indigo-400 text-sm">📂 Famille :</span>
|
|
<span className="text-white text-sm font-semibold">{selectedTab}</span>
|
|
<button onClick={() => { setStep('famille'); setSelectedTab(null) }} className="ml-2 text-xs text-slate-400 hover:text-slate-200">changer</button>
|
|
</div>
|
|
<p className="text-gray-300 text-sm">
|
|
Détecte les lignes de <strong className="text-white">{selectedTab}</strong> où <code className="text-indigo-300">Publié=1</code> et <code className="text-indigo-300">ID vide</code>.
|
|
</p>
|
|
<button onClick={analyse} disabled={loading}
|
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
|
|
{loading ? 'Analyse en cours…' : 'Analyser le Sheets'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{step === 'metafields' && (
|
|
<div className="space-y-6">
|
|
<PendingRowsTable byTab={byTab} total={total} />
|
|
{total > 0 && (
|
|
<>
|
|
<hr className="border-slate-700" />
|
|
|
|
{/* Collection Shopify */}
|
|
<div>
|
|
<h2 className="text-base font-semibold text-white mb-1">Collection Shopify</h2>
|
|
{detectedCollectionId ? (
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className="text-xs text-green-400">✓ Détectée depuis le Sheets :</span>
|
|
<span className="text-xs text-white font-medium">
|
|
{collections.find(c => c.id === detectedCollectionId)?.title ?? `ID ${detectedCollectionId}`}
|
|
</span>
|
|
<button type="button" onClick={() => { setDetectedCollectionId(''); setCollectionOverride('') }}
|
|
className="text-xs text-gray-500 hover:text-gray-300">modifier</button>
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-amber-400 mb-2">⚠ Aucune collection détectée dans le Sheets — choisissez-en une :</p>
|
|
)}
|
|
{(!detectedCollectionId || collectionOverride !== detectedCollectionId) && (
|
|
<div className="space-y-2">
|
|
{selectedCollection && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-blue-300 font-medium">→ {selectedCollection.title}</span>
|
|
<button type="button" onClick={() => setCollectionOverride('')}
|
|
className="text-xs text-gray-500 hover:text-gray-300">✕</button>
|
|
</div>
|
|
)}
|
|
<input
|
|
type="text"
|
|
value={collectionSearch}
|
|
onChange={e => setCollectionSearch(e.target.value)}
|
|
placeholder={loadingCollections ? 'Chargement…' : 'Rechercher une collection Shopify…'}
|
|
className="w-full max-w-sm bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
|
/>
|
|
{collectionSearch && filteredCollections.length > 0 && (
|
|
<div className="max-w-sm bg-slate-700 border border-slate-600 rounded shadow-lg max-h-48 overflow-y-auto">
|
|
{filteredCollections.map(c => (
|
|
<button key={c.id} type="button"
|
|
onClick={() => { setCollectionOverride(c.id); setCollectionSearch('') }}
|
|
className="w-full text-left px-3 py-1.5 text-sm text-white hover:bg-slate-600">
|
|
{c.title}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
{collectionSearch && filteredCollections.length === 0 && (
|
|
<p className="text-xs text-gray-400">Aucune collection trouvée</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<hr className="border-slate-700" />
|
|
<div>
|
|
<h2 className="text-base font-semibold text-white mb-3">Résolution des métachamps</h2>
|
|
<MetafieldResolver specificColumns={specificColumns} onResolved={handleResolved} />
|
|
</div>
|
|
<button onClick={() => setStep('confirmation')}
|
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors">
|
|
Continuer →
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{step === 'confirmation' && (
|
|
<div className="space-y-4">
|
|
<div className="space-y-2 text-sm">
|
|
<p className="text-gray-300"><span className="text-white font-medium">{total} produit{total > 1 ? 's' : ''}</span> à créer dans Shopify</p>
|
|
<p className="text-gray-300">
|
|
Collection : <span className="text-white font-medium">
|
|
{collections.find(c => c.id === collectionOverride)?.title ?? (collectionOverride ? `ID ${collectionOverride}` : <span className="text-amber-400">Aucune</span>)}
|
|
</span>
|
|
</p>
|
|
<p className="text-gray-300"><span className="text-white font-medium">{resolutions.filter(r => r.action !== 'skip').length}</span> type{resolutions.filter(r => r.action !== 'skip').length > 1 ? 's' : ''} de métachamps à assigner</p>
|
|
<p className="text-gray-300"><span className="text-white font-medium">{resolutions.filter(r => r.action === 'create').length}</span> nouvelle{resolutions.filter(r => r.action === 'create').length > 1 ? 's' : ''} définition{resolutions.filter(r => r.action === 'create').length > 1 ? 's' : ''} à créer dans Shopify</p>
|
|
<p className="text-yellow-300 text-xs mt-2">⚠ Les descriptions seront générées par OpenAI GPT-4o (~0.01$ par produit)</p>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<button onClick={() => setStep('metafields')}
|
|
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-gray-300 rounded-lg text-sm font-medium transition-colors">
|
|
← Retour
|
|
</button>
|
|
<button onClick={execute}
|
|
className="px-4 py-2 bg-green-700 hover:bg-green-600 text-white rounded-lg text-sm font-medium transition-colors">
|
|
Lancer la création ({total} produits)
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{step === 'execution' && (
|
|
<div className="space-y-4">
|
|
<CreationProgress events={events} done={done} />
|
|
<div className="flex gap-3">
|
|
{!done && (
|
|
<button onClick={stop}
|
|
className="px-4 py-2 bg-red-700 hover:bg-red-600 text-white rounded-lg text-sm font-medium transition-colors">
|
|
⏹ Arrêter
|
|
</button>
|
|
)}
|
|
{done && (
|
|
<>
|
|
{stopped && <p className="text-amber-400 text-sm self-center">⚠ Création interrompue — les produits déjà créés sont conservés dans Shopify.</p>}
|
|
<button onClick={reset}
|
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors">
|
|
Nouvelle session
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|