From eb5f6051754087999471cb421d59a7557624a311 Mon Sep 17 00:00:00 2001 From: Mathieu Date: Fri, 26 Jun 2026 14:05:25 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20refactor=20mockup=20page=20with=203-ste?= =?UTF-8?q?p=20flow=20(product=20=E2=86=92=20image=20=E2=86=92=20template)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/app/(dashboard)/mockup/page.tsx | 466 ++++++++++++++++++++++------ 1 file changed, 374 insertions(+), 92 deletions(-) diff --git a/src/app/(dashboard)/mockup/page.tsx b/src/app/(dashboard)/mockup/page.tsx index 81396da..426f975 100644 --- a/src/app/(dashboard)/mockup/page.tsx +++ b/src/app/(dashboard)/mockup/page.tsx @@ -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 { + 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('generate') + const [step, setStep] = useState('product') + // Step 1 — product const [products, setProducts] = useState([]) const [search, setSearch] = useState('') const [showDropdown, setShowDropdown] = useState(false) const [selectedProduct, setSelectedProduct] = useState(null) - const [family, setFamily] = useState('Menuiserie') + const [family, setFamily] = useState('') + const [placement, setPlacement] = useState('') const [dimensions, setDimensions] = useState('') - const [cutoutB64, setCutoutB64] = useState(null) const dropdownRef = useRef(null) + // Step 2 — image + const [selectedImageUrl, setSelectedImageUrl] = useState(null) + const [cutoutB64, setCutoutB64] = useState(null) + const [loadingImage, setLoadingImage] = useState(false) + const fileRef = useRef(null) + + // Step 3 — template + generation + const [templates, setTemplates] = useState([]) + const [selectedTemplate, setSelectedTemplate] = useState(null) + const [instruction, setInstruction] = useState('') + const [generating, setGenerating] = useState(false) + const [resultB64, setResultB64] = useState(null) + const [uploading, setUploading] = useState(false) + const [uploadedUrl, setUploadedUrl] = useState(null) + const [error, setError] = useState(null) + const templateFileRef = useRef(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) => { + 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) => { 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) => { + 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 = () => ( +
+ {(['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 ( +
+ {i > 0 &&
} + +
+ ) + })} +
+ ) + return ( <>
) : ( -
-
-

Produit

+
+ - {/* Product picker */} -
- - {selectedProduct ? ( -
- {selectedProduct.title} - {selectedProduct.ref} - + {/* ── STEP 1 : Produit ── */} + {step === 'product' && ( +
+

Rechercher un produit

+
+ { 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 && ( +
+ {filtered.map(p => ( + + ))} +
+ )} + {showDropdown && search.length >= 2 && filtered.length === 0 && ( +
+

Aucun résultat

+
+ )} +
+
+ )} + + {/* ── STEP 2 : Image ── */} + {step === 'image' && selectedProduct && ( +
+
+
+

Choisir une image

+

{selectedProduct.title} · {selectedProduct.ref}

+
+ +
+ + {selectedProduct.images.length > 0 ? ( +
+

Images Shopify

+
+ {selectedProduct.images.map((url, i) => ( + + ))} +
) : ( - <> - { 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 && ( -
- {filtered.map(p => ( -
+ )} + + {/* ── STEP 3 : Template + génération ── */} + {step === 'template' && selectedProduct && cutoutB64 && ( +
+ {/* Recap produit + image */} +
+ {selectedImageUrl && ( + + )} +
+

{selectedProduct.title}

+

{selectedProduct.ref} + {dimensions && · {dimensions}} +

+
+ +
+ + {/* Famille + placement carrelage */} +
+
+
+ + +
+
+ + 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" /> +
+
+ {isCarrelage && ( +
+

Placement

+
+ {[{ val: 'sol', label: 'Sol' }, { val: 'mur', label: 'Mur (faïence)' }].map(({ val, label }) => ( + ))}
+
+ )} +
+ + {/* Templates */} +
+
+

Template

+ + +
+ {filteredTemplates.length === 0 ? ( +

Aucun template pour cette famille. Uploadez-en un.

+ ) : ( +
+ {filteredTemplates.map(t => ( + + ))} +
+ )} +
+ + {/* Instruction + génération */} +
+ 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" /> + +
+ + {error &&

{error}

} + + {/* Résultat */} + {resultB64 && ( +
+ Mockup +
+ 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" /> + +
+ {uploadedUrl ? ( +

✓ Uploadé sur Shopify

+ ) : ( + )} - {showDropdown && search.length >= 2 && filtered.length === 0 && ( -
-

Aucun résultat

-
- )} - +
)}
- -
-
- - -
-
- - 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" /> -
-
- -
- - -
-
- - {cutoutB64 && selectedProduct ? ( - - ) : ( -

- {!selectedProduct ? 'Sélectionnez un produit pour commencer.' : 'Chargez une image PNG découpée.'} -

)}
)}