Files
gestion-materiaux-destock/src/components/bannieres/BannerBuilder.tsx
T
2026-06-25 10:46:45 +02:00

418 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
)
}