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
|
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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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/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')
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user