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,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