feat: add banner builder with product search and canvas rendering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
'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
|
||||
}
|
||||
|
||||
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) {
|
||||
// Product search
|
||||
const [query, setQuery] = useState('')
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [product, setProduct] = useState<BannerProductData | null>(null)
|
||||
const [searchError, setSearchError] = useState<string | null>(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)
|
||||
|
||||
// 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)
|
||||
|
||||
const searchProduct = async () => {
|
||||
if (!query.trim()) return
|
||||
setSearching(true)
|
||||
setSearchError(null)
|
||||
setProduct(null)
|
||||
setPreviewUrl(null)
|
||||
try {
|
||||
const res = await fetch(`/api/theme/product-banner-data?ref=${encodeURIComponent(query.trim())}`)
|
||||
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 {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getConfig = useCallback((): BannerConfig => ({
|
||||
scheme,
|
||||
promoLabel,
|
||||
title,
|
||||
price,
|
||||
comparePrice,
|
||||
productImageUrl: selectedImageUrl,
|
||||
}), [scheme, promoLabel, title, price, comparePrice, selectedImageUrl])
|
||||
|
||||
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]) // 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 */}
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">Produit</h3>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && searchProduct()}
|
||||
placeholder="Référence / UGS…"
|
||||
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
|
||||
onClick={searchProduct}
|
||||
disabled={searching}
|
||||
className="px-3 py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white rounded disabled:opacity-50"
|
||||
>
|
||||
{searching ? '…' : '🔍'}
|
||||
</button>
|
||||
</div>
|
||||
{searchError && <p className="text-xs text-red-400 mt-1">{searchError}</p>}
|
||||
{product && (
|
||||
<div className="mt-2 p-2 bg-slate-700 rounded text-xs">
|
||||
<p className="text-white font-medium truncate">{product.title}</p>
|
||||
<p className="text-slate-400">{product.sku} · {formatPrice(product.price)}</p>
|
||||
{product.allImages.length > 1 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-slate-400 mb-1">Choisir l'image :</p>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{product.allImages.map((url, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setSelectedImageUrl(url)}
|
||||
className={`rounded overflow-hidden border-2 transition-all ${selectedImageUrl === url ? 'border-indigo-500' : 'border-slate-600'}`}
|
||||
>
|
||||
<img src={url} alt={`img ${i}`} className="w-12 h-12 object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Thème couleur */}
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user