'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 { 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([]) const [showSuggestions, setShowSuggestions] = useState(false) const [loadingSuggestions, setLoadingSuggestions] = useState(false) const [loadingProduct, setLoadingProduct] = useState(false) const [product, setProduct] = useState(null) const [searchError, setSearchError] = useState(null) const comboRef = useRef(null) // Banner config const [scheme, setScheme] = useState('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(null) const [backgroundBlob, setBackgroundBlob] = useState(null) const [backgroundPreview, setBackgroundPreview] = useState(null) const bgFileRef = useRef(null) // Generation const [rendering, setRendering] = useState(false) const [previewUrl, setPreviewUrl] = useState(null) const [uploading, setUploading] = useState(false) const [error, setError] = useState(null) const previewRef = useRef(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) => { 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 (
{ if (e.target === e.currentTarget) onClose() }} >
{/* Header */}

Créer une bannière

{/* ── Panneau gauche : configuration ─────────────────────────────── */}
{/* Recherche produit — combobox */}

Produit

{ 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" /> {loadingSuggestions || loadingProduct ? '⏳' : '🔍'}
{showSuggestions && suggestions.length > 0 && (
{suggestions.map(s => ( ))}
)}
{searchError &&

{searchError}

} {product && (

{product.title}

{product.sku} · {formatPrice(product.price)}

{product.allImages.length > 1 && (

Choisir l'image :

{product.allImages.map((url, i) => ( ))}
)}
)}
{/* Image de fond */}

Image de fond

{backgroundPreview ? (
Fond
) : ( )} {backgroundPreview && (

Un overlay sombre est appliqué automatiquement pour la lisibilité du texte.

)}
{/* Thème couleur */}

{backgroundPreview ? 'Couleur du texte' : 'Thème couleur'}

{schemeEntries.map(([key, s]) => ( ))}
{/* Textes */}

Textes

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" />