56 Commits

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 13:35:39 +02:00
C_Ma_For 4f617b15b2 feat: add reverse stock sync (Shopify → Sheets)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 12:20:59 +02:00
C_Ma_For 401b43cc04 fix: add rate limiting with retry on 429 in sync-stock
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 11:59:37 +02:00
C_Ma_For d276f6224c fix: log errors in writeShopifyIdToSheet and skip 404s in sync-stock
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 11:40:12 +02:00
C_Ma_For a9850c8efd feat: add bulk stock sync from Sheets to Shopify
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:35:52 +02:00
C_Ma_For 747f8a47b8 fix: handle 'Stock (pièce)' header variant in addition to 'Stock (en pièce)'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 09:47:14 +02:00
C_Ma_For a334987b7c fix: connect inventory item to location before setting stock on product creation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 09:35:09 +02:00
C_Ma_For 06587c1ccd fix: include Surface (m2) in specificFields for metafield resolution
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 09:27:46 +02:00
C_Ma_For 99de0da823 feat: add multi-select upload and unified selection bar on images page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 12:03:20 +02:00
C_Ma_For 3ec0b9fa3c feat: add background image upload to banner builder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 10:46:45 +02:00
C_Ma_For 461667c506 fix: remove unused domain variable in BannerBuilder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 10:38:52 +02:00
C_Ma_For 34ec818ad3 feat: replace product search with searchable combobox in banner builder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 10:35:27 +02:00
C_Ma_For f650225c98 fix: remove unused graphqlUrl variable in product-banner-data route
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 10:02:36 +02:00
C_Ma_For 7ebb7e899d fix: escape apostrophe in BannerBuilder JSX
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 09:57:39 +02:00
C_Ma_For 4ce62c1473 feat: add banner builder with product search and canvas rendering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 09:53:58 +02:00
C_Ma_For fcadea6758 fix: remove unused import in bannieres page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 09:27:50 +02:00
C_Ma_For 0a3f5042fc feat: add bannières page to manage Shopify homepage slideshow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 09:12:52 +02:00
C_Ma_For d616f1d74c feat: add mockup creation from image cards on images page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 08:30:45 +02:00
C_Ma_For eec9bd5dce feat: make AI background removal optional with resize-only upload and batch detour selection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 08:21:46 +02:00
C_Ma_For c95093a81f feat: add reprocess button to re-run AI background removal on image cards
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 20:52:18 +02:00
C_Ma_For 03770d1a76 fix: propagate Shopify 404 through upload route so client retry triggers correctly
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 20:40:50 +02:00
C_Ma_For 5e1b864ce7 fix: retry upload with fresh Shopify ID on 404 (stale cached ID)
Quand l'ID en cache est périmé (produit supprimé/recréé), relance la recherche
par ref pour obtenir le nouvel ID et réessaie l'upload automatiquement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 20:35:43 +02:00
C_Ma_For cc0d57bf2b feat: remove clear Sheets IDs button from creation UI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 20:03:42 +02:00
C_Ma_For 6ca2509ca6 fix: use original filename for upload to allow multiple images per product
32263.heic -> 32263.jpg, 32263-.heic -> 32263-.jpg (non plus les deux 32263.jpg)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 19:56:42 +02:00
C_Ma_For 42989a5ef3 fix: extract ref from filename not folder name in ZIP uploads
PHOTOS:/32263-.heic → ref=32263 (non plus PHOTOS:)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:23:43 +02:00
C_Ma_For f56e52f801 feat: show original filename on image card
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:15:03 +02:00
C_Ma_For 949d7c60b9 debug: log product search result to trace not_found issues
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:01:56 +02:00
C_Ma_For 36447a5c8c fix: improve product search robustness
- search-sheets: ne bloque plus sur l'absence d'ID Shopify en col A
- Shopify GraphQL: first:10 + match exact insensible à la casse au lieu de first:1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:54:56 +02:00
C_Ma_For 920ec50402 fix: extract UGS from filename with sequence numbers and trailing dashes
151721-2.HEIC -> 151721, 29679-G-1.HEIC -> 29679-G, 27228-GRIS-.HEIC -> 27228-GRIS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:45:31 +02:00
C_Ma_For 2f6eea1122 feat: stop button for product creation stream
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:34:34 +02:00
C_Ma_For 42ef490196 fix: strip trailing dashes from filename ref (26774-.HEIC -> 26774)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:24:44 +02:00
C_Ma_For 31b764f458 fix: limit concurrent AI background removal to 2 to prevent browser crash
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:08:32 +02:00
C_Ma_For 8c9bce7aa5 feat: collection picker in product creation flow
- API /api/creation/collections liste toutes les collections Shopify
- Détection automatique depuis la colonne ID FAMILLE du Sheets
- Sélecteur avec recherche si absente ou pour modifier
- Collection affichée dans l'écran de confirmation
- collectionOverride passé à l'exécution comme fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 16:12:54 +02:00
C_Ma_For 9e7423eee3 fix: pass tab filter to execute route so only selected family is created
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:51:47 +02:00
C_Ma_For 6d8be0645d feat: use 'Stock conditionné' for Shopify stock on carrelage tabs
Priorité à Stock conditionné (boîtes) sur Stock (en pièce) si renseigné.
Colonne exclue des métachamps spécifiques.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:42:55 +02:00
C_Ma_For 114e20c9fb feat: persist metafield resolutions across sessions
Les associations colonne→métachamp sont sauvegardées dans data/metafield-resolutions.json
et rechargées automatiquement à la prochaine création de produits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:28:53 +02:00
C_Ma_For caef95bd55 feat: set stock and weight on Shopify product creation
- inventory_management: shopify + inventory_levels/set.json pour le stock
- weight/weight_unit sur le variant pour l'expédition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:11:17 +02:00
C_Ma_For 34ade6beb2 feat: clear Shopify IDs from Sheets to allow product recreation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:05:43 +02:00
C_Ma_For cdde567558 feat: placement mur/sol pour templates carrelage avec prompt adapté 2026-06-24 14:52:57 +02:00
C_Ma_For e85314eb77 feat: gestion templates mockup dans les paramètres avec familles depuis Sheets 2026-06-24 14:42:44 +02:00
C_Ma_For 5a10fd4221 feat: mockup modal avec sélection image existante, familles sheets et métachamps produit 2026-06-24 14:33:19 +02:00
C_Ma_For 93a616e997 feat: affichage images produit dans la liste avec thumbnail et compteur 2026-06-24 14:27:35 +02:00
C_Ma_For 41f5c245d4 feat: mockup modal accessible depuis la liste produits 2026-06-24 14:20:42 +02:00
C_Ma_For f6b0ba9685 feat: product picker with search in mockup page 2026-06-22 14:42:58 +02:00
C_Ma_For 4811b73c69 feat: mockup page with template library and generator UI 2026-06-22 12:27:35 +02:00
C_Ma_For 921d9d6a77 feat: MockupGenerator UI component with generate/regenerate/upload flow 2026-06-22 12:24:22 +02:00
C_Ma_For d138577a9d feat: mockup generation API route using gpt-image-1 2026-06-22 12:14:31 +02:00
C_Ma_For 1ef69dc205 feat: mockup template CRUD API routes 2026-06-22 12:10:26 +02:00
C_Ma_For 01e2fcd5e5 feat: mockup types and template storage library 2026-06-22 12:08:13 +02:00
C_Ma_For 07432bbd95 fix: conserver le type du métachamp lors de l'association manuelle
Sans le type, on envoyait toujours 'single_line_text_field' à Shopify,
causant une erreur quand le métachamp existant est de type number_decimal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 13:05:31 +02:00
C_Ma_For 731d0dbde6 feat: recherche textuelle dans la sélection de métachamp existant
Remplace le <select> par un input filtrant en temps réel sur le nom,
la clé et le namespace — utile quand il y a beaucoup de métachamps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:58:48 +02:00
C_Ma_For 42d0e2f855 feat: écriture de l'ID Shopify en col A du Sheets après création produit
Utilise un compte de service Google avec JWT pour authentifier les écritures
dans le Sheets — sans dépendance npm supplémentaire (crypto natif Node.js).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:42:17 +02:00
C_Ma_For 15f647f58a fix: vérifier le SKU exact dans la réponse Shopify (l'API ignore le filtre sur les SKUs avec tirets)
L'API variants.json ne filtre pas correctement par sku= quand le SKU contient
des tirets — elle retourne des variants aléatoires. On vérifie maintenant que
le variant retourné a bien le SKU demandé.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:01:02 +02:00
C_Ma_For cb227943f3 fix: prioritiser SKU sans suffixe numérique pour éviter faux match (ex: 26618-D-1 → 26618-D)
Un fichier nommé 26618-D-1.jpg trouvait le SKU 26618-D-1 (céramique) avant
de chercher 26618-D (la porte). On essaie maintenant d'abord le ref sans
suffixe purement numérique (-1, -2…).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:43:12 +02:00
C_Ma_For d45c9490c0 feat: curseur tolérance verre pour récupérer les zones semi-transparentes du détourage
Ajoute alphaThreshold (défaut 30) — les pixels dont l'alpha dépasse ce seuil
sont rendus opaques avant la composition sur fond blanc, évitant que le verre
ou les surfaces translucides soient supprimés par le modèle IA.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:28:42 +02:00
C_Ma_For c1fad5d0ed fix: retourner l'UGS la plus spécifique lors de la recherche par nom de fichier
Évite que "26618" matche avant "26618-D" car le tiret est un séparateur
de mot dans la regex — on collecte tous les matches et on garde le plus long.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:02:13 +02:00
50 changed files with 4324 additions and 198 deletions
+297
View File
@@ -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)}
/>
)}
</>
)
}
+223 -2
View File
@@ -1,5 +1,5 @@
'use client'
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import { Header } from '@/components/layout/Header'
import { PendingRowsTable } from '@/components/creation/PendingRowsTable'
import { MetafieldResolver } from '@/components/creation/MetafieldResolver'
@@ -9,6 +9,8 @@ import type { PendingProduct, MetafieldResolution, CreationStreamEvent } from '@
type Step = 'famille' | 'analyse' | 'metafields' | 'confirmation' | 'execution'
interface ShopifyCollection { id: string; title: string }
export default function CreationPage() {
const [step, setStep] = useState<Step>('famille')
const [selectedTab, setSelectedTab] = useState<string | null>(null)
@@ -20,10 +22,77 @@ export default function CreationPage() {
const [resolutions, setResolutions] = useState<MetafieldResolution[]>([])
const [events, setEvents] = useState<CreationStreamEvent[]>([])
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 () => {
@@ -39,6 +108,17 @@ export default function CreationPage() {
setByTab(data.byTab)
setTotal(data.total)
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')
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur')
@@ -49,14 +129,25 @@ export default function CreationPage() {
const handleResolved = useCallback((r: MetafieldResolution[]) => { setResolutions(r) }, [])
const stop = useCallback(() => {
abortRef.current?.abort()
setStopped(true)
setDone(true)
}, [])
const execute = async () => {
const controller = new AbortController()
abortRef.current = controller
setStep('execution')
setEvents([])
setDone(false)
setStopped(false)
try {
const res = await fetch('/api/creation/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resolutions }),
body: JSON.stringify({ resolutions, tab: selectedTab, collectionOverride: collectionOverride || undefined }),
signal: controller.signal,
})
if (!res.body) return
const reader = res.body.getReader()
@@ -77,6 +168,11 @@ export default function CreationPage() {
} 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)
}
@@ -89,14 +185,25 @@ export default function CreationPage() {
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 (
<>
<Header title="Création produits" />
@@ -114,10 +221,57 @@ export default function CreationPage() {
{/* É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 }}>
@@ -145,6 +299,57 @@ export default function CreationPage() {
<PendingRowsTable byTab={byTab} total={total} />
{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" />
<div>
<h2 className="text-base font-semibold text-white mb-3">Résolution des métachamps</h2>
@@ -163,6 +368,11 @@ export default function CreationPage() {
<div className="space-y-4">
<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">
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 === '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>
@@ -183,13 +393,24 @@ export default function CreationPage() {
{step === 'execution' && (
<div className="space-y-4">
<CreationProgress events={events} done={done} />
<div className="flex gap-3">
{!done && (
<button onClick={stop}
className="px-4 py-2 bg-red-700 hover:bg-red-600 text-white rounded-lg text-sm font-medium transition-colors">
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>
+131 -2
View File
@@ -1,17 +1,81 @@
'use client'
import { useState } from 'react'
import { useState, useCallback } from 'react'
import { Header } from '@/components/layout/Header'
import { DropZone } from '@/components/images/DropZone'
import { ImageCard } from '@/components/images/ImageCard'
import { Lightbox } from '@/components/images/Lightbox'
import { GlobalActions } from '@/components/images/GlobalActions'
import { MockupModalFromImage } from '@/components/mockup/MockupModalFromImage'
import { ErrorBoundary } from '@/components/common/ErrorBoundary'
import { useImages } from '@/hooks/useImages'
import type { ImageItem } from '@/types/images'
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 [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 (
<>
@@ -31,18 +95,80 @@ function ImagesPageInner() {
<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 && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{items.map((item) => (
<ImageCard
key={item.id}
item={item}
selected={selected.has(item.id)}
onSelect={toggleSelect}
onFeatherChange={setFeather}
onAlphaThresholdChange={setAlphaThreshold}
onRotate={rotate}
onUpload={uploadOne}
onRemove={removeItem}
onOpenLightbox={setLightboxItem}
onAssignProduct={assignProduct}
onReprocess={reprocess}
onDetour={detour}
onMockup={openMockup}
/>
))}
</div>
@@ -58,6 +184,9 @@ function ImagesPageInner() {
</div>
<Lightbox item={lightboxItem} onClose={() => setLightboxItem(null)} />
{mockupItem && (
<MockupModalFromImage item={mockupItem} onClose={() => setMockupItem(null)} />
)}
</>
)
}
+167
View File
@@ -0,0 +1,167 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { Header } from '@/components/layout/Header'
import { TemplateLibrary } from '@/components/mockup/TemplateLibrary'
import { MockupGenerator } from '@/components/mockup/MockupGenerator'
type Tab = 'generate' | 'library'
interface Product {
shopifyId: string
title: string
ref: string
famille?: string
longueur?: string
largeur?: string
}
export default function MockupPage() {
const [tab, setTab] = useState<Tab>('generate')
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('Menuiserie')
const [dimensions, setDimensions] = useState('')
const [cutoutB64, setCutoutB64] = useState<string | null>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
fetch('/api/products/list')
.then(r => r.json())
.then(data => setProducts(data.products ?? []))
}, [])
const filtered = search.length < 2 ? [] : products.filter(p =>
p.title.toLowerCase().includes(search.toLowerCase()) ||
p.ref.toLowerCase().includes(search.toLowerCase())
).slice(0, 20)
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = ev => {
const result = ev.target?.result as string
setCutoutB64(result.split(',')[1])
}
reader.readAsDataURL(file)
}
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 className="space-y-4">
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 space-y-3">
<h2 className="text-sm font-semibold text-white">Produit</h2>
{/* Product picker */}
<div className="relative" ref={dropdownRef}>
<label className="block text-xs text-gray-400 mb-1">Rechercher un produit</label>
{selectedProduct ? (
<div className="flex items-center gap-2 bg-slate-700 border border-slate-600 rounded px-2 py-1.5">
<span className="flex-1 text-sm text-white truncate">{selectedProduct.title}</span>
<span className="text-xs text-gray-400 shrink-0">{selectedProduct.ref}</span>
<button type="button" onClick={() => { setSelectedProduct(null); setSearch('') }}
className="text-gray-400 hover:text-white ml-1 shrink-0"></button>
</div>
) : (
<>
<input
type="text"
value={search}
onChange={e => { setSearch(e.target.value); setShowDropdown(true) }}
onFocus={() => setShowDropdown(true)}
placeholder="Nom ou référence du produit…"
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
{showDropdown && filtered.length > 0 && (
<div className="absolute z-10 mt-1 w-full bg-slate-700 border border-slate-600 rounded shadow-lg max-h-56 overflow-y-auto">
{filtered.map(p => (
<button key={p.shopifyId} type="button"
onMouseDown={() => {
setSelectedProduct(p)
setSearch('')
setShowDropdown(false)
fetch(`/api/products/sheet-info?ref=${encodeURIComponent(p.ref)}`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data?.product) return
const { famille, longueur, largeur } = data.product
if (famille) setFamily(famille)
if (longueur && largeur) setDimensions(`${longueur}x${largeur}cm`)
else if (longueur) setDimensions(`${longueur}cm`)
})
}}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-slate-600 flex justify-between gap-2">
<span className="truncate">{p.title}</span>
<span className="text-xs text-gray-400 shrink-0">{p.ref}</span>
</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 px-3 py-2">
<p className="text-xs text-gray-400">Aucun résultat</p>
</div>
)}
</>
)}
</div>
<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)}
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 (ex: 90x215cm)</label>
<input value={dimensions} onChange={e => setDimensions(e.target.value)}
placeholder="optionnel"
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500" />
</div>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Image découpée (PNG)</label>
<input type="file" accept="image/png" onChange={handleFile} className="text-sm text-gray-300" />
</div>
</div>
{cutoutB64 && selectedProduct ? (
<MockupGenerator
productCutoutBase64={cutoutB64}
productName={selectedProduct.title}
shopifyId={selectedProduct.shopifyId}
family={family}
dimensions={dimensions || undefined}
/>
) : (
<p className="text-sm text-gray-500 text-center py-8">
{!selectedProduct ? 'Sélectionnez un produit pour commencer.' : 'Chargez une image PNG découpée.'}
</p>
)}
</div>
)}
</div>
</>
)
}
+15 -9
View File
@@ -4,36 +4,42 @@ import { useSession } from 'next-auth/react'
import { Header } from '@/components/layout/Header'
import { UsersManager } from '@/components/settings/UsersManager'
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() {
const { data: session } = useSession()
const isAdmin = session?.user?.role === 'admin'
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 (
<>
<Header title="Paramètres" />
<div className="p-6 space-y-6">
{isAdmin && (
<div className="flex gap-2">
{(['connexions', 'utilisateurs'] as Tab[]).map((t) => (
{tabs.filter(t => !t.adminOnly || isAdmin).map(t => (
<button
key={t}
onClick={() => setTab(t)}
key={t.id}
onClick={() => setTab(t.id)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
tab === t
tab === t.id
? 'bg-indigo-600 text-white'
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
}`}
>
{t === 'connexions' ? 'Connexions' : 'Utilisateurs'}
{t.label}
</button>
))}
</div>
)}
{(!isAdmin || tab === 'connexions') && <ConnectionStatus />}
{tab === 'connexions' && <ConnectionStatus />}
{tab === 'mockup-templates' && <TemplateLibrary />}
{isAdmin && tab === 'utilisateurs' && <UsersManager />}
</div>
</>
+93
View File
@@ -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 })
}
+32
View File
@@ -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 })
}
+10 -4
View File
@@ -1,7 +1,7 @@
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { fetchPendingProducts } from '@/lib/creationSheets'
import { fetchPendingProducts, writeShopifyIdToSheet } from '@/lib/creationSheets'
import { generateDescriptions } from '@/lib/openaiClient'
import { ensureMetafieldDefinition, setProductMetafield } from '@/lib/shopifyMetafields'
import { addProductToCollection } from '@/lib/shopifyCollections'
@@ -20,8 +20,10 @@ export async function POST(req: NextRequest) {
}
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 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 stream = new ReadableStream({
@@ -34,7 +36,7 @@ export async function POST(req: NextRequest) {
])
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)
const pending = await fetchPendingProducts(alreadyCreated, tabFilter)
const total = pending.length
if (total === 0) {
@@ -72,6 +74,7 @@ export async function POST(req: NextRequest) {
famille: product.famille,
})
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({
ref: product.sku,
handle: product.sku.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
@@ -79,9 +82,11 @@ export async function POST(req: NextRequest) {
description: longDesc,
price: product.priceActual || '0.00',
stock: product.stock,
weightKg: product.weightKg,
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)
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)) {
@@ -92,6 +97,7 @@ export async function POST(req: NextRequest) {
await prisma.productCreation.create({
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++
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
+20
View File
@@ -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' },
})
}
+150
View File
@@ -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' },
})
}
+18
View File
@@ -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 } })
}
+2 -1
View File
@@ -26,6 +26,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json(result)
} catch (err) {
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 })
}
}
+89
View File
@@ -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 })
}
}
+32
View File
@@ -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)
}
+1
View File
@@ -19,6 +19,7 @@ export async function GET(req: NextRequest): Promise<NextResponse<ProductListRes
stock: p.stock,
status: p.status as CatalogProduct['status'],
shopifyUrl: `https://${domain}/admin/products/${p.shopifyId}`,
images: p.images ?? [],
}))
if (statusFilter !== 'all') {
+42
View File
@@ -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([])
}
}
+13 -8
View File
@@ -32,6 +32,8 @@ export async function GET(req: NextRequest) {
s => s.properties.title,
)
let bestMatch: { shopifyId: string; title: string; ugsLength: number } | null = null
for (const tab of tabs) {
const range = encodeURIComponent(`${tab}!A2:D`)
const res = await fetch(
@@ -42,24 +44,27 @@ export async function GET(req: NextRequest) {
const rows = (data.values ?? []) as string[][]
for (const row of rows) {
const shopifyId = (row[0] ?? '').trim() // col A = ID Shopify
// col C = UGS (index 2 car on commence à A)
const shopifyId = (row[0] ?? '').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 ugsLower = ugs.toLowerCase()
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) {
const title = (row[3] ?? '').trim() // col D = Nom
return NextResponse.json({ found: true, shopifyId, title })
if (isMatch && shopifyId) {
const title = (row[3] ?? '').trim()
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 })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur'
+2
View File
@@ -10,9 +10,11 @@ export async function GET(req: NextRequest) {
try {
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)
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
console.error(`[search] ref="${ref.trim()}" → ERROR: ${message}`)
return NextResponse.json({ error: message }, { status: 500 })
}
}
+19
View File
@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { readSheetProducts } from '@/lib/googleSheets'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const ref = new URL(req.url).searchParams.get('ref')
if (!ref) return NextResponse.json({ error: 'Paramètre ref manquant' }, { status: 400 })
const products = await readSheetProducts()
const product = products.find(p => p.ref.toLowerCase() === ref.toLowerCase())
if (!product) return NextResponse.json({ error: 'Produit non trouvé' }, { status: 404 })
return NextResponse.json({ product })
}
@@ -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 })
}
}
+33
View File
@@ -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 })
}
}
+15
View File
@@ -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 })
}
}
+417
View File
@@ -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&apos;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>
)
}
+72 -15
View File
@@ -1,5 +1,5 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import type { MetafieldDefinition, MetafieldResolution } from '@/types/creation'
import { normalizeMetafieldKey } from '@/lib/shopifyMetafields'
@@ -15,25 +15,31 @@ export function MetafieldResolver({ specificColumns, onResolved }: Props) {
const [resolutions, setResolutions] = useState<Record<string, MetafieldResolution>>({})
const [loading, setLoading] = useState(true)
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(() => {
if (specificColumns.length === 0) { setLoading(false); onResolved([]); return }
const params = specificColumns.map(c => encodeURIComponent(c)).join(',')
fetch(`/api/creation/metafields?columns=${params}`)
.then(r => r.json())
.then(data => {
Promise.all([
fetch(`/api/creation/metafields?columns=${params}`).then(r => r.json()),
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)
setDefinitions(data.definitions)
setMatched(data.matched)
setUnmatched(data.unmatched)
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) => {
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)
})
.catch(e => setError(e.message))
.catch(e => setError((e as Error).message))
.finally(() => setLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [specificColumns.join(',')])
@@ -44,7 +50,29 @@ export function MetafieldResolver({ specificColumns, onResolved }: Props) {
}, [resolutions, loading])
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>
@@ -133,16 +161,45 @@ export function MetafieldResolver({ specificColumns, onResolved }: Props) {
</div>
)}
{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>
<select
value={res.existingDefinitionId ?? ''}
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 }) }}
{res.existingDefinitionId ? (
<div className="flex items-center gap-2">
<span className="flex-1 text-xs text-blue-300 bg-slate-600 border border-slate-500 rounded px-2 py-1 truncate">
{definitions.find(d => d.id === res.existingDefinitionId)?.name ?? res.key} ({res.namespace}.{res.key})
</span>
<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>
</div>
) : (
<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"
>
<option value="">-- Choisir un métachamp existant --</option>
{definitions.map(d => <option key={d.id} value={d.id}>{d.name} ({d.namespace}.{d.key})</option>)}
</select>
<span>{d.name}</span>
<span className="text-gray-400 shrink-0">{d.namespace}.{d.key}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
+72 -6
View File
@@ -4,20 +4,27 @@ import type { ImageItem } from '@/types/images'
interface ImageCardProps {
item: ImageItem
selected?: boolean
onSelect?: (id: string, selected: boolean) => void
onFeatherChange: (id: string, value: number) => void
onAlphaThresholdChange: (id: string, value: number) => void
onRotate: (id: string, direction: 'cw' | 'ccw') => void
onUpload: (id: string) => void
onRemove: (id: string) => void
onOpenLightbox: (item: ImageItem) => 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 }> = {
pending: { label: '⏳ En attente', color: 'text-slate-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' },
ready: { label: '✓ Prêt', color: 'text-green-400' },
converted: { label: '✓ Prêt (sans détourage)', color: 'text-amber-400' },
processing: { label: '🤖 Détourage IA', color: 'text-blue-400' },
ready: { label: '✓ Détouré', color: 'text-green-400' },
uploading: { label: '⬆️ Upload…', color: 'text-blue-400' },
uploaded: { label: '✅ Uploadé', color: 'text-green-500' },
skipped: { label: '⏭️ Déjà sur Shopify', color: 'text-slate-400' },
@@ -93,24 +100,42 @@ function ProductSearch({ itemId, onAssign }: { itemId: string; onAssign: (id: st
export function ImageCard({
item,
selected = false,
onSelect,
onFeatherChange,
onAlphaThresholdChange,
onRotate,
onUpload,
onRemove,
onOpenLightbox,
onAssignProduct,
onReprocess,
onDetour,
onMockup,
}: ImageCardProps) {
const statusInfo = STATUS_LABELS[item.status] ?? { label: item.status, color: 'text-slate-400' }
const isProcessing = ['pending', 'converting', 'processing', 'searching', 'uploading'].includes(item.status)
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 (
<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 */}
<div
className="relative h-40 bg-slate-800 cursor-pointer"
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'}`}>
<BlobThumbnail blob={item.originalBlob} alt="Original" />
</div>
@@ -136,7 +161,8 @@ export function ImageCard({
{/* Infos */}
<div className="p-3 flex flex-col gap-2 flex-1">
<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 && (
<p className="text-xs text-slate-500 truncate">{item.shopifyProductTitle}</p>
)}
@@ -148,7 +174,7 @@ export function ImageCard({
<ProductSearch itemId={item.id} onAssign={onAssignProduct} />
)}
{/* Contrôles feather + rotation */}
{/* Contrôles feather + tolérance + rotation */}
<div className={canAdjust ? '' : 'opacity-40 pointer-events-none'}>
<label className="block text-xs text-slate-500 mb-1">
Bords : {item.feather} px
@@ -163,6 +189,19 @@ export function ImageCard({
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">
<button
onClick={() => onRotate(item.id, 'ccw')}
@@ -177,7 +216,7 @@ export function ImageCard({
{/* Boutons d'action */}
<div className="flex gap-2 mt-auto pt-1">
{item.status === 'ready' && (
{canUpload && (
<button
onClick={() => onUpload(item.id)}
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
</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 && (
<a
href={item.uploadedUrl}
+2
View File
@@ -16,6 +16,8 @@ const NAV_ITEMS: NavItem[] = [
{ href: '/images', label: 'Images', icon: '🖼️' },
{ href: '/sync', label: 'Sync Sheets', 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 },
]
+170
View File
@@ -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&apos;é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>
)
}
+348
View File
@@ -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>
)
}
+186
View File
@@ -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>
)
}
+31 -2
View File
@@ -1,5 +1,7 @@
'use client'
import { useState } from 'react'
import type { CatalogProduct } from '@/types/products'
import { MockupModal } from '@/components/mockup/MockupModal'
interface Props {
products: CatalogProduct[]
@@ -9,6 +11,7 @@ interface Props {
const PAGE_SIZE = 50
export function ProductTable({ products, loading }: Props) {
const [mockupProduct, setMockupProduct] = useState<CatalogProduct | null>(null)
if (loading) {
return (
<div className="flex items-center justify-center py-20 text-gray-400">
@@ -33,17 +36,31 @@ export function ProductTable({ products, loading }: Props) {
<thead>
<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">Images</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">Stock</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>
</thead>
<tbody>
{displayed.map((p) => (
<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">
{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-gray-300">{p.price} </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'}
</span>
</td>
<td className="py-3">
<td className="py-3 pr-4">
<a
href={p.shopifyUrl}
target="_blank"
@@ -66,10 +83,22 @@ export function ProductTable({ products, loading }: Props) {
Shopify
</a>
</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>
))}
</tbody>
</table>
{mockupProduct && (
<MockupModal product={mockupProduct} onClose={() => setMockupProduct(null)} />
)}
{products.length > PAGE_SIZE && (
<p className="text-center text-gray-400 text-xs mt-4">
Affichage des {PAGE_SIZE} premiers résultats sur {products.length}.
+148 -41
View File
@@ -1,7 +1,29 @@
'use client'
import { useReducer, useCallback } from 'react'
import { useReducer, useCallback, useRef } from 'react'
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 ───────────────────────────────────────────────────────────────────
type Action =
@@ -26,7 +48,10 @@ function reducer(state: ImageItem[], action: Action): ImageItem[] {
function refFromFilename(filename: string): string {
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> {
@@ -34,7 +59,6 @@ function blobToBase64(blob: Blob): Promise<string> {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
// Supprimer le préfixe "data:image/jpeg;base64,"
const base64 = result.split(',')[1]
resolve(base64)
}
@@ -47,6 +71,7 @@ function blobToBase64(blob: Blob): Promise<string> {
export function useImages() {
const [items, dispatch] = useReducer(reducer, [])
const queueRef = useRef(createQueue())
const update = useCallback((id: string, patch: Partial<ImageItem>) => {
dispatch({ type: 'UPDATE', id, patch })
@@ -57,24 +82,21 @@ export function useImages() {
async (id: string, ref: string) => {
update(id, { status: 'searching' })
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 sheetsData = await sheetsRes.json()
if (sheetsData.found) {
update(id, {
status: 'ready',
status: 'converted',
shopifyProductId: sheetsData.shopifyId,
shopifyProductTitle: sheetsData.title,
})
return
}
// 2. Fallback : recherche directe dans Shopify par handle/titre
const res = await fetch(`/api/products/search?ref=${encodeURIComponent(ref)}`)
const data = await res.json()
if (data.found) {
update(id, {
status: 'ready',
status: 'converted',
shopifyProductId: data.shopifyId,
shopifyProductTitle: data.title,
})
@@ -88,31 +110,40 @@ export function useImages() {
[update],
)
// Pipeline complet pour un item (avec ref)
const runPipeline = useCallback(
async (id: string, blob: Blob, ref: string, feather: number, rotation: number) => {
// Pipeline léger : conversion HEIC + recherche produit (sans détourage IA)
const runConvertAndSearch = useCallback(
async (id: string, blob: Blob, ref: string) => {
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
if (blob.type === 'image/heic' || blob.type === 'image/heif' || blob.type === '') {
try {
update(id, { status: 'converting' })
jpegBlob = await convertHeicToJpeg(blob)
// Met à jour originalBlob avec le JPEG converti (Chrome ne peut pas afficher HEIC)
update(id, { originalBlob: jpegBlob })
} catch (err) {
update(id, { status: 'error', error: `Conversion HEIC : ${(err as Error).message}` })
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
try {
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) {
update(id, { status: 'error', error: `Détourage IA : ${(err as Error).message}` })
return
@@ -120,28 +151,27 @@ export function useImages() {
let finalBlob: Blob
try {
const composited = await compositeOnWhite(rawPng, feather)
const composited = await compositeOnWhite(rawPng, feather, alphaThreshold)
finalBlob = await rotateBlob(composited, rotation)
} catch (err) {
update(id, { status: 'error', error: `Post-traitement : ${(err as Error).message}` })
return
}
update(id, { rawPngBlob: rawPng, processedBlob: finalBlob })
await searchProduct(id, ref)
update(id, { rawPngBlob: rawPng, processedBlob: finalBlob, status: 'ready' })
},
[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(
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 { rotateBlob } = await import('@/lib/client/imageRotator')
try {
const composited = await compositeOnWhite(rawPngBlob, feather)
const composited = await compositeOnWhite(rawPngBlob, feather, alphaThreshold)
const finalBlob = await rotateBlob(composited, rotation)
update(id, { processedBlob: finalBlob, feather, rotation })
update(id, { processedBlob: finalBlob, feather, rotation, alphaThreshold })
} catch (err) {
update(id, { status: 'error', error: `Retraitement : ${(err as Error).message}` })
}
@@ -167,6 +197,7 @@ export function useImages() {
processedBlob: null,
rotation: 0,
feather: 2,
alphaThreshold: 30,
shopifyProductId: null,
shopifyProductTitle: null,
uploadedUrl: null,
@@ -174,9 +205,13 @@ export function useImages() {
error: null,
progress: null,
}))
dispatch({ type: 'ADD', items: newItems })
for (const item of newItems) {
runPipeline(item.id, item.originalBlob, item.ref, item.feather, item.rotation)
const itemsWithRef = newItems.map(item => {
const basename = item.filename.split('/').pop() ?? item.filename
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 {
const ref = refFromFilename(file.name)
@@ -189,6 +224,7 @@ export function useImages() {
processedBlob: null,
rotation: 0,
feather: 2,
alphaThreshold: 30,
shopifyProductId: null,
shopifyProductTitle: null,
uploadedUrl: null,
@@ -197,18 +233,51 @@ export function useImages() {
progress: null,
}
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(
(id: string, value: number) => {
const item = items.find((i) => i.id === id)
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],
)
@@ -219,7 +288,7 @@ export function useImages() {
if (!item || !item.rawPngBlob) return
const delta = direction === 'cw' ? 90 : -90
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],
)
@@ -227,18 +296,45 @@ export function useImages() {
const uploadOne = useCallback(
async (id: string) => {
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' })
try {
const base64 = await blobToBase64(item.processedBlob)
const filename = `${item.ref}.jpg`
const res = await fetch('/api/images/upload', {
// Si détouré : utilise processedBlob ; sinon : redimensionne à 1000×1000
let uploadBlob: Blob
if (item.processedBlob) {
uploadBlob = item.processedBlob
} else {
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: item.shopifyProductId, imageBase64: base64, filename }),
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()
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
update(id, { status: data.skipped ? 'skipped' : 'uploaded', uploadedUrl: data.url })
@@ -250,10 +346,19 @@ export function useImages() {
)
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) {
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))
}
}, [items, uploadOne])
@@ -264,10 +369,12 @@ export function useImages() {
const assignProduct = useCallback(
(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 }
}
+236
View File
@@ -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 })
}
+12 -1
View File
@@ -38,8 +38,9 @@ export async function removeBackgroundRaw(
* Composite un PNG transparent sur fond blanc avec feather optionnel.
* @param pngBlob — PNG avec transparence (résultat de removeBackgroundRaw)
* @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)
return new Promise((resolve, reject) => {
@@ -55,6 +56,16 @@ export function compositeOnWhite(pngBlob: Blob, feather: number): Promise<Blob>
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) {
fgCtx.globalCompositeOperation = 'destination-over'
fgCtx.filter = `blur(${feather}px)`
+15
View File
@@ -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 })
}
+59 -4
View File
@@ -5,11 +5,11 @@ const COMMON_HEADERS = new Set([
'Description courte', 'Description longue',
'Date de début de promo', 'Date de fin de promo',
'É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',
'Famille', 'Sous famille',
"Classe d'expédition (en retrait, livraison)",
'Conditionnement', 'Surface (m2)', 'Poids (kg) fonction unité',
'Conditionnement', 'Poids (kg) fonction unité',
'Images', 'Unité', 'Marque', 'Neuf ?', 'PHOTO ?',
'ID FAMILLE', 'ID SOUS FAMILLE',
])
@@ -38,7 +38,8 @@ export function parseTabRows(tab: string, headers: string[], rows: string[][], d
sku: findColIndex(headers, 'UGS'),
nom: findColIndex(headers, 'Nom'),
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é'),
prixRemise: findColIndex(headers, 'Prix remisé'),
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),
priceMarket: get(row, idx.prixMarche).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,
vendor: get(row, idx.marque),
famille: get(row, idx.famille),
@@ -80,6 +81,60 @@ export function parseTabRows(tab: string, headers: string[], rows: string[][], d
.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(
alreadyCreatedRows: Set<string>,
tabFilter?: string,
+61 -22
View File
@@ -1,4 +1,5 @@
import type { SyncProduct } from '@/types/sync'
import { findColIndex, extractSpecificFields } from '@/lib/creationSheets'
function normalizeHandle(ref: string): string {
return ref
@@ -7,40 +8,78 @@ function normalizeHandle(ref: string): string {
.replace(/^-+|-+$/g, '')
}
/**
* Parse des lignes brutes Google Sheets en SyncProduct[].
* Colonnes : A=Référence, B=Nom, C=Description, D=Prix, E=Stock, F=Statut
* Exporté pour les tests unitaires.
*/
const HEADER_WORDS = new Set(['id', 'ref', 'sku', 'nom', 'titre', 'reference', 'prix', 'statut'])
function isValidRef(ref: string): boolean {
if (ref.length < 2) return false
if (/^[^a-zA-Z0-9]+$/.test(ref)) return false // que des caractères spéciaux
if (HEADER_WORDS.has(ref.toLowerCase())) return false
return true
function get(row: string[], idx: number): string {
return idx >= 0 ? (row[idx] ?? '').trim() : ''
}
/**
* Parse des lignes brutes Google Sheets en SyncProduct[].
* La première ligne doit être la ligne d'en-têtes.
* Colonnes détectées dynamiquement : UGS, Nom, Prix marché, Stock, Publié
*/
export function parseSheetRows(rows: string[][]): SyncProduct[] {
return rows
.map((row): SyncProduct | null => {
const ref = row[0]?.trim()
if (!ref || !isValidRef(ref)) return null
if (rows.length === 0) return []
const rawPrice = (row[3] ?? '').replace(',', '.').trim()
// Trouver la ligne d'en-têtes : celle qui contient "UGS"
const headerRowIndex = rows.findIndex((row) =>
row.some((cell) => cell?.trim().toUpperCase() === 'UGS')
)
if (headerRowIndex < 0) return []
const headers = rows[headerRowIndex].map((h) => h?.trim() ?? '')
const idx = {
sku: findColIndex(headers, 'UGS'),
nom: findColIndex(headers, 'Nom'),
prixMarche: findColIndex(headers, 'Prix marché'),
prixRemise: findColIndex(headers, 'Prix remisé'),
stock: findColIndex(headers, 'Stock (pièce)') !== -1 ? findColIndex(headers, 'Stock (pièce)') : findColIndex(headers, 'Stock (en pièce)'),
publie: findColIndex(headers, 'Publié'),
poids: findColIndex(headers, 'Poids (kg) fonction unité'),
marque: findColIndex(headers, 'Marque'),
famille: findColIndex(headers, 'Famille'),
sousFamille: findColIndex(headers, 'Sous famille'),
longueur: findColIndex(headers, 'Longueur'),
largeur: findColIndex(headers, 'Largeur'),
}
// Onglet sans colonne UGS ou Prix marché = onglet non-produit, on skip
if (idx.sku < 0 || idx.prixMarche < 0) return []
return rows.slice(headerRowIndex + 1)
.map((row): SyncProduct | null => {
const ref = get(row, idx.sku)
if (!ref || ref.length < 2) return null
const rawPrice = get(row, idx.prixMarche).replace(',', '.')
const price = parseFloat(rawPrice || '0')
// Ligne sans prix valide = séparateur ou note, pas un produit
if (isNaN(price) || price <= 0) return null
const publie = get(row, idx.publie).toLowerCase()
const status = publie.includes('inac') || publie === 'non' || publie === 'false' ? 'draft' : 'active'
const prixRemise = get(row, idx.prixRemise).replace(',', '.')
const longueur = get(row, idx.longueur)
const largeur = get(row, idx.largeur)
const specificFields = extractSpecificFields(headers, row)
return {
ref,
handle: normalizeHandle(ref),
title: row[1]?.trim() || ref,
description: row[2]?.trim() || '',
title: get(row, idx.nom) || ref,
description: '',
price: price.toFixed(2),
stock: parseInt(row[4] ?? '0', 10) || 0,
status: (row[5] ?? '').toLowerCase().includes('inac') ? 'draft' : 'active',
stock: parseInt(get(row, idx.stock) || '0', 10) || 0,
weightKg: parseFloat(get(row, idx.poids).replace(',', '.') || '0') || undefined,
status,
prixRemise: prixRemise || undefined,
marque: get(row, idx.marque) || undefined,
famille: get(row, idx.famille) || undefined,
sousFamille: get(row, idx.sousFamille) || undefined,
longueur: longueur || undefined,
largeur: largeur || undefined,
specificFields: Object.keys(specificFields).length > 0 ? specificFields : undefined,
}
})
.filter((p): p is SyncProduct => p !== null)
@@ -84,7 +123,7 @@ async function listSheetTabs(sheetId: string, apiKey: string): Promise<string[]>
}
async function readOneTab(sheetId: string, apiKey: string, sheetName: string): Promise<SyncProduct[]> {
const range = encodeURIComponent(`${sheetName}!A2:F`)
const range = encodeURIComponent(`${sheetName}!A1:AZ`)
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`
const res = await fetch(url)
if (!res.ok) {
+62
View File
@@ -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)
}
+20
View File
@@ -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
View File
@@ -27,38 +27,57 @@ export type ShopifyProductResult =
export async function searchProductByRef(ref: string): Promise<ShopifyProductResult> {
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 res = await fetch(
`${baseUrl}/variants.json?sku=${encodeURIComponent(sku)}&fields=id,sku,product_id&limit=1`,
{ headers },
)
const query = `{
productVariants(first: 10, query: "sku:${sku.replace(/"/g, '')}") {
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 }
const data = await res.json()
const variants = (data.variants ?? []) as Array<{ id: number; sku: string; product_id: number }>
if (variants.length === 0) return { found: false }
// Récupère le titre du produit
const productRes = await fetch(
`${baseUrl}/products/${variants[0].product_id}.json?fields=id,title`,
{ headers },
const edges = data?.data?.productVariants?.edges ?? []
// Cherche le match exact (insensible à la casse) parmi les résultats
const match = edges.find((e: { node: { sku: string } }) =>
e.node.sku.toLowerCase() === sku.toLowerCase()
)
if (!productRes.ok) return { found: false }
const productData = await productRes.json()
if (!match) return { found: false }
return {
found: true,
shopifyId: String(variants[0].product_id),
title: productData.product?.title ?? sku,
shopifyId: match.node.product.legacyResourceId,
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)
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(/[-_ ][^-_ ]*$/, '')
if (withoutSuffix !== ref) {
if (withoutSuffix !== ref && withoutSuffix !== withoutNumericSuffix) {
result = await searchBySku(withoutSuffix)
if (result.found) return result
}
@@ -142,6 +161,12 @@ export async function uploadProductImage(
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) {
const body = await res.text()
throw new Error(`Shopify upload error: ${res.status}${body}`)
+53 -4
View File
@@ -2,7 +2,7 @@ import type { ShopifyProductFull, SyncProduct } from '@/types/sync'
const API_VERSION = '2024-01'
function getConfig() {
export function getConfig() {
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) {
@@ -31,6 +31,7 @@ interface ShopifyAPIProduct {
body_html: string
status: string
variants: ShopifyVariant[]
images: Array<{ src: string }>
}
function toSyncProduct(p: ShopifyAPIProduct): ShopifyProductFull {
@@ -43,6 +44,7 @@ function toSyncProduct(p: ShopifyAPIProduct): ShopifyProductFull {
price: p.variants[0]?.price ?? '0.00',
stock: p.variants[0]?.inventory_quantity ?? 0,
status: p.status === 'active' ? 'active' : 'draft',
images: (p.images ?? []).map(i => i.src),
}
}
@@ -69,7 +71,7 @@ async function fetchByStatus(status: 'active' | 'draft'): Promise<ShopifyProduct
const all: ShopifyProductFull[] = []
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) {
const res = await fetch(url, { headers })
@@ -89,13 +91,23 @@ async function fetchByStatus(status: 'active' | 'draft'): Promise<ShopifyProduct
export async function createShopifyProduct(product: SyncProduct): Promise<string> {
const { baseUrl, headers } = getConfig()
const variant: Record<string, unknown> = {
price: product.price,
sku: product.ref,
inventory_management: 'shopify',
}
if (product.weightKg != null && product.weightKg > 0) {
variant.weight = product.weightKg
variant.weight_unit = 'kg'
}
const body = {
product: {
title: product.title,
body_html: product.description,
handle: product.handle,
status: product.status,
variants: [{ price: product.price, sku: product.ref }],
variants: [variant],
},
}
@@ -111,7 +123,44 @@ export async function createShopifyProduct(product: SyncProduct): Promise<string
}
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
}
/**
+211
View File
@@ -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}`
}
+4 -2
View File
@@ -1,9 +1,10 @@
export type ImageStatus =
| 'pending' // ajouté, pas encore traité
| 'converting' // HEIC → JPEG en cours
| 'processing' // détourage IA 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
| 'uploaded' // uploadé avec succès
| '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")
rotation: number // 0, 90, 180, 270
feather: number // 05, défaut 2
alphaThreshold: number // 0100, défaut 30 — pixels semi-transparents rendus opaques
shopifyProductId: string | null
shopifyProductTitle: string | null
uploadedUrl: string | null
+22
View File
@@ -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
}
+2 -1
View File
@@ -9,7 +9,8 @@ export interface CatalogProduct {
price: string // "29.99"
stock: number // inventory_quantity variante 1
status: 'active' | 'draft' | 'archived'
shopifyUrl: string // https://materiaux-destock.myshopify.com/admin/products/<id>
shopifyUrl: string
images: string[]
}
/** Filtres appliqués à la liste */
+12 -2
View File
@@ -7,13 +7,23 @@ export interface SyncProduct {
title: string // colonne B
description: string // colonne C (texte brut, stocké en body_html)
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
// Champs enrichis
prixRemise?: string
marque?: string
famille?: string
sousFamille?: string
longueur?: string
largeur?: string
specificFields?: Record<string, string>
}
/** Produit Shopify enrichi pour la comparaison */
export interface ShopifyProductFull extends SyncProduct {
shopifyId: string // ID numérique Shopify (string)
shopifyId: string
images?: string[]
}
/** Un changement unitaire calculé par computeDiff */