feat: replace product search with searchable combobox in banner builder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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([])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,12 @@ interface Props {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SearchSuggestion {
|
||||||
|
shopifyId: string
|
||||||
|
title: string
|
||||||
|
sku: string
|
||||||
|
}
|
||||||
|
|
||||||
function formatPrice(price: string): string {
|
function formatPrice(price: string): string {
|
||||||
if (!price) return ''
|
if (!price) return ''
|
||||||
const n = parseFloat(price)
|
const n = parseFloat(price)
|
||||||
@@ -25,11 +31,15 @@ function blobToBase64(blob: Blob): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BannerBuilder({ onApply, onClose }: Props) {
|
export function BannerBuilder({ onApply, onClose }: Props) {
|
||||||
// Product search
|
// Combobox produit
|
||||||
const [query, setQuery] = useState('')
|
const [inputValue, setInputValue] = useState('')
|
||||||
const [searching, setSearching] = useState(false)
|
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([])
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||||
|
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
|
||||||
|
const [loadingProduct, setLoadingProduct] = useState(false)
|
||||||
const [product, setProduct] = useState<BannerProductData | null>(null)
|
const [product, setProduct] = useState<BannerProductData | null>(null)
|
||||||
const [searchError, setSearchError] = useState<string | null>(null)
|
const [searchError, setSearchError] = useState<string | null>(null)
|
||||||
|
const comboRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Banner config
|
// Banner config
|
||||||
const [scheme, setScheme] = useState<ColorScheme>('dark-blue')
|
const [scheme, setScheme] = useState<ColorScheme>('dark-blue')
|
||||||
@@ -47,14 +57,48 @@ export function BannerBuilder({ onApply, onClose }: Props) {
|
|||||||
|
|
||||||
const previewRef = useRef<string | null>(null)
|
const previewRef = useRef<string | null>(null)
|
||||||
|
|
||||||
const searchProduct = async () => {
|
// Ferme les suggestions si clic extérieur
|
||||||
if (!query.trim()) return
|
useEffect(() => {
|
||||||
setSearching(true)
|
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)
|
setSearchError(null)
|
||||||
setProduct(null)
|
setProduct(null)
|
||||||
setPreviewUrl(null)
|
setPreviewUrl(null)
|
||||||
try {
|
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()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error)
|
if (!res.ok) throw new Error(data.error)
|
||||||
const p = data as BannerProductData
|
const p = data as BannerProductData
|
||||||
@@ -66,7 +110,7 @@ export function BannerBuilder({ onApply, onClose }: Props) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
setSearchError((e as Error).message)
|
setSearchError((e as Error).message)
|
||||||
} finally {
|
} finally {
|
||||||
setSearching(false)
|
setLoadingProduct(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,25 +191,38 @@ export function BannerBuilder({ onApply, onClose }: Props) {
|
|||||||
{/* ── Panneau gauche : configuration ─────────────────────────────── */}
|
{/* ── Panneau gauche : configuration ─────────────────────────────── */}
|
||||||
<div className="w-80 flex-shrink-0 overflow-y-auto border-r border-slate-700 p-4 space-y-5">
|
<div className="w-80 flex-shrink-0 overflow-y-auto border-r border-slate-700 p-4 space-y-5">
|
||||||
|
|
||||||
{/* Recherche produit */}
|
{/* Recherche produit — combobox */}
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">Produit</h3>
|
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">Produit</h3>
|
||||||
<div className="flex gap-1">
|
<div className="relative" ref={comboRef}>
|
||||||
<input
|
<div className="relative">
|
||||||
type="text"
|
<input
|
||||||
value={query}
|
type="text"
|
||||||
onChange={e => setQuery(e.target.value)}
|
value={inputValue}
|
||||||
onKeyDown={e => e.key === 'Enter' && searchProduct()}
|
onChange={e => { setInputValue(e.target.value); setProduct(null) }}
|
||||||
placeholder="Référence / UGS…"
|
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
|
||||||
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"
|
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"
|
||||||
<button
|
/>
|
||||||
onClick={searchProduct}
|
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 text-xs pointer-events-none">
|
||||||
disabled={searching}
|
{loadingSuggestions || loadingProduct ? '⏳' : '🔍'}
|
||||||
className="px-3 py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white rounded disabled:opacity-50"
|
</span>
|
||||||
>
|
</div>
|
||||||
{searching ? '…' : '🔍'}
|
{showSuggestions && suggestions.length > 0 && (
|
||||||
</button>
|
<div className="absolute z-20 w-full mt-1 bg-slate-700 border border-slate-600 rounded shadow-xl max-h-52 overflow-y-auto">
|
||||||
|
{suggestions.map(s => (
|
||||||
|
<button
|
||||||
|
key={s.shopifyId}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={e => { e.preventDefault(); selectSuggestion(s) }}
|
||||||
|
className="w-full text-left px-3 py-2 hover:bg-slate-600 border-b border-slate-600 last:border-0"
|
||||||
|
>
|
||||||
|
<p className="text-xs text-white truncate">{s.title}</p>
|
||||||
|
<p className="text-xs text-slate-400 font-mono">{s.sku}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{searchError && <p className="text-xs text-red-400 mt-1">{searchError}</p>}
|
{searchError && <p className="text-xs text-red-400 mt-1">{searchError}</p>}
|
||||||
{product && (
|
{product && (
|
||||||
|
|||||||
Reference in New Issue
Block a user