Full website
This commit is contained in:
1 parent
807c43ece5
commit
ea9f33f60e
40 files changed
+4503
-1202
No files matched your search
@@ -1,136 +1,120 @@
|
||||
"use client"
|
||||
import { useState, useEffect, useRef, Suspense } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
||||
import ProductCard from '../../components/ProductCard'
|
||||
import { type Product } from '@homaroma/types'
|
||||
import { API_BASE } from '../../lib/api'
|
||||
import { getCategoriesResult, getCmsPageResult, getProductsResult } from '../../lib/cms'
|
||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||
import { resolveLocalized } from '../../lib/localization'
|
||||
|
||||
function SearchResults() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const q = searchParams.get('q') || ''
|
||||
const lang = 'th'
|
||||
|
||||
const [query, setQuery] = useState(q)
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [displayCount, setDisplayCount] = useState(24)
|
||||
const loaderRef = useRef<HTMLDivElement>(null)
|
||||
export const metadata = {
|
||||
title: 'Search Products | HomAroma',
|
||||
description: 'Search HomAroma products by scent, category, or keyword.',
|
||||
}
|
||||
|
||||
// Sync input → URL
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (query.trim()) router.replace(`/search?q=${encodeURIComponent(query.trim())}`)
|
||||
}, 400)
|
||||
return () => clearTimeout(timer)
|
||||
}, [query, router])
|
||||
export default async function SearchPage({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) {
|
||||
const params = await searchParams
|
||||
const rawQuery = Array.isArray(params.q) ? params.q[0] : params.q
|
||||
const query = rawQuery?.trim() ?? ''
|
||||
const [categoriesResult, cmsLayoutResult, productsResult] = await Promise.all([
|
||||
getCategoriesResult(lang),
|
||||
getCmsPageResult('search-layout', lang),
|
||||
query ? getProductsResult(`search=${encodeURIComponent(query)}&limit=1000`, lang) : Promise.resolve({ ok: true as const, data: [] }),
|
||||
])
|
||||
const categories = categoriesResult.ok ? categoriesResult.data : []
|
||||
const cmsSections = cmsLayoutResult.ok ? cmsLayoutResult.data.sections ?? [] : []
|
||||
const { announcementSections, heroSections, bottomSections } = splitCmsLayoutSections(cmsSections)
|
||||
const products = productsResult.ok ? productsResult.data : []
|
||||
|
||||
// Fetch on URL change
|
||||
useEffect(() => {
|
||||
if (!q.trim()) { setProducts([]); return }
|
||||
setLoading(true)
|
||||
setDisplayCount(24)
|
||||
fetch(`${API_BASE}/products?search=${encodeURIComponent(q)}&limit=1000`)
|
||||
.then(r => r.json())
|
||||
.then(data => setProducts(data.data || []))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [q])
|
||||
|
||||
// Infinite scroll
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0].isIntersecting && products.length > displayCount)
|
||||
setDisplayCount(prev => Math.min(prev + 12, products.length))
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: '200px' }
|
||||
)
|
||||
if (loaderRef.current) observer.observe(loaderRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [products.length, displayCount])
|
||||
if (!cmsLayoutResult.ok && cmsLayoutResult.status !== 404) {
|
||||
console.error(`[website-cms] search-layout unavailable: ${cmsLayoutResult.error}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="pt-32 pb-12 px-6 text-center space-y-4 border-b border-white/5 bg-night-800/30">
|
||||
<p className="text-cyan-glow uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Search</p>
|
||||
<h1 className="text-5xl font-bold tracking-tight">Find Your Scent</h1>
|
||||
<div className="min-h-screen bg-pop-bg">
|
||||
{announcementSections.length > 0 && (
|
||||
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
|
||||
)}
|
||||
{heroSections.length > 0 ? (
|
||||
<DynamicPageRenderer sections={heroSections} categories={categories} lang={lang} />
|
||||
) : (
|
||||
<section className="border-b border-gray-200 bg-white px-6 py-16 text-center">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.28em] text-pop-pink">Search</p>
|
||||
<h1 className="mt-4 text-5xl font-semibold tracking-tight text-pop-black">Find Your Scent</h1>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="max-w-md mx-auto mt-6">
|
||||
<div className="flex glass border border-white/10 rounded-xl overflow-hidden">
|
||||
<svg className="w-4 h-4 text-slate-400 m-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder="Search by scent or product name..."
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
className="bg-transparent border-none outline-none text-sm p-2 w-full text-white placeholder-slate-500"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => { setQuery(''); router.replace('/search') }}
|
||||
className="px-3 text-slate-400 hover:text-white transition-colors"
|
||||
>✕</button>
|
||||
)}
|
||||
<section className="border-b border-gray-100 bg-white/70 px-6 py-8">
|
||||
<form className="mx-auto flex max-w-xl overflow-hidden rounded-full border border-gray-200 bg-white shadow-brutal" action="/search">
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={query}
|
||||
placeholder="Search by scent or product name..."
|
||||
className="min-w-0 flex-1 px-5 py-3 text-sm text-pop-black outline-none placeholder:text-gray-400"
|
||||
/>
|
||||
<button className="bg-pop-black px-6 py-3 text-sm font-bold uppercase tracking-widest text-white transition hover:bg-pop-pink">
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{categories.length > 0 && (
|
||||
<div className="mx-auto mt-5 flex max-w-3xl flex-wrap justify-center gap-2">
|
||||
{categories.map((category) => {
|
||||
const name = resolveLocalized(category.name, lang)
|
||||
if (!name) return null
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={category.id}
|
||||
href={`/search?q=${encodeURIComponent(name)}`}
|
||||
className="rounded-full border border-gray-200 bg-white px-4 py-1.5 text-xs font-semibold text-gray-600 transition hover:border-pop-pink hover:text-pop-pink"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && q && (
|
||||
<p className="text-slate-500 text-sm">
|
||||
{query && (
|
||||
<p className="mt-5 text-center text-sm text-gray-500">
|
||||
{products.length > 0
|
||||
? `Found ${products.length} result${products.length !== 1 ? 's' : ''} for "${q}"`
|
||||
: `No results for "${q}"`}
|
||||
? `Found ${products.length} result${products.length !== 1 ? 's' : ''} for "${query}"`
|
||||
: `No results for "${query}"`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||
{!q.trim() ? (
|
||||
<div className="text-center py-24 text-slate-500 space-y-3">
|
||||
<p className="text-4xl">🔍</p>
|
||||
<p className="text-lg">Type something to search</p>
|
||||
<Link href="/shop" className="text-cyan-glow text-sm hover:underline">Browse All Products →</Link>
|
||||
<div className="mx-auto max-w-7xl px-6 py-12">
|
||||
{!query ? (
|
||||
<div className="rounded-3xl bg-white px-6 py-20 text-center shadow-brutal">
|
||||
<p className="text-lg font-semibold text-pop-black">Type something to search</p>
|
||||
<Link href="/shop" className="mt-4 inline-flex font-semibold text-pop-pink">
|
||||
Browse All Products →
|
||||
</Link>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl bg-white/5 animate-pulse h-96" />
|
||||
))}
|
||||
) : !productsResult.ok ? (
|
||||
<div className="rounded-3xl border border-rose-100 bg-white px-6 py-20 text-center shadow-brutal">
|
||||
<p className="text-lg font-semibold text-pop-black">Unable to search products</p>
|
||||
<p className="mt-2 text-sm text-rose-500">{productsResult.error}</p>
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="text-center py-24 text-slate-500 space-y-4">
|
||||
<p className="text-4xl">🌿</p>
|
||||
<p className="text-lg font-semibold">No products found</p>
|
||||
<p className="text-sm">Try a different scent name or keyword</p>
|
||||
<Link href="/shop" className="text-cyan-glow text-sm hover:underline">Browse All Products →</Link>
|
||||
<div className="rounded-3xl bg-white px-6 py-20 text-center shadow-brutal">
|
||||
<p className="text-lg font-semibold text-pop-black">No products found</p>
|
||||
<p className="mt-2 text-sm text-gray-500">Try a different scent name or keyword.</p>
|
||||
<Link href="/shop" className="mt-4 inline-flex font-semibold text-pop-pink">
|
||||
Browse All Products →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
|
||||
{products.slice(0, displayCount).map(p => (
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
{displayCount < products.length && (
|
||||
<div ref={loaderRef} className="py-12 flex justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-cyan-glow border-t-transparent animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
{products.map((product) => <ProductCard key={product.id} product={product} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{bottomSections.length > 0 && (
|
||||
<DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<SearchResults />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user