From 4ce62c14735187c790ce446e4c7b5b46aadb1c78 Mon Sep 17 00:00:00 2001 From: Mathieu Date: Thu, 25 Jun 2026 09:53:58 +0200 Subject: [PATCH] feat: add banner builder with product search and canvas rendering Co-Authored-By: Claude Sonnet 4.6 --- src/app/(dashboard)/bannieres/page.tsx | 29 +- .../api/theme/product-banner-data/route.ts | 86 +++++ src/components/bannieres/BannerBuilder.tsx | 311 ++++++++++++++++++ src/lib/client/bannerRenderer.ts | 215 ++++++++++++ 4 files changed, 638 insertions(+), 3 deletions(-) create mode 100644 src/app/api/theme/product-banner-data/route.ts create mode 100644 src/components/bannieres/BannerBuilder.tsx create mode 100644 src/lib/client/bannerRenderer.ts diff --git a/src/app/(dashboard)/bannieres/page.tsx b/src/app/(dashboard)/bannieres/page.tsx index c5d8190..7b6bedd 100644 --- a/src/app/(dashboard)/bannieres/page.tsx +++ b/src/app/(dashboard)/bannieres/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, useRef, useCallback } from 'react' import { Header } from '@/components/layout/Header' +import { BannerBuilder } from '@/components/bannieres/BannerBuilder' import type { SlideshowSection, Slide } from '@/lib/shopifyTheme' // Convertit "shopify://shop_images/foo.jpg" β†’ URL CDN prΓ©visualisable via proxy @@ -28,9 +29,10 @@ interface SlideCardProps { onRemove: () => void onChangeImage: (shopifyRef: 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(null) const [uploading, setUploading] = useState(false) const [error, setError] = useState(null) @@ -94,12 +96,19 @@ function SlideCard({ slide, index, total, onMoveUp, onMoveDown, onRemove, onChan {/* Actions */}
+
)} + + {builderTargetIndex !== null && ( + { + if (builderTargetIndex !== null) changeImage(builderTargetIndex, shopifyRef) + setBuilderTargetIndex(null) + setSaved(false) + }} + onClose={() => setBuilderTargetIndex(null)} + /> + )} ) } diff --git a/src/app/api/theme/product-banner-data/route.ts b/src/app/api/theme/product-banner-data/route.ts new file mode 100644 index 0000000..93217ad --- /dev/null +++ b/src/app/api/theme/product-banner-data/route.ts @@ -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 | 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 }) + } +} diff --git a/src/components/bannieres/BannerBuilder.tsx b/src/components/bannieres/BannerBuilder.tsx new file mode 100644 index 0000000..49d4674 --- /dev/null +++ b/src/components/bannieres/BannerBuilder.tsx @@ -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 { + 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(null) + const [searchError, setSearchError] = useState(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) + + // 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) + + 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 ( +
{ if (e.target === e.currentTarget) onClose() }} + > +
+ + {/* Header */} +
+

Créer une bannière

+ +
+ +
+ {/* ── Panneau gauche : configuration ─────────────────────────────── */} +
+ + {/* Recherche produit */} +
+

Produit

+
+ 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" + /> + +
+ {searchError &&

{searchError}

} + {product && ( +
+

{product.title}

+

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

+ {product.allImages.length > 1 && ( +
+

Choisir l'image :

+
+ {product.allImages.map((url, i) => ( + + ))} +
+
+ )} +
+ )} +
+ + {/* Thème couleur */} +
+

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