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:
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { Header } from '@/components/layout/Header'
|
import { Header } from '@/components/layout/Header'
|
||||||
|
import { BannerBuilder } from '@/components/bannieres/BannerBuilder'
|
||||||
import type { SlideshowSection, Slide } from '@/lib/shopifyTheme'
|
import type { SlideshowSection, Slide } from '@/lib/shopifyTheme'
|
||||||
|
|
||||||
// Convertit "shopify://shop_images/foo.jpg" → URL CDN prévisualisable via proxy
|
// Convertit "shopify://shop_images/foo.jpg" → URL CDN prévisualisable via proxy
|
||||||
@@ -28,9 +29,10 @@ interface SlideCardProps {
|
|||||||
onRemove: () => void
|
onRemove: () => void
|
||||||
onChangeImage: (shopifyRef: string) => void
|
onChangeImage: (shopifyRef: string) => void
|
||||||
onChangeLink: (link: string) => void
|
onChangeLink: (link: string) => void
|
||||||
|
onOpenBuilder: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function SlideCard({ slide, index, total, onMoveUp, onMoveDown, onRemove, onChangeImage, onChangeLink }: SlideCardProps) {
|
function SlideCard({ slide, index, total, onMoveUp, onMoveDown, onRemove, onChangeImage, onChangeLink, onOpenBuilder }: SlideCardProps) {
|
||||||
const fileRef = useRef<HTMLInputElement>(null)
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -94,12 +96,19 @@ function SlideCard({ slide, index, total, onMoveUp, onMoveDown, onRemove, onChan
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-1.5 flex-wrap">
|
<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
|
<button
|
||||||
onClick={() => fileRef.current?.click()}
|
onClick={() => fileRef.current?.click()}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
className="flex-1 py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white rounded font-medium disabled:opacity-50"
|
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 ? '⬆️ Upload…' : '🖼️ Changer l\'image'}
|
{uploading ? '…' : '🖼️'}
|
||||||
</button>
|
</button>
|
||||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleFile} />
|
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleFile} />
|
||||||
<button
|
<button
|
||||||
@@ -132,6 +141,8 @@ export default function BannièresPage() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
fetch('/api/theme/slideshow')
|
fetch('/api/theme/slideshow')
|
||||||
@@ -263,12 +274,24 @@ export default function BannièresPage() {
|
|||||||
onRemove={() => removeSlide(i)}
|
onRemove={() => removeSlide(i)}
|
||||||
onChangeImage={ref => changeImage(i, ref)}
|
onChangeImage={ref => changeImage(i, ref)}
|
||||||
onChangeLink={link => changeLink(i, link)}
|
onChangeLink={link => changeLink(i, link)}
|
||||||
|
onOpenBuilder={() => setBuilderTargetIndex(i)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{builderTargetIndex !== null && (
|
||||||
|
<BannerBuilder
|
||||||
|
onApply={shopifyRef => {
|
||||||
|
if (builderTargetIndex !== null) changeImage(builderTargetIndex, shopifyRef)
|
||||||
|
setBuilderTargetIndex(null)
|
||||||
|
setSaved(false)
|
||||||
|
}}
|
||||||
|
onClose={() => setBuilderTargetIndex(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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 graphqlUrl = baseUrl.replace('/admin/api/', '/admin/api/').replace('products', '').replace(/\/$/, '') + '/../graphql.json'
|
||||||
|
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||||
|
const gqlRes = await fetch(`https://${domain}/admin/api/${API_VERSION}/graphql.json`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `{ productVariants(first: 5, query: "sku:${ref.replace(/"/g, '')}") {
|
||||||
|
edges { node { sku product { id legacyResourceId title } } }
|
||||||
|
} }`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const gqlData = await gqlRes.json()
|
||||||
|
const edges = gqlData?.data?.productVariants?.edges ?? []
|
||||||
|
const match = edges.find((e: { node: { sku: string } }) =>
|
||||||
|
e.node.sku.toLowerCase() === ref.toLowerCase()
|
||||||
|
)
|
||||||
|
if (match) {
|
||||||
|
const legacyId = match.node.product.legacyResourceId
|
||||||
|
const res = await fetch(`${baseUrl}/products/${legacyId}.json?fields=id,title,variants,images`, { headers })
|
||||||
|
if (res.ok) product = (await res.json()).product
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!product) return NextResponse.json({ error: 'Produit introuvable' }, { status: 404 })
|
||||||
|
|
||||||
|
const variants = product.variants as Array<{ price: string; compare_at_price: string | null; sku: string }>
|
||||||
|
const images = product.images as Array<{ src: string }>
|
||||||
|
const variant = variants[0]
|
||||||
|
|
||||||
|
const result: BannerProductData = {
|
||||||
|
id: String(product.id),
|
||||||
|
title: product.title as string,
|
||||||
|
price: variant?.price ?? '',
|
||||||
|
compareAtPrice: variant?.compare_at_price ?? null,
|
||||||
|
sku: variant?.sku ?? '',
|
||||||
|
imageUrl: images[0]?.src ?? null,
|
||||||
|
allImages: images.map(i => i.src),
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(result)
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: (err as Error).message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ─────────────────────────────────────────────────────────────────
|
||||||
|
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 })
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user