feat: refactor mockup page with 3-step flow (product → image → template)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 14:05:25 +02:00
parent ac58289bb7
commit eb5f605175
+374 -92
View File
@@ -2,35 +2,66 @@
import { useState, useEffect, useRef } from 'react'
import { Header } from '@/components/layout/Header'
import { TemplateLibrary } from '@/components/mockup/TemplateLibrary'
import { MockupGenerator } from '@/components/mockup/MockupGenerator'
import type { MockupTemplate } from '@/types/mockup'
type Tab = 'generate' | 'library'
type Step = 'product' | 'image' | 'template'
interface Product {
shopifyId: string
title: string
ref: string
famille?: string
longueur?: string
largeur?: string
images: string[]
}
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve((reader.result as string).split(',')[1])
reader.onerror = reject
reader.readAsDataURL(blob)
})
}
export default function MockupPage() {
const [tab, setTab] = useState<Tab>('generate')
const [step, setStep] = useState<Step>('product')
// Step 1 — product
const [products, setProducts] = useState<Product[]>([])
const [search, setSearch] = useState('')
const [showDropdown, setShowDropdown] = useState(false)
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null)
const [family, setFamily] = useState('Menuiserie')
const [family, setFamily] = useState('')
const [placement, setPlacement] = useState('')
const [dimensions, setDimensions] = useState('')
const [cutoutB64, setCutoutB64] = useState<string | null>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
// Step 2 — image
const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>(null)
const [cutoutB64, setCutoutB64] = useState<string | null>(null)
const [loadingImage, setLoadingImage] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
// Step 3 — template + generation
const [templates, setTemplates] = useState<MockupTemplate[]>([])
const [selectedTemplate, setSelectedTemplate] = 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 templateFileRef = useRef<HTMLInputElement>(null)
const [uploadingTemplate, setUploadingTemplate] = useState(false)
useEffect(() => {
fetch('/api/products/list')
.then(r => r.json())
.then(data => setProducts(data.products ?? []))
fetch('/api/mockup/templates')
.then(r => r.json())
.then(data => setTemplates(data as MockupTemplate[]))
}, [])
const filtered = search.length < 2 ? [] : products.filter(p =>
@@ -38,17 +69,164 @@ export default function MockupPage() {
p.ref.toLowerCase().includes(search.toLowerCase())
).slice(0, 20)
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const isCarrelage = family.toLowerCase().includes('carrelage')
const filteredTemplates = templates.filter(t => {
if (t.family !== family && t.family !== 'Tous') return false
if (isCarrelage && placement && t.placement && t.placement !== placement) return false
return true
})
const selectProduct = (p: Product) => {
setSelectedProduct(p)
setSearch('')
setShowDropdown(false)
setSelectedImageUrl(null)
setCutoutB64(null)
setResultB64(null)
setUploadedUrl(null)
setStep('image')
fetch(`/api/products/sheet-info?ref=${encodeURIComponent(p.ref)}`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data?.product) return
const { famille, longueur, largeur } = data.product
if (famille) setFamily(famille)
if (longueur && largeur) setDimensions(`${longueur}x${largeur}cm`)
else if (longueur) setDimensions(`${longueur}cm`)
})
}
const selectImage = async (url: string) => {
setSelectedImageUrl(url)
setCutoutB64(null)
setLoadingImage(true)
setError(null)
try {
const res = await fetch(`/api/images/proxy?url=${encodeURIComponent(url)}`)
if (!res.ok) throw new Error('Impossible de charger l\'image')
const blob = await res.blob()
const b64 = await blobToBase64(blob)
setCutoutB64(b64)
} catch {
setError('Impossible de charger cette image')
setSelectedImageUrl(null)
} finally {
setLoadingImage(false)
}
}
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setSelectedImageUrl(null)
const reader = new FileReader()
reader.onload = ev => {
const result = ev.target?.result as string
setCutoutB64(result.split(',')[1])
setCutoutB64((ev.target?.result as string).split(',')[1])
setStep('template')
}
reader.readAsDataURL(file)
}
const uploadTemplate = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploadingTemplate(true)
const form = new FormData()
form.append('name', file.name.replace(/\.[^.]+$/, ''))
form.append('family', family || 'Tous')
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)
setUploadedUrl(null)
try {
const res = await fetch('/api/mockup/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
templateId: selectedTemplate,
productImageBase64: cutoutB64,
productName: selectedProduct!.title,
family: family || 'Tous',
placement: placement || undefined,
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 || !selectedProduct) return
setUploading(true)
try {
const res = await fetch('/api/images/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
shopifyProductId: selectedProduct.shopifyId,
imageBase64: resultB64,
filename: `${selectedProduct.ref}-mockup.jpg`,
}),
})
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)
}
}
const StepIndicator = () => (
<div className="flex items-center gap-2 mb-6">
{(['product', 'image', 'template'] as Step[]).map((s, i) => {
const labels = ['Produit', 'Image', 'Template']
const active = step === s
const done = (step === 'image' && s === 'product') ||
(step === 'template' && (s === 'product' || s === 'image'))
return (
<div key={s} className="flex items-center gap-2">
{i > 0 && <div className={`h-px w-6 ${done ? 'bg-indigo-500' : 'bg-slate-600'}`} />}
<button
type="button"
onClick={() => done || active ? setStep(s) : undefined}
className={`flex items-center gap-1.5 text-xs font-medium transition-colors ${
active ? 'text-white' : done ? 'text-indigo-400 hover:text-indigo-300' : 'text-slate-500'
}`}
>
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
active ? 'bg-indigo-600 text-white' : done ? 'bg-indigo-500/30 text-indigo-400' : 'bg-slate-700 text-slate-500'
}`}>
{done ? '✓' : i + 1}
</span>
{labels[i]}
</button>
</div>
)
})}
</div>
)
return (
<>
<Header title="🖼️ Mockups" actions={
@@ -66,98 +244,202 @@ export default function MockupPage() {
{tab === 'library' ? (
<TemplateLibrary />
) : (
<div className="space-y-4">
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 space-y-3">
<h2 className="text-sm font-semibold text-white">Produit</h2>
<div>
<StepIndicator />
{/* Product picker */}
<div className="relative" ref={dropdownRef}>
<label className="block text-xs text-gray-400 mb-1">Rechercher un produit</label>
{selectedProduct ? (
<div className="flex items-center gap-2 bg-slate-700 border border-slate-600 rounded px-2 py-1.5">
<span className="flex-1 text-sm text-white truncate">{selectedProduct.title}</span>
<span className="text-xs text-gray-400 shrink-0">{selectedProduct.ref}</span>
<button type="button" onClick={() => { setSelectedProduct(null); setSearch('') }}
className="text-gray-400 hover:text-white ml-1 shrink-0"></button>
{/* ── STEP 1 : Produit ── */}
{step === 'product' && (
<div className="bg-slate-800 rounded-xl p-5 border border-slate-700 space-y-4">
<h2 className="text-sm font-semibold text-white">Rechercher un produit</h2>
<div className="relative" ref={dropdownRef}>
<input
type="text"
value={search}
onChange={e => { setSearch(e.target.value); setShowDropdown(true) }}
onFocus={() => setShowDropdown(true)}
placeholder="Nom ou référence du produit…"
autoFocus
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
{showDropdown && filtered.length > 0 && (
<div className="absolute z-10 mt-1 w-full bg-slate-700 border border-slate-600 rounded-lg shadow-xl max-h-72 overflow-y-auto">
{filtered.map(p => (
<button key={p.shopifyId} type="button"
onMouseDown={() => selectProduct(p)}
className="w-full text-left px-3 py-2.5 text-sm text-white hover:bg-slate-600 flex justify-between gap-2 border-b border-slate-600/50 last:border-0">
<span className="truncate">{p.title}</span>
<span className="text-xs text-gray-400 shrink-0 self-center">{p.ref}</span>
</button>
))}
</div>
)}
{showDropdown && search.length >= 2 && filtered.length === 0 && (
<div className="absolute z-10 mt-1 w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2">
<p className="text-xs text-gray-400">Aucun résultat</p>
</div>
)}
</div>
</div>
)}
{/* ── STEP 2 : Image ── */}
{step === 'image' && selectedProduct && (
<div className="bg-slate-800 rounded-xl p-5 border border-slate-700 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-white">Choisir une image</h2>
<p className="text-xs text-gray-400 mt-0.5">{selectedProduct.title} · <span className="font-mono text-indigo-300">{selectedProduct.ref}</span></p>
</div>
<button type="button" onClick={() => { setSelectedProduct(null); setStep('product') }}
className="text-xs text-gray-400 hover:text-white"> Changer</button>
</div>
{selectedProduct.images.length > 0 ? (
<div>
<p className="text-xs text-gray-400 mb-2">Images Shopify</p>
<div className="grid grid-cols-4 gap-2">
{selectedProduct.images.map((url, i) => (
<button key={i} type="button" onClick={() => { selectImage(url); setStep('template') }}
className={`relative rounded-lg overflow-hidden border-2 transition-all ${selectedImageUrl === url ? 'border-indigo-500' : 'border-slate-600 hover:border-slate-400'}`}>
<img src={url} alt={`Image ${i + 1}`} className="w-full h-20 object-cover" />
{loadingImage && selectedImageUrl === url && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<span className="text-xs text-white"></span>
</div>
)}
</button>
))}
</div>
</div>
) : (
<>
<input
type="text"
value={search}
onChange={e => { setSearch(e.target.value); setShowDropdown(true) }}
onFocus={() => setShowDropdown(true)}
placeholder="Nom ou référence du produit…"
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"
/>
{showDropdown && filtered.length > 0 && (
<div className="absolute z-10 mt-1 w-full bg-slate-700 border border-slate-600 rounded shadow-lg max-h-56 overflow-y-auto">
{filtered.map(p => (
<button key={p.shopifyId} type="button"
onMouseDown={() => {
setSelectedProduct(p)
setSearch('')
setShowDropdown(false)
fetch(`/api/products/sheet-info?ref=${encodeURIComponent(p.ref)}`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data?.product) return
const { famille, longueur, largeur } = data.product
if (famille) setFamily(famille)
if (longueur && largeur) setDimensions(`${longueur}x${largeur}cm`)
else if (longueur) setDimensions(`${longueur}cm`)
})
}}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-slate-600 flex justify-between gap-2">
<span className="truncate">{p.title}</span>
<span className="text-xs text-gray-400 shrink-0">{p.ref}</span>
<p className="text-xs text-gray-500">Aucune image disponible sur Shopify pour ce produit.</p>
)}
<div className="border-t border-slate-700 pt-3">
<p className="text-xs text-gray-400 mb-2">Ou uploader une image (PNG découpée)</p>
<input ref={fileRef} type="file" accept="image/*" onChange={handleFileUpload} className="text-xs text-gray-300" />
</div>
{error && <p className="text-xs text-red-400">{error}</p>}
</div>
)}
{/* ── STEP 3 : Template + génération ── */}
{step === 'template' && selectedProduct && cutoutB64 && (
<div className="space-y-4">
{/* Recap produit + image */}
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 flex items-center gap-3">
{selectedImageUrl && (
<img src={selectedImageUrl} alt="" className="w-12 h-12 object-cover rounded-lg border border-slate-600 shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{selectedProduct.title}</p>
<p className="text-xs text-gray-400">{selectedProduct.ref}
{dimensions && <span className="ml-2 text-green-400">· {dimensions}</span>}
</p>
</div>
<button type="button" onClick={() => setStep('image')} className="text-xs text-gray-400 hover:text-white shrink-0"> Image</button>
</div>
{/* Famille + placement carrelage */}
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 space-y-3">
<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); setPlacement('') }}
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">
{['Menuiserie', 'Baies vitrées', 'Carrelage', 'Tous'].map(f => <option key={f}>{f}</option>)}
</select>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Dimensions</label>
<input value={dimensions} onChange={e => setDimensions(e.target.value)}
placeholder="ex: 60x60cm"
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>
{isCarrelage && (
<div>
<p className="text-xs text-gray-400 mb-1.5">Placement</p>
<div className="flex gap-2">
{[{ val: 'sol', label: 'Sol' }, { val: 'mur', label: 'Mur (faïence)' }].map(({ val, label }) => (
<button key={val} type="button" onClick={() => setPlacement(p => p === val ? '' : val)}
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors ${placement === val ? 'bg-indigo-600 text-white' : 'bg-slate-700 text-gray-300 hover:bg-slate-600'}`}>
{label}
</button>
))}
</div>
</div>
)}
</div>
{/* Templates */}
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide">Template</h3>
<button type="button" onClick={() => templateFileRef.current?.click()}
disabled={uploadingTemplate}
className="text-xs text-indigo-400 hover:text-indigo-300 disabled:opacity-40">
{uploadingTemplate ? 'Upload…' : '+ Nouveau template'}
</button>
<input ref={templateFileRef} type="file" accept="image/*" className="hidden" onChange={uploadTemplate} />
</div>
{filteredTemplates.length === 0 ? (
<p className="text-xs text-gray-500">Aucun template pour cette famille. Uploadez-en un.</p>
) : (
<div className="grid grid-cols-4 gap-2">
{filteredTemplates.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-16 object-cover" />
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5">
<p className="text-xs text-white truncate">{t.name}</p>
</div>
</button>
))}
</div>
)}
</div>
{/* Instruction + génération */}
<div className="space-y-2">
<input type="text" value={instruction} onChange={e => setInstruction(e.target.value)}
placeholder="Instruction personnalisée (optionnel)…"
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<button type="button" onClick={generate}
disabled={!selectedTemplate || generating}
className="w-full py-2.5 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>
</div>
{error && <p className="text-xs text-red-400">{error}</p>}
{/* Résultat */}
{resultB64 && (
<div className="space-y-3">
<img src={`data:image/jpeg;base64,${resultB64}`} alt="Mockup"
className="w-full rounded-xl border border-slate-600" />
<div className="flex gap-2">
<input type="text" value={instruction} onChange={e => setInstruction(e.target.value)}
placeholder="Modifier et regénérer…"
className="flex-1 bg-slate-800 border border-slate-700 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-700 text-xs text-white hover:bg-slate-600 disabled:opacity-40">
{generating ? '…' : '↺'}
</button>
</div>
{uploadedUrl ? (
<p className="text-xs text-green-400 text-center"> 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>
)}
{showDropdown && search.length >= 2 && filtered.length === 0 && (
<div className="absolute z-10 mt-1 w-full bg-slate-700 border border-slate-600 rounded px-3 py-2">
<p className="text-xs text-gray-400">Aucun résultat</p>
</div>
)}
</>
</div>
)}
</div>
<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">
{['Menuiserie', 'Baies vitrées', 'Carrelage', 'Tous'].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>
<div>
<label className="block text-xs text-gray-400 mb-1">Image découpée (PNG)</label>
<input type="file" accept="image/png" onChange={handleFile} className="text-sm text-gray-300" />
</div>
</div>
{cutoutB64 && selectedProduct ? (
<MockupGenerator
productCutoutBase64={cutoutB64}
productName={selectedProduct.title}
shopifyId={selectedProduct.shopifyId}
family={family}
dimensions={dimensions || undefined}
/>
) : (
<p className="text-sm text-gray-500 text-center py-8">
{!selectedProduct ? 'Sélectionnez un produit pour commencer.' : 'Chargez une image PNG découpée.'}
</p>
)}
</div>
)}