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:
@@ -10,6 +10,9 @@ interface Product {
|
||||
shopifyId: string
|
||||
title: string
|
||||
ref: string
|
||||
famille?: string
|
||||
longueur?: string
|
||||
largeur?: string
|
||||
}
|
||||
|
||||
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">
|
||||
{filtered.map(p => (
|
||||
<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">
|
||||
<span className="truncate">{p.title}</span>
|
||||
<span className="text-xs text-gray-400 shrink-0">{p.ref}</span>
|
||||
|
||||
@@ -6,17 +6,33 @@ import { getTemplateImagePath, listTemplates } from '@/lib/mockupTemplates'
|
||||
import type { MockupGenerateRequest } from '@/types/mockup'
|
||||
|
||||
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'
|
||||
if (req.family === 'Carrelage') {
|
||||
let surface = 'floor or wall'
|
||||
let restriction = ''
|
||||
if (req.placement === 'mur') {
|
||||
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> = {
|
||||
'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',
|
||||
'Carrelage': carrelageContext,
|
||||
}
|
||||
const context = familyContext[req.family] ?? 'product installed in an appropriate room setting'
|
||||
const dimClause = req.dimensions
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -57,16 +57,18 @@ export function MockupModal({ product, onClose }: Props) {
|
||||
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]) => {
|
||||
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[])
|
||||
const tabs: string[] = (tabsData.tabs ?? []).filter((t: string) =>
|
||||
!['COLLECTIONS', 'LIVRAISONS', 'ECOPART', 'RECAP'].some(x => t.toUpperCase().includes(x))
|
||||
)
|
||||
setFamilies(tabs)
|
||||
setFamily(tabs[0] ?? '')
|
||||
const sheetFamille = sheetData?.product?.famille
|
||||
setFamily(sheetFamille && tabs.includes(sheetFamille) ? sheetFamille : tabs[0] ?? '')
|
||||
setMetafields(metaData.metafields ?? [])
|
||||
}).finally(() => setLoadingMeta(false))
|
||||
}, [product.shopifyId])
|
||||
}, [product.shopifyId, product.ref])
|
||||
|
||||
const isCarrelage = family.toLowerCase().includes('carrelage')
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export function MockupModalFromImage({ item, onClose }: Props) {
|
||||
const [templates, setTemplates] = useState<MockupTemplate[]>([])
|
||||
const [families, setFamilies] = useState<string[]>([])
|
||||
const [family, setFamily] = useState('')
|
||||
const [dimensions, setDimensions] = useState('')
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null)
|
||||
const [instruction, setInstruction] = useState('')
|
||||
const [generating, setGenerating] = useState(false)
|
||||
@@ -35,15 +36,20 @@ export function MockupModalFromImage({ item, onClose }: Props) {
|
||||
Promise.all([
|
||||
fetch('/api/mockup/templates').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[])
|
||||
const tabs: string[] = (tabsData.tabs ?? []).filter((t: string) =>
|
||||
!['COLLECTIONS', 'LIVRAISONS', 'ECOPART', 'RECAP'].some(x => t.toUpperCase().includes(x))
|
||||
)
|
||||
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 =>
|
||||
t.family === family || t.family === 'Tous'
|
||||
@@ -82,6 +88,7 @@ export function MockupModalFromImage({ item, onClose }: Props) {
|
||||
productImageBase64,
|
||||
productName: item.shopifyProductTitle ?? item.ref,
|
||||
family: family || 'Tous',
|
||||
dimensions: dimensions || undefined,
|
||||
instruction: instruction || undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
+61
-22
@@ -1,4 +1,5 @@
|
||||
import type { SyncProduct } from '@/types/sync'
|
||||
import { findColIndex, extractSpecificFields } from '@/lib/creationSheets'
|
||||
|
||||
function normalizeHandle(ref: string): string {
|
||||
return ref
|
||||
@@ -7,40 +8,78 @@ function normalizeHandle(ref: string): string {
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse des lignes brutes Google Sheets en SyncProduct[].
|
||||
* 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
|
||||
function get(row: string[], idx: number): string {
|
||||
return idx >= 0 ? (row[idx] ?? '').trim() : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[] {
|
||||
return rows
|
||||
.map((row): SyncProduct | null => {
|
||||
const ref = row[0]?.trim()
|
||||
if (!ref || !isValidRef(ref)) return null
|
||||
if (rows.length === 0) return []
|
||||
|
||||
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')
|
||||
|
||||
// Ligne sans prix valide = séparateur ou note, pas un produit
|
||||
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 {
|
||||
ref,
|
||||
handle: normalizeHandle(ref),
|
||||
title: row[1]?.trim() || ref,
|
||||
description: row[2]?.trim() || '',
|
||||
title: get(row, idx.nom) || ref,
|
||||
description: '',
|
||||
price: price.toFixed(2),
|
||||
stock: parseInt(row[4] ?? '0', 10) || 0,
|
||||
status: (row[5] ?? '').toLowerCase().includes('inac') ? 'draft' : 'active',
|
||||
stock: parseInt(get(row, idx.stock) || '0', 10) || 0,
|
||||
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)
|
||||
@@ -84,7 +123,7 @@ async function listSheetTabs(sheetId: string, apiKey: string): Promise<string[]>
|
||||
}
|
||||
|
||||
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 res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -10,6 +10,14 @@ export interface SyncProduct {
|
||||
stock: number // colonne E
|
||||
weightKg?: number // poids en kg pour l'expédition
|
||||
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 */
|
||||
|
||||
Reference in New Issue
Block a user