feat: mockup modal avec sélection image existante, familles sheets et métachamps produit

This commit is contained in:
2026-06-24 14:33:19 +02:00
parent 93a616e997
commit 5a10fd4221
3 changed files with 214 additions and 51 deletions
+18
View File
@@ -0,0 +1,18 @@
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return new Response('Non authentifié', { status: 401 })
const url = req.nextUrl.searchParams.get('url')
if (!url) return new Response('url requis', { status: 400 })
const res = await fetch(url)
if (!res.ok) return new Response('Fetch failed', { status: 502 })
const buffer = await res.arrayBuffer()
const contentType = res.headers.get('content-type') ?? 'image/jpeg'
return new Response(buffer, { headers: { 'Content-Type': contentType } })
}
@@ -0,0 +1,30 @@
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
const API_VERSION = '2024-01'
export async function GET(_req: NextRequest, { params }: { params: { shopifyId: string } }) {
const session = await getServerSession(authOptions)
if (!session) return Response.json({ error: 'Non authentifié' }, { status: 401 })
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) return Response.json({ error: 'Config Shopify manquante' }, { status: 500 })
const res = await fetch(
`https://${domain}/admin/api/${API_VERSION}/products/${params.shopifyId}/metafields.json`,
{ headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' } },
)
if (!res.ok) return Response.json({ error: `Shopify error: ${res.status}` }, { status: 502 })
const data = await res.json()
const metafields = (data.metafields ?? []) as Array<{
namespace: string
key: string
value: string
type: string
}>
return Response.json({ metafields })
}
+166 -51
View File
@@ -8,35 +8,96 @@ interface Props {
onClose: () => void
}
const FAMILIES = ['Menuiserie', 'Baies vitrées', 'Carrelage', 'Tous']
interface Metafield {
namespace: string
key: string
value: string
type: string
}
async function urlToBase64(url: string): Promise<string> {
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()
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 function MockupModal({ product, onClose }: Props) {
const [templates, setTemplates] = useState<MockupTemplate[]>([])
const [families, setFamilies] = useState<string[]>([])
const [metafields, setMetafields] = useState<Metafield[]>([])
const [loadingMeta, setLoadingMeta] = useState(true)
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null)
const [family, setFamily] = useState('Menuiserie')
const [dimensions, setDimensions] = useState('')
const [family, setFamily] = useState('')
const [selectedMeta, setSelectedMeta] = useState<string[]>([])
const [cutoutB64, setCutoutB64] = useState<string | null>(null)
const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>(null)
const [loadingImage, setLoadingImage] = useState(false)
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))
}, [])
Promise.all([
fetch('/api/mockup/templates').then(r => r.json()),
fetch('/api/sheets/tabs').then(r => r.json()),
fetch(`/api/mockup/product-meta/${product.shopifyId}`).then(r => r.json()),
]).then(([tplData, tabsData, metaData]) => {
setTemplates(tplData as MockupTemplate[])
const tabs: string[] = (tabsData.tabs ?? []).filter((t: string) =>
!['COLLECTIONS', 'LIVRAISONS', 'ECOPART', 'RECAP'].some(x => t.toUpperCase().includes(x))
)
setFamilies(tabs)
setFamily(tabs[0] ?? '')
setMetafields(metaData.metafields ?? [])
}).finally(() => setLoadingMeta(false))
}, [product.shopifyId])
const filtered = templates.filter(t => t.family === family || t.family === 'Tous')
const filteredTemplates = templates.filter(t =>
!family || t.family === family || t.family === 'Tous'
)
const handleCutout = (e: React.ChangeEvent<HTMLInputElement>) => {
const dimensionStr = selectedMeta.length > 0
? selectedMeta.map(key => {
const mf = metafields.find(m => `${m.namespace}.${m.key}` === key)
return mf ? `${mf.key}: ${mf.value}` : ''
}).filter(Boolean).join(', ')
: undefined
const selectExistingImage = async (url: string) => {
setSelectedImageUrl(url)
setCutoutB64(null)
setLoadingImage(true)
setError(null)
try {
const b64 = await urlToBase64(url)
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 => setCutoutB64((ev.target?.result as string).split(',')[1])
reader.readAsDataURL(file)
@@ -45,11 +106,10 @@ export function MockupModal({ product, onClose }: Props) {
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('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 })
@@ -59,6 +119,10 @@ export function MockupModal({ product, onClose }: Props) {
setUploadingTemplate(false)
}
const toggleMeta = (key: string) => {
setSelectedMeta(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key])
}
const generate = async () => {
if (!selectedTemplate || !cutoutB64) return
setGenerating(true)
@@ -72,8 +136,8 @@ export function MockupModal({ product, onClose }: Props) {
templateId: selectedTemplate,
productImageBase64: cutoutB64,
productName: product.title,
family,
dimensions: dimensions || undefined,
family: family || 'Tous',
dimensions: dimensionStr,
instruction: instruction || undefined,
}),
})
@@ -91,11 +155,10 @@ export function MockupModal({ product, onClose }: Props) {
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 }),
body: JSON.stringify({ shopifyProductId: product.shopifyId, imageBase64: resultB64, filename: `${product.ref}-mockup.jpg` }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
@@ -108,72 +171,124 @@ export function MockupModal({ product, onClose }: Props) {
}
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="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 className="flex items-center justify-between p-4 border-b border-slate-700 sticky top-0 bg-slate-800 z-10">
<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>
<p className="text-gray-400 text-xs mt-0.5">
{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 className="p-4 space-y-5">
{/* 1. Image de base */}
<section>
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">1. Image du produit</h3>
{product.images.length > 0 && (
<div className="mb-2">
<p className="text-xs text-gray-400 mb-1.5">Images existantes</p>
<div className="flex gap-2 flex-wrap">
{product.images.map((url, i) => (
<button key={i} type="button" onClick={() => selectExistingImage(url)}
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-16 h-16 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>
)}
<div>
<label className="block text-xs text-gray-400 mb-1">Famille</label>
<p className="text-xs text-gray-400 mb-1">Ou uploader une image découpée (PNG)</p>
<input ref={fileRef} type="file" accept="image/*" onChange={handleFileUpload} className="text-xs text-gray-300" />
</div>
{cutoutB64 && !loadingImage && (
<p className="text-xs text-green-400 mt-1"> Image prête</p>
)}
</section>
{/* 2. Famille */}
<section>
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">2. Famille</h3>
{families.length > 0 ? (
<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>)}
{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>
) : (
<p className="text-xs text-gray-500">{loadingMeta ? 'Chargement…' : 'Aucune famille disponible'}</p>
)}
</section>
{/* Template selection */}
<div>
{/* 3. Caractéristiques (metafields) */}
{metafields.length > 0 && (
<section>
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">3. Caractéristiques à inclure dans le prompt</h3>
<div className="space-y-1 max-h-36 overflow-y-auto">
{metafields.map(mf => {
const key = `${mf.namespace}.${mf.key}`
return (
<label key={key} className="flex items-center gap-2 cursor-pointer group">
<input type="checkbox" checked={selectedMeta.includes(key)} onChange={() => toggleMeta(key)}
className="rounded border-slate-600 bg-slate-700 text-indigo-500 focus:ring-indigo-500" />
<span className="text-xs text-gray-300 group-hover:text-white">
<span className="font-medium">{mf.key}</span>
<span className="text-gray-500 ml-1">{mf.value}</span>
</span>
</label>
)
})}
</div>
{dimensionStr && (
<p className="text-xs text-indigo-300 mt-1.5">Inclus dans le prompt : {dimensionStr}</p>
)}
</section>
)}
{/* 4. Template */}
<section>
<div className="flex items-center justify-between mb-2">
<label className="text-xs text-gray-400">Image de référence</label>
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide">
{metafields.length > 0 ? '4.' : '3.'} Image de référence (template)
</h3>
<button type="button" onClick={() => templateFileRef.current?.click()}
className="text-xs text-indigo-400 hover:text-indigo-300 disabled:opacity-40"
disabled={uploadingTemplate}>
disabled={uploadingTemplate}
className="text-xs text-indigo-400 hover:text-indigo-300 disabled:opacity-40">
{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>
{filteredTemplates.length === 0 ? (
<p className="text-xs text-gray-500">Aucun template pour cette famille. Uploadez-en un ci-dessus.</p>
) : (
<div className="grid grid-cols-3 gap-2">
{filtered.map(t => (
<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-20 object-cover" />
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1.5 py-1">
<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>
{/* 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>
</section>
{/* Generate */}
<button type="button" onClick={generate}
disabled={!selectedTemplate || !cutoutB64 || generating}
disabled={!selectedTemplate || !cutoutB64 || generating || loadingImage}
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>