Compare commits
66 Commits
d16cd1fbf5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ac05d2ac30 | |||
| 60dc9cc0ec | |||
| eb5f605175 | |||
| ac58289bb7 | |||
| 4f617b15b2 | |||
| 401b43cc04 | |||
| d276f6224c | |||
| a9850c8efd | |||
| 747f8a47b8 | |||
| a334987b7c | |||
| 06587c1ccd | |||
| 99de0da823 | |||
| 3ec0b9fa3c | |||
| 461667c506 | |||
| 34ec818ad3 | |||
| f650225c98 | |||
| 7ebb7e899d | |||
| 4ce62c1473 | |||
| fcadea6758 | |||
| 0a3f5042fc | |||
| d616f1d74c | |||
| eec9bd5dce | |||
| c95093a81f | |||
| 03770d1a76 | |||
| 5e1b864ce7 | |||
| cc0d57bf2b | |||
| 6ca2509ca6 | |||
| 42989a5ef3 | |||
| f56e52f801 | |||
| 949d7c60b9 | |||
| 36447a5c8c | |||
| 920ec50402 | |||
| 2f6eea1122 | |||
| 42ef490196 | |||
| 31b764f458 | |||
| 8c9bce7aa5 | |||
| 9e7423eee3 | |||
| 6d8be0645d | |||
| 114e20c9fb | |||
| caef95bd55 | |||
| 34ade6beb2 | |||
| cdde567558 | |||
| e85314eb77 | |||
| 5a10fd4221 | |||
| 93a616e997 | |||
| 41f5c245d4 | |||
| f6b0ba9685 | |||
| 4811b73c69 | |||
| 921d9d6a77 | |||
| d138577a9d | |||
| 1ef69dc205 | |||
| 01e2fcd5e5 | |||
| 07432bbd95 | |||
| 731d0dbde6 | |||
| 42d0e2f855 | |||
| 15f647f58a | |||
| cb227943f3 | |||
| d45c9490c0 | |||
| c1fad5d0ed | |||
| d8a3f26d0b | |||
| 297d47bd19 | |||
| a2c0789625 | |||
| 23aa78d3da | |||
| 1b07359415 | |||
| 6f4affcd66 | |||
| 211d8c5c01 |
@@ -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) })
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { Header } from '@/components/layout/Header'
|
||||||
|
import { BannerBuilder } from '@/components/bannieres/BannerBuilder'
|
||||||
|
import type { SlideshowSection, Slide } from '@/lib/shopifyTheme'
|
||||||
|
|
||||||
|
// Convertit "shopify://shop_images/foo.jpg" → URL CDN prévisualisable via proxy
|
||||||
|
function shopifyRefToProxyUrl(ref: string): string {
|
||||||
|
if (!ref) return ''
|
||||||
|
const filename = ref.replace('shopify://shop_images/', '')
|
||||||
|
return `/api/images/proxy?url=${encodeURIComponent(`https://cdn.shopify.com/s/files/1/0897/7373/6263/files/${filename}`)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlideCardProps {
|
||||||
|
slide: Slide
|
||||||
|
index: number
|
||||||
|
total: number
|
||||||
|
onMoveUp: () => void
|
||||||
|
onMoveDown: () => void
|
||||||
|
onRemove: () => void
|
||||||
|
onChangeImage: (shopifyRef: string) => void
|
||||||
|
onChangeLink: (link: string) => void
|
||||||
|
onOpenBuilder: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function SlideCard({ slide, index, total, onMoveUp, onMoveDown, onRemove, onChangeImage, onChangeLink, onOpenBuilder }: SlideCardProps) {
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setUploading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const base64 = await blobToBase64(file)
|
||||||
|
const res = await fetch('/api/theme/upload-file', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ imageBase64: base64, filename: file.name }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error)
|
||||||
|
onChangeImage(data.shopifyRef)
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message)
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewUrl = shopifyRefToProxyUrl(slide.settings.image)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-800 border border-slate-700 rounded-xl overflow-hidden">
|
||||||
|
{/* Image preview */}
|
||||||
|
<div className="relative h-44 bg-slate-900">
|
||||||
|
{previewUrl ? (
|
||||||
|
<img src={previewUrl} alt={`Slide ${index + 1}`} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-slate-600 text-sm">
|
||||||
|
Aucune image
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute top-2 left-2 bg-black/60 text-white text-xs px-2 py-0.5 rounded-full">
|
||||||
|
{index + 1} / {total}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
{/* Link */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">Lien (optionnel)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={slide.settings.link ?? ''}
|
||||||
|
onChange={e => onChangeLink(e.target.value)}
|
||||||
|
placeholder="https://…"
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={onOpenBuilder}
|
||||||
|
className="flex-1 py-1.5 text-xs bg-purple-700 hover:bg-purple-600 text-white rounded font-medium"
|
||||||
|
>
|
||||||
|
🎨 Créer une bannière
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="py-1.5 px-2 text-xs bg-indigo-600 hover:bg-indigo-500 text-white rounded font-medium disabled:opacity-50"
|
||||||
|
title="Uploader une image"
|
||||||
|
>
|
||||||
|
{uploading ? '…' : '🖼️'}
|
||||||
|
</button>
|
||||||
|
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleFile} />
|
||||||
|
<button
|
||||||
|
onClick={onMoveUp}
|
||||||
|
disabled={index === 0}
|
||||||
|
className="py-1.5 px-2 text-xs bg-slate-700 hover:bg-slate-600 text-slate-300 rounded disabled:opacity-30"
|
||||||
|
title="Monter"
|
||||||
|
>↑</button>
|
||||||
|
<button
|
||||||
|
onClick={onMoveDown}
|
||||||
|
disabled={index === total - 1}
|
||||||
|
className="py-1.5 px-2 text-xs bg-slate-700 hover:bg-slate-600 text-slate-300 rounded disabled:opacity-30"
|
||||||
|
title="Descendre"
|
||||||
|
>↓</button>
|
||||||
|
<button
|
||||||
|
onClick={onRemove}
|
||||||
|
className="py-1.5 px-2 text-xs bg-slate-700 hover:bg-red-900 text-slate-400 hover:text-red-300 rounded"
|
||||||
|
title="Supprimer"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BannièresPage() {
|
||||||
|
const [slideshow, setSlideshow] = useState<SlideshowSection | null>(null)
|
||||||
|
const [themeId, setThemeId] = useState<string>('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
// Index de la slide cible du builder (-1 = nouvelle slide)
|
||||||
|
const [builderTargetIndex, setBuilderTargetIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/theme/slideshow')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
if (d.error) throw new Error(d.error)
|
||||||
|
setSlideshow(d.slideshow)
|
||||||
|
setThemeId(d.themeId)
|
||||||
|
})
|
||||||
|
.catch(e => setError(e.message))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const update = useCallback((fn: (s: SlideshowSection) => SlideshowSection) => {
|
||||||
|
setSlideshow(prev => prev ? fn(prev) : prev)
|
||||||
|
setSaved(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const moveSlide = (index: number, dir: -1 | 1) => {
|
||||||
|
update(s => {
|
||||||
|
const slides = [...s.slides]
|
||||||
|
const tmp = slides[index]
|
||||||
|
slides[index] = slides[index + dir]
|
||||||
|
slides[index + dir] = tmp
|
||||||
|
return { ...s, slides }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeSlide = (index: number) => {
|
||||||
|
update(s => ({ ...s, slides: s.slides.filter((_, i) => i !== index) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const addSlide = () => {
|
||||||
|
update(s => ({
|
||||||
|
...s,
|
||||||
|
slides: [...s.slides, {
|
||||||
|
blockId: `slide_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
settings: { image: '', link: '' },
|
||||||
|
}],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeImage = (index: number, shopifyRef: string) => {
|
||||||
|
update(s => {
|
||||||
|
const slides = s.slides.map((sl, i) =>
|
||||||
|
i === index ? { ...sl, settings: { ...sl.settings, image: shopifyRef } } : sl
|
||||||
|
)
|
||||||
|
return { ...s, slides }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeLink = (index: number, link: string) => {
|
||||||
|
update(s => {
|
||||||
|
const slides = s.slides.map((sl, i) =>
|
||||||
|
i === index ? { ...sl, settings: { ...sl.settings, link } } : sl
|
||||||
|
)
|
||||||
|
return { ...s, slides }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!slideshow) return
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/theme/slideshow', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ slideshow, themeId }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error)
|
||||||
|
setSaved(true)
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header
|
||||||
|
title="Bannières homepage"
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{saved && <span className="text-xs text-green-400">✓ Publié sur Shopify</span>}
|
||||||
|
{error && <span className="text-xs text-red-400">{error}</span>}
|
||||||
|
<button
|
||||||
|
onClick={addSlide}
|
||||||
|
disabled={!slideshow}
|
||||||
|
className="px-3 py-1.5 text-xs bg-slate-700 hover:bg-slate-600 text-white rounded font-medium disabled:opacity-40"
|
||||||
|
>
|
||||||
|
+ Ajouter une slide
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={!slideshow || saving}
|
||||||
|
className="px-4 py-1.5 text-xs bg-green-700 hover:bg-green-600 text-white rounded font-medium disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{saving ? 'Publication…' : '🚀 Publier sur Shopify'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="p-6">
|
||||||
|
{loading && (
|
||||||
|
<div className="text-center py-12 text-slate-500 text-sm">Chargement du thème…</div>
|
||||||
|
)}
|
||||||
|
{!loading && !slideshow && (
|
||||||
|
<div className="text-center py-12 text-red-400 text-sm">
|
||||||
|
Section slideshow-custom introuvable dans le thème actif.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{slideshow && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{slideshow.slides.length} slide{slideshow.slides.length > 1 ? 's' : ''} · Thème actif (Meka)
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{slideshow.slides.map((slide, i) => (
|
||||||
|
<SlideCard
|
||||||
|
key={slide.blockId}
|
||||||
|
slide={slide}
|
||||||
|
index={i}
|
||||||
|
total={slideshow.slides.length}
|
||||||
|
onMoveUp={() => moveSlide(i, -1)}
|
||||||
|
onMoveDown={() => moveSlide(i, 1)}
|
||||||
|
onRemove={() => removeSlide(i)}
|
||||||
|
onChangeImage={ref => changeImage(i, ref)}
|
||||||
|
onChangeLink={link => changeLink(i, link)}
|
||||||
|
onOpenBuilder={() => setBuilderTargetIndex(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{builderTargetIndex !== null && (
|
||||||
|
<BannerBuilder
|
||||||
|
onApply={shopifyRef => {
|
||||||
|
if (builderTargetIndex !== null) changeImage(builderTargetIndex, shopifyRef)
|
||||||
|
setBuilderTargetIndex(null)
|
||||||
|
setSaved(false)
|
||||||
|
}}
|
||||||
|
onClose={() => setBuilderTargetIndex(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { Header } from '@/components/layout/Header'
|
import { Header } from '@/components/layout/Header'
|
||||||
import { PendingRowsTable } from '@/components/creation/PendingRowsTable'
|
import { PendingRowsTable } from '@/components/creation/PendingRowsTable'
|
||||||
import { MetafieldResolver } from '@/components/creation/MetafieldResolver'
|
import { MetafieldResolver } from '@/components/creation/MetafieldResolver'
|
||||||
import { CreationProgress } from '@/components/creation/CreationProgress'
|
import { CreationProgress } from '@/components/creation/CreationProgress'
|
||||||
|
import { FamilySelector } from '@/components/common/FamilySelector'
|
||||||
import type { PendingProduct, MetafieldResolution, CreationStreamEvent } from '@/types/creation'
|
import type { PendingProduct, MetafieldResolution, CreationStreamEvent } from '@/types/creation'
|
||||||
|
|
||||||
type Step = 'analyse' | 'metafields' | 'confirmation' | 'execution'
|
type Step = 'famille' | 'analyse' | 'metafields' | 'confirmation' | 'execution'
|
||||||
|
|
||||||
|
interface ShopifyCollection { id: string; title: string }
|
||||||
|
|
||||||
export default function CreationPage() {
|
export default function CreationPage() {
|
||||||
const [step, setStep] = useState<Step>('analyse')
|
const [step, setStep] = useState<Step>('famille')
|
||||||
|
const [selectedTab, setSelectedTab] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [byTab, setByTab] = useState<Record<string, PendingProduct[]>>({})
|
const [byTab, setByTab] = useState<Record<string, PendingProduct[]>>({})
|
||||||
@@ -18,17 +22,103 @@ export default function CreationPage() {
|
|||||||
const [resolutions, setResolutions] = useState<MetafieldResolution[]>([])
|
const [resolutions, setResolutions] = useState<MetafieldResolution[]>([])
|
||||||
const [events, setEvents] = useState<CreationStreamEvent[]>([])
|
const [events, setEvents] = useState<CreationStreamEvent[]>([])
|
||||||
const [done, setDone] = useState(false)
|
const [done, setDone] = useState(false)
|
||||||
|
const [stopped, setStopped] = useState(false)
|
||||||
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
// Sync stock
|
||||||
|
const [stockSyncing, setStockSyncing] = useState(false)
|
||||||
|
const [stockResult, setStockResult] = useState<{ updated: number; errors: number; fatalError?: string } | null>(null)
|
||||||
|
const [stockProgress, setStockProgress] = useState<{ current: number; total: number; title?: string } | null>(null)
|
||||||
|
|
||||||
|
const runStockStream = async (url: string, setter: typeof setStockResult) => {
|
||||||
|
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: '{}' })
|
||||||
|
if (!res.body) return
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() ?? ''
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
try {
|
||||||
|
const evt = JSON.parse(line)
|
||||||
|
if (evt.type === 'progress') setStockProgress({ current: evt.current, total: evt.total, title: evt.title })
|
||||||
|
if (evt.type === 'done') setter({ updated: evt.updated, errors: evt.errors, fatalError: evt.fatalError })
|
||||||
|
} catch { /* */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncStock = async () => {
|
||||||
|
setStockSyncing(true)
|
||||||
|
setStockResult(null)
|
||||||
|
setStockProgress(null)
|
||||||
|
try { await runStockStream('/api/creation/sync-stock', setStockResult) }
|
||||||
|
finally { setStockSyncing(false); setStockProgress(null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const [stockReverseSyncing, setStockReverseSyncing] = useState(false)
|
||||||
|
const [stockReverseResult, setStockReverseResult] = useState<{ updated: number; errors: number; fatalError?: string } | null>(null)
|
||||||
|
|
||||||
|
const syncStockReverse = async () => {
|
||||||
|
setStockReverseSyncing(true)
|
||||||
|
setStockReverseResult(null)
|
||||||
|
setStockProgress(null)
|
||||||
|
try { await runStockStream('/api/creation/sync-stock-reverse', setStockReverseResult) }
|
||||||
|
finally { setStockReverseSyncing(false); setStockProgress(null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collection
|
||||||
|
const [collections, setCollections] = useState<ShopifyCollection[]>([])
|
||||||
|
const [detectedCollectionId, setDetectedCollectionId] = useState<string>('')
|
||||||
|
const [collectionOverride, setCollectionOverride] = useState<string>('')
|
||||||
|
const [collectionSearch, setCollectionSearch] = useState('')
|
||||||
|
const [loadingCollections, setLoadingCollections] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadingCollections(true)
|
||||||
|
fetch('/api/creation/collections')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setCollections(d.collections ?? []))
|
||||||
|
.finally(() => setLoadingCollections(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSelectTab = (tab: string) => {
|
||||||
|
setSelectedTab(tab)
|
||||||
|
setStep('analyse')
|
||||||
|
setDetectedCollectionId('')
|
||||||
|
setCollectionOverride('')
|
||||||
|
setCollectionSearch('')
|
||||||
|
}
|
||||||
|
|
||||||
const analyse = async () => {
|
const analyse = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/creation/preview')
|
const url = selectedTab
|
||||||
|
? `/api/creation/preview?tab=${encodeURIComponent(selectedTab)}`
|
||||||
|
: '/api/creation/preview'
|
||||||
|
const res = await fetch(url)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.error) throw new Error(data.error)
|
if (data.error) throw new Error(data.error)
|
||||||
setByTab(data.byTab)
|
setByTab(data.byTab)
|
||||||
setTotal(data.total)
|
setTotal(data.total)
|
||||||
setSpecificColumns(data.specificColumns)
|
setSpecificColumns(data.specificColumns)
|
||||||
|
|
||||||
|
// Détecter la collection depuis les produits analysés
|
||||||
|
const products: PendingProduct[] = Object.values(data.byTab as Record<string, PendingProduct[]>).flat()
|
||||||
|
const ids = Array.from(new Set(products.map(p => p.collectionId).filter(Boolean)))
|
||||||
|
if (ids.length === 1) {
|
||||||
|
setDetectedCollectionId(ids[0])
|
||||||
|
setCollectionOverride(ids[0])
|
||||||
|
} else {
|
||||||
|
setDetectedCollectionId('')
|
||||||
|
setCollectionOverride('')
|
||||||
|
}
|
||||||
setStep('metafields')
|
setStep('metafields')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Erreur')
|
setError(e instanceof Error ? e.message : 'Erreur')
|
||||||
@@ -39,32 +129,48 @@ export default function CreationPage() {
|
|||||||
|
|
||||||
const handleResolved = useCallback((r: MetafieldResolution[]) => { setResolutions(r) }, [])
|
const handleResolved = useCallback((r: MetafieldResolution[]) => { setResolutions(r) }, [])
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
setStopped(true)
|
||||||
|
setDone(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const execute = async () => {
|
const execute = async () => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRef.current = controller
|
||||||
setStep('execution')
|
setStep('execution')
|
||||||
setEvents([])
|
setEvents([])
|
||||||
setDone(false)
|
setDone(false)
|
||||||
const res = await fetch('/api/creation/execute', {
|
setStopped(false)
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const res = await fetch('/api/creation/execute', {
|
||||||
body: JSON.stringify({ resolutions }),
|
method: 'POST',
|
||||||
})
|
headers: { 'Content-Type': 'application/json' },
|
||||||
if (!res.body) return
|
body: JSON.stringify({ resolutions, tab: selectedTab, collectionOverride: collectionOverride || undefined }),
|
||||||
const reader = res.body.getReader()
|
signal: controller.signal,
|
||||||
const decoder = new TextDecoder()
|
})
|
||||||
let buffer = ''
|
if (!res.body) return
|
||||||
while (true) {
|
const reader = res.body.getReader()
|
||||||
const { done: readerDone, value } = await reader.read()
|
const decoder = new TextDecoder()
|
||||||
if (readerDone) break
|
let buffer = ''
|
||||||
buffer += decoder.decode(value, { stream: true })
|
while (true) {
|
||||||
const lines = buffer.split('\n')
|
const { done: readerDone, value } = await reader.read()
|
||||||
buffer = lines.pop() ?? ''
|
if (readerDone) break
|
||||||
for (const line of lines) {
|
buffer += decoder.decode(value, { stream: true })
|
||||||
if (!line.trim()) continue
|
const lines = buffer.split('\n')
|
||||||
try {
|
buffer = lines.pop() ?? ''
|
||||||
const event = JSON.parse(line) as CreationStreamEvent
|
for (const line of lines) {
|
||||||
setEvents(prev => [...prev, event])
|
if (!line.trim()) continue
|
||||||
if (event.type === 'done') setDone(true)
|
try {
|
||||||
} catch { /* ligne incomplète */ }
|
const event = JSON.parse(line) as CreationStreamEvent
|
||||||
|
setEvents(prev => [...prev, event])
|
||||||
|
if (event.type === 'done') setDone(true)
|
||||||
|
} catch { /* ligne incomplète */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).name !== 'AbortError') {
|
||||||
|
setEvents(prev => [...prev, { type: 'error', title: '', message: (e as Error).message }])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setDone(true)
|
setDone(true)
|
||||||
@@ -78,6 +184,26 @@ export default function CreationPage() {
|
|||||||
]
|
]
|
||||||
const currentIdx = STEPS.findIndex(s => s.id === step)
|
const currentIdx = STEPS.findIndex(s => s.id === step)
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
abortRef.current = null
|
||||||
|
setStep('famille')
|
||||||
|
setSelectedTab(null)
|
||||||
|
setEvents([])
|
||||||
|
setDone(false)
|
||||||
|
setStopped(false)
|
||||||
|
setByTab({})
|
||||||
|
setTotal(0)
|
||||||
|
setDetectedCollectionId('')
|
||||||
|
setCollectionOverride('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredCollections = collectionSearch
|
||||||
|
? collections.filter(c => c.title.toLowerCase().includes(collectionSearch.toLowerCase()))
|
||||||
|
: collections
|
||||||
|
|
||||||
|
const selectedCollection = collections.find(c => c.id === collectionOverride)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="Création produits" />
|
<Header title="Création produits" />
|
||||||
@@ -93,13 +219,73 @@ export default function CreationPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-slate-800 rounded-xl p-6">
|
{/* Étape 0 — Sélection famille */}
|
||||||
|
{step === 'famille' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-slate-800 rounded-xl p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-white mb-4">Choisir une famille</h2>
|
||||||
|
<FamilySelector onSelect={handleSelectTab} />
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-800 rounded-xl p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-white mb-1">Sheets → Shopify</h2>
|
||||||
|
<p className="text-xs text-gray-400 mb-3">Met à jour le stock Shopify depuis les valeurs du Sheets.</p>
|
||||||
|
<button onClick={syncStock} disabled={stockSyncing || stockReverseSyncing}
|
||||||
|
className="px-4 py-2 bg-amber-700 hover:bg-amber-600 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
|
{stockSyncing ? (stockProgress ? `Mise à jour… ${stockProgress.current}/${stockProgress.total}` : 'Chargement…') : 'Synchroniser Sheets → Shopify'}
|
||||||
|
</button>
|
||||||
|
{stockSyncing && stockProgress?.title && (
|
||||||
|
<p className="mt-2 text-xs text-gray-400 truncate">→ {stockProgress.title}</p>
|
||||||
|
)}
|
||||||
|
{stockResult && (
|
||||||
|
<div className="mt-2 text-sm">
|
||||||
|
{stockResult.fatalError
|
||||||
|
? <p className="text-red-400">✕ {stockResult.fatalError}</p>
|
||||||
|
: stockResult.errors === 0
|
||||||
|
? <p className="text-green-400">✓ {stockResult.updated} produit{stockResult.updated > 1 ? 's' : ''} mis à jour</p>
|
||||||
|
: <p className="text-amber-400">✓ {stockResult.updated} mis à jour — ⚠ {stockResult.errors} erreur{stockResult.errors > 1 ? 's' : ''}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<hr className="border-slate-700" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-white mb-1">Shopify → Sheets</h2>
|
||||||
|
<p className="text-xs text-gray-400 mb-3">Écrase les stocks du Sheets avec les valeurs actuelles de Shopify.</p>
|
||||||
|
<button onClick={syncStockReverse} disabled={stockSyncing || stockReverseSyncing}
|
||||||
|
className="px-4 py-2 bg-blue-700 hover:bg-blue-600 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
|
{stockReverseSyncing ? (stockProgress ? `Lecture… ${stockProgress.current}/${stockProgress.total}` : 'Chargement…') : 'Synchroniser Shopify → Sheets'}
|
||||||
|
</button>
|
||||||
|
{stockReverseSyncing && stockProgress && (
|
||||||
|
<p className="mt-2 text-xs text-gray-400">→ {stockProgress.current}/{stockProgress.total}</p>
|
||||||
|
)}
|
||||||
|
{stockReverseResult && (
|
||||||
|
<div className="mt-2 text-sm">
|
||||||
|
{stockReverseResult.fatalError
|
||||||
|
? <p className="text-red-400">✕ {stockReverseResult.fatalError}</p>
|
||||||
|
: stockReverseResult.errors === 0
|
||||||
|
? <p className="text-green-400">✓ {stockReverseResult.updated} produit{stockReverseResult.updated > 1 ? 's' : ''} mis à jour dans le Sheets</p>
|
||||||
|
: <p className="text-amber-400">✓ {stockReverseResult.updated} mis à jour — ⚠ {stockReverseResult.errors} erreur{stockReverseResult.errors > 1 ? 's' : ''}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-slate-800 rounded-xl p-6" style={{ display: step === 'famille' ? 'none' : undefined }}>
|
||||||
{error && <div className="mb-4 p-3 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">{error}</div>}
|
{error && <div className="mb-4 p-3 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">{error}</div>}
|
||||||
|
|
||||||
{step === 'analyse' && (
|
{step === 'analyse' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 bg-indigo-950/50 border border-indigo-800/50 rounded-lg px-3 py-2 w-fit">
|
||||||
|
<span className="text-indigo-400 text-sm">📂 Famille :</span>
|
||||||
|
<span className="text-white text-sm font-semibold">{selectedTab}</span>
|
||||||
|
<button onClick={() => { setStep('famille'); setSelectedTab(null) }} className="ml-2 text-xs text-slate-400 hover:text-slate-200">changer</button>
|
||||||
|
</div>
|
||||||
<p className="text-gray-300 text-sm">
|
<p className="text-gray-300 text-sm">
|
||||||
Lit tous les onglets du Google Sheets et détecte les lignes où <code className="text-indigo-300">Publié=1</code> et <code className="text-indigo-300">ID vide</code>.
|
Détecte les lignes de <strong className="text-white">{selectedTab}</strong> où <code className="text-indigo-300">Publié=1</code> et <code className="text-indigo-300">ID vide</code>.
|
||||||
</p>
|
</p>
|
||||||
<button onClick={analyse} disabled={loading}
|
<button onClick={analyse} disabled={loading}
|
||||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
@@ -113,6 +299,57 @@ export default function CreationPage() {
|
|||||||
<PendingRowsTable byTab={byTab} total={total} />
|
<PendingRowsTable byTab={byTab} total={total} />
|
||||||
{total > 0 && (
|
{total > 0 && (
|
||||||
<>
|
<>
|
||||||
|
<hr className="border-slate-700" />
|
||||||
|
|
||||||
|
{/* Collection Shopify */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-white mb-1">Collection Shopify</h2>
|
||||||
|
{detectedCollectionId ? (
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xs text-green-400">✓ Détectée depuis le Sheets :</span>
|
||||||
|
<span className="text-xs text-white font-medium">
|
||||||
|
{collections.find(c => c.id === detectedCollectionId)?.title ?? `ID ${detectedCollectionId}`}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={() => { setDetectedCollectionId(''); setCollectionOverride('') }}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-300">modifier</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-amber-400 mb-2">⚠ Aucune collection détectée dans le Sheets — choisissez-en une :</p>
|
||||||
|
)}
|
||||||
|
{(!detectedCollectionId || collectionOverride !== detectedCollectionId) && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selectedCollection && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-blue-300 font-medium">→ {selectedCollection.title}</span>
|
||||||
|
<button type="button" onClick={() => setCollectionOverride('')}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-300">✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={collectionSearch}
|
||||||
|
onChange={e => setCollectionSearch(e.target.value)}
|
||||||
|
placeholder={loadingCollections ? 'Chargement…' : 'Rechercher une collection Shopify…'}
|
||||||
|
className="w-full max-w-sm 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"
|
||||||
|
/>
|
||||||
|
{collectionSearch && filteredCollections.length > 0 && (
|
||||||
|
<div className="max-w-sm bg-slate-700 border border-slate-600 rounded shadow-lg max-h-48 overflow-y-auto">
|
||||||
|
{filteredCollections.map(c => (
|
||||||
|
<button key={c.id} type="button"
|
||||||
|
onClick={() => { setCollectionOverride(c.id); setCollectionSearch('') }}
|
||||||
|
className="w-full text-left px-3 py-1.5 text-sm text-white hover:bg-slate-600">
|
||||||
|
{c.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{collectionSearch && filteredCollections.length === 0 && (
|
||||||
|
<p className="text-xs text-gray-400">Aucune collection trouvée</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr className="border-slate-700" />
|
<hr className="border-slate-700" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-semibold text-white mb-3">Résolution des métachamps</h2>
|
<h2 className="text-base font-semibold text-white mb-3">Résolution des métachamps</h2>
|
||||||
@@ -131,6 +368,11 @@ export default function CreationPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<p className="text-gray-300"><span className="text-white font-medium">{total} produit{total > 1 ? 's' : ''}</span> à créer dans Shopify</p>
|
<p className="text-gray-300"><span className="text-white font-medium">{total} produit{total > 1 ? 's' : ''}</span> à créer dans Shopify</p>
|
||||||
|
<p className="text-gray-300">
|
||||||
|
Collection : <span className="text-white font-medium">
|
||||||
|
{collections.find(c => c.id === collectionOverride)?.title ?? (collectionOverride ? `ID ${collectionOverride}` : <span className="text-amber-400">Aucune</span>)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
<p className="text-gray-300"><span className="text-white font-medium">{resolutions.filter(r => r.action !== 'skip').length}</span> type{resolutions.filter(r => r.action !== 'skip').length > 1 ? 's' : ''} de métachamps à assigner</p>
|
<p className="text-gray-300"><span className="text-white font-medium">{resolutions.filter(r => r.action !== 'skip').length}</span> type{resolutions.filter(r => r.action !== 'skip').length > 1 ? 's' : ''} de métachamps à assigner</p>
|
||||||
<p className="text-gray-300"><span className="text-white font-medium">{resolutions.filter(r => r.action === 'create').length}</span> nouvelle{resolutions.filter(r => r.action === 'create').length > 1 ? 's' : ''} définition{resolutions.filter(r => r.action === 'create').length > 1 ? 's' : ''} à créer dans Shopify</p>
|
<p className="text-gray-300"><span className="text-white font-medium">{resolutions.filter(r => r.action === 'create').length}</span> nouvelle{resolutions.filter(r => r.action === 'create').length > 1 ? 's' : ''} définition{resolutions.filter(r => r.action === 'create').length > 1 ? 's' : ''} à créer dans Shopify</p>
|
||||||
<p className="text-yellow-300 text-xs mt-2">⚠ Les descriptions seront générées par OpenAI GPT-4o (~0.01$ par produit)</p>
|
<p className="text-yellow-300 text-xs mt-2">⚠ Les descriptions seront générées par OpenAI GPT-4o (~0.01$ par produit)</p>
|
||||||
@@ -151,12 +393,23 @@ export default function CreationPage() {
|
|||||||
{step === 'execution' && (
|
{step === 'execution' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<CreationProgress events={events} done={done} />
|
<CreationProgress events={events} done={done} />
|
||||||
{done && (
|
<div className="flex gap-3">
|
||||||
<button onClick={() => { setStep('analyse'); setEvents([]); setDone(false); setByTab({}); setTotal(0) }}
|
{!done && (
|
||||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors">
|
<button onClick={stop}
|
||||||
Nouvelle session
|
className="px-4 py-2 bg-red-700 hover:bg-red-600 text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
</button>
|
⏹ Arrêter
|
||||||
)}
|
</button>
|
||||||
|
)}
|
||||||
|
{done && (
|
||||||
|
<>
|
||||||
|
{stopped && <p className="text-amber-400 text-sm self-center">⚠ Création interrompue — les produits déjà créés sont conservés dans Shopify.</p>}
|
||||||
|
<button onClick={reset}
|
||||||
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
|
Nouvelle session
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,81 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useState } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { Header } from '@/components/layout/Header'
|
import { Header } from '@/components/layout/Header'
|
||||||
import { DropZone } from '@/components/images/DropZone'
|
import { DropZone } from '@/components/images/DropZone'
|
||||||
import { ImageCard } from '@/components/images/ImageCard'
|
import { ImageCard } from '@/components/images/ImageCard'
|
||||||
import { Lightbox } from '@/components/images/Lightbox'
|
import { Lightbox } from '@/components/images/Lightbox'
|
||||||
import { GlobalActions } from '@/components/images/GlobalActions'
|
import { GlobalActions } from '@/components/images/GlobalActions'
|
||||||
|
import { MockupModalFromImage } from '@/components/mockup/MockupModalFromImage'
|
||||||
import { ErrorBoundary } from '@/components/common/ErrorBoundary'
|
import { ErrorBoundary } from '@/components/common/ErrorBoundary'
|
||||||
import { useImages } from '@/hooks/useImages'
|
import { useImages } from '@/hooks/useImages'
|
||||||
import type { ImageItem } from '@/types/images'
|
import type { ImageItem } from '@/types/images'
|
||||||
|
|
||||||
function ImagesPageInner() {
|
function ImagesPageInner() {
|
||||||
const { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem, assignProduct } = useImages()
|
const { items, addFiles, setFeather, setAlphaThreshold, rotate, uploadOne, uploadAll, uploadSelected, removeItem, assignProduct, reprocess, detour } = useImages()
|
||||||
const [lightboxItem, setLightboxItem] = useState<ImageItem | null>(null)
|
const [lightboxItem, setLightboxItem] = useState<ImageItem | null>(null)
|
||||||
|
const [mockupItem, setMockupItem] = useState<ImageItem | null>(null)
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
const [uploadingSelected, setUploadingSelected] = useState(false)
|
||||||
|
|
||||||
|
const openMockup = useCallback((id: string) => {
|
||||||
|
const item = items.find(i => i.id === id)
|
||||||
|
if (item) setMockupItem(item)
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
const toggleSelect = useCallback((id: string, value: boolean) => {
|
||||||
|
setSelected(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (value) next.add(id)
|
||||||
|
else next.delete(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => setSelected(new Set()), [])
|
||||||
|
|
||||||
|
const selectAllDetourable = useCallback(() => {
|
||||||
|
const ids = items
|
||||||
|
.filter(i => i.status === 'converted' || i.status === 'not_found' || i.status === 'error')
|
||||||
|
.map(i => i.id) as string[]
|
||||||
|
setSelected(new Set(ids))
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
const selectAllUploadable = useCallback(() => {
|
||||||
|
const ids = items
|
||||||
|
.filter(i => (i.status === 'ready' || i.status === 'converted') && !!i.shopifyProductId)
|
||||||
|
.map(i => i.id) as string[]
|
||||||
|
setSelected(new Set(ids))
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
const detourSelected = useCallback(() => {
|
||||||
|
Array.from(selected).forEach(id => detour(id))
|
||||||
|
setSelected(new Set())
|
||||||
|
}, [selected, detour])
|
||||||
|
|
||||||
|
const handleUploadSelected = useCallback(async () => {
|
||||||
|
setUploadingSelected(true)
|
||||||
|
await uploadSelected(selected)
|
||||||
|
setUploadingSelected(false)
|
||||||
|
setSelected(new Set())
|
||||||
|
}, [selected, uploadSelected])
|
||||||
|
|
||||||
|
const selectedDetourable = Array.from(selected).filter(id => {
|
||||||
|
const item = items.find(i => i.id === id)
|
||||||
|
return item && (item.status === 'converted' || item.status === 'not_found' || item.status === 'error')
|
||||||
|
})
|
||||||
|
const selectedUploadable = Array.from(selected).filter(id => {
|
||||||
|
const item = items.find(i => i.id === id)
|
||||||
|
return item && (item.status === 'ready' || item.status === 'converted') && !!item.shopifyProductId
|
||||||
|
})
|
||||||
|
|
||||||
|
const detourable = items.filter(i =>
|
||||||
|
i.status === 'converted' || i.status === 'not_found' || i.status === 'error'
|
||||||
|
)
|
||||||
|
const uploadable = items.filter(i =>
|
||||||
|
(i.status === 'ready' || i.status === 'converted') && !!i.shopifyProductId
|
||||||
|
)
|
||||||
|
|
||||||
|
const showSelectionBar = detourable.length > 0 || uploadable.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -31,18 +95,80 @@ function ImagesPageInner() {
|
|||||||
<GlobalActions items={items} onUploadAll={uploadAll} />
|
<GlobalActions items={items} onUploadAll={uploadAll} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Barre de sélection multiple */}
|
||||||
|
{showSelectionBar && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-slate-800 rounded-lg border border-slate-700 flex-wrap">
|
||||||
|
{/* Compteurs et raccourcis de sélection */}
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||||
|
{detourable.length > 0 && (
|
||||||
|
<button onClick={selectAllDetourable} className="hover:text-purple-300 transition-colors">
|
||||||
|
Sélectionner à détourer ({detourable.length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{detourable.length > 0 && uploadable.length > 0 && (
|
||||||
|
<span className="text-slate-600">·</span>
|
||||||
|
)}
|
||||||
|
{uploadable.length > 0 && (
|
||||||
|
<button onClick={selectAllUploadable} className="hover:text-indigo-300 transition-colors">
|
||||||
|
Sélectionner à uploader ({uploadable.length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions sur la sélection */}
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-600 text-xs">|</span>
|
||||||
|
<span className="text-xs text-white font-medium">
|
||||||
|
{selected.size} sélectionnée{selected.size > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{selectedDetourable.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={detourSelected}
|
||||||
|
className="px-3 py-1 text-xs bg-purple-700 hover:bg-purple-600 text-white font-semibold rounded"
|
||||||
|
>
|
||||||
|
✂️ Détourer ({selectedDetourable.length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{selectedUploadable.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleUploadSelected}
|
||||||
|
disabled={uploadingSelected}
|
||||||
|
className="px-3 py-1 text-xs bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{uploadingSelected
|
||||||
|
? `⬆️ Upload en cours…`
|
||||||
|
: `⬆️ Uploader (${selectedUploadable.length})`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={clearSelection} className="text-xs text-slate-500 hover:text-slate-300">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<ImageCard
|
<ImageCard
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
|
selected={selected.has(item.id)}
|
||||||
|
onSelect={toggleSelect}
|
||||||
onFeatherChange={setFeather}
|
onFeatherChange={setFeather}
|
||||||
|
onAlphaThresholdChange={setAlphaThreshold}
|
||||||
onRotate={rotate}
|
onRotate={rotate}
|
||||||
onUpload={uploadOne}
|
onUpload={uploadOne}
|
||||||
onRemove={removeItem}
|
onRemove={removeItem}
|
||||||
onOpenLightbox={setLightboxItem}
|
onOpenLightbox={setLightboxItem}
|
||||||
onAssignProduct={assignProduct}
|
onAssignProduct={assignProduct}
|
||||||
|
onReprocess={reprocess}
|
||||||
|
onDetour={detour}
|
||||||
|
onMockup={openMockup}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -58,6 +184,9 @@ function ImagesPageInner() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Lightbox item={lightboxItem} onClose={() => setLightboxItem(null)} />
|
<Lightbox item={lightboxItem} onClose={() => setLightboxItem(null)} />
|
||||||
|
{mockupItem && (
|
||||||
|
<MockupModalFromImage item={mockupItem} onClose={() => setMockupItem(null)} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,449 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { Header } from '@/components/layout/Header'
|
||||||
|
import { TemplateLibrary } from '@/components/mockup/TemplateLibrary'
|
||||||
|
import type { MockupTemplate } from '@/types/mockup'
|
||||||
|
|
||||||
|
type Tab = 'generate' | 'library'
|
||||||
|
type Step = 'product' | 'image' | 'template'
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
shopifyId: string
|
||||||
|
title: string
|
||||||
|
ref: string
|
||||||
|
images: 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() {
|
||||||
|
const [tab, setTab] = useState<Tab>('generate')
|
||||||
|
const [step, setStep] = useState<Step>('product')
|
||||||
|
|
||||||
|
// Step 1 — product
|
||||||
|
const [products, setProducts] = useState<Product[]>([])
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false)
|
||||||
|
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null)
|
||||||
|
const [family, setFamily] = useState('')
|
||||||
|
const [placement, setPlacement] = useState('')
|
||||||
|
const [dimensions, setDimensions] = useState('')
|
||||||
|
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(() => {
|
||||||
|
fetch('/api/products/list')
|
||||||
|
.then(r => r.json())
|
||||||
|
.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 =>
|
||||||
|
p.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
p.ref.toLowerCase().includes(search.toLowerCase())
|
||||||
|
).slice(0, 20)
|
||||||
|
|
||||||
|
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]
|
||||||
|
if (!file) return
|
||||||
|
setSelectedImageUrl(null)
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = ev => {
|
||||||
|
setCutoutB64((ev.target?.result as string).split(',')[1])
|
||||||
|
setStep('template')
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Header title="🖼️ Mockups" actions={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['generate', 'library'] as Tab[]).map(t => (
|
||||||
|
<button key={t} type="button" onClick={() => setTab(t)}
|
||||||
|
className={`px-3 py-1 text-xs rounded-lg transition-colors ${tab === t ? 'bg-indigo-600 text-white' : 'text-gray-400 hover:text-white'}`}>
|
||||||
|
{t === 'generate' ? 'Générer' : 'Bibliothèque templates'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
} />
|
||||||
|
|
||||||
|
<div className="p-6 max-w-2xl mx-auto">
|
||||||
|
{tab === 'library' ? (
|
||||||
|
<TemplateLibrary />
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<StepIndicator />
|
||||||
|
|
||||||
|
{/* ── 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}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={e => { setSearch(e.target.value); setShowDropdown(true) }}
|
||||||
|
onFocus={() => setShowDropdown(true)}
|
||||||
|
placeholder="Nom ou référence du produit…"
|
||||||
|
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 && (
|
||||||
|
<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 => (
|
||||||
|
<button key={p.shopifyId} type="button"
|
||||||
|
onMouseDown={() => selectProduct(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">
|
||||||
|
<span className="truncate">{p.title}</span>
|
||||||
|
<span className="text-xs text-gray-400 shrink-0 self-center">{p.ref}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showDropdown && search.length >= 2 && filtered.length === 0 && (
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Famille</label>
|
||||||
|
<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">
|
||||||
|
{['Menuiserie', 'Baies vitrées', 'Carrelage', 'Tous'].map(f => <option key={f}>{f}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Dimensions</label>
|
||||||
|
<input value={dimensions} onChange={e => setDimensions(e.target.value)}
|
||||||
|
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" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{/* Templates */}
|
||||||
|
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide">Template</h3>
|
||||||
|
<button type="button" onClick={() => templateFileRef.current?.click()}
|
||||||
|
disabled={uploadingTemplate}
|
||||||
|
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>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{filteredTemplates.map(t => (
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ interface LastSync {
|
|||||||
interface DashboardStats {
|
interface DashboardStats {
|
||||||
products: ProductStats
|
products: ProductStats
|
||||||
lastSync: LastSync | null
|
lastSync: LastSync | null
|
||||||
|
sheetDrift: boolean
|
||||||
|
sheetModifiedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({
|
function StatCard({
|
||||||
@@ -127,9 +129,6 @@ export default function DashboardPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const lastSync = stats?.lastSync
|
const lastSync = stats?.lastSync
|
||||||
const syncStatusColor =
|
|
||||||
lastSync?.status === 'success' ? 'text-green-400' :
|
|
||||||
lastSync?.status === 'error' ? 'text-red-400' : 'text-slate-400'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -137,6 +136,22 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
<div className="p-6 flex flex-col gap-8 flex-1 overflow-y-auto">
|
<div className="p-6 flex flex-col gap-8 flex-1 overflow-y-auto">
|
||||||
|
|
||||||
|
{/* ── Alerte drift Sheets ───────────────────────────────── */}
|
||||||
|
{!statsLoading && stats?.sheetDrift && (
|
||||||
|
<Link href="/sync" className="flex items-center gap-3 bg-amber-950/40 border border-amber-600/50 rounded-xl px-4 py-3 hover:bg-amber-950/60 transition-colors group">
|
||||||
|
<span className="text-xl">⚠️</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-amber-300">Google Sheets modifié depuis la dernière sync</p>
|
||||||
|
<p className="text-xs text-amber-500 mt-0.5">
|
||||||
|
{stats.sheetModifiedAt
|
||||||
|
? `Dernière modification : ${formatDate(stats.sheetModifiedAt)}`
|
||||||
|
: 'Des changements sont peut-être en attente de synchronisation'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-amber-400 group-hover:text-amber-300 whitespace-nowrap">Lancer la sync →</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── KPIs ─────────────────────────────────────────────── */}
|
{/* ── KPIs ─────────────────────────────────────────────── */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Vue d'ensemble</h2>
|
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Vue d'ensemble</h2>
|
||||||
|
|||||||
@@ -4,36 +4,42 @@ import { useSession } from 'next-auth/react'
|
|||||||
import { Header } from '@/components/layout/Header'
|
import { Header } from '@/components/layout/Header'
|
||||||
import { UsersManager } from '@/components/settings/UsersManager'
|
import { UsersManager } from '@/components/settings/UsersManager'
|
||||||
import { ConnectionStatus } from '@/components/settings/ConnectionStatus'
|
import { ConnectionStatus } from '@/components/settings/ConnectionStatus'
|
||||||
|
import { TemplateLibrary } from '@/components/mockup/TemplateLibrary'
|
||||||
|
|
||||||
type Tab = 'connexions' | 'utilisateurs'
|
type Tab = 'connexions' | 'utilisateurs' | 'mockup-templates'
|
||||||
|
|
||||||
export default function ParametresPage() {
|
export default function ParametresPage() {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const isAdmin = session?.user?.role === 'admin'
|
const isAdmin = session?.user?.role === 'admin'
|
||||||
const [tab, setTab] = useState<Tab>('connexions')
|
const [tab, setTab] = useState<Tab>('connexions')
|
||||||
|
|
||||||
|
const tabs: { id: Tab; label: string; adminOnly?: boolean }[] = [
|
||||||
|
{ id: 'connexions', label: 'Connexions' },
|
||||||
|
{ id: 'mockup-templates', label: 'Templates mockup' },
|
||||||
|
{ id: 'utilisateurs', label: 'Utilisateurs', adminOnly: true },
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="Paramètres" />
|
<Header title="Paramètres" />
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{isAdmin && (
|
<div className="flex gap-2">
|
||||||
<div className="flex gap-2">
|
{tabs.filter(t => !t.adminOnly || isAdmin).map(t => (
|
||||||
{(['connexions', 'utilisateurs'] as Tab[]).map((t) => (
|
<button
|
||||||
<button
|
key={t.id}
|
||||||
key={t}
|
onClick={() => setTab(t.id)}
|
||||||
onClick={() => setTab(t)}
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
tab === t.id
|
||||||
tab === t
|
? 'bg-indigo-600 text-white'
|
||||||
? 'bg-indigo-600 text-white'
|
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
|
||||||
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
{t.label}
|
||||||
{t === 'connexions' ? 'Connexions' : 'Utilisateurs'}
|
</button>
|
||||||
</button>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
{tab === 'connexions' && <ConnectionStatus />}
|
||||||
)}
|
{tab === 'mockup-templates' && <TemplateLibrary />}
|
||||||
{(!isAdmin || tab === 'connexions') && <ConnectionStatus />}
|
|
||||||
{isAdmin && tab === 'utilisateurs' && <UsersManager />}
|
{isAdmin && tab === 'utilisateurs' && <UsersManager />}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
'use client'
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Header } from '@/components/layout/Header'
|
||||||
|
import type { DuplicateGroup } from '@/app/api/products/duplicates/route'
|
||||||
|
|
||||||
|
type DeleteState = 'idle' | 'confirm' | 'deleting' | 'done'
|
||||||
|
|
||||||
|
interface GroupState {
|
||||||
|
group: DuplicateGroup
|
||||||
|
keepId: string
|
||||||
|
deleteState: DeleteState
|
||||||
|
deleted: string[] // IDs supprimés
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DoublonsPage() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [groupStates, setGroupStates] = useState<GroupState[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/products/duplicates')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.error) throw new Error(data.error)
|
||||||
|
setGroupStates(
|
||||||
|
(data.groups as DuplicateGroup[]).map((group) => ({
|
||||||
|
group,
|
||||||
|
keepId: group.products[0].shopifyId, // garder le plus ancien par défaut
|
||||||
|
deleteState: 'idle',
|
||||||
|
deleted: [],
|
||||||
|
error: null,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch((e: Error) => setError(e.message))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setKeep = useCallback((groupIdx: number, id: string) => {
|
||||||
|
setGroupStates((prev) =>
|
||||||
|
prev.map((gs, i) => i === groupIdx ? { ...gs, keepId: id } : gs)
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const askConfirm = useCallback((groupIdx: number) => {
|
||||||
|
setGroupStates((prev) =>
|
||||||
|
prev.map((gs, i) => i === groupIdx ? { ...gs, deleteState: 'confirm', error: null } : gs)
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const cancelConfirm = useCallback((groupIdx: number) => {
|
||||||
|
setGroupStates((prev) =>
|
||||||
|
prev.map((gs, i) => i === groupIdx ? { ...gs, deleteState: 'idle' } : gs)
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const deleteGroup = useCallback(async (groupIdx: number) => {
|
||||||
|
const gs = groupStates[groupIdx]
|
||||||
|
const toDelete = gs.group.products
|
||||||
|
.map((p) => p.shopifyId)
|
||||||
|
.filter((id) => id !== gs.keepId && !gs.deleted.includes(id))
|
||||||
|
|
||||||
|
setGroupStates((prev) =>
|
||||||
|
prev.map((s, i) => i === groupIdx ? { ...s, deleteState: 'deleting', error: null } : s)
|
||||||
|
)
|
||||||
|
|
||||||
|
const newDeleted = [...gs.deleted]
|
||||||
|
let lastError: string | null = null
|
||||||
|
|
||||||
|
for (const id of toDelete) {
|
||||||
|
const res = await fetch(`/api/products/${id}`, { method: 'DELETE' })
|
||||||
|
if (res.ok) {
|
||||||
|
newDeleted.push(id)
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
lastError = data.error ?? `Erreur HTTP ${res.status}`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setGroupStates((prev) =>
|
||||||
|
prev.map((s, i) =>
|
||||||
|
i === groupIdx
|
||||||
|
? { ...s, deleted: newDeleted, deleteState: lastError ? 'idle' : 'done', error: lastError }
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}, [groupStates])
|
||||||
|
|
||||||
|
const remaining = groupStates.filter((gs) => gs.deleteState !== 'done').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header
|
||||||
|
title="🔍 Doublons produits"
|
||||||
|
actions={
|
||||||
|
<Link href="/produits" className="text-xs text-slate-400 hover:text-slate-200">
|
||||||
|
← Retour au catalogue
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="p-6 flex flex-col gap-6 flex-1 overflow-y-auto">
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center gap-2 text-slate-400 text-sm py-12 justify-center">
|
||||||
|
<span className="animate-spin">⏳</span> Analyse du catalogue Shopify…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && groupStates.length === 0 && (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<p className="text-4xl mb-3">✅</p>
|
||||||
|
<p className="text-white font-semibold">Aucun doublon détecté</p>
|
||||||
|
<p className="text-slate-400 text-sm mt-1">Tous les produits ont une référence unique.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && groupStates.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
<span className="text-white font-semibold">{groupStates.length}</span> groupe{groupStates.length > 1 ? 's' : ''} de doublons détectés
|
||||||
|
{remaining < groupStates.length && (
|
||||||
|
<span className="ml-2 text-green-400">· {groupStates.length - remaining} résolu{groupStates.length - remaining > 1 ? 's' : ''}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{groupStates.map((gs, groupIdx) => {
|
||||||
|
const { group, keepId, deleteState, deleted, error: groupError } = gs
|
||||||
|
const isDone = deleteState === 'done'
|
||||||
|
const activeProducts = group.products.filter((p) => !deleted.includes(p.shopifyId))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.ref}
|
||||||
|
className={`border rounded-xl overflow-hidden transition-all ${
|
||||||
|
isDone
|
||||||
|
? 'border-green-700/50 bg-green-950/20 opacity-60'
|
||||||
|
: 'border-slate-700 bg-slate-800/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* En-tête groupe */}
|
||||||
|
<div className="px-4 py-3 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="font-mono text-sm text-indigo-300 font-semibold">{group.ref}</span>
|
||||||
|
<span className="ml-3 text-xs text-slate-500">
|
||||||
|
{isDone ? '✅ Résolu' : `${activeProducts.length} produits`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isDone && deleteState === 'idle' && (
|
||||||
|
<button
|
||||||
|
onClick={() => askConfirm(groupIdx)}
|
||||||
|
className="text-xs px-3 py-1.5 bg-red-900/40 hover:bg-red-800/60 border border-red-700/50 text-red-300 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Supprimer les doublons
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste des produits */}
|
||||||
|
{!isDone && (
|
||||||
|
<div className="divide-y divide-slate-700/50">
|
||||||
|
{activeProducts.map((product) => {
|
||||||
|
const isKeep = product.shopifyId === keepId
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={product.shopifyId}
|
||||||
|
className={`px-4 py-3 flex items-center gap-3 ${isKeep ? 'bg-green-950/20' : ''}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`keep-${groupIdx}`}
|
||||||
|
checked={isKeep}
|
||||||
|
onChange={() => setKeep(groupIdx, product.shopifyId)}
|
||||||
|
className="accent-green-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-white truncate">{product.title}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">
|
||||||
|
ID {product.shopifyId} ·{' '}
|
||||||
|
<span className={product.status === 'active' ? 'text-green-400' : 'text-slate-400'}>
|
||||||
|
{product.status}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{isKeep && (
|
||||||
|
<span className="text-xs text-green-400 font-medium">À garder</span>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={product.shopifyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-xs text-slate-500 hover:text-slate-300"
|
||||||
|
>
|
||||||
|
Shopify ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Zone de confirmation */}
|
||||||
|
{deleteState === 'confirm' && (
|
||||||
|
<div className="px-4 py-3 bg-red-950/30 border-t border-red-800/40 flex items-center justify-between gap-4">
|
||||||
|
<p className="text-sm text-red-300">
|
||||||
|
Supprimer définitivement{' '}
|
||||||
|
<strong>{activeProducts.length - 1} produit{activeProducts.length - 1 > 1 ? 's' : ''}</strong>{' '}
|
||||||
|
de Shopify. Cette action est irréversible.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => cancelConfirm(groupIdx)}
|
||||||
|
className="text-xs px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded-lg"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteGroup(groupIdx)}
|
||||||
|
className="text-xs px-3 py-1.5 bg-red-700 hover:bg-red-600 text-white font-semibold rounded-lg"
|
||||||
|
>
|
||||||
|
Confirmer la suppression
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleteState === 'deleting' && (
|
||||||
|
<div className="px-4 py-3 border-t border-slate-700 text-sm text-slate-400 flex items-center gap-2">
|
||||||
|
<span className="animate-spin">⏳</span> Suppression en cours…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groupError && (
|
||||||
|
<div className="px-4 py-2 border-t border-red-800/40 text-xs text-red-400">
|
||||||
|
Erreur : {groupError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
import { Header } from '@/components/layout/Header'
|
import { Header } from '@/components/layout/Header'
|
||||||
import { ProductFiltersBar } from '@/components/products/ProductFiltersBar'
|
import { ProductFiltersBar } from '@/components/products/ProductFiltersBar'
|
||||||
import { ProductTable } from '@/components/products/ProductTable'
|
import { ProductTable } from '@/components/products/ProductTable'
|
||||||
@@ -35,7 +36,14 @@ export default function ProduitsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="Produits" />
|
<Header
|
||||||
|
title="Produits"
|
||||||
|
actions={
|
||||||
|
<Link href="/produits/doublons" className="text-xs px-3 py-1.5 bg-slate-700 hover:bg-slate-600 border border-slate-600 text-slate-300 rounded-lg transition-colors">
|
||||||
|
🔍 Vérifier les doublons
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="bg-slate-800 rounded-xl p-6">
|
<div className="bg-slate-800 rounded-xl p-6">
|
||||||
<ProductFiltersBar
|
<ProductFiltersBar
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import { SyncStepper } from '@/components/sync/SyncStepper'
|
|||||||
import { DiffTable } from '@/components/sync/DiffTable'
|
import { DiffTable } from '@/components/sync/DiffTable'
|
||||||
import { SyncProgress } from '@/components/sync/SyncProgress'
|
import { SyncProgress } from '@/components/sync/SyncProgress'
|
||||||
import { SyncHistory } from '@/components/sync/SyncHistory'
|
import { SyncHistory } from '@/components/sync/SyncHistory'
|
||||||
|
import { FamilySelector } from '@/components/common/FamilySelector'
|
||||||
import type { DiffResult, SyncChange, SyncHistoryEntry } from '@/types/sync'
|
import type { DiffResult, SyncChange, SyncHistoryEntry } from '@/types/sync'
|
||||||
|
|
||||||
type Step = 1 | 2 | 3 | 4
|
type Step = 0 | 1 | 2 | 3 | 4
|
||||||
|
|
||||||
export default function SyncPage() {
|
export default function SyncPage() {
|
||||||
const [step, setStep] = useState<Step>(1)
|
const [step, setStep] = useState<Step>(0)
|
||||||
|
const [selectedTab, setSelectedTab] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [diff, setDiff] = useState<DiffResult | null>(null)
|
const [diff, setDiff] = useState<DiffResult | null>(null)
|
||||||
@@ -24,12 +26,19 @@ export default function SyncPage() {
|
|||||||
created: number; updated: number; deactivated: number; errors: number; status: string
|
created: number; updated: number; deactivated: number; errors: number; status: string
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
|
// Étape 0 → 1 : sélection famille
|
||||||
|
const handleSelectTab = useCallback((tab: string) => {
|
||||||
|
setSelectedTab(tab)
|
||||||
|
setStep(1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Étape 1 → 2 : lancer le preview
|
// Étape 1 → 2 : lancer le preview
|
||||||
const handleAnalyze = useCallback(async () => {
|
const handleAnalyze = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/sync/preview')
|
const url = selectedTab ? `/api/sync/preview?tab=${encodeURIComponent(selectedTab)}` : '/api/sync/preview'
|
||||||
|
const res = await fetch(url)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||||
setDiff(data)
|
setDiff(data)
|
||||||
@@ -39,7 +48,7 @@ export default function SyncPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [selectedTab])
|
||||||
|
|
||||||
// Étape 3 → 4 : lancer la sync en streaming
|
// Étape 3 → 4 : lancer la sync en streaming
|
||||||
const handleExecute = useCallback(async () => {
|
const handleExecute = useCallback(async () => {
|
||||||
@@ -114,13 +123,26 @@ export default function SyncPage() {
|
|||||||
|
|
||||||
<div className="p-6 flex flex-col gap-6 flex-1 overflow-y-auto">
|
<div className="p-6 flex flex-col gap-6 flex-1 overflow-y-auto">
|
||||||
{/* Stepper */}
|
{/* Stepper */}
|
||||||
<SyncStepper currentStep={step} />
|
<SyncStepper currentStep={step > 0 ? step : 1} />
|
||||||
|
|
||||||
|
{/* Étape 0 — Sélection famille */}
|
||||||
|
{step === 0 && (
|
||||||
|
<div className="bg-slate-800/50 border border-slate-700 rounded-xl p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-white mb-4">Choisir une famille</h2>
|
||||||
|
<FamilySelector onSelect={handleSelectTab} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Étape 1 — Analyser */}
|
{/* Étape 1 — Analyser */}
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<div className="flex flex-col items-center gap-4 py-8">
|
<div className="flex flex-col items-center gap-4 py-8">
|
||||||
|
<div className="flex items-center gap-2 bg-indigo-950/50 border border-indigo-800/50 rounded-lg px-4 py-2">
|
||||||
|
<span className="text-indigo-400 text-sm">📂 Famille :</span>
|
||||||
|
<span className="text-white text-sm font-semibold">{selectedTab}</span>
|
||||||
|
<button onClick={() => { setStep(0); setSelectedTab(null) }} className="ml-2 text-xs text-slate-400 hover:text-slate-200">changer</button>
|
||||||
|
</div>
|
||||||
<p className="text-slate-400 text-sm text-center max-w-md">
|
<p className="text-slate-400 text-sm text-center max-w-md">
|
||||||
Lit Google Sheets et compare avec le catalogue Shopify actuel pour générer l'aperçu des changements.
|
Compare la famille <strong className="text-white">{selectedTab}</strong> avec le catalogue Shopify.
|
||||||
</p>
|
</p>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-950 border border-red-800 text-red-300 text-sm rounded-lg px-4 py-2 max-w-md">
|
<div className="bg-red-950 border border-red-800 text-red-300 text-sm rounded-lg px-4 py-2 max-w-md">
|
||||||
@@ -196,7 +218,7 @@ export default function SyncPage() {
|
|||||||
{summary && (
|
{summary && (
|
||||||
<div className="flex gap-3 justify-end">
|
<div className="flex gap-3 justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setStep(1); setDiff(null); setSummary(null); setLog([]) }}
|
onClick={() => { setStep(0); setSelectedTab(null); setDiff(null); setSummary(null); setLog([]) }}
|
||||||
className="px-4 py-2 text-sm bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded-lg"
|
className="px-4 py-2 text-sm bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded-lg"
|
||||||
>
|
>
|
||||||
↺ Nouvelle sync
|
↺ Nouvelle sync
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
|
||||||
|
async function getAccessToken(): Promise<string | null> {
|
||||||
|
const email = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL
|
||||||
|
const rawKey = process.env.GOOGLE_SERVICE_ACCOUNT_KEY
|
||||||
|
if (!email || !rawKey) return null
|
||||||
|
|
||||||
|
const privateKey = rawKey.replace(/\\n/g, '\n')
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url')
|
||||||
|
const payload = Buffer.from(JSON.stringify({
|
||||||
|
iss: email,
|
||||||
|
scope: 'https://www.googleapis.com/auth/spreadsheets',
|
||||||
|
aud: 'https://oauth2.googleapis.com/token',
|
||||||
|
iat: now,
|
||||||
|
exp: now + 3600,
|
||||||
|
})).toString('base64url')
|
||||||
|
|
||||||
|
const { createSign } = await import('crypto')
|
||||||
|
const sign = createSign('RSA-SHA256')
|
||||||
|
sign.update(`${header}.${payload}`)
|
||||||
|
const signature = sign.sign(privateKey, 'base64url')
|
||||||
|
const jwt = `${header}.${payload}.${signature}`
|
||||||
|
|
||||||
|
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt }),
|
||||||
|
})
|
||||||
|
if (!tokenRes.ok) return null
|
||||||
|
const { access_token } = await tokenRes.json() as { access_token: string }
|
||||||
|
return access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/creation/clear-ids?tab=SOL CARRELAGE
|
||||||
|
// Efface la colonne A (ID Shopify) de toutes les lignes produit d'un onglet
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
|
||||||
|
const tab = new URL(req.url).searchParams.get('tab')
|
||||||
|
if (!tab) return NextResponse.json({ error: 'tab requis' }, { status: 400 })
|
||||||
|
|
||||||
|
const apiKey = process.env.GOOGLE_API_KEY
|
||||||
|
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||||
|
if (!apiKey || !sheetId) return NextResponse.json({ error: 'Config Google manquante' }, { status: 500 })
|
||||||
|
|
||||||
|
// Lire les données de l'onglet
|
||||||
|
const range = encodeURIComponent(`${tab}!A1:A500`)
|
||||||
|
const res = await fetch(
|
||||||
|
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`,
|
||||||
|
)
|
||||||
|
if (!res.ok) return NextResponse.json({ error: `Sheets read error: ${res.status}` }, { status: 502 })
|
||||||
|
|
||||||
|
const data = await res.json() as { values?: string[][] }
|
||||||
|
const rows = data.values ?? []
|
||||||
|
|
||||||
|
// Trouver la ligne d'en-tête (contient "ID" en col A)
|
||||||
|
const headerRowIdx = rows.findIndex(r => (r[0] ?? '').trim() === 'ID')
|
||||||
|
if (headerRowIdx === -1) return NextResponse.json({ error: 'Onglet non reconnu (pas de colonne ID)' }, { status: 400 })
|
||||||
|
|
||||||
|
// Identifier les lignes produit qui ont un ID Shopify (numérique)
|
||||||
|
const clearValues: string[][] = []
|
||||||
|
const startRow = headerRowIdx + 2 // 1-indexed, skip header row
|
||||||
|
for (let i = headerRowIdx + 1; i < rows.length; i++) {
|
||||||
|
const cell = (rows[i]?.[0] ?? '').trim()
|
||||||
|
clearValues.push([cell && /^\d+$/.test(cell) ? '' : cell])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearValues.length === 0) return NextResponse.json({ cleared: 0 })
|
||||||
|
|
||||||
|
const accessToken = await getAccessToken()
|
||||||
|
if (!accessToken) return NextResponse.json({ error: 'Impossible d\'obtenir le token Google' }, { status: 500 })
|
||||||
|
|
||||||
|
const writeRange = `${tab}!A${startRow}:A${startRow + clearValues.length - 1}`
|
||||||
|
const writeRes = await fetch(
|
||||||
|
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${encodeURIComponent(writeRange)}?valueInputOption=RAW`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ range: writeRange, majorDimension: 'ROWS', values: clearValues }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!writeRes.ok) {
|
||||||
|
const err = await writeRes.text()
|
||||||
|
return NextResponse.json({ error: `Sheets write error: ${err}` }, { status: 502 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleared = clearValues.filter(r => r[0] === '').length
|
||||||
|
return NextResponse.json({ cleared, tab })
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
|
||||||
|
const API_VERSION = '2024-01'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
|
||||||
|
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||||
|
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||||
|
if (!domain || !token) return NextResponse.json({ error: 'Config Shopify manquante' }, { status: 500 })
|
||||||
|
|
||||||
|
const baseUrl = `https://${domain}/admin/api/${API_VERSION}`
|
||||||
|
const headers = { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' }
|
||||||
|
|
||||||
|
// Récupère custom collections + smart collections
|
||||||
|
const [customRes, smartRes] = await Promise.all([
|
||||||
|
fetch(`${baseUrl}/custom_collections.json?limit=250&fields=id,title`, { headers }),
|
||||||
|
fetch(`${baseUrl}/smart_collections.json?limit=250&fields=id,title`, { headers }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const custom = customRes.ok ? (await customRes.json()).custom_collections as { id: number; title: string }[] : []
|
||||||
|
const smart = smartRes.ok ? (await smartRes.json()).smart_collections as { id: number; title: string }[] : []
|
||||||
|
|
||||||
|
const collections = [...custom, ...smart]
|
||||||
|
.map(c => ({ id: String(c.id), title: c.title }))
|
||||||
|
.sort((a, b) => a.title.localeCompare(b.title))
|
||||||
|
|
||||||
|
return NextResponse.json({ collections })
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import { fetchPendingProducts } from '@/lib/creationSheets'
|
import { fetchPendingProducts, writeShopifyIdToSheet } from '@/lib/creationSheets'
|
||||||
import { generateDescriptions } from '@/lib/openaiClient'
|
import { generateDescriptions } from '@/lib/openaiClient'
|
||||||
import { ensureMetafieldDefinition, setProductMetafield } from '@/lib/shopifyMetafields'
|
import { ensureMetafieldDefinition, setProductMetafield } from '@/lib/shopifyMetafields'
|
||||||
import { addProductToCollection } from '@/lib/shopifyCollections'
|
import { addProductToCollection } from '@/lib/shopifyCollections'
|
||||||
import { createShopifyProduct } from '@/lib/shopifySync'
|
import { createShopifyProduct, fetchAllShopifyProducts } from '@/lib/shopifySync'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import type { MetafieldResolution, CreationStreamEvent } from '@/types/creation'
|
import type { MetafieldResolution, CreationStreamEvent } from '@/types/creation'
|
||||||
|
|
||||||
@@ -20,21 +20,27 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userId = (session.user as { id: string }).id
|
const userId = (session.user as { id: string }).id
|
||||||
const body = await req.json().catch(() => ({})) as { resolutions?: MetafieldResolution[] }
|
const body = await req.json().catch(() => ({})) as { resolutions?: MetafieldResolution[]; tab?: string; collectionOverride?: string }
|
||||||
const resolutions: MetafieldResolution[] = body.resolutions ?? []
|
const resolutions: MetafieldResolution[] = body.resolutions ?? []
|
||||||
|
const tabFilter: string | undefined = body.tab ?? undefined
|
||||||
|
const collectionOverride: string | undefined = body.collectionOverride ?? undefined
|
||||||
const resolutionMap = new Map<string, MetafieldResolution>(resolutions.map(r => [r.columnHeader, r]))
|
const resolutionMap = new Map<string, MetafieldResolution>(resolutions.map(r => [r.columnHeader, r]))
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
async start(controller) {
|
async start(controller) {
|
||||||
const send = (event: CreationStreamEvent) => controller.enqueue(encode(event))
|
const send = (event: CreationStreamEvent) => controller.enqueue(encode(event))
|
||||||
try {
|
try {
|
||||||
const existing = await prisma.productCreation.findMany({ select: { sheetTab: true, sheetRow: true } })
|
const [existingRows, shopifyProducts] = await Promise.all([
|
||||||
const alreadyCreated = new Set(existing.map(e => `${e.sheetTab}:${e.sheetRow}`))
|
prisma.productCreation.findMany({ select: { sheetTab: true, sheetRow: true } }),
|
||||||
const pending = await fetchPendingProducts(alreadyCreated)
|
fetchAllShopifyProducts(),
|
||||||
|
])
|
||||||
|
const alreadyCreated = new Set(existingRows.map(e => `${e.sheetTab}:${e.sheetRow}`))
|
||||||
|
const shopifyRefs = new Set(shopifyProducts.map(p => p.ref.trim().toLowerCase()))
|
||||||
|
const pending = await fetchPendingProducts(alreadyCreated, tabFilter)
|
||||||
const total = pending.length
|
const total = pending.length
|
||||||
|
|
||||||
if (total === 0) {
|
if (total === 0) {
|
||||||
send({ type: 'done', summary: { created: 0, errors: 0, status: 'success' } })
|
send({ type: 'done', summary: { created: 0, skipped: 0, errors: 0, status: 'success' } })
|
||||||
controller.close()
|
controller.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -45,10 +51,19 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let created = 0
|
let created = 0
|
||||||
|
let skipped = 0
|
||||||
let errors = 0
|
let errors = 0
|
||||||
|
|
||||||
for (let i = 0; i < pending.length; i++) {
|
for (let i = 0; i < pending.length; i++) {
|
||||||
const product = pending[i]
|
const product = pending[i]
|
||||||
|
|
||||||
|
// Vérifier si le produit existe déjà dans Shopify par SKU
|
||||||
|
if (shopifyRefs.has(product.sku.trim().toLowerCase())) {
|
||||||
|
send({ type: 'skipped', title: product.title, sku: product.sku })
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Génération descriptions…' })
|
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Génération descriptions…' })
|
||||||
try {
|
try {
|
||||||
const { longDesc } = await generateDescriptions({
|
const { longDesc } = await generateDescriptions({
|
||||||
@@ -59,6 +74,7 @@ export async function POST(req: NextRequest) {
|
|||||||
famille: product.famille,
|
famille: product.famille,
|
||||||
})
|
})
|
||||||
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Création produit Shopify…' })
|
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Création produit Shopify…' })
|
||||||
|
console.log(`[creation] ${product.sku} stock=${product.stock}`)
|
||||||
const shopifyId = await createShopifyProduct({
|
const shopifyId = await createShopifyProduct({
|
||||||
ref: product.sku,
|
ref: product.sku,
|
||||||
handle: product.sku.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
handle: product.sku.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||||
@@ -66,9 +82,11 @@ export async function POST(req: NextRequest) {
|
|||||||
description: longDesc,
|
description: longDesc,
|
||||||
price: product.priceActual || '0.00',
|
price: product.priceActual || '0.00',
|
||||||
stock: product.stock,
|
stock: product.stock,
|
||||||
|
weightKg: product.weightKg,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
})
|
})
|
||||||
if (product.collectionId) await addProductToCollection(shopifyId, product.collectionId)
|
const effectiveCollectionId = product.collectionId || collectionOverride || ''
|
||||||
|
if (effectiveCollectionId) await addProductToCollection(shopifyId, effectiveCollectionId)
|
||||||
if (product.subCollectionId) await addProductToCollection(shopifyId, product.subCollectionId)
|
if (product.subCollectionId) await addProductToCollection(shopifyId, product.subCollectionId)
|
||||||
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Métachamps…' })
|
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Métachamps…' })
|
||||||
for (const [colHeader, value] of Object.entries(product.specificFields)) {
|
for (const [colHeader, value] of Object.entries(product.specificFields)) {
|
||||||
@@ -79,6 +97,7 @@ export async function POST(req: NextRequest) {
|
|||||||
await prisma.productCreation.create({
|
await prisma.productCreation.create({
|
||||||
data: { userId, sheetTab: product.tab, sheetRow: product.rowNumber, shopifyId, sku: product.sku, title: product.title, status: 'success' },
|
data: { userId, sheetTab: product.tab, sheetRow: product.rowNumber, shopifyId, sku: product.sku, title: product.title, status: 'success' },
|
||||||
})
|
})
|
||||||
|
await writeShopifyIdToSheet(product.tab, product.rowNumber, shopifyId)
|
||||||
created++
|
created++
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
@@ -90,10 +109,10 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const status = errors === 0 ? 'success' : created === 0 ? 'error' : 'partial'
|
const status = errors === 0 ? 'success' : created === 0 ? 'error' : 'partial'
|
||||||
send({ type: 'done', summary: { created, errors, status } })
|
send({ type: 'done', summary: { created, skipped, errors, status } })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
send({ type: 'done', summary: { created: 0, errors: 1, status: 'error' } })
|
send({ type: 'done', summary: { created: 0, skipped: 0, errors: 1, status: 'error' } })
|
||||||
console.error('[creation/execute]', message)
|
console.error('[creation/execute]', message)
|
||||||
} finally {
|
} finally {
|
||||||
controller.close()
|
controller.close()
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import { fetchPendingProducts } from '@/lib/creationSheets'
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import type { PendingProduct } from '@/types/creation'
|
import type { PendingProduct } from '@/types/creation'
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
export async function GET(req: NextRequest) {
|
||||||
export async function GET(_req: NextRequest) {
|
|
||||||
const session = await getServerSession(authOptions)
|
const session = await getServerSession(authOptions)
|
||||||
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
const tab = new URL(req.url).searchParams.get('tab') ?? undefined
|
||||||
try {
|
try {
|
||||||
const existing = await prisma.productCreation.findMany({ select: { sheetTab: true, sheetRow: true } })
|
const existing = await prisma.productCreation.findMany({ select: { sheetTab: true, sheetRow: true } })
|
||||||
const alreadyCreated = new Set(existing.map(e => `${e.sheetTab}:${e.sheetRow}`))
|
const alreadyCreated = new Set(existing.map(e => `${e.sheetTab}:${e.sheetRow}`))
|
||||||
const pending = await fetchPendingProducts(alreadyCreated)
|
const pending = await fetchPendingProducts(alreadyCreated, tab)
|
||||||
const byTab: Record<string, PendingProduct[]> = {}
|
const byTab: Record<string, PendingProduct[]> = {}
|
||||||
const specificColumns = new Set<string>()
|
const specificColumns = new Set<string>()
|
||||||
for (const p of pending) {
|
for (const p of pending) {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { loadResolutionCache, saveResolutionCache } from '@/lib/resolutionCache'
|
||||||
|
import type { MetafieldResolution } from '@/types/creation'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
const cache = await loadResolutionCache()
|
||||||
|
return NextResponse.json(cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
const body = await req.json() as Record<string, MetafieldResolution>
|
||||||
|
await saveResolutionCache(body)
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { findColIndex, writeShopifyIdToSheet } from '@/lib/creationSheets'
|
||||||
|
import { getConfig } from '@/lib/shopifySync'
|
||||||
|
|
||||||
|
void writeShopifyIdToSheet // réutilise le même mécanisme d'auth Google
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
const send = (obj: object) => controller.enqueue(new TextEncoder().encode(JSON.stringify(obj) + '\n'))
|
||||||
|
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||||
|
|
||||||
|
const shopifyFetch = async (url: string, opts: RequestInit = {}, retries = 3): Promise<Response> => {
|
||||||
|
const res = await fetch(url, opts)
|
||||||
|
if (res.status === 429 && retries > 0) {
|
||||||
|
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '2', 10)
|
||||||
|
await sleep(retryAfter * 1000)
|
||||||
|
return shopifyFetch(url, opts, retries - 1)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { baseUrl, headers: shopifyHeaders } = getConfig()
|
||||||
|
|
||||||
|
// Récupérer le location_id
|
||||||
|
const locRes = await shopifyFetch(`${baseUrl}/locations.json`, { headers: shopifyHeaders })
|
||||||
|
if (!locRes.ok) throw new Error(`Shopify locations error: ${locRes.status}`)
|
||||||
|
const locData = await locRes.json() as { locations: Array<{ id: number }> }
|
||||||
|
const locationId = locData.locations[0]?.id
|
||||||
|
if (!locationId) throw new Error('Aucune location Shopify trouvée')
|
||||||
|
|
||||||
|
// Auth Google Sheets
|
||||||
|
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||||
|
const email = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL
|
||||||
|
const rawKey = process.env.GOOGLE_SERVICE_ACCOUNT_KEY
|
||||||
|
if (!sheetId || !email || !rawKey) throw new Error('Credentials Google manquants')
|
||||||
|
const apiKey = process.env.GOOGLE_API_KEY
|
||||||
|
if (!apiKey) throw new Error('GOOGLE_API_KEY manquant')
|
||||||
|
|
||||||
|
const privateKey = rawKey.replace(/\\n/g, '\n')
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url')
|
||||||
|
const payload = Buffer.from(JSON.stringify({
|
||||||
|
iss: email,
|
||||||
|
scope: 'https://www.googleapis.com/auth/spreadsheets',
|
||||||
|
aud: 'https://oauth2.googleapis.com/token',
|
||||||
|
iat: now, exp: now + 3600,
|
||||||
|
})).toString('base64url')
|
||||||
|
const { createSign } = await import('crypto')
|
||||||
|
const sign = createSign('RSA-SHA256')
|
||||||
|
sign.update(`${header}.${payload}`)
|
||||||
|
const signature = sign.sign(privateKey, 'base64url')
|
||||||
|
const jwt = `${header}.${payload}.${signature}`
|
||||||
|
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt }),
|
||||||
|
})
|
||||||
|
if (!tokenRes.ok) throw new Error(`Google token error: ${tokenRes.status}`)
|
||||||
|
const { access_token } = await tokenRes.json() as { access_token: string }
|
||||||
|
|
||||||
|
// Lire tous les onglets
|
||||||
|
const metaRes = await fetch(
|
||||||
|
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`,
|
||||||
|
)
|
||||||
|
if (!metaRes.ok) throw new Error(`Sheets metadata error: ${metaRes.status}`)
|
||||||
|
const meta = await metaRes.json()
|
||||||
|
const tabs = (meta.sheets as Array<{ properties: { title: string } }>).map(s => s.properties.title)
|
||||||
|
|
||||||
|
type Entry = { shopifyId: string; tab: string; row: number; stockColLetter: string }
|
||||||
|
const entries: Entry[] = []
|
||||||
|
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const range = encodeURIComponent(`${tab}!A1:BZ`)
|
||||||
|
const res = await fetch(`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`)
|
||||||
|
if (!res.ok) continue
|
||||||
|
const data = await res.json()
|
||||||
|
const rows = (data.values ?? []) as string[][]
|
||||||
|
if (rows.length < 2) continue
|
||||||
|
const headerRowIdx = rows.findIndex(r => (r[0] ?? '').trim() === 'ID')
|
||||||
|
if (headerRowIdx === -1) continue
|
||||||
|
const hdrs = rows[headerRowIdx]
|
||||||
|
if (findColIndex(hdrs, 'Publié') === -1) continue
|
||||||
|
|
||||||
|
const stockCdtIdx = findColIndex(hdrs, 'Stock conditionné')
|
||||||
|
const stockIdx = findColIndex(hdrs, 'Stock (en pièce)') !== -1
|
||||||
|
? findColIndex(hdrs, 'Stock (en pièce)')
|
||||||
|
: findColIndex(hdrs, 'Stock (pièce)')
|
||||||
|
|
||||||
|
// Choisir la colonne cible (Stock conditionné en priorité)
|
||||||
|
const targetColIdx = stockCdtIdx !== -1 ? stockCdtIdx : stockIdx
|
||||||
|
if (targetColIdx === -1) continue
|
||||||
|
|
||||||
|
const colLetter = targetColIdx < 26
|
||||||
|
? String.fromCharCode(65 + targetColIdx)
|
||||||
|
: String.fromCharCode(64 + Math.floor(targetColIdx / 26)) + String.fromCharCode(65 + (targetColIdx % 26))
|
||||||
|
|
||||||
|
const get = (row: string[], i: number) => (i >= 0 ? (row[i] ?? '').trim() : '')
|
||||||
|
|
||||||
|
for (let ri = 0; ri < rows.slice(headerRowIdx + 1).length; ri++) {
|
||||||
|
const row = rows[headerRowIdx + 1 + ri]
|
||||||
|
const shopifyId = get(row, 0)
|
||||||
|
if (!shopifyId || !/^\d+$/.test(shopifyId)) continue
|
||||||
|
entries.push({ shopifyId, tab, row: headerRowIdx + 2 + ri, stockColLetter: colLetter })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send({ type: 'total', total: entries.length })
|
||||||
|
|
||||||
|
let updated = 0
|
||||||
|
let errors = 0
|
||||||
|
const batchByTab: Record<string, Array<{ range: string; values: string[][] }>> = {}
|
||||||
|
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
const { shopifyId, tab, row, stockColLetter } = entries[i]
|
||||||
|
send({ type: 'progress', current: i + 1, total: entries.length })
|
||||||
|
try {
|
||||||
|
await sleep(300)
|
||||||
|
const pRes = await shopifyFetch(`${baseUrl}/products/${shopifyId}.json?fields=variants`, { headers: shopifyHeaders })
|
||||||
|
if (pRes.status === 404) continue
|
||||||
|
if (!pRes.ok) { errors++; continue }
|
||||||
|
|
||||||
|
const pData = await pRes.json() as { product: { variants: Array<{ inventory_item_id: number }> } }
|
||||||
|
const inventoryItemId = pData.product.variants[0]?.inventory_item_id
|
||||||
|
if (!inventoryItemId) { errors++; continue }
|
||||||
|
|
||||||
|
await sleep(300)
|
||||||
|
const invRes = await shopifyFetch(
|
||||||
|
`${baseUrl}/inventory_levels.json?inventory_item_ids=${inventoryItemId}&location_ids=${locationId}`,
|
||||||
|
{ headers: shopifyHeaders }
|
||||||
|
)
|
||||||
|
if (!invRes.ok) { errors++; continue }
|
||||||
|
|
||||||
|
const invData = await invRes.json() as { inventory_levels: Array<{ available: number }> }
|
||||||
|
const available = invData.inventory_levels[0]?.available ?? 0
|
||||||
|
|
||||||
|
if (!batchByTab[tab]) batchByTab[tab] = []
|
||||||
|
batchByTab[tab].push({ range: `${tab}!${stockColLetter}${row}`, values: [[String(available)]] })
|
||||||
|
updated++
|
||||||
|
} catch {
|
||||||
|
errors++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Écrire tous les stocks dans le Sheet en batch par tab
|
||||||
|
const allData = Object.values(batchByTab).flat()
|
||||||
|
if (allData.length > 0) {
|
||||||
|
const batchRes = await fetch(
|
||||||
|
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values:batchUpdate`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${access_token}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ valueInputOption: 'RAW', data: allData }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!batchRes.ok) {
|
||||||
|
const txt = await batchRes.text()
|
||||||
|
throw new Error(`Sheets batchUpdate error: ${batchRes.status} — ${txt}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send({ type: 'done', updated, errors })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
console.error('[sync-stock-reverse] fatal:', message)
|
||||||
|
send({ type: 'done', updated: 0, errors: 1, fatalError: message })
|
||||||
|
} finally {
|
||||||
|
controller.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: { 'Content-Type': 'application/x-ndjson', 'Transfer-Encoding': 'chunked' },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { findColIndex } from '@/lib/creationSheets'
|
||||||
|
import { getConfig } from '@/lib/shopifySync'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
|
||||||
|
const { tab } = await req.json().catch(() => ({})) as { tab?: string }
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
const send = (obj: object) => controller.enqueue(new TextEncoder().encode(JSON.stringify(obj) + '\n'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { baseUrl, headers } = getConfig()
|
||||||
|
|
||||||
|
// Récupérer le location_id une seule fois
|
||||||
|
const locRes = await fetch(`${baseUrl}/locations.json`, { headers })
|
||||||
|
if (!locRes.ok) throw new Error(`Shopify locations error: ${locRes.status}`)
|
||||||
|
const locData = await locRes.json() as { locations: Array<{ id: number }> }
|
||||||
|
const locationId = locData.locations[0]?.id
|
||||||
|
if (!locationId) throw new Error('Aucune location Shopify trouvée')
|
||||||
|
|
||||||
|
// Lire tous les onglets avec un ID Shopify en colonne A
|
||||||
|
const apiKey = process.env.GOOGLE_API_KEY
|
||||||
|
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||||
|
if (!apiKey || !sheetId) throw new Error('GOOGLE_API_KEY et GOOGLE_SHEETS_ID requis')
|
||||||
|
|
||||||
|
let tabs: string[]
|
||||||
|
if (tab) {
|
||||||
|
tabs = [tab]
|
||||||
|
} else {
|
||||||
|
const metaRes = await fetch(
|
||||||
|
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`,
|
||||||
|
)
|
||||||
|
if (!metaRes.ok) throw new Error(`Sheets metadata error: ${metaRes.status}`)
|
||||||
|
const meta = await metaRes.json()
|
||||||
|
tabs = (meta.sheets as Array<{ properties: { title: string } }>).map(s => s.properties.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
type StockEntry = { shopifyId: string; stock: number; title: string; tab: string }
|
||||||
|
const entries: StockEntry[] = []
|
||||||
|
|
||||||
|
for (const t of tabs) {
|
||||||
|
const range = encodeURIComponent(`${t}!A1:BZ`)
|
||||||
|
const res = await fetch(`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`)
|
||||||
|
if (!res.ok) continue
|
||||||
|
const data = await res.json()
|
||||||
|
const rows = (data.values ?? []) as string[][]
|
||||||
|
if (rows.length < 2) continue
|
||||||
|
|
||||||
|
const headerRowIdx = rows.findIndex(r => (r[0] ?? '').trim() === 'ID')
|
||||||
|
if (headerRowIdx === -1) continue
|
||||||
|
const hdrs = rows[headerRowIdx]
|
||||||
|
if (findColIndex(hdrs, 'Publié') === -1) continue
|
||||||
|
|
||||||
|
const idIdx = 0
|
||||||
|
const nomIdx = findColIndex(hdrs, 'Nom')
|
||||||
|
const stockIdx = findColIndex(hdrs, 'Stock (en pièce)') !== -1
|
||||||
|
? findColIndex(hdrs, 'Stock (en pièce)')
|
||||||
|
: findColIndex(hdrs, 'Stock (pièce)')
|
||||||
|
const stockCdtIdx = findColIndex(hdrs, 'Stock conditionné')
|
||||||
|
|
||||||
|
const get = (row: string[], i: number) => (i >= 0 ? (row[i] ?? '').trim() : '')
|
||||||
|
|
||||||
|
for (const row of rows.slice(headerRowIdx + 1)) {
|
||||||
|
const shopifyId = get(row, idIdx)
|
||||||
|
if (!shopifyId || !/^\d+$/.test(shopifyId)) continue
|
||||||
|
const rawStock = get(row, stockCdtIdx) || get(row, stockIdx)
|
||||||
|
const stock = parseInt(rawStock || '0', 10) || 0
|
||||||
|
entries.push({ shopifyId, stock, title: get(row, nomIdx), tab: t })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send({ type: 'total', total: entries.length })
|
||||||
|
|
||||||
|
let updated = 0
|
||||||
|
let errors = 0
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||||
|
|
||||||
|
const shopifyFetch = async (url: string, opts: RequestInit, retries = 3): Promise<Response> => {
|
||||||
|
const res = await fetch(url, opts)
|
||||||
|
if (res.status === 429 && retries > 0) {
|
||||||
|
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '2', 10)
|
||||||
|
await sleep(retryAfter * 1000)
|
||||||
|
return shopifyFetch(url, opts, retries - 1)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
const { shopifyId, stock, title } = entries[i]
|
||||||
|
send({ type: 'progress', current: i + 1, total: entries.length, title })
|
||||||
|
try {
|
||||||
|
await sleep(300) // éviter le rate limiting (2 req/s max)
|
||||||
|
|
||||||
|
const pRes = await shopifyFetch(`${baseUrl}/products/${shopifyId}.json?fields=variants`, { headers })
|
||||||
|
if (pRes.status === 404) continue
|
||||||
|
if (!pRes.ok) {
|
||||||
|
send({ type: 'error', title, message: `Produit ${shopifyId} erreur (${pRes.status})` })
|
||||||
|
errors++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const pData = await pRes.json() as { product: { variants: Array<{ inventory_item_id: number }> } }
|
||||||
|
const inventoryItemId = pData.product.variants[0]?.inventory_item_id
|
||||||
|
if (!inventoryItemId) { errors++; continue }
|
||||||
|
|
||||||
|
await sleep(300)
|
||||||
|
await shopifyFetch(`${baseUrl}/inventory_levels/connect.json`, {
|
||||||
|
method: 'POST', headers,
|
||||||
|
body: JSON.stringify({ location_id: locationId, inventory_item_id: inventoryItemId }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await sleep(300)
|
||||||
|
const setRes = await shopifyFetch(`${baseUrl}/inventory_levels/set.json`, {
|
||||||
|
method: 'POST', headers,
|
||||||
|
body: JSON.stringify({ location_id: locationId, inventory_item_id: inventoryItemId, available: stock }),
|
||||||
|
})
|
||||||
|
if (!setRes.ok) {
|
||||||
|
const txt = await setRes.text()
|
||||||
|
send({ type: 'error', title, message: `stock set failed: ${setRes.status} — ${txt}` })
|
||||||
|
errors++
|
||||||
|
} else {
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
send({ type: 'error', title, message: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||||
|
errors++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send({ type: 'done', updated, errors })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
console.error('[sync-stock] fatal:', message)
|
||||||
|
send({ type: 'done', updated: 0, errors: 1, fatalError: message })
|
||||||
|
} finally {
|
||||||
|
controller.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: { 'Content-Type': 'application/x-ndjson', 'Transfer-Encoding': 'chunked' },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -43,18 +43,36 @@ async function fetchProductsLight(status: 'active' | 'draft'): Promise<LightProd
|
|||||||
return all
|
return all
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchSheetModifiedTime(): Promise<string | null> {
|
||||||
|
const apiKey = process.env.GOOGLE_API_KEY
|
||||||
|
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||||
|
if (!apiKey || !sheetId) return null
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://www.googleapis.com/drive/v3/files/${sheetId}?fields=modifiedTime&key=${apiKey}`,
|
||||||
|
{ cache: 'no-store' },
|
||||||
|
)
|
||||||
|
if (!res.ok) return null
|
||||||
|
const data = await res.json()
|
||||||
|
return (data.modifiedTime as string) ?? null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const session = await getServerSession(authOptions)
|
const session = await getServerSession(authOptions)
|
||||||
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [activeProducts, draftProducts, lastSyncRun] = await Promise.all([
|
const [activeProducts, draftProducts, lastSyncRun, sheetModifiedTime] = await Promise.all([
|
||||||
fetchProductsLight('active'),
|
fetchProductsLight('active'),
|
||||||
fetchProductsLight('draft'),
|
fetchProductsLight('draft'),
|
||||||
prisma.syncRun.findFirst({
|
prisma.syncRun.findFirst({
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: { user: { select: { name: true } } },
|
include: { user: { select: { name: true } } },
|
||||||
}),
|
}),
|
||||||
|
fetchSheetModifiedTime(),
|
||||||
])
|
])
|
||||||
|
|
||||||
const allProducts = [...activeProducts, ...draftProducts]
|
const allProducts = [...activeProducts, ...draftProducts]
|
||||||
@@ -72,6 +90,12 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
// Drift : Sheets modifié après la dernière sync réussie
|
||||||
|
const sheetDrift =
|
||||||
|
sheetModifiedTime !== null &&
|
||||||
|
lastSyncRun !== null &&
|
||||||
|
new Date(sheetModifiedTime) > lastSyncRun.createdAt
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
products: {
|
products: {
|
||||||
active: activeProducts.length,
|
active: activeProducts.length,
|
||||||
@@ -80,6 +104,8 @@ export async function GET() {
|
|||||||
withoutImages,
|
withoutImages,
|
||||||
},
|
},
|
||||||
lastSync,
|
lastSync,
|
||||||
|
sheetDrift,
|
||||||
|
sheetModifiedAt: sheetModifiedTime,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return new Response('Non authentifié', { status: 401 })
|
||||||
|
|
||||||
|
const url = req.nextUrl.searchParams.get('url')
|
||||||
|
if (!url) return new Response('url requis', { status: 400 })
|
||||||
|
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) return new Response('Fetch failed', { status: 502 })
|
||||||
|
|
||||||
|
const buffer = await res.arrayBuffer()
|
||||||
|
const contentType = res.headers.get('content-type') ?? 'image/jpeg'
|
||||||
|
return new Response(buffer, { headers: { 'Content-Type': contentType } })
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json(result)
|
return NextResponse.json(result)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
return NextResponse.json({ error: message }, { status: 500 })
|
const status = (err as { status?: number }).status === 404 ? 404 : 500
|
||||||
|
return NextResponse.json({ error: message }, { status })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { readFile } from 'fs/promises'
|
||||||
|
import { getTemplateImagePath, listTemplates } from '@/lib/mockupTemplates'
|
||||||
|
import type { MockupGenerateRequest } from '@/types/mockup'
|
||||||
|
|
||||||
|
function buildPrompt(req: MockupGenerateRequest): string {
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
const context = familyContext[req.family] ?? 'product installed in an appropriate room setting'
|
||||||
|
const dimClause = req.dimensions
|
||||||
|
? ` The product dimensions are exactly ${req.dimensions} — preserve these exact proportions, do NOT resize or distort the product to fit the scene.`
|
||||||
|
: ''
|
||||||
|
const instructionClause = req.instruction ? ` Additional instruction: ${req.instruction}.` : ''
|
||||||
|
|
||||||
|
return `Replace the product in the reference scene with the provided product image (${req.productName}). Show the ${context}.${dimClause} Keep the product's exact design, texture, colors and proportions identical to the provided image. Maintain realistic perspective and lighting consistent with the scene.${instructionClause}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return Response.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
|
||||||
|
const apiKey = process.env.OPENAI_API_KEY
|
||||||
|
if (!apiKey) return Response.json({ error: 'OPENAI_API_KEY manquant' }, { status: 500 })
|
||||||
|
|
||||||
|
const body = await req.json() as MockupGenerateRequest
|
||||||
|
|
||||||
|
const templates = await listTemplates()
|
||||||
|
const template = templates.find(t => t.id === body.templateId)
|
||||||
|
if (!template) return Response.json({ error: 'Template introuvable' }, { status: 404 })
|
||||||
|
|
||||||
|
const templateBuffer = await readFile(getTemplateImagePath(template.filename))
|
||||||
|
const templateExt = template.filename.split('.').pop() ?? 'jpg'
|
||||||
|
const templateMime = templateExt === 'png' ? 'image/png' : 'image/jpeg'
|
||||||
|
|
||||||
|
const productBuffer = Buffer.from(body.productImageBase64, 'base64')
|
||||||
|
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('model', 'gpt-image-1')
|
||||||
|
form.append('prompt', buildPrompt(body))
|
||||||
|
form.append('n', '1')
|
||||||
|
form.append('size', '1024x1024')
|
||||||
|
form.append('image[]', new Blob([templateBuffer], { type: templateMime }), `template.${templateExt}`)
|
||||||
|
form.append('image[]', new Blob([productBuffer], { type: 'image/png' }), 'product.png')
|
||||||
|
|
||||||
|
const openaiRes = await fetch('https://api.openai.com/v1/images/edits', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!openaiRes.ok) {
|
||||||
|
const err = await openaiRes.text()
|
||||||
|
return Response.json({ error: `OpenAI error: ${err}` }, { status: 502 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await openaiRes.json() as { data: Array<{ b64_json?: string }> }
|
||||||
|
const b64 = data.data?.[0]?.b64_json
|
||||||
|
if (!b64) return Response.json({ error: 'Pas de résultat OpenAI' }, { status: 502 })
|
||||||
|
|
||||||
|
return Response.json({ imageBase64: b64 })
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
|
||||||
|
const API_VERSION = '2024-01'
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest, { params }: { params: { shopifyId: string } }) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return Response.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
|
||||||
|
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||||
|
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||||
|
if (!domain || !token) return Response.json({ error: 'Config Shopify manquante' }, { status: 500 })
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`https://${domain}/admin/api/${API_VERSION}/products/${params.shopifyId}/metafields.json`,
|
||||||
|
{ headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' } },
|
||||||
|
)
|
||||||
|
if (!res.ok) return Response.json({ error: `Shopify error: ${res.status}` }, { status: 502 })
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
const metafields = (data.metafields ?? []) as Array<{
|
||||||
|
namespace: string
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
type: string
|
||||||
|
}>
|
||||||
|
|
||||||
|
return Response.json({ metafields })
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { deleteTemplate } from '@/lib/mockupTemplates'
|
||||||
|
|
||||||
|
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return Response.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
await deleteTemplate(params.id)
|
||||||
|
return Response.json({ ok: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { readFile } from 'fs/promises'
|
||||||
|
import { getTemplateImagePath } from '@/lib/mockupTemplates'
|
||||||
|
|
||||||
|
export async function GET(_req: Request, { params }: { params: { filename: string } }) {
|
||||||
|
try {
|
||||||
|
const filePath = getTemplateImagePath(params.filename)
|
||||||
|
const buffer = await readFile(filePath)
|
||||||
|
const ext = params.filename.split('.').pop() ?? 'jpg'
|
||||||
|
const contentType = ext === 'png' ? 'image/png' : 'image/jpeg'
|
||||||
|
return new Response(buffer, { headers: { 'Content-Type': contentType } })
|
||||||
|
} catch {
|
||||||
|
return new Response('Not found', { status: 404 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { listTemplates, saveTemplate } from '@/lib/mockupTemplates'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return Response.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
const templates = await listTemplates()
|
||||||
|
return Response.json(templates)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return Response.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
|
||||||
|
const form = await req.formData()
|
||||||
|
const name = form.get('name') 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 imageFile = form.get('image') as File
|
||||||
|
|
||||||
|
if (!name || !family || !imageFile) {
|
||||||
|
return Response.json({ error: 'name, family et image requis' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await imageFile.arrayBuffer())
|
||||||
|
const ext = imageFile.name.split('.').pop() ?? 'jpg'
|
||||||
|
const template = await saveTemplate({ name, family, placement, description, filename: '' }, buffer, ext)
|
||||||
|
return Response.json(template)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { deleteShopifyProduct } from '@/lib/shopifySync'
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: { id: string } },
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteShopifyProduct(params.id)
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
|
||||||
|
import type { ShopifyProductFull } from '@/types/sync'
|
||||||
|
|
||||||
|
export interface DuplicateGroup {
|
||||||
|
ref: string
|
||||||
|
products: Array<{
|
||||||
|
shopifyId: string
|
||||||
|
title: string
|
||||||
|
status: string
|
||||||
|
handle: string
|
||||||
|
shopifyUrl: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const domain = process.env.SHOPIFY_STORE_DOMAIN ?? ''
|
||||||
|
const all = await fetchAllShopifyProducts()
|
||||||
|
|
||||||
|
// Grouper par ref (SKU)
|
||||||
|
const byRef = new Map<string, ShopifyProductFull[]>()
|
||||||
|
for (const p of all) {
|
||||||
|
const key = p.ref.trim().toLowerCase()
|
||||||
|
if (!byRef.has(key)) byRef.set(key, [])
|
||||||
|
byRef.get(key)!.push(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: DuplicateGroup[] = []
|
||||||
|
for (const products of Array.from(byRef.values())) {
|
||||||
|
if (products.length < 2) continue
|
||||||
|
// Trier : le plus ancien (ID numérique le plus petit) en premier
|
||||||
|
products.sort((a: ShopifyProductFull, b: ShopifyProductFull) => Number(a.shopifyId) - Number(b.shopifyId))
|
||||||
|
groups.push({
|
||||||
|
ref: products[0].ref,
|
||||||
|
products: products.map((p: ShopifyProductFull) => ({
|
||||||
|
shopifyId: p.shopifyId,
|
||||||
|
title: p.title,
|
||||||
|
status: p.status,
|
||||||
|
handle: p.handle,
|
||||||
|
shopifyUrl: `https://${domain}/admin/products/${p.shopifyId}`,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trier les groupes par ref
|
||||||
|
groups.sort((a, b) => a.ref.localeCompare(b.ref))
|
||||||
|
|
||||||
|
return NextResponse.json({ groups, total: groups.length })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ export async function GET(req: NextRequest): Promise<NextResponse<ProductListRes
|
|||||||
stock: p.stock,
|
stock: p.stock,
|
||||||
status: p.status as CatalogProduct['status'],
|
status: p.status as CatalogProduct['status'],
|
||||||
shopifyUrl: `https://${domain}/admin/products/${p.shopifyId}`,
|
shopifyUrl: `https://${domain}/admin/products/${p.shopifyId}`,
|
||||||
|
images: p.images ?? [],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
if (statusFilter !== 'all') {
|
if (statusFilter !== 'all') {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const API_VERSION = '2024-01'
|
||||||
|
|
||||||
|
// GET /api/products/search-list?q=peinture
|
||||||
|
// Retourne jusqu'à 10 produits correspondant à la recherche (titre ou SKU)
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const q = new URL(req.url).searchParams.get('q')?.trim()
|
||||||
|
if (!q || q.length < 2) return NextResponse.json([])
|
||||||
|
|
||||||
|
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||||
|
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||||
|
if (!domain || !token) return NextResponse.json([], { status: 500 })
|
||||||
|
|
||||||
|
const headers = { 'X-Shopify-Access-Token': token }
|
||||||
|
const baseUrl = `https://${domain}/admin/api/${API_VERSION}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Recherche par titre
|
||||||
|
const res = await fetch(
|
||||||
|
`${baseUrl}/products.json?title=${encodeURIComponent(q)}&limit=10&fields=id,title,variants`,
|
||||||
|
{ headers },
|
||||||
|
)
|
||||||
|
if (!res.ok) return NextResponse.json([])
|
||||||
|
const data = await res.json()
|
||||||
|
const products = (data.products ?? []) as Array<{
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
variants: Array<{ sku: string }>
|
||||||
|
}>
|
||||||
|
|
||||||
|
const results = products.map(p => ({
|
||||||
|
shopifyId: String(p.id),
|
||||||
|
title: p.title,
|
||||||
|
sku: p.variants[0]?.sku ?? '',
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json(results)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json([])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,8 @@ export async function GET(req: NextRequest) {
|
|||||||
s => s.properties.title,
|
s => s.properties.title,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let bestMatch: { shopifyId: string; title: string; ugsLength: number } | null = null
|
||||||
|
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
const range = encodeURIComponent(`${tab}!A2:D`)
|
const range = encodeURIComponent(`${tab}!A2:D`)
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
@@ -42,24 +44,27 @@ export async function GET(req: NextRequest) {
|
|||||||
const rows = (data.values ?? []) as string[][]
|
const rows = (data.values ?? []) as string[][]
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const shopifyId = (row[0] ?? '').trim() // col A = ID Shopify
|
const shopifyId = (row[0] ?? '').trim()
|
||||||
// col C = UGS (index 2 car on commence à A)
|
|
||||||
const ugs = (row[2] ?? '').trim()
|
const ugs = (row[2] ?? '').trim()
|
||||||
|
|
||||||
// L'UGS doit apparaître comme mot entier dans le nom de fichier
|
|
||||||
// (entouré de séparateurs ou en début/fin) — ex: "dessus-30827-vue" ✓, "308270" ✗
|
|
||||||
const refLower = ref.toLowerCase()
|
const refLower = ref.toLowerCase()
|
||||||
const ugsLower = ugs.toLowerCase()
|
const ugsLower = ugs.toLowerCase()
|
||||||
const wordBoundary = new RegExp(`(^|[^a-z0-9])${ugsLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`)
|
const wordBoundary = new RegExp(`(^|[^a-z0-9])${ugsLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`)
|
||||||
const isMatch = ugs && shopifyId && wordBoundary.test(refLower)
|
// Ne requiert pas shopifyId pour le match UGS — ID peut être absent si produit externe
|
||||||
|
const isMatch = ugs && wordBoundary.test(refLower)
|
||||||
|
|
||||||
if (isMatch) {
|
if (isMatch && shopifyId) {
|
||||||
const title = (row[3] ?? '').trim() // col D = Nom
|
const title = (row[3] ?? '').trim()
|
||||||
return NextResponse.json({ found: true, shopifyId, title })
|
if (!bestMatch || ugs.length > bestMatch.ugsLength) {
|
||||||
|
bestMatch = { shopifyId, title, ugsLength: ugs.length }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (bestMatch) {
|
||||||
|
return NextResponse.json({ found: true, shopifyId: bestMatch.shopifyId, title: bestMatch.title })
|
||||||
|
}
|
||||||
return NextResponse.json({ found: false })
|
return NextResponse.json({ found: false })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur'
|
const message = err instanceof Error ? err.message : 'Erreur'
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await searchProductByRef(ref.trim())
|
const result = await searchProductByRef(ref.trim())
|
||||||
|
console.log(`[search] ref="${ref.trim()}" → found=${result.found} id=${(result as {shopifyId?: string}).shopifyId ?? '-'}`)
|
||||||
return NextResponse.json(result)
|
return NextResponse.json(result)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
console.error(`[search] ref="${ref.trim()}" → ERROR: ${message}`)
|
||||||
return NextResponse.json({ error: message }, { status: 500 })
|
return NextResponse.json({ error: message }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
|
||||||
|
const apiKey = process.env.GOOGLE_API_KEY
|
||||||
|
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||||
|
if (!apiKey || !sheetId) {
|
||||||
|
return NextResponse.json({ error: 'GOOGLE_API_KEY et GOOGLE_SHEETS_ID requis' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`
|
||||||
|
const res = await fetch(url, { cache: 'no-store' })
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text()
|
||||||
|
throw new Error(`Google Sheets API error: ${res.status} — ${err}`)
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
const tabs: string[] = (data.sheets as Array<{ properties: { title: string } }>).map(
|
||||||
|
(s) => s.properties.title,
|
||||||
|
)
|
||||||
|
return NextResponse.json({ tabs })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,15 +3,15 @@ import { readSheetProducts } from '@/lib/googleSheets'
|
|||||||
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
|
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
|
||||||
import { computeDiff } from '@/lib/syncDiff'
|
import { computeDiff } from '@/lib/syncDiff'
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
export async function GET(req: NextRequest) {
|
||||||
export async function GET(_req: NextRequest) {
|
const tab = new URL(req.url).searchParams.get('tab') ?? undefined
|
||||||
try {
|
try {
|
||||||
const [sheetProducts, shopifyProducts] = await Promise.all([
|
const [sheetProducts, shopifyProducts] = await Promise.all([
|
||||||
readSheetProducts(),
|
readSheetProducts(tab),
|
||||||
fetchAllShopifyProducts(),
|
fetchAllShopifyProducts(),
|
||||||
])
|
])
|
||||||
|
|
||||||
const diff = computeDiff(sheetProducts, shopifyProducts)
|
const diff = computeDiff(sheetProducts, shopifyProducts, tab)
|
||||||
return NextResponse.json(diff)
|
return NextResponse.json(diff)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const API_VERSION = '2024-01'
|
||||||
|
|
||||||
|
function getShopifyConfig() {
|
||||||
|
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||||
|
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||||
|
if (!domain || !token) throw new Error('Config Shopify manquante')
|
||||||
|
return {
|
||||||
|
baseUrl: `https://${domain}/admin/api/${API_VERSION}`,
|
||||||
|
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BannerProductData {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
price: string
|
||||||
|
compareAtPrice: string | null
|
||||||
|
sku: string
|
||||||
|
imageUrl: string | null
|
||||||
|
allImages: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const ref = searchParams.get('ref')
|
||||||
|
const id = searchParams.get('id')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { baseUrl, headers } = getShopifyConfig()
|
||||||
|
|
||||||
|
let product: Record<string, unknown> | null = null
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
const res = await fetch(`${baseUrl}/products/${id}.json?fields=id,title,variants,images`, { headers })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
product = data.product
|
||||||
|
}
|
||||||
|
} else if (ref) {
|
||||||
|
// Recherche par SKU via GraphQL
|
||||||
|
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||||
|
const gqlRes = await fetch(`https://${domain}/admin/api/${API_VERSION}/graphql.json`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `{ productVariants(first: 5, query: "sku:${ref.replace(/"/g, '')}") {
|
||||||
|
edges { node { sku product { id legacyResourceId title } } }
|
||||||
|
} }`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const gqlData = await gqlRes.json()
|
||||||
|
const edges = gqlData?.data?.productVariants?.edges ?? []
|
||||||
|
const match = edges.find((e: { node: { sku: string } }) =>
|
||||||
|
e.node.sku.toLowerCase() === ref.toLowerCase()
|
||||||
|
)
|
||||||
|
if (match) {
|
||||||
|
const legacyId = match.node.product.legacyResourceId
|
||||||
|
const res = await fetch(`${baseUrl}/products/${legacyId}.json?fields=id,title,variants,images`, { headers })
|
||||||
|
if (res.ok) product = (await res.json()).product
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!product) return NextResponse.json({ error: 'Produit introuvable' }, { status: 404 })
|
||||||
|
|
||||||
|
const variants = product.variants as Array<{ price: string; compare_at_price: string | null; sku: string }>
|
||||||
|
const images = product.images as Array<{ src: string }>
|
||||||
|
const variant = variants[0]
|
||||||
|
|
||||||
|
const result: BannerProductData = {
|
||||||
|
id: String(product.id),
|
||||||
|
title: product.title as string,
|
||||||
|
price: variant?.price ?? '',
|
||||||
|
compareAtPrice: variant?.compare_at_price ?? null,
|
||||||
|
sku: variant?.sku ?? '',
|
||||||
|
imageUrl: images[0]?.src ?? null,
|
||||||
|
allImages: images.map(i => i.src),
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(result)
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: (err as Error).message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import {
|
||||||
|
getActiveThemeId,
|
||||||
|
getHomepageTemplate,
|
||||||
|
extractSlideshow,
|
||||||
|
applySlideshow,
|
||||||
|
saveHomepageTemplate,
|
||||||
|
} from '@/lib/shopifyTheme'
|
||||||
|
import type { SlideshowSection } from '@/lib/shopifyTheme'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const themeId = await getActiveThemeId()
|
||||||
|
const template = await getHomepageTemplate(themeId)
|
||||||
|
const slideshow = extractSlideshow(template)
|
||||||
|
if (!slideshow) return NextResponse.json({ error: 'Section slideshow-custom introuvable' }, { status: 404 })
|
||||||
|
return NextResponse.json({ slideshow, themeId })
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: (err as Error).message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json() as { slideshow: SlideshowSection; themeId: string }
|
||||||
|
const template = await getHomepageTemplate(body.themeId)
|
||||||
|
const updated = applySlideshow(template, body.slideshow)
|
||||||
|
await saveHomepageTemplate(body.themeId, updated)
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: (err as Error).message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { uploadFileToShopify } from '@/lib/shopifyTheme'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { imageBase64, filename } = await req.json() as { imageBase64: string; filename: string }
|
||||||
|
if (!imageBase64 || !filename) {
|
||||||
|
return NextResponse.json({ error: 'imageBase64 et filename requis' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const shopifyRef = await uploadFileToShopify(imageBase64, filename)
|
||||||
|
return NextResponse.json({ shopifyRef })
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: (err as Error).message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { COLOR_SCHEMES, renderBanner } from '@/lib/client/bannerRenderer'
|
||||||
|
import type { BannerConfig, ColorScheme } from '@/lib/client/bannerRenderer'
|
||||||
|
import type { BannerProductData } from '@/app/api/theme/product-banner-data/route'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onApply: (shopifyRef: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchSuggestion {
|
||||||
|
shopifyId: string
|
||||||
|
title: string
|
||||||
|
sku: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(price: string): string {
|
||||||
|
if (!price) return ''
|
||||||
|
const n = parseFloat(price)
|
||||||
|
return isNaN(n) ? price : `${Math.round(n)} €`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 function BannerBuilder({ onApply, onClose }: Props) {
|
||||||
|
// Combobox produit
|
||||||
|
const [inputValue, setInputValue] = useState('')
|
||||||
|
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([])
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||||
|
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
|
||||||
|
const [loadingProduct, setLoadingProduct] = useState(false)
|
||||||
|
const [product, setProduct] = useState<BannerProductData | null>(null)
|
||||||
|
const [searchError, setSearchError] = useState<string | null>(null)
|
||||||
|
const comboRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Banner config
|
||||||
|
const [scheme, setScheme] = useState<ColorScheme>('dark-blue')
|
||||||
|
const [promoLabel, setPromoLabel] = useState('Destockage - Durée limitée')
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [price, setPrice] = useState('')
|
||||||
|
const [comparePrice, setComparePrice] = useState('')
|
||||||
|
const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>(null)
|
||||||
|
const [backgroundBlob, setBackgroundBlob] = useState<Blob | null>(null)
|
||||||
|
const [backgroundPreview, setBackgroundPreview] = useState<string | null>(null)
|
||||||
|
const bgFileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Generation
|
||||||
|
const [rendering, setRendering] = useState(false)
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const previewRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
// Ferme les suggestions si clic extérieur
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (comboRef.current && !comboRef.current.contains(e.target as Node)) {
|
||||||
|
setShowSuggestions(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Recherche des suggestions (debounce 300ms)
|
||||||
|
useEffect(() => {
|
||||||
|
const q = inputValue.trim()
|
||||||
|
if (q.length < 2) { setSuggestions([]); setShowSuggestions(false); return }
|
||||||
|
const t = setTimeout(async () => {
|
||||||
|
setLoadingSuggestions(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/products/search-list?q=${encodeURIComponent(q)}`)
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json() as SearchSuggestion[]
|
||||||
|
setSuggestions(data)
|
||||||
|
setShowSuggestions(data.length > 0)
|
||||||
|
} catch { /* silencieux */ } finally {
|
||||||
|
setLoadingSuggestions(false)
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [inputValue])
|
||||||
|
|
||||||
|
const selectSuggestion = async (s: SearchSuggestion) => {
|
||||||
|
setInputValue(s.title)
|
||||||
|
setShowSuggestions(false)
|
||||||
|
setSuggestions([])
|
||||||
|
setLoadingProduct(true)
|
||||||
|
setSearchError(null)
|
||||||
|
setProduct(null)
|
||||||
|
setPreviewUrl(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/theme/product-banner-data?id=${s.shopifyId}`)
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error)
|
||||||
|
const p = data as BannerProductData
|
||||||
|
setProduct(p)
|
||||||
|
setTitle(p.title.toUpperCase())
|
||||||
|
setPrice(formatPrice(p.price))
|
||||||
|
setComparePrice(p.compareAtPrice ? formatPrice(p.compareAtPrice) : '')
|
||||||
|
setSelectedImageUrl(p.imageUrl)
|
||||||
|
} catch (e) {
|
||||||
|
setSearchError((e as Error).message)
|
||||||
|
} finally {
|
||||||
|
setLoadingProduct(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBgFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setBackgroundBlob(file)
|
||||||
|
const url = URL.createObjectURL(file)
|
||||||
|
if (backgroundPreview) URL.revokeObjectURL(backgroundPreview)
|
||||||
|
setBackgroundPreview(url)
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeBg = () => {
|
||||||
|
if (backgroundPreview) URL.revokeObjectURL(backgroundPreview)
|
||||||
|
setBackgroundBlob(null)
|
||||||
|
setBackgroundPreview(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConfig = useCallback((): BannerConfig => ({
|
||||||
|
scheme,
|
||||||
|
promoLabel,
|
||||||
|
title,
|
||||||
|
price,
|
||||||
|
comparePrice,
|
||||||
|
productImageUrl: selectedImageUrl,
|
||||||
|
backgroundImageBlob: backgroundBlob,
|
||||||
|
}), [scheme, promoLabel, title, price, comparePrice, selectedImageUrl, backgroundBlob])
|
||||||
|
|
||||||
|
const generate = useCallback(async () => {
|
||||||
|
setRendering(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const blob = await renderBanner(getConfig())
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
if (previewRef.current) URL.revokeObjectURL(previewRef.current)
|
||||||
|
previewRef.current = url
|
||||||
|
setPreviewUrl(url)
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message)
|
||||||
|
} finally {
|
||||||
|
setRendering(false)
|
||||||
|
}
|
||||||
|
}, [getConfig])
|
||||||
|
|
||||||
|
// Régénère automatiquement quand les paramètres changent (debounce 600ms)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!title && !product) return
|
||||||
|
const t = setTimeout(() => { generate() }, 600)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [scheme, promoLabel, title, price, comparePrice, selectedImageUrl, backgroundBlob]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const applyToSlide = async () => {
|
||||||
|
if (!previewRef.current) return
|
||||||
|
setUploading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
// Convertit l'objectURL en blob
|
||||||
|
const res = await fetch(previewRef.current)
|
||||||
|
const blob = await res.blob()
|
||||||
|
const base64 = await blobToBase64(blob)
|
||||||
|
const filename = `banniere-${(product?.sku ?? 'custom').toLowerCase().replace(/[^a-z0-9]/g, '-')}-${Date.now()}.jpg`
|
||||||
|
|
||||||
|
const uploadRes = await fetch('/api/theme/upload-file', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ imageBase64: base64, filename }),
|
||||||
|
})
|
||||||
|
const uploadData = await uploadRes.json()
|
||||||
|
if (!uploadRes.ok) throw new Error(uploadData.error)
|
||||||
|
onApply(uploadData.shopifyRef)
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message)
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemeEntries = Object.entries(COLOR_SCHEMES) as [ColorScheme, typeof COLOR_SCHEMES[ColorScheme]][]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||||
|
>
|
||||||
|
<div className="bg-slate-800 border border-slate-700 rounded-xl w-full max-w-5xl max-h-[95vh] flex flex-col">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-slate-700">
|
||||||
|
<h2 className="text-white font-semibold text-sm">Créer une bannière</h2>
|
||||||
|
<button type="button" onClick={onClose} className="text-gray-400 hover:text-white text-lg leading-none">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* ── Panneau gauche : configuration ─────────────────────────────── */}
|
||||||
|
<div className="w-80 flex-shrink-0 overflow-y-auto border-r border-slate-700 p-4 space-y-5">
|
||||||
|
|
||||||
|
{/* Recherche produit — combobox */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">Produit</h3>
|
||||||
|
<div className="relative" ref={comboRef}>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={e => { setInputValue(e.target.value); setProduct(null) }}
|
||||||
|
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
|
||||||
|
placeholder="Rechercher par nom ou référence…"
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 pr-7 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 text-xs pointer-events-none">
|
||||||
|
{loadingSuggestions || loadingProduct ? '⏳' : '🔍'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{showSuggestions && suggestions.length > 0 && (
|
||||||
|
<div className="absolute z-20 w-full mt-1 bg-slate-700 border border-slate-600 rounded shadow-xl max-h-52 overflow-y-auto">
|
||||||
|
{suggestions.map(s => (
|
||||||
|
<button
|
||||||
|
key={s.shopifyId}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={e => { e.preventDefault(); selectSuggestion(s) }}
|
||||||
|
className="w-full text-left px-3 py-2 hover:bg-slate-600 border-b border-slate-600 last:border-0"
|
||||||
|
>
|
||||||
|
<p className="text-xs text-white truncate">{s.title}</p>
|
||||||
|
<p className="text-xs text-slate-400 font-mono">{s.sku}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{searchError && <p className="text-xs text-red-400 mt-1">{searchError}</p>}
|
||||||
|
{product && (
|
||||||
|
<div className="mt-2 p-2 bg-slate-700 rounded text-xs">
|
||||||
|
<p className="text-white font-medium truncate">{product.title}</p>
|
||||||
|
<p className="text-slate-400">{product.sku} · {formatPrice(product.price)}</p>
|
||||||
|
{product.allImages.length > 1 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-slate-400 mb-1">Choisir l'image :</p>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{product.allImages.map((url, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedImageUrl(url)}
|
||||||
|
className={`rounded overflow-hidden border-2 transition-all ${selectedImageUrl === url ? 'border-indigo-500' : 'border-slate-600'}`}
|
||||||
|
>
|
||||||
|
<img src={url} alt={`img ${i}`} className="w-12 h-12 object-cover" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Image de fond */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">Image de fond</h3>
|
||||||
|
{backgroundPreview ? (
|
||||||
|
<div className="relative rounded overflow-hidden border border-slate-600">
|
||||||
|
<img src={backgroundPreview} alt="Fond" className="w-full h-24 object-cover" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={removeBg}
|
||||||
|
className="absolute top-1 right-1 bg-black/70 hover:bg-red-900 text-white text-xs px-1.5 py-0.5 rounded"
|
||||||
|
>
|
||||||
|
✕ Retirer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => bgFileRef.current?.click()}
|
||||||
|
className="w-full py-3 border-2 border-dashed border-slate-600 hover:border-indigo-500 rounded text-xs text-slate-400 hover:text-indigo-400 transition-colors"
|
||||||
|
>
|
||||||
|
+ Uploader une image de fond
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input ref={bgFileRef} type="file" accept="image/*" className="hidden" onChange={handleBgFile} />
|
||||||
|
{backgroundPreview && (
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Un overlay sombre est appliqué automatiquement pour la lisibilité du texte.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Thème couleur */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">
|
||||||
|
{backgroundPreview ? 'Couleur du texte' : 'Thème couleur'}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{schemeEntries.map(([key, s]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setScheme(key)}
|
||||||
|
style={{ background: s.bg, borderColor: scheme === key ? '#6366f1' : '#475569' }}
|
||||||
|
className={`px-3 py-2 rounded text-xs border-2 font-medium transition-all`}
|
||||||
|
>
|
||||||
|
<span style={{ color: s.titleColor }}>{s.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Textes */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">Textes</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-0.5">Badge promotionnel</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={promoLabel}
|
||||||
|
onChange={e => setPromoLabel(e.target.value)}
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-0.5">Titre produit</label>
|
||||||
|
<textarea
|
||||||
|
value={title}
|
||||||
|
onChange={e => setTitle(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-xs text-slate-400 mb-0.5">Prix affiché</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={price}
|
||||||
|
onChange={e => setPrice(e.target.value)}
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-xs text-slate-400 mb-0.5">Prix barré</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={comparePrice}
|
||||||
|
onChange={e => setComparePrice(e.target.value)}
|
||||||
|
placeholder="optionnel"
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Panneau droit : aperçu ──────────────────────────────────────── */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<div className="flex-1 flex items-center justify-center bg-slate-900 p-4 overflow-auto">
|
||||||
|
{rendering && (
|
||||||
|
<p className="text-slate-500 text-sm">Génération en cours…</p>
|
||||||
|
)}
|
||||||
|
{!rendering && previewUrl && (
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Aperçu bannière"
|
||||||
|
className="max-w-full rounded shadow-lg"
|
||||||
|
style={{ maxHeight: '60vh' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!rendering && !previewUrl && (
|
||||||
|
<div className="text-center text-slate-600">
|
||||||
|
<p className="text-4xl mb-2">🎨</p>
|
||||||
|
<p className="text-sm">Recherchez un produit pour commencer</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer actions */}
|
||||||
|
<div className="p-4 border-t border-slate-700 flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
<span className="text-xs text-slate-500">Format : 1500 × 500 px</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={generate}
|
||||||
|
disabled={rendering || (!title && !product)}
|
||||||
|
className="px-4 py-2 text-xs bg-slate-700 hover:bg-slate-600 text-white rounded font-medium disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{rendering ? '⏳ Rendu…' : '↺ Régénérer'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={applyToSlide}
|
||||||
|
disabled={!previewUrl || uploading || rendering}
|
||||||
|
className="px-4 py-2 text-xs bg-green-700 hover:bg-green-600 text-white rounded font-medium disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{uploading ? '⬆️ Upload…' : '✓ Appliquer à la slide'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
'use client'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSelect: (tab: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FamilySelector({ onSelect }: Props) {
|
||||||
|
const [tabs, setTabs] = useState<string[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/sheets/tabs')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.error) throw new Error(data.error)
|
||||||
|
setTabs(data.tabs)
|
||||||
|
})
|
||||||
|
.catch((e: Error) => setError(e.message))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-slate-400 text-sm py-8 justify-center">
|
||||||
|
<span className="animate-spin">⏳</span> Chargement des familles…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">
|
||||||
|
Impossible de charger les familles : {error}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-slate-300 text-sm">
|
||||||
|
Choisissez une famille (onglet Google Sheets) pour travailler uniquement sur celle-ci.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => onSelect(tab)}
|
||||||
|
className="flex items-center gap-2 px-4 py-3 bg-slate-700 hover:bg-indigo-700 border border-slate-600 hover:border-indigo-500 rounded-xl text-sm text-slate-200 hover:text-white font-medium transition-all text-left group"
|
||||||
|
>
|
||||||
|
<span className="text-base group-hover:scale-110 transition-transform">📂</span>
|
||||||
|
<span className="truncate">{tab}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export function CreationProgress({ events, done }: Props) {
|
|||||||
const lastProgress = [...events].reverse().find(e => e.type === 'progress') as Extract<CreationStreamEvent, { type: 'progress' }> | undefined
|
const lastProgress = [...events].reverse().find(e => e.type === 'progress') as Extract<CreationStreamEvent, { type: 'progress' }> | undefined
|
||||||
const doneEvent = events.find(e => e.type === 'done') as Extract<CreationStreamEvent, { type: 'done' }> | undefined
|
const doneEvent = events.find(e => e.type === 'done') as Extract<CreationStreamEvent, { type: 'done' }> | undefined
|
||||||
const errors = events.filter(e => e.type === 'error') as Array<Extract<CreationStreamEvent, { type: 'error' }>>
|
const errors = events.filter(e => e.type === 'error') as Array<Extract<CreationStreamEvent, { type: 'error' }>>
|
||||||
|
const skipped = events.filter(e => e.type === 'skipped') as Array<Extract<CreationStreamEvent, { type: 'skipped' }>>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -26,6 +27,17 @@ export function CreationProgress({ events, done }: Props) {
|
|||||||
<p className="text-xs text-gray-400 mt-1 truncate">{lastProgress.tab} — {lastProgress.title}</p>
|
<p className="text-xs text-gray-400 mt-1 truncate">{lastProgress.tab} — {lastProgress.title}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{skipped.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{skipped.map((e, i) => (
|
||||||
|
<div key={i} className="flex gap-2 p-2 bg-amber-900/20 border border-amber-800/40 rounded text-xs text-amber-300">
|
||||||
|
<span>⚠️</span>
|
||||||
|
<span className="font-medium">{e.title}</span>
|
||||||
|
<span className="text-amber-500">— SKU {e.sku} déjà présent dans Shopify, ignoré</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{errors.length > 0 && (
|
{errors.length > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{errors.map((e, i) => (
|
{errors.map((e, i) => (
|
||||||
@@ -43,6 +55,7 @@ export function CreationProgress({ events, done }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-300 mt-1">
|
<p className="text-sm text-gray-300 mt-1">
|
||||||
{doneEvent.summary.created} créé{doneEvent.summary.created > 1 ? 's' : ''}
|
{doneEvent.summary.created} créé{doneEvent.summary.created > 1 ? 's' : ''}
|
||||||
|
{doneEvent.summary.skipped > 0 && ` — ${doneEvent.summary.skipped} déjà existant${doneEvent.summary.skipped > 1 ? 's' : ''} (ignorés)`}
|
||||||
{doneEvent.summary.errors > 0 && ` — ${doneEvent.summary.errors} erreur${doneEvent.summary.errors > 1 ? 's' : ''}`}
|
{doneEvent.summary.errors > 0 && ` — ${doneEvent.summary.errors} erreur${doneEvent.summary.errors > 1 ? 's' : ''}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import type { MetafieldDefinition, MetafieldResolution } from '@/types/creation'
|
import type { MetafieldDefinition, MetafieldResolution } from '@/types/creation'
|
||||||
import { normalizeMetafieldKey } from '@/lib/shopifyMetafields'
|
import { normalizeMetafieldKey } from '@/lib/shopifyMetafields'
|
||||||
|
|
||||||
@@ -15,25 +15,31 @@ export function MetafieldResolver({ specificColumns, onResolved }: Props) {
|
|||||||
const [resolutions, setResolutions] = useState<Record<string, MetafieldResolution>>({})
|
const [resolutions, setResolutions] = useState<Record<string, MetafieldResolution>>({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [searches, setSearches] = useState<Record<string, string>>({})
|
||||||
|
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (specificColumns.length === 0) { setLoading(false); onResolved([]); return }
|
if (specificColumns.length === 0) { setLoading(false); onResolved([]); return }
|
||||||
const params = specificColumns.map(c => encodeURIComponent(c)).join(',')
|
const params = specificColumns.map(c => encodeURIComponent(c)).join(',')
|
||||||
fetch(`/api/creation/metafields?columns=${params}`)
|
Promise.all([
|
||||||
.then(r => r.json())
|
fetch(`/api/creation/metafields?columns=${params}`).then(r => r.json()),
|
||||||
.then(data => {
|
fetch('/api/creation/resolutions').then(r => r.json()),
|
||||||
|
])
|
||||||
|
.then(([data, cache]: [{ error?: string; definitions: MetafieldDefinition[]; matched: Record<string, MetafieldResolution>; unmatched: string[] }, Record<string, MetafieldResolution>]) => {
|
||||||
if (data.error) throw new Error(data.error)
|
if (data.error) throw new Error(data.error)
|
||||||
setDefinitions(data.definitions)
|
setDefinitions(data.definitions)
|
||||||
setMatched(data.matched)
|
setMatched(data.matched)
|
||||||
setUnmatched(data.unmatched)
|
setUnmatched(data.unmatched)
|
||||||
const init: Record<string, MetafieldResolution> = { ...data.matched }
|
const init: Record<string, MetafieldResolution> = { ...data.matched }
|
||||||
// Défaut : 'skip' — l'utilisateur choisit explicitement ce qu'il veut créer
|
|
||||||
data.unmatched.forEach((col: string) => {
|
data.unmatched.forEach((col: string) => {
|
||||||
init[col] = { columnHeader: col, action: 'skip', namespace: 'custom', key: normalizeMetafieldKey(col) }
|
// Utilise le cache persisté si disponible, sinon 'skip' par défaut
|
||||||
|
init[col] = cache[col] ?? { columnHeader: col, action: 'skip', namespace: 'custom', key: normalizeMetafieldKey(col) }
|
||||||
})
|
})
|
||||||
setResolutions(init)
|
setResolutions(init)
|
||||||
})
|
})
|
||||||
.catch(e => setError(e.message))
|
.catch(e => setError((e as Error).message))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [specificColumns.join(',')])
|
}, [specificColumns.join(',')])
|
||||||
@@ -44,7 +50,29 @@ export function MetafieldResolver({ specificColumns, onResolved }: Props) {
|
|||||||
}, [resolutions, loading])
|
}, [resolutions, loading])
|
||||||
|
|
||||||
const updateResolution = (col: string, partial: Partial<MetafieldResolution>) => {
|
const updateResolution = (col: string, partial: Partial<MetafieldResolution>) => {
|
||||||
setResolutions(prev => ({ ...prev, [col]: { ...prev[col], ...partial } }))
|
setResolutions(prev => {
|
||||||
|
const next = { ...prev, [col]: { ...prev[col], ...partial } }
|
||||||
|
// Sauvegarde en différé pour éviter un appel par frappe
|
||||||
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
||||||
|
saveTimeoutRef.current = setTimeout(() => {
|
||||||
|
fetch('/api/creation/resolutions', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(next),
|
||||||
|
}).catch(() => null)
|
||||||
|
}, 800)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredDefs = (col: string) => {
|
||||||
|
const q = (searches[col] ?? '').toLowerCase()
|
||||||
|
if (!q) return definitions
|
||||||
|
return definitions.filter(d =>
|
||||||
|
d.name.toLowerCase().includes(q) ||
|
||||||
|
d.key.toLowerCase().includes(q) ||
|
||||||
|
d.namespace.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <p className="text-gray-400 text-sm">Analyse des métachamps Shopify…</p>
|
if (loading) return <p className="text-gray-400 text-sm">Analyse des métachamps Shopify…</p>
|
||||||
@@ -133,16 +161,45 @@ export function MetafieldResolver({ specificColumns, onResolved }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{res.action === 'map' && (
|
{res.action === 'map' && (
|
||||||
<div>
|
<div className="relative" ref={openDropdown === col ? dropdownRef : undefined}>
|
||||||
<label className="block text-xs text-gray-400 mb-1">Métachamp existant</label>
|
<label className="block text-xs text-gray-400 mb-1">Métachamp existant</label>
|
||||||
<select
|
{res.existingDefinitionId ? (
|
||||||
value={res.existingDefinitionId ?? ''}
|
<div className="flex items-center gap-2">
|
||||||
onChange={e => { const def = definitions.find(d => d.id === e.target.value); if (def) updateResolution(col, { existingDefinitionId: def.id, key: def.key, namespace: def.namespace }) }}
|
<span className="flex-1 text-xs text-blue-300 bg-slate-600 border border-slate-500 rounded px-2 py-1 truncate">
|
||||||
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
|
{definitions.find(d => d.id === res.existingDefinitionId)?.name ?? res.key} ({res.namespace}.{res.key})
|
||||||
>
|
</span>
|
||||||
<option value="">-- Choisir un métachamp existant --</option>
|
<button type="button" onClick={() => { updateResolution(col, { existingDefinitionId: undefined, key: normalizeMetafieldKey(col), namespace: 'custom' }); setSearches(p => ({ ...p, [col]: '' })); setOpenDropdown(col) }} className="text-xs text-gray-400 hover:text-white px-1">✕</button>
|
||||||
{definitions.map(d => <option key={d.id} value={d.id}>{d.name} ({d.namespace}.{d.key})</option>)}
|
</div>
|
||||||
</select>
|
) : (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
value={searches[col] ?? ''}
|
||||||
|
onChange={e => { setSearches(p => ({ ...p, [col]: e.target.value })); setOpenDropdown(col) }}
|
||||||
|
onFocus={() => setOpenDropdown(col)}
|
||||||
|
placeholder="Rechercher un métachamp…"
|
||||||
|
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
{openDropdown === col && (
|
||||||
|
<div className="absolute z-10 mt-1 w-full bg-slate-700 border border-slate-600 rounded shadow-lg max-h-48 overflow-y-auto">
|
||||||
|
{filteredDefs(col).length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-400 px-3 py-2">Aucun résultat</p>
|
||||||
|
) : filteredDefs(col).map(d => (
|
||||||
|
<button
|
||||||
|
key={d.id}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={() => { updateResolution(col, { existingDefinitionId: d.id, key: d.key, namespace: d.namespace, type: d.type }); setOpenDropdown(null) }}
|
||||||
|
className="w-full text-left px-3 py-1.5 text-xs text-white hover:bg-slate-600 flex justify-between gap-2"
|
||||||
|
>
|
||||||
|
<span>{d.name}</span>
|
||||||
|
<span className="text-gray-400 shrink-0">{d.namespace}.{d.key}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,25 +4,32 @@ import type { ImageItem } from '@/types/images'
|
|||||||
|
|
||||||
interface ImageCardProps {
|
interface ImageCardProps {
|
||||||
item: ImageItem
|
item: ImageItem
|
||||||
|
selected?: boolean
|
||||||
|
onSelect?: (id: string, selected: boolean) => void
|
||||||
onFeatherChange: (id: string, value: number) => void
|
onFeatherChange: (id: string, value: number) => void
|
||||||
|
onAlphaThresholdChange: (id: string, value: number) => void
|
||||||
onRotate: (id: string, direction: 'cw' | 'ccw') => void
|
onRotate: (id: string, direction: 'cw' | 'ccw') => void
|
||||||
onUpload: (id: string) => void
|
onUpload: (id: string) => void
|
||||||
onRemove: (id: string) => void
|
onRemove: (id: string) => void
|
||||||
onOpenLightbox: (item: ImageItem) => void
|
onOpenLightbox: (item: ImageItem) => void
|
||||||
onAssignProduct: (id: string, shopifyProductId: string, shopifyProductTitle: string) => void
|
onAssignProduct: (id: string, shopifyProductId: string, shopifyProductTitle: string) => void
|
||||||
|
onReprocess: (id: string) => void
|
||||||
|
onDetour: (id: string) => void
|
||||||
|
onMockup: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
pending: { label: '⏳ En attente', color: 'text-slate-400' },
|
pending: { label: '⏳ En attente', color: 'text-slate-400' },
|
||||||
converting: { label: '🔄 Conversion HEIC', color: 'text-yellow-400' },
|
converting: { label: '🔄 Conversion HEIC', color: 'text-yellow-400' },
|
||||||
processing: { label: '🤖 Détourage IA', color: 'text-blue-400' },
|
searching: { label: '🔍 Recherche…', color: 'text-purple-400' },
|
||||||
searching: { label: '🔍 Recherche…', color: 'text-purple-400' },
|
converted: { label: '✓ Prêt (sans détourage)', color: 'text-amber-400' },
|
||||||
ready: { label: '✓ Prêt', color: 'text-green-400' },
|
processing: { label: '🤖 Détourage IA', color: 'text-blue-400' },
|
||||||
uploading: { label: '⬆️ Upload…', color: 'text-blue-400' },
|
ready: { label: '✓ Détouré', color: 'text-green-400' },
|
||||||
uploaded: { label: '✅ Uploadé', color: 'text-green-500' },
|
uploading: { label: '⬆️ Upload…', color: 'text-blue-400' },
|
||||||
skipped: { label: '⏭️ Déjà sur Shopify', color: 'text-slate-400' },
|
uploaded: { label: '✅ Uploadé', color: 'text-green-500' },
|
||||||
not_found: { label: '❌ Réf. introuvable', color: 'text-red-400' },
|
skipped: { label: '⏭️ Déjà sur Shopify', color: 'text-slate-400' },
|
||||||
error: { label: '❌ Erreur', color: 'text-red-400' },
|
not_found: { label: '❌ Réf. introuvable', color: 'text-red-400' },
|
||||||
|
error: { label: '❌ Erreur', color: 'text-red-400' },
|
||||||
}
|
}
|
||||||
|
|
||||||
function BlobThumbnail({ blob, alt }: { blob: Blob; alt: string }) {
|
function BlobThumbnail({ blob, alt }: { blob: Blob; alt: string }) {
|
||||||
@@ -93,24 +100,42 @@ function ProductSearch({ itemId, onAssign }: { itemId: string; onAssign: (id: st
|
|||||||
|
|
||||||
export function ImageCard({
|
export function ImageCard({
|
||||||
item,
|
item,
|
||||||
|
selected = false,
|
||||||
|
onSelect,
|
||||||
onFeatherChange,
|
onFeatherChange,
|
||||||
|
onAlphaThresholdChange,
|
||||||
onRotate,
|
onRotate,
|
||||||
onUpload,
|
onUpload,
|
||||||
onRemove,
|
onRemove,
|
||||||
onOpenLightbox,
|
onOpenLightbox,
|
||||||
onAssignProduct,
|
onAssignProduct,
|
||||||
|
onReprocess,
|
||||||
|
onDetour,
|
||||||
|
onMockup,
|
||||||
}: ImageCardProps) {
|
}: ImageCardProps) {
|
||||||
const statusInfo = STATUS_LABELS[item.status] ?? { label: item.status, color: 'text-slate-400' }
|
const statusInfo = STATUS_LABELS[item.status] ?? { label: item.status, color: 'text-slate-400' }
|
||||||
const isProcessing = ['pending', 'converting', 'processing', 'searching', 'uploading'].includes(item.status)
|
const isProcessing = ['pending', 'converting', 'processing', 'searching', 'uploading'].includes(item.status)
|
||||||
const canAdjust = !!item.rawPngBlob && !isProcessing
|
const canAdjust = !!item.rawPngBlob && !isProcessing
|
||||||
|
const canUpload = (item.status === 'ready' || item.status === 'converted') && !!item.shopifyProductId
|
||||||
|
const canDetour = (item.status === 'converted' || item.status === 'error' || item.status === 'not_found') && !isProcessing
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden flex flex-col">
|
<div className={`bg-slate-900 border rounded-xl overflow-hidden flex flex-col ${selected ? 'border-indigo-500 ring-1 ring-indigo-500' : 'border-slate-700'}`}>
|
||||||
{/* Miniatures avant / après */}
|
{/* Miniatures avant / après */}
|
||||||
<div
|
<div
|
||||||
className="relative h-40 bg-slate-800 cursor-pointer"
|
className="relative h-40 bg-slate-800 cursor-pointer"
|
||||||
onClick={() => onOpenLightbox(item)}
|
onClick={() => onOpenLightbox(item)}
|
||||||
>
|
>
|
||||||
|
{onSelect && (
|
||||||
|
<div className="absolute top-2 left-2 z-10" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onChange={e => onSelect(item.id, e.target.checked)}
|
||||||
|
className="w-4 h-4 accent-indigo-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className={`absolute top-0 bottom-0 left-0 overflow-hidden ${item.processedBlob ? 'w-1/2' : 'w-full'}`}>
|
<div className={`absolute top-0 bottom-0 left-0 overflow-hidden ${item.processedBlob ? 'w-1/2' : 'w-full'}`}>
|
||||||
<BlobThumbnail blob={item.originalBlob} alt="Original" />
|
<BlobThumbnail blob={item.originalBlob} alt="Original" />
|
||||||
</div>
|
</div>
|
||||||
@@ -136,7 +161,8 @@ export function ImageCard({
|
|||||||
{/* Infos */}
|
{/* Infos */}
|
||||||
<div className="p-3 flex flex-col gap-2 flex-1">
|
<div className="p-3 flex flex-col gap-2 flex-1">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-mono text-slate-300 truncate">{item.ref}</p>
|
<p className="text-xs font-mono text-slate-300 truncate" title={item.filename}>{item.filename}</p>
|
||||||
|
<p className="text-xs text-slate-500 truncate">Réf : {item.ref}</p>
|
||||||
{item.shopifyProductTitle && (
|
{item.shopifyProductTitle && (
|
||||||
<p className="text-xs text-slate-500 truncate">{item.shopifyProductTitle}</p>
|
<p className="text-xs text-slate-500 truncate">{item.shopifyProductTitle}</p>
|
||||||
)}
|
)}
|
||||||
@@ -148,7 +174,7 @@ export function ImageCard({
|
|||||||
<ProductSearch itemId={item.id} onAssign={onAssignProduct} />
|
<ProductSearch itemId={item.id} onAssign={onAssignProduct} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Contrôles feather + rotation */}
|
{/* Contrôles feather + tolérance + rotation */}
|
||||||
<div className={canAdjust ? '' : 'opacity-40 pointer-events-none'}>
|
<div className={canAdjust ? '' : 'opacity-40 pointer-events-none'}>
|
||||||
<label className="block text-xs text-slate-500 mb-1">
|
<label className="block text-xs text-slate-500 mb-1">
|
||||||
Bords : {item.feather} px
|
Bords : {item.feather} px
|
||||||
@@ -163,6 +189,19 @@ export function ImageCard({
|
|||||||
className="w-full accent-indigo-500"
|
className="w-full accent-indigo-500"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<label className="block text-xs text-slate-500 mb-1 mt-2">
|
||||||
|
Tolérance verre : {item.alphaThreshold}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
value={item.alphaThreshold}
|
||||||
|
onChange={(e) => onAlphaThresholdChange(item.id, Number(e.target.value))}
|
||||||
|
className="w-full accent-amber-500"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex gap-1 mt-2">
|
<div className="flex gap-1 mt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => onRotate(item.id, 'ccw')}
|
onClick={() => onRotate(item.id, 'ccw')}
|
||||||
@@ -177,7 +216,7 @@ export function ImageCard({
|
|||||||
|
|
||||||
{/* Boutons d'action */}
|
{/* Boutons d'action */}
|
||||||
<div className="flex gap-2 mt-auto pt-1">
|
<div className="flex gap-2 mt-auto pt-1">
|
||||||
{item.status === 'ready' && (
|
{canUpload && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onUpload(item.id)}
|
onClick={() => onUpload(item.id)}
|
||||||
className="flex-1 py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded"
|
className="flex-1 py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded"
|
||||||
@@ -185,6 +224,33 @@ export function ImageCard({
|
|||||||
⬆️ Uploader
|
⬆️ Uploader
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{canDetour && (
|
||||||
|
<button
|
||||||
|
onClick={() => onDetour(item.id)}
|
||||||
|
title="Lancer le détourage IA"
|
||||||
|
className="flex-1 py-1.5 text-xs bg-purple-700 hover:bg-purple-600 text-white font-semibold rounded"
|
||||||
|
>
|
||||||
|
✂️ Détourer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(item.status === 'ready' || item.status === 'uploaded' || item.status === 'skipped' || item.status === 'converted') && (
|
||||||
|
<button
|
||||||
|
onClick={() => onMockup(item.id)}
|
||||||
|
title="Créer un mockup"
|
||||||
|
className="py-1.5 px-2 text-xs bg-slate-800 hover:bg-teal-900 text-slate-400 hover:text-teal-300 rounded"
|
||||||
|
>
|
||||||
|
🖼️
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(item.status === 'ready' || item.status === 'uploaded' || item.status === 'skipped' || item.status === 'error') && item.rawPngBlob && (
|
||||||
|
<button
|
||||||
|
onClick={() => onReprocess(item.id)}
|
||||||
|
title="Relancer le détourage IA"
|
||||||
|
className="py-1.5 px-2 text-xs bg-slate-800 hover:bg-amber-900 text-slate-400 hover:text-amber-300 rounded"
|
||||||
|
>
|
||||||
|
🔄
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{item.status === 'uploaded' && item.uploadedUrl && (
|
{item.status === 'uploaded' && item.uploadedUrl && (
|
||||||
<a
|
<a
|
||||||
href={item.uploadedUrl}
|
href={item.uploadedUrl}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
{ href: '/images', label: 'Images', icon: '🖼️' },
|
{ href: '/images', label: 'Images', icon: '🖼️' },
|
||||||
{ href: '/sync', label: 'Sync Sheets', icon: '🔄' },
|
{ href: '/sync', label: 'Sync Sheets', icon: '🔄' },
|
||||||
{ href: '/creation', label: 'Création', icon: '✨' },
|
{ href: '/creation', label: 'Création', icon: '✨' },
|
||||||
|
{ href: '/mockup', label: 'Mockups', icon: '🖼️' },
|
||||||
|
{ href: '/bannieres', label: 'Bannières', icon: '🎨' },
|
||||||
{ href: '/parametres', label: 'Paramètres', icon: '⚙️', adminOnly: true },
|
{ href: '/parametres', label: 'Paramètres', icon: '⚙️', adminOnly: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import type { MockupTemplate } from '@/types/mockup'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
productCutoutBase64: string
|
||||||
|
productName: string
|
||||||
|
shopifyId?: string
|
||||||
|
family: string
|
||||||
|
dimensions?: string
|
||||||
|
onUploaded?: (url: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MockupGenerator({ productCutoutBase64, productName, shopifyId, family, dimensions, onUploaded }: Props) {
|
||||||
|
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 [error, setError] = useState<string | null>(null)
|
||||||
|
const [uploadedUrl, setUploadedUrl] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/mockup/templates')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then((data: MockupTemplate[]) => {
|
||||||
|
const filtered = data.filter(t => !family || t.family === family || t.family === 'Tous')
|
||||||
|
setTemplates(filtered.length > 0 ? filtered : data)
|
||||||
|
})
|
||||||
|
}, [family])
|
||||||
|
|
||||||
|
const generate = async () => {
|
||||||
|
if (!selectedTemplate) return
|
||||||
|
setGenerating(true)
|
||||||
|
setError(null)
|
||||||
|
setResultB64(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/mockup/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
templateId: selectedTemplate,
|
||||||
|
productImageBase64: productCutoutBase64,
|
||||||
|
productName,
|
||||||
|
family,
|
||||||
|
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 || !shopifyId) return
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const filename = `${productName.toLowerCase().replace(/\s+/g, '-')}-mockup.jpg`
|
||||||
|
const res = await fetch('/api/images/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ shopifyProductId: shopifyId, imageBase64: resultB64, filename }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error)
|
||||||
|
setUploadedUrl(data.url)
|
||||||
|
onUploaded?.(data.url)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Erreur upload')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-2">Template de mise en ambiance</label>
|
||||||
|
{templates.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-500">Aucun template disponible — ajoutez-en dans la bibliothèque.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{templates.map(t => (
|
||||||
|
<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-24 object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-2 py-1">
|
||||||
|
<p className="text-xs text-white truncate">{t.name}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dimensions ? (
|
||||||
|
<p className="text-xs text-green-400">Dimensions : {dimensions}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-amber-400">Dimensions non renseignées — le mockup peut ne pas être à l'échelle.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={generate}
|
||||||
|
disabled={!selectedTemplate || generating}
|
||||||
|
className="w-full py-2 px-4 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>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
|
||||||
|
{resultB64 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<img
|
||||||
|
src={`data:image/jpeg;base64,${resultB64}`}
|
||||||
|
alt="Mockup généré"
|
||||||
|
className="w-full rounded-lg border border-slate-600"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={instruction}
|
||||||
|
onChange={e => setInstruction(e.target.value)}
|
||||||
|
placeholder="Instruction supplémentaire (optionnel)…"
|
||||||
|
className="flex-1 bg-slate-700 border border-slate-600 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-600 text-xs text-white hover:bg-slate-500 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{generating ? '…' : '↺ Regénérer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{uploadedUrl ? (
|
||||||
|
<p className="text-xs text-green-400">✓ Uploadé sur Shopify</p>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={upload}
|
||||||
|
disabled={uploading || !shopifyId}
|
||||||
|
className="w-full py-2 px-4 rounded-lg bg-emerald-600 text-white text-sm font-medium disabled:opacity-40 hover:bg-emerald-500 transition-colors"
|
||||||
|
>
|
||||||
|
{uploading ? 'Upload…' : shopifyId ? '↑ Uploader sur Shopify' : 'Produit Shopify requis pour uploader'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import type { CatalogProduct } from '@/types/products'
|
||||||
|
import type { MockupTemplate } from '@/types/mockup'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
product: CatalogProduct
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Metafield {
|
||||||
|
namespace: string
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function urlToBase64(url: string): Promise<string> {
|
||||||
|
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()
|
||||||
|
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 function MockupModal({ product, onClose }: Props) {
|
||||||
|
const [templates, setTemplates] = useState<MockupTemplate[]>([])
|
||||||
|
const [families, setFamilies] = useState<string[]>([])
|
||||||
|
const [metafields, setMetafields] = useState<Metafield[]>([])
|
||||||
|
const [loadingMeta, setLoadingMeta] = useState(true)
|
||||||
|
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null)
|
||||||
|
const [family, setFamily] = useState('')
|
||||||
|
const [placement, setPlacement] = useState<string>('')
|
||||||
|
const [selectedMeta, setSelectedMeta] = useState<string[]>([])
|
||||||
|
const [cutoutB64, setCutoutB64] = useState<string | null>(null)
|
||||||
|
const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>(null)
|
||||||
|
const [loadingImage, setLoadingImage] = useState(false)
|
||||||
|
|
||||||
|
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 fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
const templateFileRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [uploadingTemplate, setUploadingTemplate] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
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()),
|
||||||
|
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)
|
||||||
|
const sheetFamille = sheetData?.product?.famille
|
||||||
|
setFamily(sheetFamille && tabs.includes(sheetFamille) ? sheetFamille : tabs[0] ?? '')
|
||||||
|
setMetafields(metaData.metafields ?? [])
|
||||||
|
}).finally(() => setLoadingMeta(false))
|
||||||
|
}, [product.shopifyId, product.ref])
|
||||||
|
|
||||||
|
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 dimensionStr = selectedMeta.length > 0
|
||||||
|
? selectedMeta.map(key => {
|
||||||
|
const mf = metafields.find(m => `${m.namespace}.${m.key}` === key)
|
||||||
|
return mf ? `${mf.key}: ${mf.value}` : ''
|
||||||
|
}).filter(Boolean).join(', ')
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const selectExistingImage = async (url: string) => {
|
||||||
|
setSelectedImageUrl(url)
|
||||||
|
setCutoutB64(null)
|
||||||
|
setLoadingImage(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const b64 = await urlToBase64(url)
|
||||||
|
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]
|
||||||
|
if (!file) return
|
||||||
|
setSelectedImageUrl(null)
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = ev => setCutoutB64((ev.target?.result as string).split(',')[1])
|
||||||
|
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 toggleMeta = (key: string) => {
|
||||||
|
setSelectedMeta(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key])
|
||||||
|
}
|
||||||
|
|
||||||
|
const generate = async () => {
|
||||||
|
if (!selectedTemplate || !cutoutB64) return
|
||||||
|
setGenerating(true)
|
||||||
|
setError(null)
|
||||||
|
setResultB64(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/mockup/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
templateId: selectedTemplate,
|
||||||
|
productImageBase64: cutoutB64,
|
||||||
|
productName: product.title,
|
||||||
|
family: family || 'Tous',
|
||||||
|
placement: placement || undefined,
|
||||||
|
dimensions: dimensionStr,
|
||||||
|
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) return
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/images/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ shopifyProductId: product.shopifyId, imageBase64: resultB64, filename: `${product.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||||
|
<div className="bg-slate-800 border border-slate-700 rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-slate-700 sticky top-0 bg-slate-800 z-10">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-white font-semibold text-sm">Créer un mockup</h2>
|
||||||
|
<p className="text-gray-400 text-xs mt-0.5">
|
||||||
|
{product.title} <span className="font-mono text-indigo-300">{product.ref}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} className="text-gray-400 hover:text-white text-lg leading-none">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-5">
|
||||||
|
|
||||||
|
{/* 1. Image de base */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">1. Image du produit</h3>
|
||||||
|
{product.images.length > 0 && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<p className="text-xs text-gray-400 mb-1.5">Images existantes</p>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{product.images.map((url, i) => (
|
||||||
|
<button key={i} type="button" onClick={() => selectExistingImage(url)}
|
||||||
|
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-16 h-16 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>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-400 mb-1">Ou uploader une image découpée (PNG)</p>
|
||||||
|
<input ref={fileRef} type="file" accept="image/*" onChange={handleFileUpload} className="text-xs text-gray-300" />
|
||||||
|
</div>
|
||||||
|
{cutoutB64 && !loadingImage && (
|
||||||
|
<p className="text-xs text-green-400 mt-1">✓ Image prête</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 2. Famille + placement */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">2. Famille</h3>
|
||||||
|
{families.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<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">
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 3. Caractéristiques (metafields) */}
|
||||||
|
{metafields.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">3. Caractéristiques à inclure dans le prompt</h3>
|
||||||
|
<div className="space-y-1 max-h-36 overflow-y-auto">
|
||||||
|
{metafields.map(mf => {
|
||||||
|
const key = `${mf.namespace}.${mf.key}`
|
||||||
|
return (
|
||||||
|
<label key={key} className="flex items-center gap-2 cursor-pointer group">
|
||||||
|
<input type="checkbox" checked={selectedMeta.includes(key)} onChange={() => toggleMeta(key)}
|
||||||
|
className="rounded border-slate-600 bg-slate-700 text-indigo-500 focus:ring-indigo-500" />
|
||||||
|
<span className="text-xs text-gray-300 group-hover:text-white">
|
||||||
|
<span className="font-medium">{mf.key}</span>
|
||||||
|
<span className="text-gray-500 ml-1">{mf.value}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{dimensionStr && (
|
||||||
|
<p className="text-xs text-indigo-300 mt-1.5">Inclus dans le prompt : {dimensionStr}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 4. Template */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide">
|
||||||
|
{metafields.length > 0 ? '4.' : '3.'} Image de référence (template)
|
||||||
|
</h3>
|
||||||
|
<button type="button" onClick={() => templateFileRef.current?.click()}
|
||||||
|
disabled={uploadingTemplate}
|
||||||
|
className="text-xs text-indigo-400 hover:text-indigo-300 disabled:opacity-40">
|
||||||
|
{uploadingTemplate ? 'Upload…' : '+ Uploader une nouvelle'}
|
||||||
|
</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 ci-dessus.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{filteredTemplates.map(t => (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Generate */}
|
||||||
|
<button type="button" onClick={generate}
|
||||||
|
disabled={!selectedTemplate || !cutoutB64 || generating || loadingImage}
|
||||||
|
className="w-full py-2 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>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
|
||||||
|
{/* Result */}
|
||||||
|
{resultB64 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<img src={`data:image/jpeg;base64,${resultB64}`} alt="Mockup" className="w-full rounded-lg border border-slate-600" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input type="text" value={instruction} onChange={e => setInstruction(e.target.value)}
|
||||||
|
placeholder="Instruction pour regénérer (optionnel)…"
|
||||||
|
className="flex-1 bg-slate-700 border border-slate-600 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-600 text-xs text-white hover:bg-slate-500 disabled:opacity-40">
|
||||||
|
{generating ? '…' : '↺ Regénérer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{uploadedUrl ? (
|
||||||
|
<p className="text-xs text-green-400">✓ 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,288 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import type { ImageItem } from '@/types/images'
|
||||||
|
import type { MockupTemplate } from '@/types/mockup'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: ImageItem
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
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(() => {
|
||||||
|
Promise.all([
|
||||||
|
fetch('/api/mockup/templates').then(r => r.json()),
|
||||||
|
fetch('/api/sheets/tabs').then(r => r.json()),
|
||||||
|
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)
|
||||||
|
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'
|
||||||
|
)
|
||||||
|
|
||||||
|
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) return
|
||||||
|
setGenerating(true)
|
||||||
|
setError(null)
|
||||||
|
setResultB64(null)
|
||||||
|
try {
|
||||||
|
// Utilise l'image détourée si disponible, sinon l'originale
|
||||||
|
const sourceBlob = item.processedBlob ?? item.originalBlob
|
||||||
|
const productImageBase64 = await blobToBase64(sourceBlob)
|
||||||
|
const res = await fetch('/api/mockup/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
templateId: selectedTemplate,
|
||||||
|
productImageBase64,
|
||||||
|
productName: item.shopifyProductTitle ?? item.ref,
|
||||||
|
family: family || 'Tous',
|
||||||
|
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 || !item.shopifyProductId) return
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/images/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
shopifyProductId: item.shopifyProductId,
|
||||||
|
imageBase64: resultB64,
|
||||||
|
filename: `${item.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 downloadResult = () => {
|
||||||
|
if (!resultB64) return
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = `data:image/jpeg;base64,${resultB64}`
|
||||||
|
link.download = `${item.ref}-mockup.jpg`
|
||||||
|
link.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceLabel = item.processedBlob ? 'image détourée' : 'image originale'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||||
|
>
|
||||||
|
<div className="bg-slate-800 border border-slate-700 rounded-xl w-full max-w-xl max-h-[90vh] overflow-y-auto">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-slate-700 sticky top-0 bg-slate-800 z-10">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-white font-semibold text-sm">Créer un mockup</h2>
|
||||||
|
<p className="text-gray-400 text-xs mt-0.5">
|
||||||
|
{item.shopifyProductTitle ?? item.ref}
|
||||||
|
<span className="ml-2 text-indigo-300 font-mono">{item.ref}</span>
|
||||||
|
<span className="ml-2 text-slate-500">· {sourceLabel}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} className="text-gray-400 hover:text-white text-lg leading-none">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-5">
|
||||||
|
|
||||||
|
{/* Famille */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">Famille</h3>
|
||||||
|
<select
|
||||||
|
value={family}
|
||||||
|
onChange={e => setFamily(e.target.value)}
|
||||||
|
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>)}
|
||||||
|
</select>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Template */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide">Template</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => templateFileRef.current?.click()}
|
||||||
|
disabled={uploadingTemplate}
|
||||||
|
className="text-xs text-indigo-400 hover:text-indigo-300 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{uploadingTemplate ? 'Upload…' : '+ Nouveau'}
|
||||||
|
</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>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{filteredTemplates.map(t => (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Instruction libre */}
|
||||||
|
<section>
|
||||||
|
<label className="block text-xs font-semibold text-gray-300 uppercase tracking-wide mb-1">
|
||||||
|
Instruction personnalisée <span className="text-gray-500 font-normal normal-case">(optionnel)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={instruction}
|
||||||
|
onChange={e => setInstruction(e.target.value)}
|
||||||
|
placeholder="Ex: fond beige, lumière naturelle, ambiance chaleureuse…"
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={generate}
|
||||||
|
disabled={!selectedTemplate || generating}
|
||||||
|
className="w-full py-2 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>
|
||||||
|
|
||||||
|
{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-lg border border-slate-600"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={instruction}
|
||||||
|
onChange={e => setInstruction(e.target.value)}
|
||||||
|
placeholder="Modifier l'instruction et regénérer…"
|
||||||
|
className="flex-1 bg-slate-700 border border-slate-600 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-600 text-xs text-white hover:bg-slate-500 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{generating ? '…' : '↺'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={downloadResult}
|
||||||
|
className="flex-1 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
↓ Télécharger
|
||||||
|
</button>
|
||||||
|
{item.shopifyProductId ? (
|
||||||
|
uploadedUrl ? (
|
||||||
|
<p className="flex-1 py-2 text-center text-xs text-green-400 self-center">✓ Uploadé sur Shopify</p>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={upload}
|
||||||
|
disabled={uploading}
|
||||||
|
className="flex-1 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
{uploading ? 'Upload…' : '↑ Uploader sur Shopify'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p className="flex-1 py-2 text-center text-xs text-slate-500 self-center">Pas de produit associé</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import type { MockupTemplate } from '@/types/mockup'
|
||||||
|
|
||||||
|
const CARRELAGE_PLACEMENTS = ['sol', 'mur']
|
||||||
|
|
||||||
|
export function TemplateLibrary() {
|
||||||
|
const [templates, setTemplates] = useState<MockupTemplate[]>([])
|
||||||
|
const [families, setFamilies] = useState<string[]>(['Tous'])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [family, setFamily] = useState('Tous')
|
||||||
|
const [placement, setPlacement] = useState<string>('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
setLoading(true)
|
||||||
|
Promise.all([
|
||||||
|
fetch('/api/mockup/templates').then(r => r.json()),
|
||||||
|
fetch('/api/sheets/tabs').then(r => r.json()),
|
||||||
|
]).then(([tplData, tabsData]) => {
|
||||||
|
setTemplates(tplData as MockupTemplate[])
|
||||||
|
const tabs: string[] = (tabsData.tabs ?? []).filter((t: string) =>
|
||||||
|
!['COLLECTIONS', 'LIVRAISONS', 'ECOPART', 'RECAP'].some(x => t.toUpperCase().includes(x))
|
||||||
|
)
|
||||||
|
const allFamilies = ['Tous', ...tabs]
|
||||||
|
setFamilies(allFamilies)
|
||||||
|
if (!allFamilies.includes(family)) setFamily(allFamilies[0])
|
||||||
|
}).finally(() => setLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(load, [])
|
||||||
|
|
||||||
|
const isCarrelage = family.toLowerCase().includes('carrelage')
|
||||||
|
|
||||||
|
const upload = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const file = fileRef.current?.files?.[0]
|
||||||
|
if (!file || !name || !family) return
|
||||||
|
if (isCarrelage && !placement) return
|
||||||
|
setUploading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('name', name)
|
||||||
|
form.append('family', family)
|
||||||
|
form.append('description', description)
|
||||||
|
if (isCarrelage && placement) form.append('placement', placement)
|
||||||
|
form.append('image', file)
|
||||||
|
const res = await fetch('/api/mockup/templates', { method: 'POST', body: form })
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error)
|
||||||
|
setName(''); setDescription(''); setPlacement('')
|
||||||
|
if (fileRef.current) fileRef.current.value = ''
|
||||||
|
load()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Erreur')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (id: string) => {
|
||||||
|
await fetch(`/api/mockup/templates/${id}`, { method: 'DELETE' })
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const allUsedFamilies = Array.from(new Set(templates.map(t => t.family)))
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Upload form */}
|
||||||
|
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700">
|
||||||
|
<h2 className="text-sm font-semibold text-white mb-3">Ajouter un template</h2>
|
||||||
|
<form onSubmit={upload} className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Nom</label>
|
||||||
|
<input value={name} onChange={e => setName(e.target.value)}
|
||||||
|
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" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Famille</label>
|
||||||
|
<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">
|
||||||
|
{families.map(f => <option key={f}>{f}</option>)}
|
||||||
|
</select>
|
||||||
|
</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>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Description (optionnel)</label>
|
||||||
|
<input value={description} onChange={e => setDescription(e.target.value)}
|
||||||
|
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" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Image</label>
|
||||||
|
<input ref={fileRef} type="file" accept="image/*" className="text-sm text-gray-300" />
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
<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">
|
||||||
|
{uploading ? 'Ajout…' : '+ Ajouter'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Templates grouped by family */}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-gray-400">Chargement…</p>
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500">Aucun template. Ajoutez-en un ci-dessus.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{displayFamilies.map(f => {
|
||||||
|
const groups = groupedTemplates(f)
|
||||||
|
const total = groups.reduce((n, g) => n + g.items.length, 0)
|
||||||
|
if (total === 0) return null
|
||||||
|
return (
|
||||||
|
<div key={f}>
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mb-3 flex items-center gap-2">
|
||||||
|
{f}
|
||||||
|
<span className="text-xs text-gray-500">{total} template{total > 1 ? 's' : ''}</span>
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{groups.map((group, gi) => (
|
||||||
|
<div key={gi}>
|
||||||
|
{group.label && (
|
||||||
|
<p className="text-xs text-gray-400 font-medium mb-2 uppercase tracking-wide">{group.label}</p>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||||
|
{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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
import { useState } from 'react'
|
||||||
import type { CatalogProduct } from '@/types/products'
|
import type { CatalogProduct } from '@/types/products'
|
||||||
|
import { MockupModal } from '@/components/mockup/MockupModal'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
products: CatalogProduct[]
|
products: CatalogProduct[]
|
||||||
@@ -9,6 +11,7 @@ interface Props {
|
|||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
export function ProductTable({ products, loading }: Props) {
|
export function ProductTable({ products, loading }: Props) {
|
||||||
|
const [mockupProduct, setMockupProduct] = useState<CatalogProduct | null>(null)
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-20 text-gray-400">
|
<div className="flex items-center justify-center py-20 text-gray-400">
|
||||||
@@ -33,17 +36,31 @@ export function ProductTable({ products, loading }: Props) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-gray-400 border-b border-slate-700">
|
<tr className="text-left text-gray-400 border-b border-slate-700">
|
||||||
<th className="pb-3 pr-4 font-medium">Référence</th>
|
<th className="pb-3 pr-4 font-medium">Référence</th>
|
||||||
|
<th className="pb-3 pr-4 font-medium">Images</th>
|
||||||
<th className="pb-3 pr-4 font-medium">Titre</th>
|
<th className="pb-3 pr-4 font-medium">Titre</th>
|
||||||
<th className="pb-3 pr-4 font-medium">Prix</th>
|
<th className="pb-3 pr-4 font-medium">Prix</th>
|
||||||
<th className="pb-3 pr-4 font-medium">Stock</th>
|
<th className="pb-3 pr-4 font-medium">Stock</th>
|
||||||
<th className="pb-3 pr-4 font-medium">Statut</th>
|
<th className="pb-3 pr-4 font-medium">Statut</th>
|
||||||
<th className="pb-3 font-medium">Lien</th>
|
<th className="pb-3 pr-4 font-medium">Lien</th>
|
||||||
|
<th className="pb-3 font-medium"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{displayed.map((p) => (
|
{displayed.map((p) => (
|
||||||
<tr key={p.shopifyId} className="border-b border-slate-700/50 hover:bg-slate-700/30 transition-colors">
|
<tr key={p.shopifyId} className="border-b border-slate-700/50 hover:bg-slate-700/30 transition-colors">
|
||||||
<td className="py-3 pr-4 font-mono text-indigo-300">{p.ref}</td>
|
<td className="py-3 pr-4 font-mono text-indigo-300">{p.ref}</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
{p.images.length > 0 ? (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<img src={p.images[0]} alt="" className="w-8 h-8 object-cover rounded border border-slate-600" />
|
||||||
|
{p.images.length > 1 && (
|
||||||
|
<span className="text-xs text-gray-400">+{p.images.length - 1}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-600">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="py-3 pr-4 text-white">{p.title}</td>
|
<td className="py-3 pr-4 text-white">{p.title}</td>
|
||||||
<td className="py-3 pr-4 text-gray-300">{p.price} €</td>
|
<td className="py-3 pr-4 text-gray-300">{p.price} €</td>
|
||||||
<td className="py-3 pr-4 text-gray-300">{p.stock}</td>
|
<td className="py-3 pr-4 text-gray-300">{p.stock}</td>
|
||||||
@@ -56,7 +73,7 @@ export function ProductTable({ products, loading }: Props) {
|
|||||||
{p.status === 'active' ? 'Actif' : 'Inactif'}
|
{p.status === 'active' ? 'Actif' : 'Inactif'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3">
|
<td className="py-3 pr-4">
|
||||||
<a
|
<a
|
||||||
href={p.shopifyUrl}
|
href={p.shopifyUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -66,10 +83,22 @@ export function ProductTable({ products, loading }: Props) {
|
|||||||
Shopify ↗
|
Shopify ↗
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMockupProduct(p)}
|
||||||
|
className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-indigo-600 text-gray-300 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Mockup
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{mockupProduct && (
|
||||||
|
<MockupModal product={mockupProduct} onClose={() => setMockupProduct(null)} />
|
||||||
|
)}
|
||||||
{products.length > PAGE_SIZE && (
|
{products.length > PAGE_SIZE && (
|
||||||
<p className="text-center text-gray-400 text-xs mt-4">
|
<p className="text-center text-gray-400 text-xs mt-4">
|
||||||
Affichage des {PAGE_SIZE} premiers résultats sur {products.length}.
|
Affichage des {PAGE_SIZE} premiers résultats sur {products.length}.
|
||||||
|
|||||||
@@ -56,28 +56,33 @@ export function DiffTable({ diff }: DiffTableProps) {
|
|||||||
{paginated.length === 0 ? (
|
{paginated.length === 0 ? (
|
||||||
<p className="text-slate-600 text-sm py-4 text-center">Aucun changement dans cette catégorie.</p>
|
<p className="text-slate-600 text-sm py-4 text-center">Aucun changement dans cette catégorie.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="border border-slate-700 rounded-xl overflow-hidden">
|
<div className="border border-slate-700 rounded-xl overflow-x-auto">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs table-fixed">
|
||||||
|
<colgroup>
|
||||||
|
<col className="w-36" />
|
||||||
|
<col className="w-32" />
|
||||||
|
<col />
|
||||||
|
</colgroup>
|
||||||
<thead className="bg-slate-800 text-slate-400">
|
<thead className="bg-slate-800 text-slate-400">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 text-left">Ref</th>
|
<th className="px-3 py-2 text-left">Ref</th>
|
||||||
<th className="px-3 py-2 text-left">Type</th>
|
<th className="px-3 py-2 text-left">Type</th>
|
||||||
<th className="px-3 py-2 text-left">Champs modifiés</th>
|
<th className="px-3 py-2 text-left">Détail</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{paginated.map((change) => (
|
{paginated.map((change) => (
|
||||||
<tr key={change.ref} className={`border-t border-slate-800 ${TYPE_CONFIG[change.type].bg}`}>
|
<tr key={change.ref} className={`border-t border-slate-800 ${TYPE_CONFIG[change.type].bg}`}>
|
||||||
<td className="px-3 py-2 font-mono text-slate-300">{change.ref}</td>
|
<td className="px-3 py-2 font-mono text-slate-300 truncate max-w-0" title={change.ref}>{change.ref}</td>
|
||||||
<td className={`px-3 py-2 font-semibold ${TYPE_CONFIG[change.type].color}`}>
|
<td className={`px-3 py-2 font-semibold whitespace-nowrap ${TYPE_CONFIG[change.type].color}`}>
|
||||||
{TYPE_CONFIG[change.type].label}
|
{TYPE_CONFIG[change.type].label}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-slate-400">
|
<td className="px-3 py-2 text-slate-400">
|
||||||
{change.type === 'update' && change.changedFields?.map((f) => (
|
{change.type === 'update' && change.changedFields?.map((f) => (
|
||||||
<span key={f.field} className="inline-block mr-2">
|
<span key={f.field} className="inline-block mr-3">
|
||||||
<span className="text-slate-500">{f.field} : </span>
|
<span className="text-slate-500">{f.field} : </span>
|
||||||
<span className="line-through text-red-400">{f.from}</span>
|
<span className="line-through text-red-400 mr-1">{f.from}</span>
|
||||||
<span className="text-slate-400"> → </span>
|
<span className="text-slate-500 mr-1">→</span>
|
||||||
<span className="text-green-400">{f.to}</span>
|
<span className="text-green-400">{f.to}</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+151
-44
@@ -1,7 +1,29 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useReducer, useCallback } from 'react'
|
import { useReducer, useCallback, useRef } from 'react'
|
||||||
import type { ImageItem, ImageStatus } from '@/types/images'
|
import type { ImageItem, ImageStatus } from '@/types/images'
|
||||||
|
|
||||||
|
// File d'attente avec concurrence limitée pour le pipeline IA
|
||||||
|
const CONCURRENCY = 2
|
||||||
|
|
||||||
|
function createQueue() {
|
||||||
|
let running = 0
|
||||||
|
const queue: Array<() => Promise<void>> = []
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
if (running >= CONCURRENCY || queue.length === 0) return
|
||||||
|
running++
|
||||||
|
const task = queue.shift()!
|
||||||
|
task().finally(() => { running--; next() })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enqueue(task: () => Promise<void>) {
|
||||||
|
queue.push(task)
|
||||||
|
next()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Reducer ───────────────────────────────────────────────────────────────────
|
// ── Reducer ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
@@ -26,7 +48,10 @@ function reducer(state: ImageItem[], action: Action): ImageItem[] {
|
|||||||
|
|
||||||
function refFromFilename(filename: string): string {
|
function refFromFilename(filename: string): string {
|
||||||
const dot = filename.lastIndexOf('.')
|
const dot = filename.lastIndexOf('.')
|
||||||
return dot === -1 ? filename : filename.slice(0, dot)
|
const base = dot === -1 ? filename : filename.slice(0, dot)
|
||||||
|
// 1. Supprime le suffixe de numérotation "-N" (ex: "151721-2" → "151721", "29679-G-1" → "29679-G")
|
||||||
|
// 2. Supprime les tirets/espaces résiduels en fin (ex: "27228-GRIS-" → "27228-GRIS", "26774-" → "26774")
|
||||||
|
return base.replace(/-\d+$/, '').replace(/[-_\s]+$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
function blobToBase64(blob: Blob): Promise<string> {
|
function blobToBase64(blob: Blob): Promise<string> {
|
||||||
@@ -34,7 +59,6 @@ function blobToBase64(blob: Blob): Promise<string> {
|
|||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
const result = reader.result as string
|
const result = reader.result as string
|
||||||
// Supprimer le préfixe "data:image/jpeg;base64,"
|
|
||||||
const base64 = result.split(',')[1]
|
const base64 = result.split(',')[1]
|
||||||
resolve(base64)
|
resolve(base64)
|
||||||
}
|
}
|
||||||
@@ -47,6 +71,7 @@ function blobToBase64(blob: Blob): Promise<string> {
|
|||||||
|
|
||||||
export function useImages() {
|
export function useImages() {
|
||||||
const [items, dispatch] = useReducer(reducer, [])
|
const [items, dispatch] = useReducer(reducer, [])
|
||||||
|
const queueRef = useRef(createQueue())
|
||||||
|
|
||||||
const update = useCallback((id: string, patch: Partial<ImageItem>) => {
|
const update = useCallback((id: string, patch: Partial<ImageItem>) => {
|
||||||
dispatch({ type: 'UPDATE', id, patch })
|
dispatch({ type: 'UPDATE', id, patch })
|
||||||
@@ -57,24 +82,21 @@ export function useImages() {
|
|||||||
async (id: string, ref: string) => {
|
async (id: string, ref: string) => {
|
||||||
update(id, { status: 'searching' })
|
update(id, { status: 'searching' })
|
||||||
try {
|
try {
|
||||||
// 1. Cherche d'abord dans Google Sheets (col C = UGS → col A = Shopify ID)
|
|
||||||
const sheetsRes = await fetch(`/api/products/search-sheets?ref=${encodeURIComponent(ref)}`)
|
const sheetsRes = await fetch(`/api/products/search-sheets?ref=${encodeURIComponent(ref)}`)
|
||||||
const sheetsData = await sheetsRes.json()
|
const sheetsData = await sheetsRes.json()
|
||||||
if (sheetsData.found) {
|
if (sheetsData.found) {
|
||||||
update(id, {
|
update(id, {
|
||||||
status: 'ready',
|
status: 'converted',
|
||||||
shopifyProductId: sheetsData.shopifyId,
|
shopifyProductId: sheetsData.shopifyId,
|
||||||
shopifyProductTitle: sheetsData.title,
|
shopifyProductTitle: sheetsData.title,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fallback : recherche directe dans Shopify par handle/titre
|
|
||||||
const res = await fetch(`/api/products/search?ref=${encodeURIComponent(ref)}`)
|
const res = await fetch(`/api/products/search?ref=${encodeURIComponent(ref)}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.found) {
|
if (data.found) {
|
||||||
update(id, {
|
update(id, {
|
||||||
status: 'ready',
|
status: 'converted',
|
||||||
shopifyProductId: data.shopifyId,
|
shopifyProductId: data.shopifyId,
|
||||||
shopifyProductTitle: data.title,
|
shopifyProductTitle: data.title,
|
||||||
})
|
})
|
||||||
@@ -88,31 +110,40 @@ export function useImages() {
|
|||||||
[update],
|
[update],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Pipeline complet pour un item (avec ref)
|
// Pipeline léger : conversion HEIC + recherche produit (sans détourage IA)
|
||||||
const runPipeline = useCallback(
|
const runConvertAndSearch = useCallback(
|
||||||
async (id: string, blob: Blob, ref: string, feather: number, rotation: number) => {
|
async (id: string, blob: Blob, ref: string) => {
|
||||||
const { convertHeicToJpeg } = await import('@/lib/client/heicConverter')
|
const { convertHeicToJpeg } = await import('@/lib/client/heicConverter')
|
||||||
const { removeBackgroundRaw, compositeOnWhite } = await import('@/lib/client/bgRemover')
|
|
||||||
const { rotateBlob } = await import('@/lib/client/imageRotator')
|
|
||||||
|
|
||||||
let jpegBlob = blob
|
let jpegBlob = blob
|
||||||
|
|
||||||
if (blob.type === 'image/heic' || blob.type === 'image/heif' || blob.type === '') {
|
if (blob.type === 'image/heic' || blob.type === 'image/heif' || blob.type === '') {
|
||||||
try {
|
try {
|
||||||
update(id, { status: 'converting' })
|
update(id, { status: 'converting' })
|
||||||
jpegBlob = await convertHeicToJpeg(blob)
|
jpegBlob = await convertHeicToJpeg(blob)
|
||||||
// Met à jour originalBlob avec le JPEG converti (Chrome ne peut pas afficher HEIC)
|
|
||||||
update(id, { originalBlob: jpegBlob })
|
update(id, { originalBlob: jpegBlob })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
update(id, { status: 'error', error: `Conversion HEIC : ${(err as Error).message}` })
|
update(id, { status: 'error', error: `Conversion HEIC : ${(err as Error).message}` })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
update(id, { status: 'searching' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await searchProduct(id, ref)
|
||||||
|
},
|
||||||
|
[update, searchProduct],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pipeline IA complet : détourage + composite + rotation
|
||||||
|
const runBgRemoval = useCallback(
|
||||||
|
async (id: string, blob: Blob, feather: number, rotation: number, alphaThreshold: number) => {
|
||||||
|
const { removeBackgroundRaw, compositeOnWhite } = await import('@/lib/client/bgRemover')
|
||||||
|
const { rotateBlob } = await import('@/lib/client/imageRotator')
|
||||||
|
|
||||||
let rawPng: Blob
|
let rawPng: Blob
|
||||||
try {
|
try {
|
||||||
update(id, { status: 'processing', progress: null })
|
update(id, { status: 'processing', progress: null })
|
||||||
rawPng = await removeBackgroundRaw(jpegBlob, (p) => update(id, { progress: p }))
|
rawPng = await removeBackgroundRaw(blob, (p) => update(id, { progress: p }))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
update(id, { status: 'error', error: `Détourage IA : ${(err as Error).message}` })
|
update(id, { status: 'error', error: `Détourage IA : ${(err as Error).message}` })
|
||||||
return
|
return
|
||||||
@@ -120,28 +151,27 @@ export function useImages() {
|
|||||||
|
|
||||||
let finalBlob: Blob
|
let finalBlob: Blob
|
||||||
try {
|
try {
|
||||||
const composited = await compositeOnWhite(rawPng, feather)
|
const composited = await compositeOnWhite(rawPng, feather, alphaThreshold)
|
||||||
finalBlob = await rotateBlob(composited, rotation)
|
finalBlob = await rotateBlob(composited, rotation)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
update(id, { status: 'error', error: `Post-traitement : ${(err as Error).message}` })
|
update(id, { status: 'error', error: `Post-traitement : ${(err as Error).message}` })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
update(id, { rawPngBlob: rawPng, processedBlob: finalBlob })
|
update(id, { rawPngBlob: rawPng, processedBlob: finalBlob, status: 'ready' })
|
||||||
await searchProduct(id, ref)
|
|
||||||
},
|
},
|
||||||
[update, searchProduct],
|
[update],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Recomposite rapide (feather ou rotation changés) — ne relance pas l'IA
|
// Recomposite rapide (feather, alphaThreshold ou rotation changés) — ne relance pas l'IA
|
||||||
const recomposite = useCallback(
|
const recomposite = useCallback(
|
||||||
async (id: string, rawPngBlob: Blob, feather: number, rotation: number) => {
|
async (id: string, rawPngBlob: Blob, feather: number, rotation: number, alphaThreshold: number) => {
|
||||||
const { compositeOnWhite } = await import('@/lib/client/bgRemover')
|
const { compositeOnWhite } = await import('@/lib/client/bgRemover')
|
||||||
const { rotateBlob } = await import('@/lib/client/imageRotator')
|
const { rotateBlob } = await import('@/lib/client/imageRotator')
|
||||||
try {
|
try {
|
||||||
const composited = await compositeOnWhite(rawPngBlob, feather)
|
const composited = await compositeOnWhite(rawPngBlob, feather, alphaThreshold)
|
||||||
const finalBlob = await rotateBlob(composited, rotation)
|
const finalBlob = await rotateBlob(composited, rotation)
|
||||||
update(id, { processedBlob: finalBlob, feather, rotation })
|
update(id, { processedBlob: finalBlob, feather, rotation, alphaThreshold })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
update(id, { status: 'error', error: `Retraitement : ${(err as Error).message}` })
|
update(id, { status: 'error', error: `Retraitement : ${(err as Error).message}` })
|
||||||
}
|
}
|
||||||
@@ -167,6 +197,7 @@ export function useImages() {
|
|||||||
processedBlob: null,
|
processedBlob: null,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
feather: 2,
|
feather: 2,
|
||||||
|
alphaThreshold: 30,
|
||||||
shopifyProductId: null,
|
shopifyProductId: null,
|
||||||
shopifyProductTitle: null,
|
shopifyProductTitle: null,
|
||||||
uploadedUrl: null,
|
uploadedUrl: null,
|
||||||
@@ -174,9 +205,13 @@ export function useImages() {
|
|||||||
error: null,
|
error: null,
|
||||||
progress: null,
|
progress: null,
|
||||||
}))
|
}))
|
||||||
dispatch({ type: 'ADD', items: newItems })
|
const itemsWithRef = newItems.map(item => {
|
||||||
for (const item of newItems) {
|
const basename = item.filename.split('/').pop() ?? item.filename
|
||||||
runPipeline(item.id, item.originalBlob, item.ref, item.feather, item.rotation)
|
return { ...item, ref: refFromFilename(basename) }
|
||||||
|
})
|
||||||
|
dispatch({ type: 'ADD', items: itemsWithRef })
|
||||||
|
for (const item of itemsWithRef) {
|
||||||
|
queueRef.current.enqueue(() => runConvertAndSearch(item.id, item.originalBlob, item.ref))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const ref = refFromFilename(file.name)
|
const ref = refFromFilename(file.name)
|
||||||
@@ -189,6 +224,7 @@ export function useImages() {
|
|||||||
processedBlob: null,
|
processedBlob: null,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
feather: 2,
|
feather: 2,
|
||||||
|
alphaThreshold: 30,
|
||||||
shopifyProductId: null,
|
shopifyProductId: null,
|
||||||
shopifyProductTitle: null,
|
shopifyProductTitle: null,
|
||||||
uploadedUrl: null,
|
uploadedUrl: null,
|
||||||
@@ -197,18 +233,51 @@ export function useImages() {
|
|||||||
progress: null,
|
progress: null,
|
||||||
}
|
}
|
||||||
dispatch({ type: 'ADD', items: [newItem] })
|
dispatch({ type: 'ADD', items: [newItem] })
|
||||||
runPipeline(newItem.id, newItem.originalBlob, newItem.ref, newItem.feather, newItem.rotation)
|
queueRef.current.enqueue(() => runConvertAndSearch(newItem.id, newItem.originalBlob, newItem.ref))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[runPipeline],
|
[runConvertAndSearch],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Lancer le détourage IA sur une image (depuis statut converted ou error)
|
||||||
|
const detour = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
const item = items.find((i) => i.id === id)
|
||||||
|
if (!item) return
|
||||||
|
queueRef.current.enqueue(() =>
|
||||||
|
runBgRemoval(item.id, item.originalBlob, item.feather, item.rotation, item.alphaThreshold)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[items, runBgRemoval],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Relancer le pipeline complet (conversion + détourage) sur une image déjà traitée
|
||||||
|
const reprocess = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
const item = items.find((i) => i.id === id)
|
||||||
|
if (!item) return
|
||||||
|
queueRef.current.enqueue(() =>
|
||||||
|
runBgRemoval(item.id, item.originalBlob, item.feather, item.rotation, item.alphaThreshold)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[items, runBgRemoval],
|
||||||
)
|
)
|
||||||
|
|
||||||
const setFeather = useCallback(
|
const setFeather = useCallback(
|
||||||
(id: string, value: number) => {
|
(id: string, value: number) => {
|
||||||
const item = items.find((i) => i.id === id)
|
const item = items.find((i) => i.id === id)
|
||||||
if (!item || !item.rawPngBlob) return
|
if (!item || !item.rawPngBlob) return
|
||||||
recomposite(id, item.rawPngBlob, value, item.rotation)
|
recomposite(id, item.rawPngBlob, value, item.rotation, item.alphaThreshold)
|
||||||
|
},
|
||||||
|
[items, recomposite],
|
||||||
|
)
|
||||||
|
|
||||||
|
const setAlphaThreshold = useCallback(
|
||||||
|
(id: string, value: number) => {
|
||||||
|
const item = items.find((i) => i.id === id)
|
||||||
|
if (!item || !item.rawPngBlob) return
|
||||||
|
recomposite(id, item.rawPngBlob, item.feather, item.rotation, value)
|
||||||
},
|
},
|
||||||
[items, recomposite],
|
[items, recomposite],
|
||||||
)
|
)
|
||||||
@@ -219,7 +288,7 @@ export function useImages() {
|
|||||||
if (!item || !item.rawPngBlob) return
|
if (!item || !item.rawPngBlob) return
|
||||||
const delta = direction === 'cw' ? 90 : -90
|
const delta = direction === 'cw' ? 90 : -90
|
||||||
const newRotation = ((item.rotation + delta) % 360 + 360) % 360
|
const newRotation = ((item.rotation + delta) % 360 + 360) % 360
|
||||||
recomposite(id, item.rawPngBlob, item.feather, newRotation)
|
recomposite(id, item.rawPngBlob, item.feather, newRotation, item.alphaThreshold)
|
||||||
},
|
},
|
||||||
[items, recomposite],
|
[items, recomposite],
|
||||||
)
|
)
|
||||||
@@ -227,18 +296,45 @@ export function useImages() {
|
|||||||
const uploadOne = useCallback(
|
const uploadOne = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
const item = items.find((i) => i.id === id)
|
const item = items.find((i) => i.id === id)
|
||||||
if (!item || item.status !== 'ready' || !item.processedBlob || !item.shopifyProductId) return
|
if (!item) return
|
||||||
|
const canUpload = item.status === 'ready' || item.status === 'converted'
|
||||||
|
if (!canUpload || !item.shopifyProductId) return
|
||||||
|
|
||||||
update(id, { status: 'uploading' })
|
update(id, { status: 'uploading' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const base64 = await blobToBase64(item.processedBlob)
|
// Si détouré : utilise processedBlob ; sinon : redimensionne à 1000×1000
|
||||||
const filename = `${item.ref}.jpg`
|
let uploadBlob: Blob
|
||||||
const res = await fetch('/api/images/upload', {
|
if (item.processedBlob) {
|
||||||
method: 'POST',
|
uploadBlob = item.processedBlob
|
||||||
headers: { 'Content-Type': 'application/json' },
|
} else {
|
||||||
body: JSON.stringify({ shopifyProductId: item.shopifyProductId, imageBase64: base64, filename }),
|
const { resizeToSquare } = await import('@/lib/client/imageResizer')
|
||||||
})
|
uploadBlob = await resizeToSquare(item.originalBlob)
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64 = await blobToBase64(uploadBlob)
|
||||||
|
const basename = item.filename.split('/').pop()?.replace(/\.[^.]+$/, '') ?? item.ref
|
||||||
|
const filename = `${basename}.jpg`
|
||||||
|
|
||||||
|
const doUpload = async (shopifyProductId: string) =>
|
||||||
|
fetch('/api/images/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ shopifyProductId, imageBase64: base64, filename }),
|
||||||
|
})
|
||||||
|
|
||||||
|
let res = await doUpload(item.shopifyProductId)
|
||||||
|
|
||||||
|
// 404 = ID périmé → relancer la recherche par ref
|
||||||
|
if (res.status === 404) {
|
||||||
|
const searchRes = await fetch(`/api/products/search?ref=${encodeURIComponent(item.ref)}`)
|
||||||
|
const searchData = await searchRes.json()
|
||||||
|
if (searchData.found && searchData.shopifyId) {
|
||||||
|
update(id, { shopifyProductId: searchData.shopifyId, shopifyProductTitle: searchData.title })
|
||||||
|
res = await doUpload(searchData.shopifyId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||||
update(id, { status: data.skipped ? 'skipped' : 'uploaded', uploadedUrl: data.url })
|
update(id, { status: data.skipped ? 'skipped' : 'uploaded', uploadedUrl: data.url })
|
||||||
@@ -250,10 +346,19 @@ export function useImages() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const uploadAll = useCallback(async () => {
|
const uploadAll = useCallback(async () => {
|
||||||
const ready = items.filter((i) => i.status === 'ready')
|
const ready = items.filter((i) => i.status === 'ready' || i.status === 'converted')
|
||||||
for (const item of ready) {
|
for (const item of ready) {
|
||||||
await uploadOne(item.id)
|
await uploadOne(item.id)
|
||||||
// Pause de 500ms entre chaque upload pour éviter le rate limit Shopify
|
await new Promise(r => setTimeout(r, 500))
|
||||||
|
}
|
||||||
|
}, [items, uploadOne])
|
||||||
|
|
||||||
|
const uploadSelected = useCallback(async (ids: Set<string>) => {
|
||||||
|
const toUpload = items.filter(i =>
|
||||||
|
ids.has(i.id) && (i.status === 'ready' || i.status === 'converted') && !!i.shopifyProductId
|
||||||
|
)
|
||||||
|
for (const item of toUpload) {
|
||||||
|
await uploadOne(item.id)
|
||||||
await new Promise(r => setTimeout(r, 500))
|
await new Promise(r => setTimeout(r, 500))
|
||||||
}
|
}
|
||||||
}, [items, uploadOne])
|
}, [items, uploadOne])
|
||||||
@@ -264,10 +369,12 @@ export function useImages() {
|
|||||||
|
|
||||||
const assignProduct = useCallback(
|
const assignProduct = useCallback(
|
||||||
(id: string, shopifyProductId: string, shopifyProductTitle: string) => {
|
(id: string, shopifyProductId: string, shopifyProductTitle: string) => {
|
||||||
update(id, { shopifyProductId, shopifyProductTitle, status: 'ready' })
|
const item = items.find((i) => i.id === id)
|
||||||
|
const newStatus = item?.processedBlob ? 'ready' : 'converted'
|
||||||
|
update(id, { shopifyProductId, shopifyProductTitle, status: newStatus })
|
||||||
},
|
},
|
||||||
[update],
|
[items, update],
|
||||||
)
|
)
|
||||||
|
|
||||||
return { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem, assignProduct }
|
return { items, addFiles, setFeather, setAlphaThreshold, rotate, uploadOne, uploadAll, uploadSelected, removeItem, assignProduct, reprocess, detour }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
// Rendu de bannières homepage sur canvas OffscreenCanvas
|
||||||
|
// Format : 1500×500 (paysage, ratio 3:1)
|
||||||
|
|
||||||
|
export const BANNER_WIDTH = 1500
|
||||||
|
export const BANNER_HEIGHT = 500
|
||||||
|
|
||||||
|
export type ColorScheme = 'dark-blue' | 'dark-green' | 'white' | 'red' | 'black'
|
||||||
|
|
||||||
|
export const COLOR_SCHEMES: Record<ColorScheme, {
|
||||||
|
label: string
|
||||||
|
bg: string
|
||||||
|
badgeBg: string
|
||||||
|
badgeText: string
|
||||||
|
titleColor: string
|
||||||
|
priceColor: string
|
||||||
|
strikePriceColor: string
|
||||||
|
subtitleColor: string
|
||||||
|
}> = {
|
||||||
|
'dark-blue': {
|
||||||
|
label: 'Bleu nuit',
|
||||||
|
bg: '#0f1c3f',
|
||||||
|
badgeBg: '#f0c040',
|
||||||
|
badgeText: '#1a1a1a',
|
||||||
|
titleColor: '#ffffff',
|
||||||
|
priceColor: '#ff7a2f',
|
||||||
|
strikePriceColor: '#8899bb',
|
||||||
|
subtitleColor: '#aabbdd',
|
||||||
|
},
|
||||||
|
'dark-green': {
|
||||||
|
label: 'Vert forêt',
|
||||||
|
bg: '#0f2b1e',
|
||||||
|
badgeBg: '#4caf50',
|
||||||
|
badgeText: '#ffffff',
|
||||||
|
titleColor: '#ffffff',
|
||||||
|
priceColor: '#ffcc00',
|
||||||
|
strikePriceColor: '#7aaa88',
|
||||||
|
subtitleColor: '#99cc99',
|
||||||
|
},
|
||||||
|
'white': {
|
||||||
|
label: 'Blanc',
|
||||||
|
bg: '#ffffff',
|
||||||
|
badgeBg: '#e53935',
|
||||||
|
badgeText: '#ffffff',
|
||||||
|
titleColor: '#1a1a2e',
|
||||||
|
priceColor: '#e53935',
|
||||||
|
strikePriceColor: '#aaaaaa',
|
||||||
|
subtitleColor: '#555566',
|
||||||
|
},
|
||||||
|
'red': {
|
||||||
|
label: 'Rouge',
|
||||||
|
bg: '#b71c1c',
|
||||||
|
badgeBg: '#ffeb3b',
|
||||||
|
badgeText: '#1a1a1a',
|
||||||
|
titleColor: '#ffffff',
|
||||||
|
priceColor: '#ffeb3b',
|
||||||
|
strikePriceColor: '#ff8a80',
|
||||||
|
subtitleColor: '#ffcdd2',
|
||||||
|
},
|
||||||
|
'black': {
|
||||||
|
label: 'Noir',
|
||||||
|
bg: '#111111',
|
||||||
|
badgeBg: '#e0e0e0',
|
||||||
|
badgeText: '#111111',
|
||||||
|
titleColor: '#ffffff',
|
||||||
|
priceColor: '#ffcc00',
|
||||||
|
strikePriceColor: '#666666',
|
||||||
|
subtitleColor: '#aaaaaa',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapText(
|
||||||
|
ctx: OffscreenCanvasRenderingContext2D,
|
||||||
|
text: string,
|
||||||
|
maxWidth: number,
|
||||||
|
): string[] {
|
||||||
|
const words = text.split(' ')
|
||||||
|
const lines: string[] = []
|
||||||
|
let current = ''
|
||||||
|
for (const word of words) {
|
||||||
|
const test = current ? `${current} ${word}` : word
|
||||||
|
if (ctx.measureText(test).width > maxWidth && current) {
|
||||||
|
lines.push(current)
|
||||||
|
current = word
|
||||||
|
} else {
|
||||||
|
current = test
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) lines.push(current)
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundRect(
|
||||||
|
ctx: OffscreenCanvasRenderingContext2D,
|
||||||
|
x: number, y: number, w: number, h: number, r: number,
|
||||||
|
) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x + r, y)
|
||||||
|
ctx.lineTo(x + w - r, y)
|
||||||
|
ctx.quadraticCurveTo(x + w, y, x + w, y + r)
|
||||||
|
ctx.lineTo(x + w, y + h - r)
|
||||||
|
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
|
||||||
|
ctx.lineTo(x + r, y + h)
|
||||||
|
ctx.quadraticCurveTo(x, y + h, x, y + h - r)
|
||||||
|
ctx.lineTo(x, y + r)
|
||||||
|
ctx.quadraticCurveTo(x, y, x + r, y)
|
||||||
|
ctx.closePath()
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BannerConfig {
|
||||||
|
scheme: ColorScheme
|
||||||
|
promoLabel: string // ex: "Destockage - Durée limitée"
|
||||||
|
title: string // ex: "PEINTURE FAÇADE PLIOLITE PEF PLIO BLANC MAT CECIL, 10 L"
|
||||||
|
price: string // ex: "79 €"
|
||||||
|
comparePrice: string // ex: "119 €" (barré)
|
||||||
|
productImageUrl: string | null
|
||||||
|
backgroundImageBlob?: Blob | null // image de fond uploadée (remplace la couleur unie)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderBanner(config: BannerConfig): Promise<Blob> {
|
||||||
|
const W = BANNER_WIDTH
|
||||||
|
const H = BANNER_HEIGHT
|
||||||
|
const canvas = new OffscreenCanvas(W, H)
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
const scheme = COLOR_SCHEMES[config.scheme]
|
||||||
|
|
||||||
|
// ── Fond ─────────────────────────────────────────────────────────────────
|
||||||
|
if (config.backgroundImageBlob) {
|
||||||
|
try {
|
||||||
|
const bgImg = await createImageBitmap(config.backgroundImageBlob)
|
||||||
|
// Cover : remplit tout le canvas en centrant
|
||||||
|
const scale = Math.max(W / bgImg.width, H / bgImg.height)
|
||||||
|
const dw = bgImg.width * scale
|
||||||
|
const dh = bgImg.height * scale
|
||||||
|
const dx = (W - dw) / 2
|
||||||
|
const dy = (H - dh) / 2
|
||||||
|
ctx.drawImage(bgImg, dx, dy, dw, dh)
|
||||||
|
bgImg.close()
|
||||||
|
// Overlay semi-transparent pour lisibilité du texte
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.35)'
|
||||||
|
ctx.fillRect(0, 0, W, H)
|
||||||
|
} catch {
|
||||||
|
ctx.fillStyle = scheme.bg
|
||||||
|
ctx.fillRect(0, 0, W, H)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = scheme.bg
|
||||||
|
ctx.fillRect(0, 0, W, H)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zone texte : 55% gauche, zone image : 45% droite
|
||||||
|
const textZoneW = Math.round(W * 0.55)
|
||||||
|
const imageZoneX = textZoneW
|
||||||
|
const imageZoneW = W - textZoneW
|
||||||
|
const pad = 60
|
||||||
|
|
||||||
|
// ── Image produit ─────────────────────────────────────────────────────────
|
||||||
|
if (config.productImageUrl) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/images/proxy?url=${encodeURIComponent(config.productImageUrl)}`)
|
||||||
|
const blob = await res.blob()
|
||||||
|
const img = await createImageBitmap(blob)
|
||||||
|
|
||||||
|
// Calcul pour centrer/contenir dans la zone droite avec padding
|
||||||
|
const maxW = imageZoneW - pad * 2
|
||||||
|
const maxH = H - pad * 2
|
||||||
|
const scale = Math.min(maxW / img.width, maxH / img.height)
|
||||||
|
const dw = img.width * scale
|
||||||
|
const dh = img.height * scale
|
||||||
|
const dx = imageZoneX + (imageZoneW - dw) / 2
|
||||||
|
const dy = (H - dh) / 2
|
||||||
|
ctx.drawImage(img, dx, dy, dw, dh)
|
||||||
|
img.close()
|
||||||
|
} catch {
|
||||||
|
// Image non chargeable — on continue sans
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Badge promo ───────────────────────────────────────────────────────────
|
||||||
|
let curY = 80
|
||||||
|
if (config.promoLabel.trim()) {
|
||||||
|
ctx.font = 'bold 26px Arial, sans-serif'
|
||||||
|
const badgeText = config.promoLabel.toUpperCase()
|
||||||
|
const metrics = ctx.measureText(badgeText)
|
||||||
|
const bw = metrics.width + 32
|
||||||
|
const bh = 44
|
||||||
|
ctx.fillStyle = scheme.badgeBg
|
||||||
|
roundRect(ctx, pad, curY, bw, bh, 8)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.fillStyle = scheme.badgeText
|
||||||
|
ctx.textBaseline = 'middle'
|
||||||
|
ctx.fillText(badgeText, pad + 16, curY + bh / 2)
|
||||||
|
curY += bh + 28
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Titre produit ─────────────────────────────────────────────────────────
|
||||||
|
const titleMaxW = textZoneW - pad * 2
|
||||||
|
ctx.font = 'bold 52px Arial, sans-serif'
|
||||||
|
ctx.fillStyle = scheme.titleColor
|
||||||
|
ctx.textBaseline = 'top'
|
||||||
|
const titleLines = wrapText(ctx, config.title, titleMaxW)
|
||||||
|
const displayLines = titleLines.slice(0, 3) // max 3 lignes
|
||||||
|
for (const line of displayLines) {
|
||||||
|
ctx.fillText(line, pad, curY)
|
||||||
|
curY += 60
|
||||||
|
}
|
||||||
|
curY += 16
|
||||||
|
|
||||||
|
// ── Prix ──────────────────────────────────────────────────────────────────
|
||||||
|
if (config.price.trim()) {
|
||||||
|
ctx.font = 'bold 72px Arial, sans-serif'
|
||||||
|
ctx.fillStyle = scheme.priceColor
|
||||||
|
ctx.textBaseline = 'top'
|
||||||
|
ctx.fillText(config.price, pad, curY)
|
||||||
|
|
||||||
|
if (config.comparePrice.trim()) {
|
||||||
|
const priceW = ctx.measureText(config.price).width
|
||||||
|
ctx.font = '36px Arial, sans-serif'
|
||||||
|
ctx.fillStyle = scheme.strikePriceColor
|
||||||
|
const cpText = `Au lieu de ${config.comparePrice}`
|
||||||
|
const cpX = pad + priceW + 20
|
||||||
|
const cpY = curY + 32
|
||||||
|
ctx.fillText(cpText, cpX, cpY)
|
||||||
|
|
||||||
|
// Ligne de barrage
|
||||||
|
const cpW = ctx.measureText(cpText).width
|
||||||
|
ctx.strokeStyle = scheme.strikePriceColor
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(cpX, cpY + 22)
|
||||||
|
ctx.lineTo(cpX + cpW, cpY + 22)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return canvas.convertToBlob({ type: 'image/jpeg', quality: 0.95 })
|
||||||
|
}
|
||||||
@@ -38,8 +38,9 @@ export async function removeBackgroundRaw(
|
|||||||
* Composite un PNG transparent sur fond blanc avec feather optionnel.
|
* Composite un PNG transparent sur fond blanc avec feather optionnel.
|
||||||
* @param pngBlob — PNG avec transparence (résultat de removeBackgroundRaw)
|
* @param pngBlob — PNG avec transparence (résultat de removeBackgroundRaw)
|
||||||
* @param feather — récupération des bords en px (0 = strict, 5 = doux)
|
* @param feather — récupération des bords en px (0 = strict, 5 = doux)
|
||||||
|
* @param alphaThreshold — pixels avec alpha > seuil deviennent opaques (0 = désactivé, ex: 30 pour récupérer le verre)
|
||||||
*/
|
*/
|
||||||
export function compositeOnWhite(pngBlob: Blob, feather: number): Promise<Blob> {
|
export function compositeOnWhite(pngBlob: Blob, feather: number, alphaThreshold = 0): Promise<Blob> {
|
||||||
const objectUrl = URL.createObjectURL(pngBlob)
|
const objectUrl = URL.createObjectURL(pngBlob)
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -55,6 +56,16 @@ export function compositeOnWhite(pngBlob: Blob, feather: number): Promise<Blob>
|
|||||||
|
|
||||||
fgCtx.drawImage(img, 0, 0)
|
fgCtx.drawImage(img, 0, 0)
|
||||||
|
|
||||||
|
// Remonte les pixels semi-transparents (ex: verre) à pleine opacité
|
||||||
|
if (alphaThreshold > 0) {
|
||||||
|
const imageData = fgCtx.getImageData(0, 0, width, height)
|
||||||
|
const data = imageData.data
|
||||||
|
for (let i = 3; i < data.length; i += 4) {
|
||||||
|
if (data[i] > alphaThreshold) data[i] = 255
|
||||||
|
}
|
||||||
|
fgCtx.putImageData(imageData, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
if (feather > 0) {
|
if (feather > 0) {
|
||||||
fgCtx.globalCompositeOperation = 'destination-over'
|
fgCtx.globalCompositeOperation = 'destination-over'
|
||||||
fgCtx.filter = `blur(${feather}px)`
|
fgCtx.filter = `blur(${feather}px)`
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export async function resizeToSquare(blob: Blob, size = 1000): Promise<Blob> {
|
||||||
|
const img = await createImageBitmap(blob)
|
||||||
|
const canvas = new OffscreenCanvas(size, size)
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.fillRect(0, 0, size, size)
|
||||||
|
const scale = Math.min(size / img.width, size / img.height)
|
||||||
|
const w = img.width * scale
|
||||||
|
const h = img.height * scale
|
||||||
|
const x = (size - w) / 2
|
||||||
|
const y = (size - h) / 2
|
||||||
|
ctx.drawImage(img, x, y, w, h)
|
||||||
|
img.close()
|
||||||
|
return canvas.convertToBlob({ type: 'image/jpeg', quality: 0.92 })
|
||||||
|
}
|
||||||
+73
-12
@@ -5,11 +5,11 @@ const COMMON_HEADERS = new Set([
|
|||||||
'Description courte', 'Description longue',
|
'Description courte', 'Description longue',
|
||||||
'Date de début de promo', 'Date de fin de promo',
|
'Date de début de promo', 'Date de fin de promo',
|
||||||
'État de la TVA', 'Classe de TVA', 'En stock ?',
|
'État de la TVA', 'Classe de TVA', 'En stock ?',
|
||||||
'Stock (en pièce)', 'Vendre individuellement ?',
|
'Stock (en pièce)', 'Stock (pièce)', 'Stock conditionné', 'Vendre individuellement ?',
|
||||||
'Prix marché', 'Prix remisé', 'Code ecopart', 'Ecopart montant',
|
'Prix marché', 'Prix remisé', 'Code ecopart', 'Ecopart montant',
|
||||||
'Famille', 'Sous famille',
|
'Famille', 'Sous famille',
|
||||||
"Classe d'expédition (en retrait, livraison)",
|
"Classe d'expédition (en retrait, livraison)",
|
||||||
'Conditionnement', 'Surface (m2)', 'Poids (kg) fonction unité',
|
'Conditionnement', 'Poids (kg) fonction unité',
|
||||||
'Images', 'Unité', 'Marque', 'Neuf ?', 'PHOTO ?',
|
'Images', 'Unité', 'Marque', 'Neuf ?', 'PHOTO ?',
|
||||||
'ID FAMILLE', 'ID SOUS FAMILLE',
|
'ID FAMILLE', 'ID SOUS FAMILLE',
|
||||||
])
|
])
|
||||||
@@ -38,7 +38,8 @@ export function parseTabRows(tab: string, headers: string[], rows: string[][], d
|
|||||||
sku: findColIndex(headers, 'UGS'),
|
sku: findColIndex(headers, 'UGS'),
|
||||||
nom: findColIndex(headers, 'Nom'),
|
nom: findColIndex(headers, 'Nom'),
|
||||||
publie: findColIndex(headers, 'Publié'),
|
publie: findColIndex(headers, 'Publié'),
|
||||||
stock: findColIndex(headers, 'Stock (en pièce)'),
|
stock: findColIndex(headers, 'Stock (en pièce)') !== -1 ? findColIndex(headers, 'Stock (en pièce)') : findColIndex(headers, 'Stock (pièce)'),
|
||||||
|
stockConditionne: findColIndex(headers, 'Stock conditionné'),
|
||||||
prixMarche: findColIndex(headers, 'Prix marché'),
|
prixMarche: findColIndex(headers, 'Prix marché'),
|
||||||
prixRemise: findColIndex(headers, 'Prix remisé'),
|
prixRemise: findColIndex(headers, 'Prix remisé'),
|
||||||
poids: findColIndex(headers, 'Poids (kg) fonction unité'),
|
poids: findColIndex(headers, 'Poids (kg) fonction unité'),
|
||||||
@@ -65,7 +66,7 @@ export function parseTabRows(tab: string, headers: string[], rows: string[][], d
|
|||||||
title: get(row, idx.nom),
|
title: get(row, idx.nom),
|
||||||
priceMarket: get(row, idx.prixMarche).replace(',', '.'),
|
priceMarket: get(row, idx.prixMarche).replace(',', '.'),
|
||||||
priceActual: get(row, idx.prixRemise).replace(',', '.'),
|
priceActual: get(row, idx.prixRemise).replace(',', '.'),
|
||||||
stock: parseInt(get(row, idx.stock) || '0', 10) || 0,
|
stock: parseInt(get(row, idx.stockConditionne) || get(row, idx.stock) || '0', 10) || 0,
|
||||||
weightKg: parseFloat(get(row, idx.poids).replace(',', '.') || '0') || 0,
|
weightKg: parseFloat(get(row, idx.poids).replace(',', '.') || '0') || 0,
|
||||||
vendor: get(row, idx.marque),
|
vendor: get(row, idx.marque),
|
||||||
famille: get(row, idx.famille),
|
famille: get(row, idx.famille),
|
||||||
@@ -80,21 +81,81 @@ export function parseTabRows(tab: string, headers: string[], rows: string[][], d
|
|||||||
.filter((p): p is PendingProduct => p !== null)
|
.filter((p): p is PendingProduct => p !== null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Écrit l'ID Shopify en colonne A de la ligne correspondante dans Google Sheets.
|
||||||
|
* Utilise un compte de service avec accès éditeur sur la feuille.
|
||||||
|
*/
|
||||||
|
export async function writeShopifyIdToSheet(tab: string, rowNumber: number, shopifyId: string): Promise<void> {
|
||||||
|
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||||
|
const email = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL
|
||||||
|
const rawKey = process.env.GOOGLE_SERVICE_ACCOUNT_KEY
|
||||||
|
if (!sheetId || !email || !rawKey) return
|
||||||
|
|
||||||
|
// Génère un JWT pour l'authentification Google
|
||||||
|
const privateKey = rawKey.replace(/\\n/g, '\n')
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url')
|
||||||
|
const payload = Buffer.from(JSON.stringify({
|
||||||
|
iss: email,
|
||||||
|
scope: 'https://www.googleapis.com/auth/spreadsheets',
|
||||||
|
aud: 'https://oauth2.googleapis.com/token',
|
||||||
|
iat: now,
|
||||||
|
exp: now + 3600,
|
||||||
|
})).toString('base64url')
|
||||||
|
|
||||||
|
const { createSign } = await import('crypto')
|
||||||
|
const sign = createSign('RSA-SHA256')
|
||||||
|
sign.update(`${header}.${payload}`)
|
||||||
|
const signature = sign.sign(privateKey, 'base64url')
|
||||||
|
const jwt = `${header}.${payload}.${signature}`
|
||||||
|
|
||||||
|
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt }),
|
||||||
|
})
|
||||||
|
if (!tokenRes.ok) {
|
||||||
|
console.error(`[sheets] token error ${tokenRes.status} for ${tab}!A${rowNumber}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { access_token } = await tokenRes.json() as { access_token: string }
|
||||||
|
|
||||||
|
const range = encodeURIComponent(`${tab}!A${rowNumber}`)
|
||||||
|
const writeRes = await fetch(
|
||||||
|
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?valueInputOption=RAW`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { Authorization: `Bearer ${access_token}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ range: `${tab}!A${rowNumber}`, majorDimension: 'ROWS', values: [[shopifyId]] }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!writeRes.ok) {
|
||||||
|
const txt = await writeRes.text()
|
||||||
|
console.error(`[sheets] write error ${writeRes.status} for ${tab}!A${rowNumber}: ${txt}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchPendingProducts(
|
export async function fetchPendingProducts(
|
||||||
alreadyCreatedRows: Set<string>,
|
alreadyCreatedRows: Set<string>,
|
||||||
|
tabFilter?: string,
|
||||||
): Promise<PendingProduct[]> {
|
): Promise<PendingProduct[]> {
|
||||||
const apiKey = process.env.GOOGLE_API_KEY
|
const apiKey = process.env.GOOGLE_API_KEY
|
||||||
const sheetId = process.env.GOOGLE_SHEETS_ID
|
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||||
if (!apiKey || !sheetId) throw new Error('GOOGLE_API_KEY et GOOGLE_SHEETS_ID requis')
|
if (!apiKey || !sheetId) throw new Error('GOOGLE_API_KEY et GOOGLE_SHEETS_ID requis')
|
||||||
|
|
||||||
const metaRes = await fetch(
|
let tabs: string[]
|
||||||
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`,
|
if (tabFilter) {
|
||||||
)
|
tabs = [tabFilter]
|
||||||
if (!metaRes.ok) throw new Error(`Sheets metadata error: ${metaRes.status}`)
|
} else {
|
||||||
const meta = await metaRes.json()
|
const metaRes = await fetch(
|
||||||
const tabs: string[] = (meta.sheets as Array<{ properties: { title: string } }>).map(
|
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`,
|
||||||
s => s.properties.title,
|
)
|
||||||
)
|
if (!metaRes.ok) throw new Error(`Sheets metadata error: ${metaRes.status}`)
|
||||||
|
const meta = await metaRes.json()
|
||||||
|
tabs = (meta.sheets as Array<{ properties: { title: string } }>).map(
|
||||||
|
s => s.properties.title,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const all: PendingProduct[] = []
|
const all: PendingProduct[] = []
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
|
|||||||
+70
-16
@@ -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,28 +8,81 @@ function normalizeHandle(ref: string): string {
|
|||||||
.replace(/^-+|-+$/g, '')
|
.replace(/^-+|-+$/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function get(row: string[], idx: number): string {
|
||||||
|
return idx >= 0 ? (row[idx] ?? '').trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse des lignes brutes Google Sheets en SyncProduct[].
|
* Parse des lignes brutes Google Sheets en SyncProduct[].
|
||||||
* Colonnes : A=Référence, B=Nom, C=Description, D=Prix, E=Stock, F=Statut
|
* La première ligne doit être la ligne d'en-têtes.
|
||||||
* Exporté pour les tests unitaires.
|
* 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) 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
|
||||||
|
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 rawRemise = get(row, idx.prixRemise).replace(',', '.')
|
||||||
|
const prixRemise = parseFloat(rawRemise || '0')
|
||||||
|
const hasRemise = !isNaN(prixRemise) && prixRemise > 0
|
||||||
|
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: isNaN(price) ? '0.00' : price.toFixed(2),
|
price: hasRemise ? prixRemise.toFixed(2) : price.toFixed(2),
|
||||||
stock: parseInt(row[4] ?? '0', 10) || 0,
|
compareAtPrice: hasRemise ? price.toFixed(2) : undefined,
|
||||||
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: hasRemise ? prixRemise.toFixed(2) : 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)
|
||||||
@@ -40,16 +94,16 @@ export function parseSheetRows(rows: string[][]): SyncProduct[] {
|
|||||||
* Sinon, lit tous les onglets du fichier et combine les produits.
|
* Sinon, lit tous les onglets du fichier et combine les produits.
|
||||||
* Le fichier Sheets doit être partagé en lecture ("Toute personne avec le lien").
|
* Le fichier Sheets doit être partagé en lecture ("Toute personne avec le lien").
|
||||||
*/
|
*/
|
||||||
export async function readSheetProducts(): Promise<SyncProduct[]> {
|
export async function readSheetProducts(tab?: string): Promise<SyncProduct[]> {
|
||||||
const apiKey = process.env.GOOGLE_API_KEY
|
const apiKey = process.env.GOOGLE_API_KEY
|
||||||
const sheetId = process.env.GOOGLE_SHEETS_ID
|
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||||
const singleTab = process.env.GOOGLE_SHEETS_TAB
|
const envTab = process.env.GOOGLE_SHEETS_TAB
|
||||||
|
|
||||||
if (!apiKey || !sheetId) {
|
if (!apiKey || !sheetId) {
|
||||||
throw new Error('Variables GOOGLE_API_KEY et GOOGLE_SHEETS_ID requises')
|
throw new Error('Variables GOOGLE_API_KEY et GOOGLE_SHEETS_ID requises')
|
||||||
}
|
}
|
||||||
|
|
||||||
const sheetNames = singleTab ? [singleTab] : await listSheetTabs(sheetId, apiKey)
|
const sheetNames = tab ? [tab] : envTab ? [envTab] : await listSheetTabs(sheetId, apiKey)
|
||||||
|
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
sheetNames.map((name) => readOneTab(sheetId, apiKey, name)),
|
sheetNames.map((name) => readOneTab(sheetId, apiKey, name)),
|
||||||
@@ -72,7 +126,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) {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { readFile, writeFile, mkdir, unlink } from 'fs/promises'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import type { MockupTemplate } from '@/types/mockup'
|
||||||
|
|
||||||
|
export const TEMPLATES_DIR = path.join(process.cwd(), 'data', 'mockup-templates')
|
||||||
|
const IMAGES_DIR = path.join(TEMPLATES_DIR, 'images')
|
||||||
|
const INDEX_PATH = path.join(TEMPLATES_DIR, 'index.json')
|
||||||
|
|
||||||
|
async function ensureDirs() {
|
||||||
|
await mkdir(IMAGES_DIR, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readIndex(): Promise<MockupTemplate[]> {
|
||||||
|
if (!existsSync(INDEX_PATH)) return []
|
||||||
|
const raw = await readFile(INDEX_PATH, 'utf-8')
|
||||||
|
return JSON.parse(raw) as MockupTemplate[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeIndex(templates: MockupTemplate[]): Promise<void> {
|
||||||
|
await ensureDirs()
|
||||||
|
await writeFile(INDEX_PATH, JSON.stringify(templates, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTemplates(): Promise<MockupTemplate[]> {
|
||||||
|
return readIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTemplatesByFamily(family: string): Promise<MockupTemplate[]> {
|
||||||
|
const all = await readIndex()
|
||||||
|
return all.filter(t => t.family === family)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveTemplate(
|
||||||
|
meta: Omit<MockupTemplate, 'id'>,
|
||||||
|
imageBuffer: Buffer,
|
||||||
|
ext: string,
|
||||||
|
): Promise<MockupTemplate> {
|
||||||
|
await ensureDirs()
|
||||||
|
const id = randomUUID()
|
||||||
|
const filename = `${id}.${ext}`
|
||||||
|
await writeFile(path.join(IMAGES_DIR, filename), imageBuffer)
|
||||||
|
const template: MockupTemplate = { ...meta, id, filename }
|
||||||
|
const all = await readIndex()
|
||||||
|
all.push(template)
|
||||||
|
await writeIndex(all)
|
||||||
|
return template
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTemplate(id: string): Promise<void> {
|
||||||
|
const all = await readIndex()
|
||||||
|
const tpl = all.find(t => t.id === id)
|
||||||
|
if (!tpl) throw new Error('Template not found')
|
||||||
|
const filePath = path.join(IMAGES_DIR, tpl.filename)
|
||||||
|
if (existsSync(filePath)) await unlink(filePath)
|
||||||
|
await writeIndex(all.filter(t => t.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTemplateImagePath(filename: string): string {
|
||||||
|
return path.join(IMAGES_DIR, filename)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||||
|
import { join } from 'path'
|
||||||
|
import type { MetafieldResolution } from '@/types/creation'
|
||||||
|
|
||||||
|
const CACHE_DIR = join(process.cwd(), 'data')
|
||||||
|
const CACHE_FILE = join(CACHE_DIR, 'metafield-resolutions.json')
|
||||||
|
|
||||||
|
export async function loadResolutionCache(): Promise<Record<string, MetafieldResolution>> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(CACHE_FILE, 'utf-8')
|
||||||
|
return JSON.parse(raw) as Record<string, MetafieldResolution>
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveResolutionCache(cache: Record<string, MetafieldResolution>): Promise<void> {
|
||||||
|
await mkdir(CACHE_DIR, { recursive: true })
|
||||||
|
await writeFile(CACHE_FILE, JSON.stringify(cache, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
+44
-19
@@ -27,38 +27,57 @@ export type ShopifyProductResult =
|
|||||||
export async function searchProductByRef(ref: string): Promise<ShopifyProductResult> {
|
export async function searchProductByRef(ref: string): Promise<ShopifyProductResult> {
|
||||||
const { baseUrl, headers } = getConfig()
|
const { baseUrl, headers } = getConfig()
|
||||||
|
|
||||||
// Recherche par SKU via l'API variants
|
// Recherche par SKU via l'API GraphQL (supporte les SKUs avec tirets, contrairement au REST)
|
||||||
|
const graphqlUrl = `https://${process.env.SHOPIFY_STORE_DOMAIN}/admin/api/${API_VERSION}/graphql.json`
|
||||||
const searchBySku = async (sku: string): Promise<ShopifyProductResult> => {
|
const searchBySku = async (sku: string): Promise<ShopifyProductResult> => {
|
||||||
const res = await fetch(
|
const query = `{
|
||||||
`${baseUrl}/variants.json?sku=${encodeURIComponent(sku)}&fields=id,sku,product_id&limit=1`,
|
productVariants(first: 10, query: "sku:${sku.replace(/"/g, '')}") {
|
||||||
{ headers },
|
edges {
|
||||||
)
|
node {
|
||||||
|
sku
|
||||||
|
product { id legacyResourceId title }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
const res = await fetch(graphqlUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ query }),
|
||||||
|
})
|
||||||
if (!res.ok) return { found: false }
|
if (!res.ok) return { found: false }
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
const variants = (data.variants ?? []) as Array<{ id: number; sku: string; product_id: number }>
|
const edges = data?.data?.productVariants?.edges ?? []
|
||||||
if (variants.length === 0) return { found: false }
|
// Cherche le match exact (insensible à la casse) parmi les résultats
|
||||||
|
const match = edges.find((e: { node: { sku: string } }) =>
|
||||||
// Récupère le titre du produit
|
e.node.sku.toLowerCase() === sku.toLowerCase()
|
||||||
const productRes = await fetch(
|
|
||||||
`${baseUrl}/products/${variants[0].product_id}.json?fields=id,title`,
|
|
||||||
{ headers },
|
|
||||||
)
|
)
|
||||||
if (!productRes.ok) return { found: false }
|
if (!match) return { found: false }
|
||||||
const productData = await productRes.json()
|
|
||||||
return {
|
return {
|
||||||
found: true,
|
found: true,
|
||||||
shopifyId: String(variants[0].product_id),
|
shopifyId: match.node.product.legacyResourceId,
|
||||||
title: productData.product?.title ?? sku,
|
title: match.node.product.title,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. SKU exact (ex: "30827")
|
// Si le ref se termine par un suffixe purement numérique (ex: "26618-D-1" → "26618-D"),
|
||||||
|
// on essaie d'abord sans ce suffixe pour éviter de matcher un autre produit qui aurait
|
||||||
|
// accidentellement ce SKU complet
|
||||||
|
const withoutNumericSuffix = /[-_]\d+$/.test(ref) ? ref.replace(/[-_]\d+$/, '') : null
|
||||||
|
|
||||||
|
// 1. SKU sans suffixe numérique en priorité (ex: "26618-D-1" → "26618-D")
|
||||||
|
if (withoutNumericSuffix) {
|
||||||
|
const result = await searchBySku(withoutNumericSuffix)
|
||||||
|
if (result.found) return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. SKU exact
|
||||||
let result = await searchBySku(ref)
|
let result = await searchBySku(ref)
|
||||||
if (result.found) return result
|
if (result.found) return result
|
||||||
|
|
||||||
// 2. SKU sans suffixe : "30827-dessus" → "30827" (retire tout après le dernier séparateur)
|
// 3. SKU sans suffixe non-numérique : "30827-dessus" → "30827"
|
||||||
const withoutSuffix = ref.replace(/[-_ ][^-_ ]*$/, '')
|
const withoutSuffix = ref.replace(/[-_ ][^-_ ]*$/, '')
|
||||||
if (withoutSuffix !== ref) {
|
if (withoutSuffix !== ref && withoutSuffix !== withoutNumericSuffix) {
|
||||||
result = await searchBySku(withoutSuffix)
|
result = await searchBySku(withoutSuffix)
|
||||||
if (result.found) return result
|
if (result.found) return result
|
||||||
}
|
}
|
||||||
@@ -142,6 +161,12 @@ export async function uploadProductImage(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (res.status === 404) {
|
||||||
|
const err = new Error(`Shopify upload error: 404 — product not found`) as Error & { status: number }
|
||||||
|
err.status = 404
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.text()
|
const body = await res.text()
|
||||||
throw new Error(`Shopify upload error: ${res.status} — ${body}`)
|
throw new Error(`Shopify upload error: ${res.status} — ${body}`)
|
||||||
|
|||||||
@@ -1,17 +1,5 @@
|
|||||||
import type { MetafieldDefinition, MetafieldResolution } from '@/types/creation'
|
import type { MetafieldDefinition, MetafieldResolution } from '@/types/creation'
|
||||||
|
|
||||||
const API_VERSION = '2024-01'
|
|
||||||
|
|
||||||
function getConfig() {
|
|
||||||
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
|
||||||
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
|
||||||
if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
|
|
||||||
return {
|
|
||||||
baseUrl: `https://${domain}/admin/api/${API_VERSION}`,
|
|
||||||
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeMetafieldKey(header: string): string {
|
export function normalizeMetafieldKey(header: string): string {
|
||||||
return header
|
return header
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -144,16 +132,46 @@ export async function setProductMetafield(
|
|||||||
value: string,
|
value: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (resolution.action === 'skip' || !value.trim()) return
|
if (resolution.action === 'skip' || !value.trim()) return
|
||||||
const { baseUrl, headers } = getConfig()
|
|
||||||
const res = await fetch(`${baseUrl}/products/${shopifyProductId}/metafields.json`, {
|
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||||
|
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||||
|
if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
|
||||||
|
|
||||||
|
const gid = `gid://shopify/Product/${shopifyProductId}`
|
||||||
|
const type = resolution.type ?? 'single_line_text_field'
|
||||||
|
|
||||||
|
const mutation = `mutation MetafieldsSet($metafields: [MetafieldsSetInput!]!) {
|
||||||
|
metafieldsSet(metafields: $metafields) {
|
||||||
|
metafields { key namespace value }
|
||||||
|
userErrors { field message }
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
const res = await fetch(`https://${domain}/admin/api/2024-01/graphql.json`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
metafield: { namespace: resolution.namespace, key: resolution.key, value, type: resolution.type ?? 'single_line_text_field' },
|
query: mutation,
|
||||||
|
variables: {
|
||||||
|
metafields: [{
|
||||||
|
ownerId: gid,
|
||||||
|
namespace: resolution.namespace,
|
||||||
|
key: resolution.key,
|
||||||
|
value,
|
||||||
|
type,
|
||||||
|
}],
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.text()
|
const err = await res.text()
|
||||||
throw new Error(`Shopify set metafield error: ${res.status} — ${err}`)
|
throw new Error(`Shopify metafieldsSet HTTP error: ${res.status} — ${err}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
const errors = data.data?.metafieldsSet?.userErrors ?? []
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(`Shopify metafieldsSet error: ${errors.map((e: { message: string }) => e.message).join(', ')}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-5
@@ -2,7 +2,7 @@ import type { ShopifyProductFull, SyncProduct } from '@/types/sync'
|
|||||||
|
|
||||||
const API_VERSION = '2024-01'
|
const API_VERSION = '2024-01'
|
||||||
|
|
||||||
function getConfig() {
|
export function getConfig() {
|
||||||
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||||
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||||
if (!domain || !token) {
|
if (!domain || !token) {
|
||||||
@@ -20,6 +20,7 @@ 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
|
||||||
}
|
}
|
||||||
@@ -31,6 +32,7 @@ interface ShopifyAPIProduct {
|
|||||||
body_html: string
|
body_html: string
|
||||||
status: string
|
status: string
|
||||||
variants: ShopifyVariant[]
|
variants: ShopifyVariant[]
|
||||||
|
images: Array<{ src: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSyncProduct(p: ShopifyAPIProduct): ShopifyProductFull {
|
function toSyncProduct(p: ShopifyAPIProduct): ShopifyProductFull {
|
||||||
@@ -41,8 +43,10 @@ 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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +73,7 @@ async function fetchByStatus(status: 'active' | 'draft'): Promise<ShopifyProduct
|
|||||||
const all: ShopifyProductFull[] = []
|
const all: ShopifyProductFull[] = []
|
||||||
|
|
||||||
let url: string | null =
|
let url: string | null =
|
||||||
`${baseUrl}/products.json?limit=250&fields=id,title,handle,status,body_html,variants&status=${status}`
|
`${baseUrl}/products.json?limit=250&fields=id,title,handle,status,body_html,variants,images&status=${status}`
|
||||||
|
|
||||||
while (url) {
|
while (url) {
|
||||||
const res = await fetch(url, { headers })
|
const res = await fetch(url, { headers })
|
||||||
@@ -89,13 +93,24 @@ async function fetchByStatus(status: 'active' | 'draft'): Promise<ShopifyProduct
|
|||||||
export async function createShopifyProduct(product: SyncProduct): Promise<string> {
|
export async function createShopifyProduct(product: SyncProduct): Promise<string> {
|
||||||
const { baseUrl, headers } = getConfig()
|
const { baseUrl, headers } = getConfig()
|
||||||
|
|
||||||
|
const variant: Record<string, unknown> = {
|
||||||
|
price: product.price,
|
||||||
|
compare_at_price: product.compareAtPrice ?? null,
|
||||||
|
sku: product.ref,
|
||||||
|
inventory_management: 'shopify',
|
||||||
|
}
|
||||||
|
if (product.weightKg != null && product.weightKg > 0) {
|
||||||
|
variant.weight = product.weightKg
|
||||||
|
variant.weight_unit = 'kg'
|
||||||
|
}
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
product: {
|
product: {
|
||||||
title: product.title,
|
title: product.title,
|
||||||
body_html: product.description,
|
body_html: product.description,
|
||||||
handle: product.handle,
|
handle: product.handle,
|
||||||
status: product.status,
|
status: product.status,
|
||||||
variants: [{ price: product.price, sku: product.ref }],
|
variants: [variant],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +126,44 @@ export async function createShopifyProduct(product: SyncProduct): Promise<string
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
return String(data.product.id)
|
const shopifyId = String(data.product.id)
|
||||||
|
const variantId = String(data.product.variants[0].id)
|
||||||
|
|
||||||
|
// Récupérer le location_id par défaut pour setter le stock
|
||||||
|
try {
|
||||||
|
const locRes = await fetch(`${baseUrl}/locations.json`, { headers })
|
||||||
|
if (locRes.ok) {
|
||||||
|
const locData = await locRes.json() as { locations: Array<{ id: number }> }
|
||||||
|
const locationId = locData.locations[0]?.id
|
||||||
|
const inventoryItemId = data.product.variants[0].inventory_item_id
|
||||||
|
if (locationId && inventoryItemId) {
|
||||||
|
// Connect l'inventory item à la location avant de setter la quantité
|
||||||
|
await fetch(`${baseUrl}/inventory_levels/connect.json`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ location_id: locationId, inventory_item_id: inventoryItemId }),
|
||||||
|
})
|
||||||
|
const setRes = await fetch(`${baseUrl}/inventory_levels/set.json`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
location_id: locationId,
|
||||||
|
inventory_item_id: inventoryItemId,
|
||||||
|
available: product.stock ?? 0,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!setRes.ok) {
|
||||||
|
const errText = await setRes.text()
|
||||||
|
console.error(`[shopify] inventory_levels/set failed: ${setRes.status} — ${errText}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[shopify] stock set error:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
void variantId
|
||||||
|
return shopifyId
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,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 }],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +216,21 @@ export async function deactivateShopifyProduct(shopifyId: string): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime définitivement un produit Shopify.
|
||||||
|
*/
|
||||||
|
export async function deleteShopifyProduct(shopifyId: string): Promise<void> {
|
||||||
|
const { baseUrl, headers } = getConfig()
|
||||||
|
const res = await fetch(`${baseUrl}/products/${shopifyId}.json`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text()
|
||||||
|
throw new Error(`Shopify deleteProduct error: ${res.status} — ${err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Récupère l'ID de la première variante d'un produit Shopify.
|
* Récupère l'ID de la première variante d'un produit Shopify.
|
||||||
* Nécessaire pour mettre à jour le prix (update variant).
|
* Nécessaire pour mettre à jour le prix (update variant).
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
const API_VERSION = '2024-01'
|
||||||
|
|
||||||
|
function getConfig() {
|
||||||
|
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||||
|
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||||
|
if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
|
||||||
|
return {
|
||||||
|
baseUrl: `https://${domain}/admin/api/${API_VERSION}`,
|
||||||
|
graphqlUrl: `https://${domain}/admin/api/${API_VERSION}/graphql.json`,
|
||||||
|
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlideSettings {
|
||||||
|
image: string // "shopify://shop_images/filename.jpg"
|
||||||
|
link?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Slide {
|
||||||
|
blockId: string
|
||||||
|
settings: SlideSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlideshowSection {
|
||||||
|
sectionId: string
|
||||||
|
slides: Slide[]
|
||||||
|
blockOrder: string[]
|
||||||
|
sectionSettings: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trouver le thème actif ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getActiveThemeId(): Promise<string> {
|
||||||
|
const { baseUrl, headers } = getConfig()
|
||||||
|
const res = await fetch(`${baseUrl}/themes.json`, { headers })
|
||||||
|
const data = await res.json()
|
||||||
|
const main = (data.themes as Array<{ id: number; role: string }>).find(t => t.role === 'main')
|
||||||
|
if (!main) throw new Error('Aucun thème actif trouvé')
|
||||||
|
return String(main.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lire le template homepage ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getHomepageTemplate(themeId: string): Promise<Record<string, unknown>> {
|
||||||
|
const { baseUrl, headers } = getConfig()
|
||||||
|
const url = `${baseUrl}/themes/${themeId}/assets.json?asset%5Bkey%5D=templates%2Findex.json`
|
||||||
|
const res = await fetch(url, { headers })
|
||||||
|
const data = await res.json()
|
||||||
|
const value = (data.asset as { value: string }).value
|
||||||
|
return JSON.parse(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Écrire le template homepage ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function saveHomepageTemplate(
|
||||||
|
themeId: string,
|
||||||
|
template: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const { baseUrl, headers } = getConfig()
|
||||||
|
const res = await fetch(`${baseUrl}/themes/${themeId}/assets.json`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ asset: { key: 'templates/index.json', value: JSON.stringify(template, null, 2) } }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text()
|
||||||
|
throw new Error(`Shopify theme write error: ${res.status} — ${body}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Extraire le slideshow depuis le template ──────────────────────────────────
|
||||||
|
|
||||||
|
export function extractSlideshow(template: Record<string, unknown>): SlideshowSection | null {
|
||||||
|
const sections = template.sections as Record<string, unknown>
|
||||||
|
if (!sections) return null
|
||||||
|
|
||||||
|
const entry = Object.entries(sections).find(([, v]) => {
|
||||||
|
const sec = v as Record<string, unknown>
|
||||||
|
return typeof sec.type === 'string' && sec.type.includes('slideshow-custom')
|
||||||
|
})
|
||||||
|
if (!entry) return null
|
||||||
|
|
||||||
|
const [sectionId, sec] = entry
|
||||||
|
const section = sec as Record<string, unknown>
|
||||||
|
const blocks = section.blocks as Record<string, { type: string; settings: SlideSettings }>
|
||||||
|
const blockOrder = section.block_order as string[]
|
||||||
|
|
||||||
|
const slides: Slide[] = (blockOrder ?? [])
|
||||||
|
.filter(id => blocks[id]?.type === 'slide')
|
||||||
|
.map(id => ({ blockId: id, settings: blocks[id].settings }))
|
||||||
|
|
||||||
|
return {
|
||||||
|
sectionId,
|
||||||
|
slides,
|
||||||
|
blockOrder: blockOrder ?? [],
|
||||||
|
sectionSettings: (section.settings as Record<string, unknown>) ?? {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mettre à jour les slides dans le template ─────────────────────────────────
|
||||||
|
|
||||||
|
export function applySlideshow(
|
||||||
|
template: Record<string, unknown>,
|
||||||
|
slideshow: SlideshowSection,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const sections = { ...(template.sections as Record<string, unknown>) }
|
||||||
|
const existing = sections[slideshow.sectionId] as Record<string, unknown>
|
||||||
|
|
||||||
|
const blocks: Record<string, unknown> = {}
|
||||||
|
for (const slide of slideshow.slides) {
|
||||||
|
blocks[slide.blockId] = { type: 'slide', settings: slide.settings }
|
||||||
|
}
|
||||||
|
|
||||||
|
sections[slideshow.sectionId] = {
|
||||||
|
...existing,
|
||||||
|
blocks,
|
||||||
|
block_order: slideshow.slides.map(s => s.blockId),
|
||||||
|
settings: slideshow.sectionSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...template, sections }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Uploader une image dans Shopify Files et retourner la ref shopify:// ──────
|
||||||
|
|
||||||
|
export async function uploadFileToShopify(
|
||||||
|
imageBase64: string,
|
||||||
|
filename: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const { graphqlUrl, headers } = getConfig()
|
||||||
|
|
||||||
|
// 1. Créer un staged upload
|
||||||
|
const stageRes = await fetch(graphqlUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `mutation stagedUploadsCreate($input: [StagedUploadInput!]!) {
|
||||||
|
stagedUploadsCreate(input: $input) {
|
||||||
|
stagedTargets {
|
||||||
|
url
|
||||||
|
resourceUrl
|
||||||
|
parameters { name value }
|
||||||
|
}
|
||||||
|
userErrors { field message }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
variables: {
|
||||||
|
input: [{
|
||||||
|
filename,
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
resource: 'FILE',
|
||||||
|
fileSize: String(Math.ceil(imageBase64.length * 0.75)),
|
||||||
|
httpMethod: 'POST',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const stageData = await stageRes.json()
|
||||||
|
const target = stageData?.data?.stagedUploadsCreate?.stagedTargets?.[0]
|
||||||
|
if (!target) throw new Error('Staged upload échoué')
|
||||||
|
|
||||||
|
// 2. Uploader le fichier vers l'URL staging
|
||||||
|
const binaryStr = atob(imageBase64)
|
||||||
|
const bytes = new Uint8Array(binaryStr.length)
|
||||||
|
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i)
|
||||||
|
const blob = new Blob([bytes], { type: 'image/jpeg' })
|
||||||
|
|
||||||
|
const form = new FormData()
|
||||||
|
for (const { name, value } of target.parameters as Array<{ name: string; value: string }>) {
|
||||||
|
form.append(name, value)
|
||||||
|
}
|
||||||
|
form.append('file', blob, filename)
|
||||||
|
|
||||||
|
const uploadRes = await fetch(target.url, { method: 'POST', body: form })
|
||||||
|
if (!uploadRes.ok) throw new Error(`Upload staging échoué: ${uploadRes.status}`)
|
||||||
|
|
||||||
|
// 3. Enregistrer le fichier dans Shopify Files
|
||||||
|
const createRes = await fetch(graphqlUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `mutation fileCreate($files: [FileCreateInput!]!) {
|
||||||
|
fileCreate(files: $files) {
|
||||||
|
files {
|
||||||
|
... on MediaImage {
|
||||||
|
id
|
||||||
|
image { url }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userErrors { field message }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
variables: {
|
||||||
|
files: [{ originalSource: target.resourceUrl, contentType: 'IMAGE' }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const createData = await createRes.json()
|
||||||
|
const fileUrl = createData?.data?.fileCreate?.files?.[0]?.image?.url as string | undefined
|
||||||
|
|
||||||
|
if (!fileUrl) {
|
||||||
|
// Fallback: construire la ref depuis le resourceUrl
|
||||||
|
const resourceUrl = target.resourceUrl as string
|
||||||
|
const fname = resourceUrl.split('/').pop()?.split('?')[0] ?? filename
|
||||||
|
return `shopify://shop_images/${fname}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extraire le nom de fichier depuis l'URL CDN
|
||||||
|
const cdnFilename = fileUrl.split('/files/')[1]?.split('?')[0] ?? filename
|
||||||
|
return `shopify://shop_images/${cdnFilename}`
|
||||||
|
}
|
||||||
+10
-4
@@ -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,
|
||||||
@@ -18,10 +18,13 @@ function getChangedFields(
|
|||||||
/**
|
/**
|
||||||
* Calcule le diff entre les produits Sheets et Shopify.
|
* Calcule le diff entre les produits Sheets et Shopify.
|
||||||
* Matching par handle (ref normalisée).
|
* Matching par handle (ref normalisée).
|
||||||
|
* Si tabFilter est fourni, les désactivations sont ignorées : on ne peut pas savoir
|
||||||
|
* si un produit Shopify absent du tab appartient à une autre famille ou a été supprimé.
|
||||||
*/
|
*/
|
||||||
export function computeDiff(
|
export function computeDiff(
|
||||||
sheetProducts: SyncProduct[],
|
sheetProducts: SyncProduct[],
|
||||||
shopifyProducts: ShopifyProductFull[],
|
shopifyProducts: ShopifyProductFull[],
|
||||||
|
tabFilter?: string,
|
||||||
): DiffResult {
|
): DiffResult {
|
||||||
const shopifyMap = new Map(shopifyProducts.map((p) => [p.handle, p]))
|
const shopifyMap = new Map(shopifyProducts.map((p) => [p.handle, p]))
|
||||||
const sheetHandles = new Set(sheetProducts.map((p) => p.handle))
|
const sheetHandles = new Set(sheetProducts.map((p) => p.handle))
|
||||||
@@ -48,9 +51,12 @@ export function computeDiff(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const shopify of shopifyProducts) {
|
// Désactivations uniquement en mode catalogue complet (sans filtre par famille)
|
||||||
if (shopify.status === 'active' && !sheetHandles.has(shopify.handle)) {
|
if (!tabFilter) {
|
||||||
changes.push({ type: 'deactivate', ref: shopify.ref, shopifyProduct: shopify })
|
for (const shopify of shopifyProducts) {
|
||||||
|
if (shopify.status === 'active' && !sheetHandles.has(shopify.handle)) {
|
||||||
|
changes.push({ type: 'deactivate', ref: shopify.ref, shopifyProduct: shopify })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ export interface MetafieldResolution {
|
|||||||
export type CreationStreamEvent =
|
export type CreationStreamEvent =
|
||||||
| { type: 'progress'; current: number; total: number; tab: string; title: string; step: string }
|
| { type: 'progress'; current: number; total: number; tab: string; title: string; step: string }
|
||||||
| { type: 'error'; title: string; message: string }
|
| { type: 'error'; title: string; message: string }
|
||||||
| { type: 'done'; summary: { created: number; errors: number; status: 'success' | 'partial' | 'error' } }
|
| { type: 'skipped'; title: string; sku: string }
|
||||||
|
| { type: 'done'; summary: { created: number; skipped: number; errors: number; status: 'success' | 'partial' | 'error' } }
|
||||||
|
|
||||||
/** Résumé d'une création (stocké en DB) */
|
/** Résumé d'une création (stocké en DB) */
|
||||||
export interface CreationRecord {
|
export interface CreationRecord {
|
||||||
|
|||||||
+4
-2
@@ -1,9 +1,10 @@
|
|||||||
export type ImageStatus =
|
export type ImageStatus =
|
||||||
| 'pending' // ajouté, pas encore traité
|
| 'pending' // ajouté, pas encore traité
|
||||||
| 'converting' // HEIC → JPEG en cours
|
| 'converting' // HEIC → JPEG en cours
|
||||||
| 'processing' // détourage IA en cours
|
|
||||||
| 'searching' // recherche produit Shopify en cours
|
| 'searching' // recherche produit Shopify en cours
|
||||||
| 'ready' // prêt à uploader
|
| 'converted' // converti + recherche terminée, détourage non lancé
|
||||||
|
| 'processing' // détourage IA en cours
|
||||||
|
| 'ready' // détouré, prêt à uploader
|
||||||
| 'uploading' // upload Shopify en cours
|
| 'uploading' // upload Shopify en cours
|
||||||
| 'uploaded' // uploadé avec succès
|
| 'uploaded' // uploadé avec succès
|
||||||
| 'skipped' // image déjà présente sur le produit Shopify
|
| 'skipped' // image déjà présente sur le produit Shopify
|
||||||
@@ -24,6 +25,7 @@ export interface ImageItem {
|
|||||||
processedBlob: Blob | null // JPEG final sur fond blanc, pivoté (pour upload + vue "après")
|
processedBlob: Blob | null // JPEG final sur fond blanc, pivoté (pour upload + vue "après")
|
||||||
rotation: number // 0, 90, 180, 270
|
rotation: number // 0, 90, 180, 270
|
||||||
feather: number // 0–5, défaut 2
|
feather: number // 0–5, défaut 2
|
||||||
|
alphaThreshold: number // 0–100, défaut 30 — pixels semi-transparents rendus opaques
|
||||||
shopifyProductId: string | null
|
shopifyProductId: string | null
|
||||||
shopifyProductTitle: string | null
|
shopifyProductTitle: string | null
|
||||||
uploadedUrl: string | null
|
uploadedUrl: string | null
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export interface MockupTemplate {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
family: string
|
||||||
|
placement?: string
|
||||||
|
filename: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockupGenerateRequest {
|
||||||
|
templateId: string
|
||||||
|
productImageBase64: string
|
||||||
|
productName: string
|
||||||
|
dimensions?: string
|
||||||
|
family: string
|
||||||
|
placement?: string
|
||||||
|
instruction?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockupGenerateResult {
|
||||||
|
imageBase64: string
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@ export interface CatalogProduct {
|
|||||||
price: string // "29.99"
|
price: string // "29.99"
|
||||||
stock: number // inventory_quantity variante 1
|
stock: number // inventory_quantity variante 1
|
||||||
status: 'active' | 'draft' | 'archived'
|
status: 'active' | 'draft' | 'archived'
|
||||||
shopifyUrl: string // https://materiaux-destock.myshopify.com/admin/products/<id>
|
shopifyUrl: string
|
||||||
|
images: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Filtres appliqués à la liste */
|
/** Filtres appliqués à la liste */
|
||||||
|
|||||||
+13
-2
@@ -7,13 +7,24 @@ export interface SyncProduct {
|
|||||||
title: string // colonne B
|
title: string // colonne B
|
||||||
description: string // colonne C (texte brut, stocké en body_html)
|
description: string // colonne C (texte brut, stocké en body_html)
|
||||||
price: string // colonne D — "29.99" (2 décimales)
|
price: string // colonne D — "29.99" (2 décimales)
|
||||||
stock: number // colonne E — info affichage uniquement (non synchronisé)
|
stock: number // colonne E
|
||||||
|
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
|
||||||
|
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 */
|
||||||
export interface ShopifyProductFull extends SyncProduct {
|
export interface ShopifyProductFull extends SyncProduct {
|
||||||
shopifyId: string // ID numérique Shopify (string)
|
shopifyId: string
|
||||||
|
images?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Un changement unitaire calculé par computeDiff */
|
/** Un changement unitaire calculé par computeDiff */
|
||||||
|
|||||||
Reference in New Issue
Block a user