feat: auto-fill mockup dimensions and family from Google Sheets

- Extend Sheets read range from A1:Z to A1:AZ (52 columns)
- Parse longueur, largeur, marque, famille, sousFamille, prixRemise, specificFields
- Add /api/products/sheet-info endpoint to fetch product data by ref
- Auto-fill family and dimensions on product selection in all 3 mockup entry points
- Strengthen carrelage mockup prompt with strict tile-size consistency rules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 13:35:39 +02:00
parent 4f617b15b2
commit ac58289bb7
7 changed files with 142 additions and 35 deletions
+17 -1
View File
@@ -10,6 +10,9 @@ interface Product {
shopifyId: string shopifyId: string
title: string title: string
ref: string ref: string
famille?: string
longueur?: string
largeur?: string
} }
export default function MockupPage() { export default function MockupPage() {
@@ -91,7 +94,20 @@ export default function MockupPage() {
<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"> <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 => ( {filtered.map(p => (
<button key={p.shopifyId} type="button" <button key={p.shopifyId} type="button"
onMouseDown={() => { setSelectedProduct(p); setSearch(''); setShowDropdown(false) }} 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"> 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="truncate">{p.title}</span>
<span className="text-xs text-gray-400 shrink-0">{p.ref}</span> <span className="text-xs text-gray-400 shrink-0">{p.ref}</span>
+22 -6
View File
@@ -6,17 +6,33 @@ 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') {
if (req.family === 'Carrelage' && req.placement === 'mur') { let surface = 'floor or wall'
carrelageContext = 'wall tiles (faïence) covering the wall of a modern bathroom or kitchen — applied only to the wall, not the floor' let restriction = ''
} else if (req.family === 'Carrelage' && req.placement === 'sol') { if (req.placement === 'mur') {
carrelageContext = 'floor tiles covering the floor of a modern room — applied only to the floor, not the walls' surface = 'wall'
restriction = ' Apply tiles only to the wall surface, not the floor.'
} else if (req.placement === 'sol') {
surface = 'floor'
restriction = ' Apply tiles only to the floor surface, not the walls.'
}
const dimClause = req.dimensions
? ` The real-world tile size is ${req.dimensions}.`
: ''
const instructionClause = req.instruction ? ` Additional instruction: ${req.instruction}.` : ''
return `Replace the ${surface} covering in the reference scene with the provided tile image (${req.productName}).${dimClause}${restriction} CRITICAL RULES — you MUST follow all of them:
1. Every single tile in the entire image must be EXACTLY the same size — no exceptions anywhere in the scene.
2. The tile pattern must be a perfectly uniform grid: identical tile dimensions repeated consistently across the entire ${surface}.
3. Tiles may be partially cut at edges, corners, or where the ${surface} meets other surfaces — but each visible portion must match the same grid unit size.
4. NEVER vary the tile scale or apparent size in different parts of the image, even for artistic or perspective reasons.
5. Copy the tile design, texture, color, and proportions from the provided image exactly — do not alter them.
6. Maintain realistic lighting and perspective consistent with the reference scene.${instructionClause}`
} }
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': 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
+19
View File
@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { readSheetProducts } from '@/lib/googleSheets'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const ref = new URL(req.url).searchParams.get('ref')
if (!ref) return NextResponse.json({ error: 'Paramètre ref manquant' }, { status: 400 })
const products = await readSheetProducts()
const product = products.find(p => p.ref.toLowerCase() === ref.toLowerCase())
if (!product) return NextResponse.json({ error: 'Produit non trouvé' }, { status: 404 })
return NextResponse.json({ product })
}
+5 -3
View File
@@ -57,16 +57,18 @@ export function MockupModal({ product, onClose }: Props) {
fetch('/api/mockup/templates').then(r => r.json()), fetch('/api/mockup/templates').then(r => r.json()),
fetch('/api/sheets/tabs').then(r => r.json()), fetch('/api/sheets/tabs').then(r => r.json()),
fetch(`/api/mockup/product-meta/${product.shopifyId}`).then(r => r.json()), fetch(`/api/mockup/product-meta/${product.shopifyId}`).then(r => r.json()),
]).then(([tplData, tabsData, metaData]) => { product.ref ? fetch(`/api/products/sheet-info?ref=${encodeURIComponent(product.ref)}`).then(r => r.ok ? r.json() : null) : Promise.resolve(null),
]).then(([tplData, tabsData, metaData, sheetData]) => {
setTemplates(tplData as MockupTemplate[]) setTemplates(tplData as MockupTemplate[])
const tabs: string[] = (tabsData.tabs ?? []).filter((t: string) => const tabs: string[] = (tabsData.tabs ?? []).filter((t: string) =>
!['COLLECTIONS', 'LIVRAISONS', 'ECOPART', 'RECAP'].some(x => t.toUpperCase().includes(x)) !['COLLECTIONS', 'LIVRAISONS', 'ECOPART', 'RECAP'].some(x => t.toUpperCase().includes(x))
) )
setFamilies(tabs) setFamilies(tabs)
setFamily(tabs[0] ?? '') const sheetFamille = sheetData?.product?.famille
setFamily(sheetFamille && tabs.includes(sheetFamille) ? sheetFamille : tabs[0] ?? '')
setMetafields(metaData.metafields ?? []) setMetafields(metaData.metafields ?? [])
}).finally(() => setLoadingMeta(false)) }).finally(() => setLoadingMeta(false))
}, [product.shopifyId]) }, [product.shopifyId, product.ref])
const isCarrelage = family.toLowerCase().includes('carrelage') const isCarrelage = family.toLowerCase().includes('carrelage')
+10 -3
View File
@@ -21,6 +21,7 @@ export function MockupModalFromImage({ item, onClose }: Props) {
const [templates, setTemplates] = useState<MockupTemplate[]>([]) const [templates, setTemplates] = useState<MockupTemplate[]>([])
const [families, setFamilies] = useState<string[]>([]) const [families, setFamilies] = useState<string[]>([])
const [family, setFamily] = useState('') const [family, setFamily] = useState('')
const [dimensions, setDimensions] = useState('')
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null) const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null)
const [instruction, setInstruction] = useState('') const [instruction, setInstruction] = useState('')
const [generating, setGenerating] = useState(false) const [generating, setGenerating] = useState(false)
@@ -35,15 +36,20 @@ export function MockupModalFromImage({ item, onClose }: Props) {
Promise.all([ Promise.all([
fetch('/api/mockup/templates').then(r => r.json()), fetch('/api/mockup/templates').then(r => r.json()),
fetch('/api/sheets/tabs').then(r => r.json()), fetch('/api/sheets/tabs').then(r => r.json()),
]).then(([tplData, tabsData]) => { item.ref ? fetch(`/api/products/sheet-info?ref=${encodeURIComponent(item.ref)}`).then(r => r.ok ? r.json() : null) : Promise.resolve(null),
]).then(([tplData, tabsData, sheetData]) => {
setTemplates(tplData as MockupTemplate[]) setTemplates(tplData as MockupTemplate[])
const tabs: string[] = (tabsData.tabs ?? []).filter((t: string) => const tabs: string[] = (tabsData.tabs ?? []).filter((t: string) =>
!['COLLECTIONS', 'LIVRAISONS', 'ECOPART', 'RECAP'].some(x => t.toUpperCase().includes(x)) !['COLLECTIONS', 'LIVRAISONS', 'ECOPART', 'RECAP'].some(x => t.toUpperCase().includes(x))
) )
setFamilies(tabs) setFamilies(tabs)
setFamily(tabs[0] ?? '') const sheetFamille = sheetData?.product?.famille
setFamily(sheetFamille && tabs.includes(sheetFamille) ? sheetFamille : tabs[0] ?? '')
const { longueur, largeur } = sheetData?.product ?? {}
if (longueur && largeur) setDimensions(`${longueur}x${largeur}cm`)
else if (longueur) setDimensions(`${longueur}cm`)
}) })
}, []) }, [item.ref])
const filteredTemplates = templates.filter(t => const filteredTemplates = templates.filter(t =>
t.family === family || t.family === 'Tous' t.family === family || t.family === 'Tous'
@@ -82,6 +88,7 @@ export function MockupModalFromImage({ item, onClose }: Props) {
productImageBase64, productImageBase64,
productName: item.shopifyProductTitle ?? item.ref, productName: item.shopifyProductTitle ?? item.ref,
family: family || 'Tous', family: family || 'Tous',
dimensions: dimensions || undefined,
instruction: instruction || undefined, instruction: instruction || undefined,
}), }),
}) })
+61 -22
View File
@@ -1,4 +1,5 @@
import type { SyncProduct } from '@/types/sync' import type { SyncProduct } from '@/types/sync'
import { findColIndex, extractSpecificFields } from '@/lib/creationSheets'
function normalizeHandle(ref: string): string { function normalizeHandle(ref: string): string {
return ref return ref
@@ -7,40 +8,78 @@ function normalizeHandle(ref: string): string {
.replace(/^-+|-+$/g, '') .replace(/^-+|-+$/g, '')
} }
/** function get(row: string[], idx: number): string {
* Parse des lignes brutes Google Sheets en SyncProduct[]. return idx >= 0 ? (row[idx] ?? '').trim() : ''
* Colonnes : A=Référence, B=Nom, C=Description, D=Prix, E=Stock, F=Statut
* Exporté pour les tests unitaires.
*/
const HEADER_WORDS = new Set(['id', 'ref', 'sku', 'nom', 'titre', 'reference', 'prix', 'statut'])
function isValidRef(ref: string): boolean {
if (ref.length < 2) return false
if (/^[^a-zA-Z0-9]+$/.test(ref)) return false // que des caractères spéciaux
if (HEADER_WORDS.has(ref.toLowerCase())) return false
return true
} }
/**
* Parse des lignes brutes Google Sheets en SyncProduct[].
* La première ligne doit être la ligne d'en-têtes.
* Colonnes détectées dynamiquement : UGS, Nom, Prix marché, Stock, Publié
*/
export function parseSheetRows(rows: string[][]): SyncProduct[] { export function parseSheetRows(rows: string[][]): SyncProduct[] {
return rows if (rows.length === 0) return []
.map((row): SyncProduct | null => {
const ref = row[0]?.trim()
if (!ref || !isValidRef(ref)) return null
const rawPrice = (row[3] ?? '').replace(',', '.').trim() // Trouver la ligne d'en-têtes : celle qui contient "UGS"
const headerRowIndex = rows.findIndex((row) =>
row.some((cell) => cell?.trim().toUpperCase() === 'UGS')
)
if (headerRowIndex < 0) return []
const headers = rows[headerRowIndex].map((h) => h?.trim() ?? '')
const idx = {
sku: findColIndex(headers, 'UGS'),
nom: findColIndex(headers, 'Nom'),
prixMarche: findColIndex(headers, 'Prix marché'),
prixRemise: findColIndex(headers, 'Prix remisé'),
stock: findColIndex(headers, 'Stock (pièce)') !== -1 ? findColIndex(headers, 'Stock (pièce)') : findColIndex(headers, 'Stock (en pièce)'),
publie: findColIndex(headers, 'Publié'),
poids: findColIndex(headers, 'Poids (kg) fonction unité'),
marque: findColIndex(headers, 'Marque'),
famille: findColIndex(headers, 'Famille'),
sousFamille: findColIndex(headers, 'Sous famille'),
longueur: findColIndex(headers, 'Longueur'),
largeur: findColIndex(headers, 'Largeur'),
}
// Onglet sans colonne UGS ou Prix marché = onglet non-produit, on skip
if (idx.sku < 0 || idx.prixMarche < 0) return []
return rows.slice(headerRowIndex + 1)
.map((row): SyncProduct | null => {
const ref = get(row, idx.sku)
if (!ref || ref.length < 2) return null
const rawPrice = get(row, idx.prixMarche).replace(',', '.')
const price = parseFloat(rawPrice || '0') const price = parseFloat(rawPrice || '0')
// Ligne sans prix valide = séparateur ou note, pas un produit // Ligne sans prix valide = séparateur ou note, pas un produit
if (isNaN(price) || price <= 0) return null if (isNaN(price) || price <= 0) return null
const publie = get(row, idx.publie).toLowerCase()
const status = publie.includes('inac') || publie === 'non' || publie === 'false' ? 'draft' : 'active'
const prixRemise = get(row, idx.prixRemise).replace(',', '.')
const longueur = get(row, idx.longueur)
const largeur = get(row, idx.largeur)
const specificFields = extractSpecificFields(headers, row)
return { return {
ref, ref,
handle: normalizeHandle(ref), handle: normalizeHandle(ref),
title: row[1]?.trim() || ref, title: get(row, idx.nom) || ref,
description: row[2]?.trim() || '', description: '',
price: price.toFixed(2), price: price.toFixed(2),
stock: parseInt(row[4] ?? '0', 10) || 0, stock: parseInt(get(row, idx.stock) || '0', 10) || 0,
status: (row[5] ?? '').toLowerCase().includes('inac') ? 'draft' : 'active', weightKg: parseFloat(get(row, idx.poids).replace(',', '.') || '0') || undefined,
status,
prixRemise: prixRemise || undefined,
marque: get(row, idx.marque) || undefined,
famille: get(row, idx.famille) || undefined,
sousFamille: get(row, idx.sousFamille) || undefined,
longueur: longueur || undefined,
largeur: largeur || undefined,
specificFields: Object.keys(specificFields).length > 0 ? specificFields : undefined,
} }
}) })
.filter((p): p is SyncProduct => p !== null) .filter((p): p is SyncProduct => p !== null)
@@ -84,7 +123,7 @@ async function listSheetTabs(sheetId: string, apiKey: string): Promise<string[]>
} }
async function readOneTab(sheetId: string, apiKey: string, sheetName: string): Promise<SyncProduct[]> { async function readOneTab(sheetId: string, apiKey: string, sheetName: string): Promise<SyncProduct[]> {
const range = encodeURIComponent(`${sheetName}!A2:F`) const range = encodeURIComponent(`${sheetName}!A1:AZ`)
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}` const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`
const res = await fetch(url) const res = await fetch(url)
if (!res.ok) { if (!res.ok) {
+8
View File
@@ -10,6 +10,14 @@ export interface SyncProduct {
stock: number // colonne E stock: number // colonne E
weightKg?: number // poids en kg pour l'expédition weightKg?: number // poids en kg pour l'expédition
status: 'active' | 'draft' // colonne F : "inactif"/"inactif" → draft, tout autre → active status: 'active' | 'draft' // colonne F : "inactif"/"inactif" → draft, tout autre → active
// Champs enrichis
prixRemise?: string
marque?: string
famille?: string
sousFamille?: string
longueur?: string
largeur?: string
specificFields?: Record<string, string>
} }
/** Produit Shopify enrichi pour la comparaison */ /** Produit Shopify enrichi pour la comparaison */