Compare commits
3 Commits
ac58289bb7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ac05d2ac30 | |||
| 60dc9cc0ec | |||
| eb5f605175 |
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Met à jour compare_at_price sur tous les produits Shopify existants
|
||||||
|
* en lisant Prix marché / Prix remisé depuis Google Sheets.
|
||||||
|
*
|
||||||
|
* Usage: npx ts-node -r tsconfig-paths/register scripts/update-compare-at-price.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as dotenv from 'dotenv'
|
||||||
|
dotenv.config({ path: '.env.local' })
|
||||||
|
|
||||||
|
import { readSheetProducts } from '../src/lib/googleSheets'
|
||||||
|
import { fetchAllShopifyProducts, getConfig } from '../src/lib/shopifySync'
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Lecture Google Sheets…')
|
||||||
|
const sheetProducts = await readSheetProducts()
|
||||||
|
console.log(`${sheetProducts.length} produits dans Sheets`)
|
||||||
|
|
||||||
|
console.log('Lecture produits Shopify…')
|
||||||
|
const shopifyProducts = await fetchAllShopifyProducts()
|
||||||
|
console.log(`${shopifyProducts.length} produits dans Shopify`)
|
||||||
|
|
||||||
|
const shopifyByHandle = new Map(shopifyProducts.map(p => [p.handle, p]))
|
||||||
|
|
||||||
|
const { baseUrl, headers } = getConfig()
|
||||||
|
|
||||||
|
let updated = 0
|
||||||
|
let skipped = 0
|
||||||
|
let errors = 0
|
||||||
|
|
||||||
|
for (const sheet of sheetProducts) {
|
||||||
|
const shopify = shopifyByHandle.get(sheet.handle)
|
||||||
|
if (!shopify) { skipped++; continue }
|
||||||
|
|
||||||
|
const newCompareAt = sheet.compareAtPrice ?? null
|
||||||
|
const currentCompareAt = (shopify as { compareAtPrice?: string }).compareAtPrice ?? null
|
||||||
|
|
||||||
|
// Normalise pour comparer (null vs undefined vs "0.00" vs null)
|
||||||
|
const norm = (v: string | null) => (!v || v === '0.00') ? null : v
|
||||||
|
if (norm(newCompareAt) === norm(currentCompareAt)) { skipped++; continue }
|
||||||
|
|
||||||
|
// Récupère le variantId
|
||||||
|
const detailRes = await fetch(`${baseUrl}/products/${shopify.shopifyId}.json?fields=variants`, { headers })
|
||||||
|
if (!detailRes.ok) { console.error(`Erreur lecture variant ${sheet.ref}`); errors++; continue }
|
||||||
|
const detail = await detailRes.json() as { product: { variants: Array<{ id: number }> } }
|
||||||
|
const variantId = detail.product.variants[0]?.id
|
||||||
|
if (!variantId) { errors++; continue }
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/products/${shopify.shopifyId}.json`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
product: {
|
||||||
|
id: shopify.shopifyId,
|
||||||
|
variants: [{ id: variantId, price: sheet.price, compare_at_price: newCompareAt }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
console.log(`✓ ${sheet.ref} — price=${sheet.price} compare_at=${newCompareAt ?? 'null'}`)
|
||||||
|
updated++
|
||||||
|
} else {
|
||||||
|
const err = await res.text()
|
||||||
|
console.error(`✗ ${sheet.ref} — ${err}`)
|
||||||
|
errors++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
await new Promise(r => setTimeout(r, 300))
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nTerminé : ${updated} mis à jour, ${skipped} inchangés, ${errors} erreurs`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => { console.error(err); process.exit(1) })
|
||||||
@@ -2,35 +2,66 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { Header } from '@/components/layout/Header'
|
import { Header } from '@/components/layout/Header'
|
||||||
import { TemplateLibrary } from '@/components/mockup/TemplateLibrary'
|
import { TemplateLibrary } from '@/components/mockup/TemplateLibrary'
|
||||||
import { MockupGenerator } from '@/components/mockup/MockupGenerator'
|
import type { MockupTemplate } from '@/types/mockup'
|
||||||
|
|
||||||
type Tab = 'generate' | 'library'
|
type Tab = 'generate' | 'library'
|
||||||
|
type Step = 'product' | 'image' | 'template'
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
shopifyId: string
|
shopifyId: string
|
||||||
title: string
|
title: string
|
||||||
ref: string
|
ref: string
|
||||||
famille?: string
|
images: string[]
|
||||||
longueur?: string
|
}
|
||||||
largeur?: string
|
|
||||||
|
function blobToBase64(blob: Blob): Promise<string> {
|
||||||
|
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 default function MockupPage() {
|
export default function MockupPage() {
|
||||||
const [tab, setTab] = useState<Tab>('generate')
|
const [tab, setTab] = useState<Tab>('generate')
|
||||||
|
const [step, setStep] = useState<Step>('product')
|
||||||
|
|
||||||
|
// Step 1 — product
|
||||||
const [products, setProducts] = useState<Product[]>([])
|
const [products, setProducts] = useState<Product[]>([])
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [showDropdown, setShowDropdown] = useState(false)
|
const [showDropdown, setShowDropdown] = useState(false)
|
||||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null)
|
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null)
|
||||||
const [family, setFamily] = useState('Menuiserie')
|
const [family, setFamily] = useState('')
|
||||||
|
const [placement, setPlacement] = useState('')
|
||||||
const [dimensions, setDimensions] = useState('')
|
const [dimensions, setDimensions] = useState('')
|
||||||
const [cutoutB64, setCutoutB64] = useState<string | null>(null)
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Step 2 — image
|
||||||
|
const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>(null)
|
||||||
|
const [cutoutB64, setCutoutB64] = useState<string | null>(null)
|
||||||
|
const [loadingImage, setLoadingImage] = useState(false)
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Step 3 — template + generation
|
||||||
|
const [templates, setTemplates] = useState<MockupTemplate[]>([])
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null)
|
||||||
|
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 templateFileRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [uploadingTemplate, setUploadingTemplate] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/products/list')
|
fetch('/api/products/list')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => setProducts(data.products ?? []))
|
.then(data => setProducts(data.products ?? []))
|
||||||
|
fetch('/api/mockup/templates')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => setTemplates(data as MockupTemplate[]))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const filtered = search.length < 2 ? [] : products.filter(p =>
|
const filtered = search.length < 2 ? [] : products.filter(p =>
|
||||||
@@ -38,17 +69,164 @@ export default function MockupPage() {
|
|||||||
p.ref.toLowerCase().includes(search.toLowerCase())
|
p.ref.toLowerCase().includes(search.toLowerCase())
|
||||||
).slice(0, 20)
|
).slice(0, 20)
|
||||||
|
|
||||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const isCarrelage = family.toLowerCase().includes('carrelage')
|
||||||
|
|
||||||
|
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 selectProduct = (p: Product) => {
|
||||||
|
setSelectedProduct(p)
|
||||||
|
setSearch('')
|
||||||
|
setShowDropdown(false)
|
||||||
|
setSelectedImageUrl(null)
|
||||||
|
setCutoutB64(null)
|
||||||
|
setResultB64(null)
|
||||||
|
setUploadedUrl(null)
|
||||||
|
setStep('image')
|
||||||
|
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`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectImage = async (url: string) => {
|
||||||
|
setSelectedImageUrl(url)
|
||||||
|
setCutoutB64(null)
|
||||||
|
setLoadingImage(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
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()
|
||||||
|
const b64 = await blobToBase64(blob)
|
||||||
|
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]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
setSelectedImageUrl(null)
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = ev => {
|
reader.onload = ev => {
|
||||||
const result = ev.target?.result as string
|
setCutoutB64((ev.target?.result as string).split(',')[1])
|
||||||
setCutoutB64(result.split(',')[1])
|
setStep('template')
|
||||||
}
|
}
|
||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uploadTemplate = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setUploadingTemplate(true)
|
||||||
|
const form = new FormData()
|
||||||
|
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 })
|
||||||
|
const tpl = await res.json() as MockupTemplate
|
||||||
|
setTemplates(prev => [...prev, tpl])
|
||||||
|
setSelectedTemplate(tpl.id)
|
||||||
|
setUploadingTemplate(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const generate = async () => {
|
||||||
|
if (!selectedTemplate || !cutoutB64) return
|
||||||
|
setGenerating(true)
|
||||||
|
setError(null)
|
||||||
|
setResultB64(null)
|
||||||
|
setUploadedUrl(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/mockup/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
templateId: selectedTemplate,
|
||||||
|
productImageBase64: cutoutB64,
|
||||||
|
productName: selectedProduct!.title,
|
||||||
|
family: family || 'Tous',
|
||||||
|
placement: placement || undefined,
|
||||||
|
dimensions: dimensions || undefined,
|
||||||
|
instruction: instruction || undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error)
|
||||||
|
setResultB64(data.imageBase64)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Erreur')
|
||||||
|
} finally {
|
||||||
|
setGenerating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const upload = async () => {
|
||||||
|
if (!resultB64 || !selectedProduct) return
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/images/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
shopifyProductId: selectedProduct.shopifyId,
|
||||||
|
imageBase64: resultB64,
|
||||||
|
filename: `${selectedProduct.ref}-mockup.jpg`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error)
|
||||||
|
setUploadedUrl(data.url)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Erreur upload')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepIndicator = () => (
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
{(['product', 'image', 'template'] as Step[]).map((s, i) => {
|
||||||
|
const labels = ['Produit', 'Image', 'Template']
|
||||||
|
const active = step === s
|
||||||
|
const done = (step === 'image' && s === 'product') ||
|
||||||
|
(step === 'template' && (s === 'product' || s === 'image'))
|
||||||
|
return (
|
||||||
|
<div key={s} className="flex items-center gap-2">
|
||||||
|
{i > 0 && <div className={`h-px w-6 ${done ? 'bg-indigo-500' : 'bg-slate-600'}`} />}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => done || active ? setStep(s) : undefined}
|
||||||
|
className={`flex items-center gap-1.5 text-xs font-medium transition-colors ${
|
||||||
|
active ? 'text-white' : done ? 'text-indigo-400 hover:text-indigo-300' : 'text-slate-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
|
||||||
|
active ? 'bg-indigo-600 text-white' : done ? 'bg-indigo-500/30 text-indigo-400' : 'bg-slate-700 text-slate-500'
|
||||||
|
}`}>
|
||||||
|
{done ? '✓' : i + 1}
|
||||||
|
</span>
|
||||||
|
{labels[i]}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="🖼️ Mockups" actions={
|
<Header title="🖼️ Mockups" actions={
|
||||||
@@ -66,98 +244,202 @@ export default function MockupPage() {
|
|||||||
{tab === 'library' ? (
|
{tab === 'library' ? (
|
||||||
<TemplateLibrary />
|
<TemplateLibrary />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div>
|
||||||
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 space-y-3">
|
<StepIndicator />
|
||||||
<h2 className="text-sm font-semibold text-white">Produit</h2>
|
|
||||||
|
|
||||||
{/* Product picker */}
|
{/* ── STEP 1 : Produit ── */}
|
||||||
|
{step === 'product' && (
|
||||||
|
<div className="bg-slate-800 rounded-xl p-5 border border-slate-700 space-y-4">
|
||||||
|
<h2 className="text-sm font-semibold text-white">Rechercher un produit</h2>
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<label className="block text-xs text-gray-400 mb-1">Rechercher un produit</label>
|
|
||||||
{selectedProduct ? (
|
|
||||||
<div className="flex items-center gap-2 bg-slate-700 border border-slate-600 rounded px-2 py-1.5">
|
|
||||||
<span className="flex-1 text-sm text-white truncate">{selectedProduct.title}</span>
|
|
||||||
<span className="text-xs text-gray-400 shrink-0">{selectedProduct.ref}</span>
|
|
||||||
<button type="button" onClick={() => { setSelectedProduct(null); setSearch('') }}
|
|
||||||
className="text-gray-400 hover:text-white ml-1 shrink-0">✕</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => { setSearch(e.target.value); setShowDropdown(true) }}
|
onChange={e => { setSearch(e.target.value); setShowDropdown(true) }}
|
||||||
onFocus={() => setShowDropdown(true)}
|
onFocus={() => setShowDropdown(true)}
|
||||||
placeholder="Nom ou référence du produit…"
|
placeholder="Nom ou référence du produit…"
|
||||||
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"
|
autoFocus
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
/>
|
/>
|
||||||
{showDropdown && filtered.length > 0 && (
|
{showDropdown && filtered.length > 0 && (
|
||||||
<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-lg shadow-xl max-h-72 overflow-y-auto">
|
||||||
{filtered.map(p => (
|
{filtered.map(p => (
|
||||||
<button key={p.shopifyId} type="button"
|
<button key={p.shopifyId} type="button"
|
||||||
onMouseDown={() => {
|
onMouseDown={() => selectProduct(p)}
|
||||||
setSelectedProduct(p)
|
className="w-full text-left px-3 py-2.5 text-sm text-white hover:bg-slate-600 flex justify-between gap-2 border-b border-slate-600/50 last:border-0">
|
||||||
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="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 self-center">{p.ref}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showDropdown && search.length >= 2 && filtered.length === 0 && (
|
{showDropdown && search.length >= 2 && filtered.length === 0 && (
|
||||||
<div className="absolute z-10 mt-1 w-full bg-slate-700 border border-slate-600 rounded px-3 py-2">
|
<div className="absolute z-10 mt-1 w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2">
|
||||||
<p className="text-xs text-gray-400">Aucun résultat</p>
|
<p className="text-xs text-gray-400">Aucun résultat</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── STEP 2 : Image ── */}
|
||||||
|
{step === 'image' && selectedProduct && (
|
||||||
|
<div className="bg-slate-800 rounded-xl p-5 border border-slate-700 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-white">Choisir une image</h2>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">{selectedProduct.title} · <span className="font-mono text-indigo-300">{selectedProduct.ref}</span></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => { setSelectedProduct(null); setStep('product') }}
|
||||||
|
className="text-xs text-gray-400 hover:text-white">← Changer</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedProduct.images.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-400 mb-2">Images Shopify</p>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{selectedProduct.images.map((url, i) => (
|
||||||
|
<button key={i} type="button" onClick={() => { selectImage(url); setStep('template') }}
|
||||||
|
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-full h-20 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>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-500">Aucune image disponible sur Shopify pour ce produit.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border-t border-slate-700 pt-3">
|
||||||
|
<p className="text-xs text-gray-400 mb-2">Ou uploader une image (PNG découpée)</p>
|
||||||
|
<input ref={fileRef} type="file" accept="image/*" onChange={handleFileUpload} className="text-xs text-gray-300" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── STEP 3 : Template + génération ── */}
|
||||||
|
{step === 'template' && selectedProduct && cutoutB64 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Recap produit + image */}
|
||||||
|
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 flex items-center gap-3">
|
||||||
|
{selectedImageUrl && (
|
||||||
|
<img src={selectedImageUrl} alt="" className="w-12 h-12 object-cover rounded-lg border border-slate-600 shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-white truncate">{selectedProduct.title}</p>
|
||||||
|
<p className="text-xs text-gray-400">{selectedProduct.ref}
|
||||||
|
{dimensions && <span className="ml-2 text-green-400">· {dimensions}</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => setStep('image')} className="text-xs text-gray-400 hover:text-white shrink-0">← Image</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Famille + placement carrelage */}
|
||||||
|
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 space-y-3">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<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">
|
||||||
{['Menuiserie', 'Baies vitrées', 'Carrelage', 'Tous'].map(f => <option key={f}>{f}</option>)}
|
{['Menuiserie', 'Baies vitrées', 'Carrelage', 'Tous'].map(f => <option key={f}>{f}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-400 mb-1">Dimensions (ex: 90x215cm)</label>
|
<label className="block text-xs text-gray-400 mb-1">Dimensions</label>
|
||||||
<input value={dimensions} onChange={e => setDimensions(e.target.value)}
|
<input value={dimensions} onChange={e => setDimensions(e.target.value)}
|
||||||
placeholder="optionnel"
|
placeholder="ex: 60x60cm"
|
||||||
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>
|
||||||
|
{isCarrelage && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-400 mb-1">Image découpée (PNG)</label>
|
<p className="text-xs text-gray-400 mb-1.5">Placement</p>
|
||||||
<input type="file" accept="image/png" onChange={handleFile} className="text-sm text-gray-300" />
|
<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>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{cutoutB64 && selectedProduct ? (
|
{/* Templates */}
|
||||||
<MockupGenerator
|
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 space-y-3">
|
||||||
productCutoutBase64={cutoutB64}
|
<div className="flex items-center justify-between">
|
||||||
productName={selectedProduct.title}
|
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide">Template</h3>
|
||||||
shopifyId={selectedProduct.shopifyId}
|
<button type="button" onClick={() => templateFileRef.current?.click()}
|
||||||
family={family}
|
disabled={uploadingTemplate}
|
||||||
dimensions={dimensions || undefined}
|
className="text-xs text-indigo-400 hover:text-indigo-300 disabled:opacity-40">
|
||||||
/>
|
{uploadingTemplate ? 'Upload…' : '+ Nouveau template'}
|
||||||
|
</button>
|
||||||
|
<input ref={templateFileRef} type="file" accept="image/*" className="hidden" onChange={uploadTemplate} />
|
||||||
|
</div>
|
||||||
|
{filteredTemplates.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-500">Aucun template pour cette famille. Uploadez-en un.</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-gray-500 text-center py-8">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
{!selectedProduct ? 'Sélectionnez un produit pour commencer.' : 'Chargez une image PNG découpée.'}
|
{filteredTemplates.map(t => (
|
||||||
</p>
|
<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-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>
|
||||||
|
|
||||||
|
{/* Instruction + génération */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input type="text" value={instruction} onChange={e => setInstruction(e.target.value)}
|
||||||
|
placeholder="Instruction personnalisée (optionnel)…"
|
||||||
|
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||||
|
<button type="button" onClick={generate}
|
||||||
|
disabled={!selectedTemplate || generating}
|
||||||
|
className="w-full py-2.5 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
|
||||||
|
{/* Résultat */}
|
||||||
|
{resultB64 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<img src={`data:image/jpeg;base64,${resultB64}`} alt="Mockup"
|
||||||
|
className="w-full rounded-xl border border-slate-600" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input type="text" value={instruction} onChange={e => setInstruction(e.target.value)}
|
||||||
|
placeholder="Modifier et regénérer…"
|
||||||
|
className="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||||
|
<button type="button" onClick={generate} disabled={generating}
|
||||||
|
className="px-3 py-1.5 rounded bg-slate-700 text-xs text-white hover:bg-slate-600 disabled:opacity-40">
|
||||||
|
{generating ? '…' : '↺'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{uploadedUrl ? (
|
||||||
|
<p className="text-xs text-green-400 text-center">✓ Uploadé sur Shopify</p>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={upload} disabled={uploading}
|
||||||
|
className="w-full py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium disabled:opacity-40 hover:bg-emerald-500 transition-colors">
|
||||||
|
{uploading ? 'Upload…' : '↑ Uploader sur Shopify'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { readSheetProducts } from '@/lib/googleSheets'
|
||||||
|
import { fetchAllShopifyProducts, getConfig } from '@/lib/shopifySync'
|
||||||
|
|
||||||
|
export const maxDuration = 300
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const sheetProducts = await readSheetProducts()
|
||||||
|
const shopifyProducts = await fetchAllShopifyProducts()
|
||||||
|
const shopifyByHandle = new Map(shopifyProducts.map(p => [p.handle, p]))
|
||||||
|
const { baseUrl, headers } = getConfig()
|
||||||
|
|
||||||
|
const norm = (v: string | null | undefined) => (!v || v === '0.00') ? null : v
|
||||||
|
|
||||||
|
let updated = 0, skipped = 0, errors = 0
|
||||||
|
const log: string[] = []
|
||||||
|
|
||||||
|
for (const sheet of sheetProducts) {
|
||||||
|
const shopify = shopifyByHandle.get(sheet.handle)
|
||||||
|
if (!shopify) { skipped++; continue }
|
||||||
|
|
||||||
|
const newCompareAt = sheet.compareAtPrice ?? null
|
||||||
|
const currentCompareAt = (shopify as { compareAtPrice?: string }).compareAtPrice ?? null
|
||||||
|
|
||||||
|
if (norm(newCompareAt) === norm(currentCompareAt) && sheet.price === shopify.price) {
|
||||||
|
skipped++; continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailRes = await fetch(`${baseUrl}/products/${shopify.shopifyId}.json?fields=variants`, { headers })
|
||||||
|
if (!detailRes.ok) { errors++; log.push(`✗ ${sheet.ref} — lecture variant`); continue }
|
||||||
|
const detail = await detailRes.json() as { product: { variants: Array<{ id: number }> } }
|
||||||
|
const variantId = detail.product.variants[0]?.id
|
||||||
|
if (!variantId) { errors++; continue }
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/products/${shopify.shopifyId}.json`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
product: {
|
||||||
|
id: shopify.shopifyId,
|
||||||
|
variants: [{ id: variantId, price: sheet.price, compare_at_price: newCompareAt }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
updated++
|
||||||
|
log.push(`✓ ${sheet.ref} price=${sheet.price} compare_at=${newCompareAt ?? 'null'}`)
|
||||||
|
} else {
|
||||||
|
errors++
|
||||||
|
log.push(`✗ ${sheet.ref} — ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 300))
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ updated, skipped, errors, log })
|
||||||
|
}
|
||||||
@@ -59,7 +59,9 @@ export function parseSheetRows(rows: string[][]): SyncProduct[] {
|
|||||||
const publie = get(row, idx.publie).toLowerCase()
|
const publie = get(row, idx.publie).toLowerCase()
|
||||||
const status = publie.includes('inac') || publie === 'non' || publie === 'false' ? 'draft' : 'active'
|
const status = publie.includes('inac') || publie === 'non' || publie === 'false' ? 'draft' : 'active'
|
||||||
|
|
||||||
const prixRemise = get(row, idx.prixRemise).replace(',', '.')
|
const rawRemise = get(row, idx.prixRemise).replace(',', '.')
|
||||||
|
const prixRemise = parseFloat(rawRemise || '0')
|
||||||
|
const hasRemise = !isNaN(prixRemise) && prixRemise > 0
|
||||||
const longueur = get(row, idx.longueur)
|
const longueur = get(row, idx.longueur)
|
||||||
const largeur = get(row, idx.largeur)
|
const largeur = get(row, idx.largeur)
|
||||||
const specificFields = extractSpecificFields(headers, row)
|
const specificFields = extractSpecificFields(headers, row)
|
||||||
@@ -69,11 +71,12 @@ export function parseSheetRows(rows: string[][]): SyncProduct[] {
|
|||||||
handle: normalizeHandle(ref),
|
handle: normalizeHandle(ref),
|
||||||
title: get(row, idx.nom) || ref,
|
title: get(row, idx.nom) || ref,
|
||||||
description: '',
|
description: '',
|
||||||
price: price.toFixed(2),
|
price: hasRemise ? prixRemise.toFixed(2) : price.toFixed(2),
|
||||||
|
compareAtPrice: hasRemise ? price.toFixed(2) : undefined,
|
||||||
stock: parseInt(get(row, idx.stock) || '0', 10) || 0,
|
stock: parseInt(get(row, idx.stock) || '0', 10) || 0,
|
||||||
weightKg: parseFloat(get(row, idx.poids).replace(',', '.') || '0') || undefined,
|
weightKg: parseFloat(get(row, idx.poids).replace(',', '.') || '0') || undefined,
|
||||||
status,
|
status,
|
||||||
prixRemise: prixRemise || undefined,
|
prixRemise: hasRemise ? prixRemise.toFixed(2) : undefined,
|
||||||
marque: get(row, idx.marque) || undefined,
|
marque: get(row, idx.marque) || undefined,
|
||||||
famille: get(row, idx.famille) || undefined,
|
famille: get(row, idx.famille) || undefined,
|
||||||
sousFamille: get(row, idx.sousFamille) || undefined,
|
sousFamille: get(row, idx.sousFamille) || undefined,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function getConfig() {
|
|||||||
interface ShopifyVariant {
|
interface ShopifyVariant {
|
||||||
id: number
|
id: number
|
||||||
price: string
|
price: string
|
||||||
|
compare_at_price: string | null
|
||||||
sku: string
|
sku: string
|
||||||
inventory_quantity: number
|
inventory_quantity: number
|
||||||
}
|
}
|
||||||
@@ -42,6 +43,7 @@ function toSyncProduct(p: ShopifyAPIProduct): ShopifyProductFull {
|
|||||||
title: p.title,
|
title: p.title,
|
||||||
description: p.body_html ?? '',
|
description: p.body_html ?? '',
|
||||||
price: p.variants[0]?.price ?? '0.00',
|
price: p.variants[0]?.price ?? '0.00',
|
||||||
|
compareAtPrice: p.variants[0]?.compare_at_price ?? undefined,
|
||||||
stock: p.variants[0]?.inventory_quantity ?? 0,
|
stock: p.variants[0]?.inventory_quantity ?? 0,
|
||||||
status: p.status === 'active' ? 'active' : 'draft',
|
status: p.status === 'active' ? 'active' : 'draft',
|
||||||
images: (p.images ?? []).map(i => i.src),
|
images: (p.images ?? []).map(i => i.src),
|
||||||
@@ -93,6 +95,7 @@ export async function createShopifyProduct(product: SyncProduct): Promise<string
|
|||||||
|
|
||||||
const variant: Record<string, unknown> = {
|
const variant: Record<string, unknown> = {
|
||||||
price: product.price,
|
price: product.price,
|
||||||
|
compare_at_price: product.compareAtPrice ?? null,
|
||||||
sku: product.ref,
|
sku: product.ref,
|
||||||
inventory_management: 'shopify',
|
inventory_management: 'shopify',
|
||||||
}
|
}
|
||||||
@@ -179,7 +182,7 @@ export async function updateShopifyProduct(
|
|||||||
title: product.title,
|
title: product.title,
|
||||||
body_html: product.description,
|
body_html: product.description,
|
||||||
status: product.status,
|
status: product.status,
|
||||||
variants: [{ id: variantId, price: product.price }],
|
variants: [{ id: variantId, price: product.price, compare_at_price: product.compareAtPrice ?? null }],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { SyncProduct, ShopifyProductFull, SyncChange, DiffResult } from '@/types/sync'
|
import type { SyncProduct, ShopifyProductFull, SyncChange, DiffResult } from '@/types/sync'
|
||||||
|
|
||||||
const COMPARABLE_FIELDS = ['title', 'description', 'price', 'status'] as const
|
const COMPARABLE_FIELDS = ['title', 'description', 'price', 'compareAtPrice', 'status'] as const
|
||||||
|
|
||||||
function getChangedFields(
|
function getChangedFields(
|
||||||
sheet: SyncProduct,
|
sheet: SyncProduct,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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
|
||||||
|
compareAtPrice?: string
|
||||||
// Champs enrichis
|
// Champs enrichis
|
||||||
prixRemise?: string
|
prixRemise?: string
|
||||||
marque?: string
|
marque?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user