feat: mockup modal accessible depuis la liste produits
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import type { CatalogProduct } from '@/types/products'
|
||||
import type { MockupTemplate } from '@/types/mockup'
|
||||
|
||||
interface Props {
|
||||
product: CatalogProduct
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const FAMILIES = ['Menuiserie', 'Baies vitrées', 'Carrelage', 'Tous']
|
||||
|
||||
export function MockupModal({ product, onClose }: Props) {
|
||||
const [templates, setTemplates] = useState<MockupTemplate[]>([])
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null)
|
||||
const [family, setFamily] = useState('Menuiserie')
|
||||
const [dimensions, setDimensions] = useState('')
|
||||
const [cutoutB64, setCutoutB64] = useState<string | null>(null)
|
||||
const [instruction, setInstruction] = useState('')
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [resultB64, setResultB64] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadedUrl, setUploadedUrl] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const templateFileRef = useRef<HTMLInputElement>(null)
|
||||
const [uploadingTemplate, setUploadingTemplate] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/mockup/templates')
|
||||
.then(r => r.json())
|
||||
.then((data: MockupTemplate[]) => setTemplates(data))
|
||||
}, [])
|
||||
|
||||
const filtered = templates.filter(t => t.family === family || t.family === 'Tous')
|
||||
|
||||
const handleCutout = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = ev => setCutoutB64((ev.target?.result as string).split(',')[1])
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const uploadTemplate = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const name = file.name.replace(/\.[^.]+$/, '')
|
||||
setUploadingTemplate(true)
|
||||
const form = new FormData()
|
||||
form.append('name', name)
|
||||
form.append('family', family)
|
||||
form.append('description', '')
|
||||
form.append('image', file)
|
||||
const res = await fetch('/api/mockup/templates', { method: 'POST', body: form })
|
||||
const tpl = await res.json() as MockupTemplate
|
||||
setTemplates(prev => [...prev, tpl])
|
||||
setSelectedTemplate(tpl.id)
|
||||
setUploadingTemplate(false)
|
||||
}
|
||||
|
||||
const generate = async () => {
|
||||
if (!selectedTemplate || !cutoutB64) return
|
||||
setGenerating(true)
|
||||
setError(null)
|
||||
setResultB64(null)
|
||||
try {
|
||||
const res = await fetch('/api/mockup/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
templateId: selectedTemplate,
|
||||
productImageBase64: cutoutB64,
|
||||
productName: product.title,
|
||||
family,
|
||||
dimensions: dimensions || undefined,
|
||||
instruction: instruction || undefined,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error)
|
||||
setResultB64(data.imageBase64)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erreur')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const upload = async () => {
|
||||
if (!resultB64) return
|
||||
setUploading(true)
|
||||
try {
|
||||
const filename = `${product.ref}-mockup.jpg`
|
||||
const res = await fetch('/api/images/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ shopifyProductId: product.shopifyId, imageBase64: resultB64, filename }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error)
|
||||
setUploadedUrl(data.url)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erreur upload')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="bg-slate-800 border border-slate-700 rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-700">
|
||||
<div>
|
||||
<h2 className="text-white font-semibold text-sm">Créer un mockup</h2>
|
||||
<p className="text-gray-400 text-xs mt-0.5 truncate max-w-sm">{product.title} <span className="font-mono text-indigo-300">{product.ref}</span></p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-white text-lg leading-none">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Family + dimensions */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Famille</label>
|
||||
<select value={family} onChange={e => setFamily(e.target.value)}
|
||||
className="w-full 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">
|
||||
{FAMILIES.map(f => <option key={f}>{f}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Dimensions (ex: 90x215cm)</label>
|
||||
<input value={dimensions} onChange={e => setDimensions(e.target.value)} placeholder="optionnel"
|
||||
className="w-full 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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template selection */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-xs text-gray-400">Image de référence</label>
|
||||
<button type="button" onClick={() => templateFileRef.current?.click()}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300 disabled:opacity-40"
|
||||
disabled={uploadingTemplate}>
|
||||
{uploadingTemplate ? 'Upload…' : '+ Uploader une nouvelle'}
|
||||
</button>
|
||||
<input ref={templateFileRef} type="file" accept="image/*" className="hidden" onChange={uploadTemplate} />
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 py-2">Aucun template pour cette famille. Uploadez-en un ci-dessus.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{filtered.map(t => (
|
||||
<button key={t.id} type="button" onClick={() => setSelectedTemplate(t.id)}
|
||||
className={`relative rounded-lg overflow-hidden border-2 transition-all ${selectedTemplate === t.id ? 'border-indigo-500' : 'border-slate-600 hover:border-slate-400'}`}>
|
||||
<img src={`/api/mockup/templates/image/${t.filename}`} alt={t.name} className="w-full h-20 object-cover" />
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1.5 py-1">
|
||||
<p className="text-xs text-white truncate">{t.name}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product cutout */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Image découpée du produit (PNG)</label>
|
||||
<input ref={fileRef} type="file" accept="image/png" onChange={handleCutout} className="text-sm text-gray-300" />
|
||||
{cutoutB64 && <p className="text-xs text-green-400 mt-1">✓ Image chargée</p>}
|
||||
</div>
|
||||
|
||||
{/* Generate */}
|
||||
<button type="button" onClick={generate}
|
||||
disabled={!selectedTemplate || !cutoutB64 || generating}
|
||||
className="w-full py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium disabled:opacity-40 hover:bg-indigo-500 transition-colors">
|
||||
{generating ? 'Génération en cours…' : 'Générer le mockup'}
|
||||
</button>
|
||||
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
|
||||
{/* Result */}
|
||||
{resultB64 && (
|
||||
<div className="space-y-3">
|
||||
<img src={`data:image/jpeg;base64,${resultB64}`} alt="Mockup" className="w-full rounded-lg border border-slate-600" />
|
||||
<div className="flex gap-2">
|
||||
<input type="text" value={instruction} onChange={e => setInstruction(e.target.value)}
|
||||
placeholder="Instruction pour regénérer (optionnel)…"
|
||||
className="flex-1 bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||
<button type="button" onClick={generate} disabled={generating}
|
||||
className="px-3 py-1.5 rounded bg-slate-600 text-xs text-white hover:bg-slate-500 disabled:opacity-40">
|
||||
{generating ? '…' : '↺ Regénérer'}
|
||||
</button>
|
||||
</div>
|
||||
{uploadedUrl ? (
|
||||
<p className="text-xs text-green-400">✓ Uploadé sur Shopify</p>
|
||||
) : (
|
||||
<button type="button" onClick={upload} disabled={uploading}
|
||||
className="w-full py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium disabled:opacity-40 hover:bg-emerald-500 transition-colors">
|
||||
{uploading ? 'Upload…' : '↑ Uploader sur Shopify'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import type { CatalogProduct } from '@/types/products'
|
||||
import { MockupModal } from '@/components/mockup/MockupModal'
|
||||
|
||||
interface Props {
|
||||
products: CatalogProduct[]
|
||||
@@ -9,6 +11,7 @@ interface Props {
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export function ProductTable({ products, loading }: Props) {
|
||||
const [mockupProduct, setMockupProduct] = useState<CatalogProduct | null>(null)
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20 text-gray-400">
|
||||
@@ -37,7 +40,8 @@ export function ProductTable({ products, loading }: Props) {
|
||||
<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>
|
||||
<th className="pb-3 pr-4 font-medium">Lien</th>
|
||||
<th className="pb-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -56,7 +60,7 @@ export function ProductTable({ products, loading }: Props) {
|
||||
{p.status === 'active' ? 'Actif' : 'Inactif'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<td className="py-3 pr-4">
|
||||
<a
|
||||
href={p.shopifyUrl}
|
||||
target="_blank"
|
||||
@@ -66,10 +70,22 @@ export function ProductTable({ products, loading }: Props) {
|
||||
Shopify ↗
|
||||
</a>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMockupProduct(p)}
|
||||
className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-indigo-600 text-gray-300 hover:text-white transition-colors"
|
||||
>
|
||||
Mockup
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{mockupProduct && (
|
||||
<MockupModal product={mockupProduct} onClose={() => setMockupProduct(null)} />
|
||||
)}
|
||||
{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}.
|
||||
|
||||
Reference in New Issue
Block a user