From 34ec818ad33ff5d8de9bb787eb711db05406db84 Mon Sep 17 00:00:00 2001 From: Mathieu Date: Thu, 25 Jun 2026 10:35:27 +0200 Subject: [PATCH] feat: replace product search with searchable combobox in banner builder Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/products/search-list/route.ts | 42 ++++++++ src/components/bannieres/BannerBuilder.tsx | 107 ++++++++++++++++----- 2 files changed, 124 insertions(+), 25 deletions(-) create mode 100644 src/app/api/products/search-list/route.ts diff --git a/src/app/api/products/search-list/route.ts b/src/app/api/products/search-list/route.ts new file mode 100644 index 0000000..6a81730 --- /dev/null +++ b/src/app/api/products/search-list/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server' + +const API_VERSION = '2024-01' + +// GET /api/products/search-list?q=peinture +// Retourne jusqu'à 10 produits correspondant à la recherche (titre ou SKU) +export async function GET(req: NextRequest) { + const q = new URL(req.url).searchParams.get('q')?.trim() + if (!q || q.length < 2) return NextResponse.json([]) + + const domain = process.env.SHOPIFY_STORE_DOMAIN + const token = process.env.SHOPIFY_ADMIN_API_TOKEN + if (!domain || !token) return NextResponse.json([], { status: 500 }) + + const headers = { 'X-Shopify-Access-Token': token } + const baseUrl = `https://${domain}/admin/api/${API_VERSION}` + + try { + // Recherche par titre + const res = await fetch( + `${baseUrl}/products.json?title=${encodeURIComponent(q)}&limit=10&fields=id,title,variants`, + { headers }, + ) + if (!res.ok) return NextResponse.json([]) + const data = await res.json() + const products = (data.products ?? []) as Array<{ + id: number + title: string + variants: Array<{ sku: string }> + }> + + const results = products.map(p => ({ + shopifyId: String(p.id), + title: p.title, + sku: p.variants[0]?.sku ?? '', + })) + + return NextResponse.json(results) + } catch { + return NextResponse.json([]) + } +} diff --git a/src/components/bannieres/BannerBuilder.tsx b/src/components/bannieres/BannerBuilder.tsx index d58e4e3..cf85e06 100644 --- a/src/components/bannieres/BannerBuilder.tsx +++ b/src/components/bannieres/BannerBuilder.tsx @@ -9,6 +9,12 @@ interface Props { onClose: () => void } +interface SearchSuggestion { + shopifyId: string + title: string + sku: string +} + function formatPrice(price: string): string { if (!price) return '' const n = parseFloat(price) @@ -25,11 +31,15 @@ function blobToBase64(blob: Blob): Promise { } export function BannerBuilder({ onApply, onClose }: Props) { - // Product search - const [query, setQuery] = useState('') - const [searching, setSearching] = useState(false) + // Combobox produit + const [inputValue, setInputValue] = useState('') + const [suggestions, setSuggestions] = useState([]) + const [showSuggestions, setShowSuggestions] = useState(false) + const [loadingSuggestions, setLoadingSuggestions] = useState(false) + const [loadingProduct, setLoadingProduct] = useState(false) const [product, setProduct] = useState(null) const [searchError, setSearchError] = useState(null) + const comboRef = useRef(null) // Banner config const [scheme, setScheme] = useState('dark-blue') @@ -47,14 +57,48 @@ export function BannerBuilder({ onApply, onClose }: Props) { const previewRef = useRef(null) - const searchProduct = async () => { - if (!query.trim()) return - setSearching(true) + // 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 { + // Recherche par titre via l'API Shopify REST + const domain = window.location.origin + 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?ref=${encodeURIComponent(query.trim())}`) + 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 @@ -66,7 +110,7 @@ export function BannerBuilder({ onApply, onClose }: Props) { } catch (e) { setSearchError((e as Error).message) } finally { - setSearching(false) + setLoadingProduct(false) } } @@ -147,25 +191,38 @@ export function BannerBuilder({ onApply, onClose }: Props) { {/* ── Panneau gauche : configuration ─────────────────────────────── */}
- {/* Recherche produit */} + {/* Recherche produit — combobox */}

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" - /> - +
+
+ { 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" + /> + + {loadingSuggestions || loadingProduct ? '⏳' : '🔍'} + +
+ {showSuggestions && suggestions.length > 0 && ( +
+ {suggestions.map(s => ( + + ))} +
+ )}
{searchError &&

{searchError}

} {product && (