feat: implement server-side product search, category filtering, and infinite scroll pagination on the homepage

This commit is contained in:
Yoga Pangestu 2026-04-23 23:57:41 +07:00
parent 470fbd4b40
commit b010e1a126
2 changed files with 177 additions and 49 deletions

View File

@ -9,10 +9,28 @@ class HomepageController extends Controller
{ {
public function __invoke() public function __invoke()
{ {
$search = request('search');
$categorySlug = request('category');
return inertia('homepage', [ return inertia('homepage', [
'categories' => Category::all(), 'categories' => Category::active()->get(),
'bestSellers' => Product::with(['categories', 'prices'])->active()->latest()->take(4)->get(), 'bestSellers' => Product::with(['categories' => fn ($q) => $q->active(), 'prices'])
'topSellingProducts' => Product::with(['categories', 'prices'])->withCount('orderItems')->active()->orderBy('order_items_count', 'desc')->take(3)->get(), ->when($search, function ($query, $search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
})
->when($categorySlug, function ($query, $categorySlug) {
$query->whereHas('categories', function ($q) use ($categorySlug) {
$q->where('categories.slug', $categorySlug);
});
})
->active()
->latest()
->paginate(12)
->withQueryString(),
'topSellingProducts' => Product::with(['categories' => fn ($q) => $q->active(), 'prices'])->withCount('orderItems')->active()->orderBy('order_items_count', 'desc')->take(3)->get(),
]); ]);
} }
} }

View File

@ -1,12 +1,62 @@
import { Link } from '@inertiajs/react';
import { ArrowRight, Facebook, Instagram, Twitter, X, Youtube } from 'lucide-react';
import { useEffect, useState } from 'react';
import { formatCurrency } from '@/lib/formatters'; import { formatCurrency } from '@/lib/formatters';
import { Link, router } from '@inertiajs/react';
import { ArrowRight, Facebook, Instagram, Search, X } from 'lucide-react';
import { useEffect, useState } from 'react';
export default function Homepage({ categories = [], bestSellers = [], topSellingProducts = [] }: { categories: any[], bestSellers: any[], topSellingProducts: any[] }) { export default function Homepage({ categories = [], bestSellers, topSellingProducts = [] }: { categories: any[], bestSellers: any, topSellingProducts: any[] }) {
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const [selectedProduct, setSelectedProduct] = useState<any>(null); const [selectedProduct, setSelectedProduct] = useState<any>(null);
const [allProducts, setAllProducts] = useState<any[]>(bestSellers.data);
const [search, setSearch] = useState('');
const [selectedCategorySlug, setSelectedCategorySlug] = useState('');
useEffect(() => {
const timer = setTimeout(() => {
router.get(window.location.pathname, { search, category: selectedCategorySlug }, {
preserveState: true,
preserveScroll: true,
only: ['bestSellers'],
onSuccess: (page: any) => {
setAllProducts(page.props.bestSellers.data);
}
});
}, 500);
return () => clearTimeout(timer);
}, [search]);
useEffect(() => {
router.get(window.location.pathname, { search, category: selectedCategorySlug }, {
preserveState: true,
preserveScroll: true,
only: ['bestSellers'],
onSuccess: (page: any) => {
setAllProducts(page.props.bestSellers.data);
}
});
}, [selectedCategorySlug]);
useEffect(() => {
if (bestSellers.current_page > 1) {
setAllProducts(prev => {
// Prevent duplicates if data already exists
const existingIds = new Set(prev.map(p => p.id));
const newProducts = bestSellers.data.filter((p: any) => !existingIds.has(p.id));
return [...prev, ...newProducts];
});
}
}, [bestSellers]);
const loadMore = () => {
if (bestSellers.next_page_url) {
router.get(bestSellers.next_page_url, {}, {
preserveState: true,
preserveScroll: true,
only: ['bestSellers'],
});
}
};
useEffect(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => {
@ -39,7 +89,7 @@ export default function Homepage({ categories = [], bestSellers = [], topSelling
}; };
}, []); }, []);
const scrollToSection = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => { const scrollToSection = (e: React.MouseEvent<HTMLElement>, id: string) => {
e.preventDefault(); e.preventDefault();
const element = document.querySelector(id); const element = document.querySelector(id);
if (element) { if (element) {
@ -103,10 +153,21 @@ export default function Homepage({ categories = [], bestSellers = [], topSelling
<div className="order-2 flex flex-col justify-center lg:order-1 lg:col-span-3"> <div className="order-2 flex flex-col justify-center lg:order-1 lg:col-span-3">
<div className="relative mb-8"> <div className="relative mb-8">
<div className="custom-scrollbar flex max-h-[300px] flex-col overflow-y-auto border-y border-[#1e4a6d]/20"> <div className="custom-scrollbar flex max-h-[300px] flex-col overflow-y-auto border-y border-[#1e4a6d]/20">
{categories.map((cat) => ( {categories.map((cat: any) => (
<button key={cat.id} className="group flex items-center justify-between border-b border-[#1e4a6d]/10 px-4 py-4 text-left text-xs font-bold uppercase tracking-wider transition-all hover:bg-[#1e4a6d] hover:text-white last:border-0"> <button
key={cat.id}
onClick={(e) => {
setSelectedCategorySlug(cat.slug);
scrollToSection(e, '#product');
}}
className={`group flex items-center justify-between border-b border-[#1e4a6d]/10 px-4 py-4 text-left text-xs font-bold uppercase tracking-wider transition-all hover:bg-[#1e4a6d] hover:text-white last:border-0 ${
selectedCategorySlug === cat.slug ? 'bg-[#1e4a6d] text-white' : ''
}`}
>
{cat.name} {cat.name}
<ArrowRight size={14} className="opacity-0 transition-all group-hover:translate-x-1 group-hover:opacity-100" /> <ArrowRight size={14} className={`transition-all group-hover:translate-x-1 group-hover:opacity-100 ${
selectedCategorySlug === cat.slug ? 'opacity-100 translate-x-1' : 'opacity-0'
}`} />
</button> </button>
))} ))}
{categories.length === 0 && ( {categories.length === 0 && (
@ -124,9 +185,8 @@ export default function Homepage({ categories = [], bestSellers = [], topSelling
<p className="mb-4 text-[10px] font-bold uppercase tracking-widest opacity-60">Ikuti Kami</p> <p className="mb-4 text-[10px] font-bold uppercase tracking-widest opacity-60">Ikuti Kami</p>
<div className="flex gap-4"> <div className="flex gap-4">
<Facebook size={16} className="cursor-pointer hover:opacity-50" /> <Facebook size={16} className="cursor-pointer hover:opacity-50" />
<Twitter size={16} className="cursor-pointer hover:opacity-50" /> <X size={16} className="cursor-pointer hover:opacity-50" />
<Instagram size={16} className="cursor-pointer hover:opacity-50" /> <Instagram size={16} className="cursor-pointer hover:opacity-50" />
<Youtube size={16} className="cursor-pointer hover:opacity-50" />
</div> </div>
</div> </div>
</div> </div>
@ -134,7 +194,7 @@ export default function Homepage({ categories = [], bestSellers = [], topSelling
{/* Hero Image */} {/* Hero Image */}
<div className="order-1 lg:order-2 lg:col-span-4"> <div className="order-1 lg:order-2 lg:col-span-4">
<div className="relative aspect-[3/4] overflow-hidden bg-white shadow-xl"> <div className="relative aspect-[3/4] overflow-hidden bg-white shadow-xl">
<img src="https://images.unsplash.com/photo-1605763240000-7e93b172d754?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MTV8fGRyZXNzfGVufDB8fDB8fHww" alt="Model" className="h-full w-full object-cover" /> <img src="https://images.unsplash.com/photo-1719947348984-b7af56dcdbfd?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1yZWxhdGVkfDF8fHxlbnwwfHx8fHw%3D" alt="Model" className="h-full w-full object-cover" />
</div> </div>
</div> </div>
@ -147,9 +207,9 @@ export default function Homepage({ categories = [], bestSellers = [], topSelling
<p className="mb-8 text-sm leading-relaxed opacity-70"> <p className="mb-8 text-sm leading-relaxed opacity-70">
Tingkatkan gaya harian Anda dengan koleksi esensial yang dibuat dengan ketelitian dan kualitas tinggi. Mulai dari bahan berkelanjutan hingga desain yang tak lekang oleh waktu. Tingkatkan gaya harian Anda dengan koleksi esensial yang dibuat dengan ketelitian dan kualitas tinggi. Mulai dari bahan berkelanjutan hingga desain yang tak lekang oleh waktu.
</p> </p>
<button className="bg-[#1e4a6d] px-8 py-3 text-[10px] font-bold uppercase tracking-[0.2em] text-white transition-all hover:bg-[#15344e]"> <a href="#product" onClick={(e) => scrollToSection(e, '#product')} className="inline-block bg-[#1e4a6d] px-8 py-3 text-[10px] font-bold uppercase tracking-[0.2em] text-white transition-all hover:bg-[#15344e]">
Jelajahi Sekarang Jelajahi Sekarang
</button> </a>
<div className="mt-16 flex gap-4"> <div className="mt-16 flex gap-4">
{topSellingProducts.map((product, i) => ( {topSellingProducts.map((product, i) => (
@ -159,7 +219,7 @@ export default function Homepage({ categories = [], bestSellers = [], topSelling
onClick={() => setSelectedProduct(product)} onClick={() => setSelectedProduct(product)}
> >
<img <img
src={product.thumbnail_url || 'https://images.unsplash.com/photo-1594179056536-dc8714fc8fdf?w=200&auto=format&fit=crop&q=60'} src={product.thumbnail_url || 'https://images.unsplash.com/photo-1719947348940-7aa339028cbc?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1yZWxhdGVkfDgzfHx8ZW58MHx8fHx8'}
alt={product.name} alt={product.name}
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
@ -167,7 +227,7 @@ export default function Homepage({ categories = [], bestSellers = [], topSelling
))} ))}
{topSellingProducts.length === 0 && ( {topSellingProducts.length === 0 && (
<div className="flex gap-4"> <div className="flex gap-4">
{[1,2,3].map(i => ( {[1, 2, 3].map(i => (
<div key={i} className="h-24 w-20 rounded-t-full border border-dashed border-[#1e4a6d]/20 bg-transparent"></div> <div key={i} className="h-24 w-20 rounded-t-full border border-dashed border-[#1e4a6d]/20 bg-transparent"></div>
))} ))}
</div> </div>
@ -182,11 +242,38 @@ export default function Homepage({ categories = [], bestSellers = [], topSelling
{/* Best Sellers Section */} {/* Best Sellers Section */}
<section id="product" className="border-b border-[#1e4a6d]/20"> <section id="product" className="border-b border-[#1e4a6d]/20">
<div className="mx-auto max-w-7xl"> <div className="mx-auto max-w-7xl">
<div className="flex items-center justify-between border-x border-[#1e4a6d]/20 px-6 py-6 lg:px-12"> <div className="flex flex-col gap-4 border-x border-[#1e4a6d]/20 px-6 py-6 sm:flex-row sm:items-center sm:justify-between lg:px-12">
<h2 className="text-xl font-medium">Produk Terlaris</h2> <h2 className="text-xl font-medium">Produk Terlaris</h2>
<a href="#product" onClick={(e) => scrollToSection(e, '#product')} className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest hover:opacity-70 transition-all"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
Semua Produk <ArrowRight size={14} /> {/* Category Filter */}
</a> <div className="relative">
<select
value={selectedCategorySlug}
onChange={(e) => setSelectedCategorySlug(e.target.value)}
className="appearance-none border border-[#1e4a6d]/20 bg-transparent py-2 pl-4 pr-10 text-[10px] font-bold uppercase tracking-widest outline-none transition-all focus:border-[#1e4a6d] cursor-pointer"
>
<option value="">Semua Kategori</option>
{categories.map((cat: any) => (
<option key={cat.id} value={cat.slug}>{cat.name}</option>
))}
</select>
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<div className="h-1 w-1 rotate-45 border-b border-r border-[#1e4a6d]"></div>
</div>
</div>
{/* Search Input */}
<div className="relative flex items-center">
<Search size={14} className="absolute left-3 opacity-40" />
<input
type="text"
placeholder="Cari produk..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="border border-[#1e4a6d]/20 bg-transparent py-2 pl-10 pr-4 text-[10px] font-bold uppercase tracking-widest outline-none transition-all focus:border-[#1e4a6d] w-full sm:w-[200px]"
/>
</div>
</div>
</div> </div>
</div> </div>
@ -194,14 +281,14 @@ export default function Homepage({ categories = [], bestSellers = [], topSelling
<div className="border-y border-[#1e4a6d]/20"> <div className="border-y border-[#1e4a6d]/20">
<div className="mx-auto max-w-7xl"> <div className="mx-auto max-w-7xl">
<div className="grid grid-cols-1 border-x border-[#1e4a6d]/20 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-1 border-x border-[#1e4a6d]/20 sm:grid-cols-2 lg:grid-cols-4">
{bestSellers.map((item, idx) => ( {allProducts.map((item, idx) => (
<div key={item.id} className="group border-b border-[#1e4a6d]/20 p-8 last:border-b-0 sm:border-r sm:even:border-r-0 lg:border-b-0 lg:border-r lg:last:border-r-0"> <div key={item.id} className="group border-b border-[#1e4a6d]/20 p-8 last:border-b-0 sm:border-r sm:even:border-r-0 lg:border-b-0 lg:border-r lg:last:border-r-0">
<div <div
className="mb-8 aspect-square overflow-hidden bg-white cursor-pointer" className="mb-8 aspect-square overflow-hidden bg-white cursor-pointer"
onClick={() => setSelectedProduct(item)} onClick={() => setSelectedProduct(item)}
> >
<img <img
src={item.thumbnail_url || 'https://images.unsplash.com/photo-1594179056536-dc8714fc8fdf?w=500&auto=format&fit=crop&q=60'} src={item.thumbnail_url || 'https://images.unsplash.com/photo-1719947348940-7aa339028cbc?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1yZWxhdGVkfDgzfHx8ZW58MHx8fHx8'}
alt={item.name} alt={item.name}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-110" className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-110"
/> />
@ -221,12 +308,37 @@ export default function Homepage({ categories = [], bestSellers = [], topSelling
</div> </div>
</div> </div>
))} ))}
{bestSellers.length === 0 && ( {allProducts.length === 0 && (
<div className="col-span-full py-20 text-center text-xs uppercase tracking-widest opacity-40"> <div className="col-span-full border-x border-[#1e4a6d]/20 py-32 text-center">
Produk belum tersedia <div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-[#1e4a6d]/5">
<Search size={24} className="opacity-20" />
</div>
<h3 className="mb-2 text-xl font-medium">Produk tidak ditemukan</h3>
<p className="mb-8 text-sm opacity-60">Coba gunakan kata kunci lain atau periksa ejaan Anda.</p>
<button
onClick={() => {
setSearch('');
setSelectedCategorySlug('');
}}
className="border border-[#1e4a6d] px-8 py-3 text-[10px] font-bold uppercase tracking-widest transition-all hover:bg-[#1e4a6d] hover:text-white"
>
Bersihkan Filter
</button>
</div> </div>
)} )}
</div> </div>
{bestSellers.next_page_url && (
<div className="border-t border-[#1e4a6d]/20 py-12 text-center border-x">
<button
onClick={loadMore}
className="group relative inline-flex items-center gap-4 border border-[#1e4a6d] px-12 py-4 text-[10px] font-bold uppercase tracking-[0.2em] transition-all hover:bg-[#1e4a6d] hover:text-white"
>
Muat Lebih Banyak
<ArrowRight size={14} className="transition-transform group-hover:translate-x-1" />
</button>
</div>
)}
</div> </div>
</div> </div>
</section> </section>
@ -297,7 +409,7 @@ export default function Homepage({ categories = [], bestSellers = [], topSelling
</p> </p>
<div className="flex gap-4"> <div className="flex gap-4">
<Facebook size={18} className="cursor-pointer hover:opacity-50" /> <Facebook size={18} className="cursor-pointer hover:opacity-50" />
<Twitter size={18} className="cursor-pointer hover:opacity-50" /> <X size={18} className="cursor-pointer hover:opacity-50" />
<Instagram size={18} className="cursor-pointer hover:opacity-50" /> <Instagram size={18} className="cursor-pointer hover:opacity-50" />
</div> </div>
</div> </div>
@ -362,19 +474,17 @@ export default function Homepage({ categories = [], bestSellers = [], topSelling
</div> </div>
<div className="mt-20 flex gap-6"> <div className="mt-20 flex gap-6">
<Facebook size={24} className="hover:opacity-50 cursor-pointer" /> <Facebook size={24} className="hover:opacity-50 cursor-pointer" />
<Twitter size={24} className="hover:opacity-50 cursor-pointer" /> <X size={24} className="hover:opacity-50 cursor-pointer" />
<Instagram size={24} className="hover:opacity-50 cursor-pointer" /> <Instagram size={24} className="hover:opacity-50 cursor-pointer" />
</div> </div>
</div> </div>
)} )}
{/* Product Detail Modal */} {/* Product Detail Modal */}
<div className={`fixed inset-0 z-[70] flex items-center justify-center p-4 transition-all duration-500 ${ <div className={`fixed inset-0 z-[70] flex items-center justify-center p-4 transition-all duration-500 ${selectedProduct ? 'visible opacity-100' : 'invisible opacity-0'
selectedProduct ? 'visible opacity-100' : 'invisible opacity-0'
}`}> }`}>
<div className="absolute inset-0 bg-[#1e4a6d]/40 backdrop-blur-sm" onClick={() => setSelectedProduct(null)}></div> <div className="absolute inset-0 bg-[#1e4a6d]/40 backdrop-blur-sm" onClick={() => setSelectedProduct(null)}></div>
<div className={`relative w-full max-w-4xl overflow-hidden bg-[#f7f3eb] shadow-2xl transition-all duration-500 ${ <div className={`relative w-full max-w-4xl overflow-hidden bg-[#f7f3eb] shadow-2xl transition-all duration-500 ${selectedProduct ? 'translate-y-0 scale-100' : 'translate-y-10 scale-95'
selectedProduct ? 'translate-y-0 scale-100' : 'translate-y-10 scale-95'
}`}> }`}>
<button className="absolute right-4 top-4 z-10 p-2 hover:rotate-90 transition-transform" onClick={() => setSelectedProduct(null)}> <button className="absolute right-4 top-4 z-10 p-2 hover:rotate-90 transition-transform" onClick={() => setSelectedProduct(null)}>
<X size={24} /> <X size={24} />
@ -385,7 +495,7 @@ export default function Homepage({ categories = [], bestSellers = [], topSelling
<div className="border-b border-[#1e4a6d]/10 md:border-b-0 md:border-r overflow-hidden bg-white"> <div className="border-b border-[#1e4a6d]/10 md:border-b-0 md:border-r overflow-hidden bg-white">
<div className="aspect-[4/5] overflow-hidden"> <div className="aspect-[4/5] overflow-hidden">
<img <img
src={selectedProduct?.thumbnail_url || 'https://images.unsplash.com/photo-1594179056536-dc8714fc8fdf?w=800&auto=format&fit=crop&q=60'} src={selectedProduct?.thumbnail_url || 'https://images.unsplash.com/photo-1719947348940-7aa339028cbc?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1yZWxhdGVkfDgzfHx8ZW58MHx8fHx8'}
alt={selectedProduct?.name} alt={selectedProduct?.name}
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />