diff --git a/src/components/creation/CreationProgress.tsx b/src/components/creation/CreationProgress.tsx new file mode 100644 index 0000000..2e1c378 --- /dev/null +++ b/src/components/creation/CreationProgress.tsx @@ -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 | undefined + const doneEvent = events.find(e => e.type === 'done') as Extract | undefined + const errors = events.filter(e => e.type === 'error') as Array> + + return ( +
+ {lastProgress && !done && ( +
+
+ {lastProgress.step} + {lastProgress.current}/{lastProgress.total} +
+
+
+
+

{lastProgress.tab} — {lastProgress.title}

+
+ )} + {errors.length > 0 && ( +
+ {errors.map((e, i) => ( +
+ {e.title} + — {e.message} +
+ ))} +
+ )} + {doneEvent && ( +
+

+ {doneEvent.summary.status === 'success' ? '✓ Terminé' : doneEvent.summary.status === 'partial' ? '⚠ Partiel' : '✗ Erreur'} +

+

+ {doneEvent.summary.created} créé{doneEvent.summary.created > 1 ? 's' : ''} + {doneEvent.summary.errors > 0 && ` — ${doneEvent.summary.errors} erreur${doneEvent.summary.errors > 1 ? 's' : ''}`} +

+
+ )} + {!done && !lastProgress &&

Démarrage…

} +
+ ) +} diff --git a/src/components/creation/MetafieldResolver.tsx b/src/components/creation/MetafieldResolver.tsx new file mode 100644 index 0000000..3f5337a --- /dev/null +++ b/src/components/creation/MetafieldResolver.tsx @@ -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([]) + const [matched, setMatched] = useState>({}) + const [unmatched, setUnmatched] = useState([]) + const [resolutions, setResolutions] = useState>({}) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 = { ...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) => { + setResolutions(prev => ({ ...prev, [col]: { ...prev[col], ...partial } })) + } + + if (loading) return

Analyse des métachamps Shopify…

+ if (error) return

{error}

+ if (specificColumns.length === 0) return

Aucune colonne spécifique détectée.

+ + return ( +
+ {Object.entries(matched).length > 0 && ( +
+

+ ✓ {Object.entries(matched).length} colonne{Object.entries(matched).length > 1 ? 's' : ''} avec correspondance automatique +

+
+ {Object.entries(matched).map(([col, res]) => ( +
+ {col} + → {res.namespace}.{res.key} +
+ ))} +
+
+ )} + {unmatched.length > 0 && ( +
+

+ ⚠ {unmatched.length} colonne{unmatched.length > 1 ? 's' : ''} sans correspondance — action requise +

+
+ {unmatched.map(col => { + const res = resolutions[col] + if (!res) return null + return ( +
+

{col}

+
+ {(['create', 'map', 'skip'] as const).map(action => ( + + ))} +
+ {res.action === 'create' && ( +
+ 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" /> + 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" /> +
+ )} + {res.action === 'map' && ( + + )} +
+ ) + })} +
+
+ )} +
+ ) +} diff --git a/src/components/creation/PendingRowsTable.tsx b/src/components/creation/PendingRowsTable.tsx new file mode 100644 index 0000000..b4b22c1 --- /dev/null +++ b/src/components/creation/PendingRowsTable.tsx @@ -0,0 +1,54 @@ +'use client' +import type { PendingProduct } from '@/types/creation' + +interface Props { + byTab: Record + total: number +} + +export function PendingRowsTable({ byTab, total }: Props) { + if (total === 0) { + return ( +
+ Aucun produit en attente (Publié=1 et ID vide). +
+ ) + } + return ( +
+ {Object.entries(byTab).map(([tab, products]) => ( +
+

+ {tab} — {products.length} produit{products.length > 1 ? 's' : ''} +

+
+ + + + + + + + + + + + {products.map((p) => ( + + + + + + + + ))} + +
SKUTitrePrixStockMétachamps
{p.sku}{p.title}{p.priceActual} €{p.stock} + {Object.keys(p.specificFields).length} champ{Object.keys(p.specificFields).length > 1 ? 's' : ''} +
+
+
+ ))} +
+ ) +}