feat: placement mur/sol pour templates carrelage avec prompt adapté
This commit is contained in:
@@ -6,10 +6,17 @@ import { getTemplateImagePath, listTemplates } from '@/lib/mockupTemplates'
|
|||||||
import type { MockupGenerateRequest } from '@/types/mockup'
|
import type { MockupGenerateRequest } from '@/types/mockup'
|
||||||
|
|
||||||
function buildPrompt(req: MockupGenerateRequest): string {
|
function buildPrompt(req: MockupGenerateRequest): string {
|
||||||
|
let carrelageContext = 'tiles covering the floor or wall of a modern room'
|
||||||
|
if (req.family === 'Carrelage' && req.placement === 'mur') {
|
||||||
|
carrelageContext = 'wall tiles (faïence) covering the wall of a modern bathroom or kitchen — applied only to the wall, not the floor'
|
||||||
|
} else if (req.family === 'Carrelage' && req.placement === 'sol') {
|
||||||
|
carrelageContext = 'floor tiles covering the floor of a modern room — applied only to the floor, not the walls'
|
||||||
|
}
|
||||||
|
|
||||||
const familyContext: Record<string, string> = {
|
const familyContext: Record<string, string> = {
|
||||||
'Menuiserie': 'door installed in a residential entrance or hallway, visible wall and floor context',
|
'Menuiserie': 'door installed in a residential entrance or hallway, visible wall and floor context',
|
||||||
'Baies vitrées': 'bay window or sliding door installed in a living room with garden view',
|
'Baies vitrées': 'bay window or sliding door installed in a living room with garden view',
|
||||||
'Carrelage': 'tiles covering the floor or wall of a modern room',
|
'Carrelage': carrelageContext,
|
||||||
}
|
}
|
||||||
const context = familyContext[req.family] ?? 'product installed in an appropriate room setting'
|
const context = familyContext[req.family] ?? 'product installed in an appropriate room setting'
|
||||||
const dimClause = req.dimensions
|
const dimClause = req.dimensions
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export async function POST(req: NextRequest) {
|
|||||||
const form = await req.formData()
|
const form = await req.formData()
|
||||||
const name = form.get('name') as string
|
const name = form.get('name') as string
|
||||||
const family = form.get('family') as string
|
const family = form.get('family') as string
|
||||||
|
const placement = (form.get('placement') as string) || undefined
|
||||||
const description = (form.get('description') as string) ?? ''
|
const description = (form.get('description') as string) ?? ''
|
||||||
const imageFile = form.get('image') as File
|
const imageFile = form.get('image') as File
|
||||||
|
|
||||||
@@ -26,6 +27,6 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const buffer = Buffer.from(await imageFile.arrayBuffer())
|
const buffer = Buffer.from(await imageFile.arrayBuffer())
|
||||||
const ext = imageFile.name.split('.').pop() ?? 'jpg'
|
const ext = imageFile.name.split('.').pop() ?? 'jpg'
|
||||||
const template = await saveTemplate({ name, family, description, filename: '' }, buffer, ext)
|
const template = await saveTemplate({ name, family, placement, description, filename: '' }, buffer, ext)
|
||||||
return Response.json(template)
|
return Response.json(template)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function MockupModal({ product, onClose }: Props) {
|
|||||||
|
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null)
|
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null)
|
||||||
const [family, setFamily] = useState('')
|
const [family, setFamily] = useState('')
|
||||||
|
const [placement, setPlacement] = useState<string>('')
|
||||||
const [selectedMeta, setSelectedMeta] = useState<string[]>([])
|
const [selectedMeta, setSelectedMeta] = useState<string[]>([])
|
||||||
const [cutoutB64, setCutoutB64] = useState<string | null>(null)
|
const [cutoutB64, setCutoutB64] = useState<string | null>(null)
|
||||||
const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>(null)
|
const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>(null)
|
||||||
@@ -67,9 +68,13 @@ export function MockupModal({ product, onClose }: Props) {
|
|||||||
}).finally(() => setLoadingMeta(false))
|
}).finally(() => setLoadingMeta(false))
|
||||||
}, [product.shopifyId])
|
}, [product.shopifyId])
|
||||||
|
|
||||||
const filteredTemplates = templates.filter(t =>
|
const isCarrelage = family.toLowerCase().includes('carrelage')
|
||||||
!family || t.family === family || t.family === 'Tous'
|
|
||||||
)
|
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 dimensionStr = selectedMeta.length > 0
|
const dimensionStr = selectedMeta.length > 0
|
||||||
? selectedMeta.map(key => {
|
? selectedMeta.map(key => {
|
||||||
@@ -137,6 +142,7 @@ export function MockupModal({ product, onClose }: Props) {
|
|||||||
productImageBase64: cutoutB64,
|
productImageBase64: cutoutB64,
|
||||||
productName: product.title,
|
productName: product.title,
|
||||||
family: family || 'Tous',
|
family: family || 'Tous',
|
||||||
|
placement: placement || undefined,
|
||||||
dimensions: dimensionStr,
|
dimensions: dimensionStr,
|
||||||
instruction: instruction || undefined,
|
instruction: instruction || undefined,
|
||||||
}),
|
}),
|
||||||
@@ -218,14 +224,29 @@ export function MockupModal({ product, onClose }: Props) {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 2. Famille */}
|
{/* 2. Famille + placement */}
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">2. Famille</h3>
|
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">2. Famille</h3>
|
||||||
{families.length > 0 ? (
|
{families.length > 0 ? (
|
||||||
<select value={family} onChange={e => setFamily(e.target.value)}
|
<div className="space-y-2">
|
||||||
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">
|
<select value={family} onChange={e => { setFamily(e.target.value); setPlacement('') }}
|
||||||
{families.map(f => <option key={f}>{f}</option>)}
|
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">
|
||||||
</select>
|
{families.map(f => <option key={f}>{f}</option>)}
|
||||||
|
</select>
|
||||||
|
{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>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-gray-500">{loadingMeta ? 'Chargement…' : 'Aucune famille disponible'}</p>
|
<p className="text-xs text-gray-500">{loadingMeta ? 'Chargement…' : 'Aucune famille disponible'}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import type { MockupTemplate } from '@/types/mockup'
|
import type { MockupTemplate } from '@/types/mockup'
|
||||||
|
|
||||||
|
const CARRELAGE_PLACEMENTS = ['sol', 'mur']
|
||||||
|
|
||||||
export function TemplateLibrary() {
|
export function TemplateLibrary() {
|
||||||
const [templates, setTemplates] = useState<MockupTemplate[]>([])
|
const [templates, setTemplates] = useState<MockupTemplate[]>([])
|
||||||
const [families, setFamilies] = useState<string[]>(['Tous'])
|
const [families, setFamilies] = useState<string[]>(['Tous'])
|
||||||
@@ -10,6 +12,7 @@ export function TemplateLibrary() {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [family, setFamily] = useState('Tous')
|
const [family, setFamily] = useState('Tous')
|
||||||
|
const [placement, setPlacement] = useState<string>('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const fileRef = useRef<HTMLInputElement>(null)
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@@ -31,10 +34,13 @@ export function TemplateLibrary() {
|
|||||||
|
|
||||||
useEffect(load, [])
|
useEffect(load, [])
|
||||||
|
|
||||||
|
const isCarrelage = family.toLowerCase().includes('carrelage')
|
||||||
|
|
||||||
const upload = async (e: React.FormEvent) => {
|
const upload = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const file = fileRef.current?.files?.[0]
|
const file = fileRef.current?.files?.[0]
|
||||||
if (!file || !name || !family) return
|
if (!file || !name || !family) return
|
||||||
|
if (isCarrelage && !placement) return
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
@@ -42,10 +48,11 @@ export function TemplateLibrary() {
|
|||||||
form.append('name', name)
|
form.append('name', name)
|
||||||
form.append('family', family)
|
form.append('family', family)
|
||||||
form.append('description', description)
|
form.append('description', description)
|
||||||
|
if (isCarrelage && placement) form.append('placement', placement)
|
||||||
form.append('image', file)
|
form.append('image', file)
|
||||||
const res = await fetch('/api/mockup/templates', { method: 'POST', body: form })
|
const res = await fetch('/api/mockup/templates', { method: 'POST', body: form })
|
||||||
if (!res.ok) throw new Error((await res.json()).error)
|
if (!res.ok) throw new Error((await res.json()).error)
|
||||||
setName(''); setDescription('')
|
setName(''); setDescription(''); setPlacement('')
|
||||||
if (fileRef.current) fileRef.current.value = ''
|
if (fileRef.current) fileRef.current.value = ''
|
||||||
load()
|
load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -63,6 +70,16 @@ export function TemplateLibrary() {
|
|||||||
const allUsedFamilies = Array.from(new Set(templates.map(t => t.family)))
|
const allUsedFamilies = Array.from(new Set(templates.map(t => t.family)))
|
||||||
const displayFamilies = families.filter(f => allUsedFamilies.includes(f) || f === 'Tous')
|
const displayFamilies = families.filter(f => allUsedFamilies.includes(f) || f === 'Tous')
|
||||||
|
|
||||||
|
const groupedTemplates = (familyName: string) => {
|
||||||
|
const byFamily = templates.filter(t => t.family === familyName)
|
||||||
|
const isCarr = familyName.toLowerCase().includes('carrelage')
|
||||||
|
if (!isCarr) return [{ label: null, items: byFamily }]
|
||||||
|
return CARRELAGE_PLACEMENTS.map(p => ({
|
||||||
|
label: p === 'mur' ? 'Mur (faïence)' : 'Sol',
|
||||||
|
items: byFamily.filter(t => t.placement === p),
|
||||||
|
})).filter(g => g.items.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Upload form */}
|
{/* Upload form */}
|
||||||
@@ -73,21 +90,36 @@ export function TemplateLibrary() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-400 mb-1">Nom</label>
|
<label className="block text-xs text-gray-400 mb-1">Nom</label>
|
||||||
<input value={name} onChange={e => setName(e.target.value)}
|
<input value={name} onChange={e => setName(e.target.value)}
|
||||||
placeholder="ex: Entrée moderne"
|
placeholder="ex: Salle de bain moderne"
|
||||||
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" />
|
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>
|
<div>
|
||||||
<label className="block text-xs text-gray-400 mb-1">Famille</label>
|
<label className="block text-xs text-gray-400 mb-1">Famille</label>
|
||||||
<select value={family} onChange={e => setFamily(e.target.value)}
|
<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">
|
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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isCarrelage && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Placement <span className="text-red-400">*</span></label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{CARRELAGE_PLACEMENTS.map(p => (
|
||||||
|
<button key={p} type="button" onClick={() => setPlacement(p)}
|
||||||
|
className={`px-4 py-1.5 rounded text-sm font-medium transition-colors ${placement === p ? 'bg-indigo-600 text-white' : 'bg-slate-700 text-gray-300 hover:bg-slate-600'}`}>
|
||||||
|
{p === 'mur' ? 'Mur (faïence)' : 'Sol'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-400 mb-1">Description (optionnel)</label>
|
<label className="block text-xs text-gray-400 mb-1">Description (optionnel)</label>
|
||||||
<input value={description} onChange={e => setDescription(e.target.value)}
|
<input value={description} onChange={e => setDescription(e.target.value)}
|
||||||
placeholder="ex: Pièce lumineuse avec parquet"
|
placeholder="ex: Salle de bain lumineuse"
|
||||||
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" />
|
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>
|
<div>
|
||||||
@@ -95,7 +127,7 @@ export function TemplateLibrary() {
|
|||||||
<input ref={fileRef} type="file" accept="image/*" className="text-sm text-gray-300" />
|
<input ref={fileRef} type="file" accept="image/*" className="text-sm text-gray-300" />
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
<button type="submit" disabled={uploading || !name || !family}
|
<button type="submit" disabled={uploading || !name || !family || (isCarrelage && !placement)}
|
||||||
className="px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg disabled:opacity-40 hover:bg-indigo-500">
|
className="px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg disabled:opacity-40 hover:bg-indigo-500">
|
||||||
{uploading ? 'Ajout…' : '+ Ajouter'}
|
{uploading ? 'Ajout…' : '+ Ajouter'}
|
||||||
</button>
|
</button>
|
||||||
@@ -110,27 +142,37 @@ export function TemplateLibrary() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{displayFamilies.map(f => {
|
{displayFamilies.map(f => {
|
||||||
const byFamily = templates.filter(t => t.family === f)
|
const groups = groupedTemplates(f)
|
||||||
if (byFamily.length === 0) return null
|
const total = groups.reduce((n, g) => n + g.items.length, 0)
|
||||||
|
if (total === 0) return null
|
||||||
return (
|
return (
|
||||||
<div key={f}>
|
<div key={f}>
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-2 flex items-center gap-2">
|
<h3 className="text-sm font-medium text-gray-300 mb-3 flex items-center gap-2">
|
||||||
{f}
|
{f}
|
||||||
<span className="text-xs text-gray-500">{byFamily.length} template{byFamily.length > 1 ? 's' : ''}</span>
|
<span className="text-xs text-gray-500">{total} template{total > 1 ? 's' : ''}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
<div className="space-y-4">
|
||||||
{byFamily.map(t => (
|
{groups.map((group, gi) => (
|
||||||
<div key={t.id} className="relative rounded-lg overflow-hidden border border-slate-600 group">
|
<div key={gi}>
|
||||||
<img src={`/api/mockup/templates/image/${t.filename}`} alt={t.name}
|
{group.label && (
|
||||||
className="w-full h-32 object-cover" />
|
<p className="text-xs text-gray-400 font-medium mb-2 uppercase tracking-wide">{group.label}</p>
|
||||||
<div className="p-2 bg-slate-800">
|
)}
|
||||||
<p className="text-xs text-white font-medium truncate">{t.name}</p>
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||||
{t.description && <p className="text-xs text-gray-400 truncate">{t.description}</p>}
|
{group.items.map(t => (
|
||||||
|
<div key={t.id} className="relative rounded-lg overflow-hidden border border-slate-600 group">
|
||||||
|
<img src={`/api/mockup/templates/image/${t.filename}`} alt={t.name}
|
||||||
|
className="w-full h-32 object-cover" />
|
||||||
|
<div className="p-2 bg-slate-800">
|
||||||
|
<p className="text-xs text-white font-medium truncate">{t.name}</p>
|
||||||
|
{t.description && <p className="text-xs text-gray-400 truncate">{t.description}</p>}
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => remove(t.id)}
|
||||||
|
className="absolute top-1 right-1 bg-red-600/80 hover:bg-red-500 text-white text-xs rounded px-1.5 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={() => remove(t.id)}
|
|
||||||
className="absolute top-1 right-1 bg-red-600/80 hover:bg-red-500 text-white text-xs rounded px-1.5 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export interface MockupTemplate {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
family: string
|
family: string
|
||||||
|
placement?: string
|
||||||
filename: string
|
filename: string
|
||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
@@ -12,6 +13,7 @@ export interface MockupGenerateRequest {
|
|||||||
productName: string
|
productName: string
|
||||||
dimensions?: string
|
dimensions?: string
|
||||||
family: string
|
family: string
|
||||||
|
placement?: string
|
||||||
instruction?: string
|
instruction?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user