114e20c9fb
Les associations colonne→métachamp sont sauvegardées dans data/metafield-resolutions.json et rechargées automatiquement à la prochaine création de produits. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
220 lines
12 KiB
TypeScript
220 lines
12 KiB
TypeScript
'use client'
|
|
import { useState, useEffect, useRef } 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)
|
|
const [searches, setSearches] = useState<Record<string, string>>({})
|
|
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
|
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (specificColumns.length === 0) { setLoading(false); onResolved([]); return }
|
|
const params = specificColumns.map(c => encodeURIComponent(c)).join(',')
|
|
Promise.all([
|
|
fetch(`/api/creation/metafields?columns=${params}`).then(r => r.json()),
|
|
fetch('/api/creation/resolutions').then(r => r.json()),
|
|
])
|
|
.then(([data, cache]: [{ error?: string; definitions: MetafieldDefinition[]; matched: Record<string, MetafieldResolution>; unmatched: string[] }, Record<string, MetafieldResolution>]) => {
|
|
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) => {
|
|
// Utilise le cache persisté si disponible, sinon 'skip' par défaut
|
|
init[col] = cache[col] ?? { columnHeader: col, action: 'skip', namespace: 'custom', key: normalizeMetafieldKey(col) }
|
|
})
|
|
setResolutions(init)
|
|
})
|
|
.catch(e => setError((e as Error).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 => {
|
|
const next = { ...prev, [col]: { ...prev[col], ...partial } }
|
|
// Sauvegarde en différé pour éviter un appel par frappe
|
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
|
saveTimeoutRef.current = setTimeout(() => {
|
|
fetch('/api/creation/resolutions', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(next),
|
|
}).catch(() => null)
|
|
}, 800)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const filteredDefs = (col: string) => {
|
|
const q = (searches[col] ?? '').toLowerCase()
|
|
if (!q) return definitions
|
|
return definitions.filter(d =>
|
|
d.name.toLowerCase().includes(q) ||
|
|
d.key.toLowerCase().includes(q) ||
|
|
d.namespace.toLowerCase().includes(q)
|
|
)
|
|
}
|
|
|
|
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>
|
|
<div className="mb-3 p-3 bg-slate-700/50 rounded-lg border border-slate-600">
|
|
<p className="text-xs text-gray-400">
|
|
Ces colonnes n'ont pas de métachamp correspondant dans Shopify.
|
|
Choisissez pour chacune : <span className="text-indigo-300 font-medium">Créer nouveau</span> (nouveau métachamp Shopify),
|
|
<span className="text-blue-300 font-medium"> Associer</span> (utiliser un existant), ou
|
|
<span className="text-gray-300 font-medium"> Ignorer</span> (ne pas synchroniser).
|
|
</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{unmatched.map(col => {
|
|
const res = resolutions[col]
|
|
if (!res) return null
|
|
return (
|
|
<div key={col} className={`p-3 rounded-lg space-y-2 border ${res.action === 'skip' ? 'bg-slate-700/40 border-slate-700' : res.action === 'create' ? 'bg-indigo-900/20 border-indigo-700/50' : 'bg-blue-900/20 border-blue-700/50'}`}>
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm text-white font-medium">{col}</p>
|
|
{res.action === 'create' && <span className="text-xs text-indigo-300">✓ Sera créé dans Shopify</span>}
|
|
{res.action === 'map' && <span className="text-xs text-blue-300">✓ Associé à un existant</span>}
|
|
{res.action === 'skip' && <span className="text-xs text-gray-500">— Ignoré</span>}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{(['create', 'map', 'skip'] as const).map(action => (
|
|
<button
|
|
key={action}
|
|
type="button"
|
|
onClick={() => updateResolution(col, { action })}
|
|
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
|
res.action === action
|
|
? action === 'create' ? 'bg-indigo-600 text-white'
|
|
: action === 'map' ? 'bg-blue-600 text-white'
|
|
: 'bg-slate-500 text-white'
|
|
: 'bg-slate-600 text-gray-400 hover:bg-slate-500 hover:text-gray-200'
|
|
}`}
|
|
>
|
|
{action === 'create' ? '+ Créer nouveau' : action === 'map' ? '↔ Associer existant' : '✕ Ignorer'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{res.action === 'create' && (
|
|
<div className="flex gap-2 mt-1">
|
|
<div className="flex-1">
|
|
<label className="block text-xs text-gray-400 mb-1">Clé Shopify</label>
|
|
<input
|
|
type="text"
|
|
value={res.key}
|
|
onChange={e => updateResolution(col, { key: e.target.value })}
|
|
placeholder="ex: puissance"
|
|
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
<div className="w-28">
|
|
<label className="block text-xs text-gray-400 mb-1">Namespace</label>
|
|
<input
|
|
type="text"
|
|
value={res.namespace}
|
|
onChange={e => updateResolution(col, { namespace: e.target.value })}
|
|
placeholder="custom"
|
|
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{res.action === 'map' && (
|
|
<div className="relative" ref={openDropdown === col ? dropdownRef : undefined}>
|
|
<label className="block text-xs text-gray-400 mb-1">Métachamp existant</label>
|
|
{res.existingDefinitionId ? (
|
|
<div className="flex items-center gap-2">
|
|
<span className="flex-1 text-xs text-blue-300 bg-slate-600 border border-slate-500 rounded px-2 py-1 truncate">
|
|
{definitions.find(d => d.id === res.existingDefinitionId)?.name ?? res.key} ({res.namespace}.{res.key})
|
|
</span>
|
|
<button type="button" onClick={() => { updateResolution(col, { existingDefinitionId: undefined, key: normalizeMetafieldKey(col), namespace: 'custom' }); setSearches(p => ({ ...p, [col]: '' })); setOpenDropdown(col) }} className="text-xs text-gray-400 hover:text-white px-1">✕</button>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<input
|
|
type="text"
|
|
autoFocus
|
|
value={searches[col] ?? ''}
|
|
onChange={e => { setSearches(p => ({ ...p, [col]: e.target.value })); setOpenDropdown(col) }}
|
|
onFocus={() => setOpenDropdown(col)}
|
|
placeholder="Rechercher un métachamp…"
|
|
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
/>
|
|
{openDropdown === col && (
|
|
<div className="absolute z-10 mt-1 w-full bg-slate-700 border border-slate-600 rounded shadow-lg max-h-48 overflow-y-auto">
|
|
{filteredDefs(col).length === 0 ? (
|
|
<p className="text-xs text-gray-400 px-3 py-2">Aucun résultat</p>
|
|
) : filteredDefs(col).map(d => (
|
|
<button
|
|
key={d.id}
|
|
type="button"
|
|
onMouseDown={() => { updateResolution(col, { existingDefinitionId: d.id, key: d.key, namespace: d.namespace, type: d.type }); setOpenDropdown(null) }}
|
|
className="w-full text-left px-3 py-1.5 text-xs text-white hover:bg-slate-600 flex justify-between gap-2"
|
|
>
|
|
<span>{d.name}</span>
|
|
<span className="text-gray-400 shrink-0">{d.namespace}.{d.key}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
{/* Résumé des actions */}
|
|
<div className="mt-3 flex gap-4 text-xs text-gray-400">
|
|
<span className="text-indigo-300 font-medium">{Object.values(resolutions).filter(r => r.action === 'create').length} à créer</span>
|
|
<span className="text-blue-300 font-medium">{Object.values(resolutions).filter(r => r.action === 'map').length} à associer</span>
|
|
<span>{Object.values(resolutions).filter(r => r.action === 'skip').length} ignorés</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|