filter with ajax

This commit is contained in:
Bayu Lukman Yusuf 2026-01-08 12:54:55 +07:00
parent 80f6dc8612
commit c71b7ff5c2
6 changed files with 703 additions and 364 deletions

View File

@ -66,6 +66,7 @@ class ProductController extends Controller
$page = $request->page ?? 1;
$search = $request->search;
$filter = $request->filter ?? [];
$sortBy = $request->sort_by ?? 'relevance';
@ -102,15 +103,23 @@ class ProductController extends Controller
'price_range_end' => $price_range_end,
]);
// Check if there are more products
$hasMore = count($products) >= $limit;
// Render product cards HTML
$productHtml = '';
foreach ($products as $product) {
$productHtml .= '<div class="col-6 col-md-4 mb-2 mb-sm-3 mb-md-0">';
$productHtml .= view('components.home.product-card', ['product' => $product])->render();
if (count($products) == 0) {
$productHtml = '<div class="col-12">';
$productHtml .= 'Pencarian tidak ditemukan';
$productHtml .= '</div>';
} else {
foreach ($products as $product) {
$productHtml .= '<div class="col-6 col-md-4 mb-2 mb-sm-3 mb-md-0">';
$productHtml .= view('components.home.product-card', ['product' => $product])->render();
$productHtml .= '</div>';
}
}
// filter
$filter = $request->filter ?? [];
if (isset($filter['category']) && $filter['category']){
@ -123,7 +132,6 @@ class ProductController extends Controller
}
}
if (isset($filter['gender']) && $filter['gender']) {
$gender = Gender::find($filter['gender']);
@ -141,13 +149,13 @@ class ProductController extends Controller
'filters' => $filters,
'products' => $productHtml,
'count' => count($products),
'has_more' => count($products) >= $limit
'has_more' => $hasMore,
'current_page' => $page
]);
}
public function index(Request $request)
{
$productRepository = new ProductRepository;
$products = [];

View File

@ -6,83 +6,57 @@ use App\Models\DiscountItem;
use App\Models\Items;
use App\Models\StoreCategoryMap;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
class ProductRepository
{
public function getList($params)
{
$event = @$params["event"];
$sort = @$params["sort"];
$search = @$params["search"];
$category_id = @$params["category_id"];
$brand_id = @$params["brand_id"];
$gender_id = @$params["gender_id"];
$limit = @$params["limit"];
{
$event = @$params['event'];
$sort = @$params['sort'];
$search = @$params['search'];
$category_id = @$params['category_id'];
$brand_id = @$params['brand_id'];
$gender_id = @$params['gender_id'];
$limit = @$params['limit'];
$location_id = @$params["location_id"];
$is_consignment = @$params["is_consignment"];
$price_range_start = @$params["price_range_start"];
$price_range_end = @$params["price_range_end"];
$location_id = @$params['location_id'];
$is_consignment = @$params['is_consignment'];
$price_range_start = @$params['price_range_start'];
$price_range_end = @$params['price_range_end'];
$sorting_ids = [];
if ($sort == "price_low_to_high" || $sort == "price_high_to_low"){
$sorting_ids = DiscountItem::whereHas('discount', function($query) use ($location_id, $is_consignment) {
$query->where('type', 'price')
->where(function ($query) {
$query->where('valid_at', '<=', now())
->orWhereNull('valid_at');
})
->where(function ($query) {
$query->where('expired_at', '>', now())
->orWhereNull('expired_at');
})
->where(function ($query) use ($location_id, $is_consignment) {
if (! $is_consignment) {
$query->whereNull('discounts.location_id');
}
if ($location_id) {
$query->orWhere('discounts.location_id', $location_id);
}
});
})
->orderBy('discount_items.price', $sort == "price_low_to_high" ? 'asc' : 'desc')
->pluck('item_reference_id')->toArray();
if ($sort == 'price_low_to_high' || $sort == 'price_high_to_low') {
$params_filter = [];
$params_filter['location_id'] = $location_id;
$params_filter['sort'] = $sort;
$sorting_ids = collect($this->getProductList($params_filter))->pluck('id')->toArray();
// Log::info($sorting_ids);
}
$where_ids = [];
if ($price_range_start && $price_range_end) {
$where_ids = DiscountItem::whereHas('discount', function($query) use ($location_id, $is_consignment, $price_range_start, $price_range_end) {
$query->where('type', 'price')
->where(function ($query) {
$query->where('valid_at', '<=', now())
->orWhereNull('valid_at');
})
->where(function ($query) {
$query->where('expired_at', '>', now())
->orWhereNull('expired_at');
})
->where(function ($query) use ($location_id, $is_consignment) {
if (! $is_consignment) {
$query->whereNull('discounts.location_id');
}
$params_filter = [];
$params_filter['location_id'] = $location_id;
$params_filter['price_range_start'] = $price_range_start;
$params_filter['price_range_end'] = $price_range_end;
$where_ids = collect($this->getProductList($params_filter))->pluck('id')->toArray();
if (! empty($sorting_ids) && count($sorting_ids) > 0) {
$where_ids = array_values(
array_intersect($sorting_ids, $where_ids)
);
}
if ($location_id) {
$query->orWhere('discounts.location_id', $location_id);
}
})
->where('discount_items.price', '>=', $price_range_start)
->where('discount_items.price', '<=', $price_range_end);
})
->pluck('item_reference_id')->toArray();
}
$builder = Items::select('items.*','percent')
$builder = Items::select('items.*', 'percent')
->leftJoin('item_dimension', 'item_dimension.no', 'items.number')
->leftJoin(DB::raw("(select distinct on (item_id) item_id, percent, discounts.created_at,
->leftJoin(DB::raw("(select distinct on (item_id) item_id, percent, discounts.created_at,
case when discounts.valid_at is null then 'special-offer' else 'limited-sale' end as event
from discount_items
left join item_reference on item_reference_id = item_reference.id
@ -91,32 +65,38 @@ class ProductRepository
( discounts.valid_at is null or discounts.valid_at <= now()) and
( discounts.valid_at is null or discounts.expired_at >= now())
order by item_id, discounts.created_at desc
) as d"),"d.item_id","=","items.id")
->when(true, function($query) use ($event, $sort,$sorting_ids){
if ($event){
$query->orderByRaw("case when brand = 'CHAMELO' then 1 else 2 end ASC ");
) as d"), 'd.item_id', '=', 'items.id')
->when(true, function ($query) use ($event, $sort, $sorting_ids) {
if ($event) {
$query->orderByRaw("case when brand = 'CHAMELO' then 1 else 2 end ASC ");
$query->orderBy("percent", "desc");
}else{
$query->orderBy('percent', 'desc');
} else {
/* if ($event == "special-offer"){
$query->orderByRaw("case when brand = 'CHAMELO' then 1 else 2 end ASC ");
} */
if (! empty($sorting_ids) && count($sorting_ids) > 0) {
$caseStatements = [];
foreach ($sorting_ids as $index => $id) {
$caseStatements[] = "WHEN items.id = {$id} THEN " . ($index + 1);
}
$caseStatements[] = "ELSE 999";
$query->orderByRaw("
CASE
" . implode("\n ", $caseStatements) . "
END ASC
");
if (!empty($sorting_ids)) {
$ids = implode(',', $sorting_ids);
$query->orderByRaw("array_position(ARRAY[$ids], items.id)");
}else if ($sort == "new"){
$query->orderByRaw("case when category1 in ('CLUBS','CLUB','COMPONENT HEAD') and brand = 'PXG' then 1 else 2 end ASC");
} else {
$query->orderByRaw("case when d.created_at is not null then 1 else 2 end ASC, random()");
}
} elseif ($sort == 'new') {
$query->orderByRaw("case when category1 in ('CLUBS','CLUB','COMPONENT HEAD') and brand = 'PXG' then 1 else 2 end ASC");
} else {
$query->orderByRaw('case when d.created_at is not null then 1 else 2 end ASC, random()');
}
// $query->orderByRaw("items.created_at desc NULLS LAST");
}
}
})
->where("is_publish", true)
})
->where('is_publish', true)
->when($search, function ($query) use ($search) {
$query->where(function ($query) use ($search) {
$query->where('number', 'ILIKE', "%$search%")
@ -130,8 +110,11 @@ class ProductRepository
->when($gender_id, function ($query) use ($gender_id) {
$query->where('gender_id', $gender_id);
})
->when($event, function($query) use ($event){
->when($event, function ($query) use ($event) {
$query->where('d.event', $event);
})
->when($where_ids, function ($query) use ($where_ids) {
$query->whereIn('items.id', $where_ids);
});
if ($category_id) {
@ -155,10 +138,128 @@ class ProductRepository
});
}
return $builder->paginate($limit);
}
public function getProductList($params)
{
$bindings = [];
// =========================
// ORDER BY
// =========================
$order_by = 'ORDER BY dpr.discount_price ASC NULLS LAST';
$sort = $params['sort'] ?? null;
if ($sort === 'price_high_to_low') {
$order_by = 'ORDER BY dpr.discount_price DESC NULLS LAST';
} elseif ($sort === 'price_low_to_high') {
$order_by = 'ORDER BY dpr.discount_price ASC NULLS LAST';
} elseif ($sort === 'new') {
$order_by = 'ORDER BY items.created_at DESC NULLS LAST';
}
$location_id = $params['location_id'] ?? null;
// =========================
// WHERE CONDITIONS
// =========================
$wheres = [];
$price_range_start = $params['price_range_start'] ?? null;
$price_range_end = $params['price_range_end'] ?? null;
if ($price_range_start !== null && $price_range_end !== null) {
$wheres[] = '(dpr.discount_price BETWEEN ? AND ?)';
$bindings[] = $price_range_start;
$bindings[] = $price_range_end;
}
$whereSql = $wheres
? 'AND '.implode(' AND ', $wheres)
: '';
// =========================
// QUERY
// =========================
$sql = "SELECT
items.*,
dp.percent,
dp.event,
dpr.discount_price
FROM items
LEFT JOIN item_dimension
ON item_dimension.no = items.number
-- =========================
-- DISCOUNT PERCENT
-- =========================
LEFT JOIN (
SELECT DISTINCT ON (di.item_reference_id)
di.item_reference_id,
di.percent,
d.created_at,
d.location_id,
CASE
WHEN d.valid_at IS NULL THEN 'special-offer'
ELSE 'limited-sale'
END AS event
FROM discount_items di
JOIN discounts d ON d.id = di.discount_id
WHERE d.type = 'discount'
AND (d.valid_at IS NULL OR d.valid_at <= NOW())
AND (d.expired_at IS NULL OR d.expired_at >= NOW())
AND (
(22 IS NOT NULL AND (d.location_id = 22 OR d.location_id IS NULL))
OR (22 IS NULL AND d.location_id IS NULL)
)
ORDER BY
di.item_reference_id,
CASE
WHEN d.location_id = 22 THEN 1
ELSE 2
END,
d.created_at DESC
) dp ON dp.item_reference_id = items.id
-- =========================
-- DISCOUNT PRICE
-- =========================
LEFT JOIN (
SELECT DISTINCT ON (di.item_reference_id)
di.item_reference_id,
di.price AS discount_price,
d.created_at,
d.location_id
FROM discount_items di
JOIN discounts d ON d.id = di.discount_id
WHERE d.type = 'price'
AND (d.valid_at IS NULL OR d.valid_at <= NOW())
AND (d.expired_at IS NULL OR d.expired_at >= NOW())
AND (
(22 IS NOT NULL AND (d.location_id = 22 OR d.location_id IS NULL))
OR (22 IS NULL AND d.location_id IS NULL)
)
ORDER BY
di.item_reference_id,
CASE
WHEN d.location_id = 22 THEN 1
ELSE 2
END,
d.created_at DESC
) dpr ON dpr.item_reference_id = items.id
WHERE items.is_publish = TRUE
AND items.deleted_at IS NULL
$whereSql
$order_by
";
$select= DB::select($sql, $bindings);
return $select;
}
public function getMinMaxPrice()
{
@ -167,14 +268,14 @@ class ProductRepository
'max' => DiscountItem::max('price'),
];
}
public function show($slug)
{
$product = Items::where('slug', $slug)->first();
$product = Items::where('slug', $slug)
->when(is_int($slug), function ($query) use ($slug) {
return $query->orWhere('id', $slug);
})
->first();
if ($product == null) {
abort(404);
@ -193,10 +294,10 @@ class ProductRepository
if (empty($ids)) {
return collect();
}
// Remove except_ids from the list
$ids = array_diff($ids, (array)$except_ids);
$ids = array_diff($ids, (array) $except_ids);
return Items::whereIn('id', $ids)->inRandomOrder()->take(10)->get();
}
}

View File

@ -1,6 +1,7 @@
<?php
return [
"title" => "Catalog",
'categories' => 'Categories',
'genders' => 'Genders',
'price' => 'Price',

View File

@ -1,6 +1,7 @@
<?php
return [
"title" => "Katalog",
'categories' => 'Kategori',
'genders' => 'Gender',
'price' => 'Harga',

View File

@ -11,7 +11,7 @@
aria-label="Add to Wishlist">
<i class="ci-heart animate-target"></i>
</button>
<a class="d-flex bg-body-tertiary rounded p-3" href="{{ route('product.detail', $product->slug ?? '0') }}">
<a class="d-flex bg-body-tertiary rounded p-3" href="{{ route('product.detail', $product->slug ?? $product->id ?? '0') }}">
<div class="ratio" style="--cz-aspect-ratio: calc(308 / 274 * 100%)">
<img src="{{ $product->image_url }}" loading="lazy" class="w-100 h-100 object-cover">
</div>
@ -33,7 +33,7 @@
</div>
<div class="nav mb-2">
<a class="nav-link animate-target min-w-0 text-dark-emphasis p-0"
href="{{ route('product.detail', $product->slug ?? '0') }}">
href="{{ route('product.detail', $product->slug ?? $product->id ?? '0') }}">
<span class="text-truncate">{{ $product->name ?? '' }}</span>
</a>
</div>

View File

@ -1,8 +1,6 @@
@extends('layouts.landing', ['title' => 'Fashion Store - Catalog'])
@section('content')
<x-layout.header />
<main class="content-wrapper">
@ -11,13 +9,13 @@
<nav class="container pt-2 pt-xxl-3 my-3 my-md-4" aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('second', ['home', 'fashion-v1']) }}">Home</a></li>
<li class="breadcrumb-item active" aria-current="page">Catalog with sidebar filters</li>
<li class="breadcrumb-item active" aria-current="page">{{ __('catalog_fashion.title') }}</li>
</ol>
</nav>
<!-- Page title -->
<h1 class="h3 container pb-3 pb-lg-4">Shop catalog</h1>
<h1 class="h3 container pb-3 pb-lg-4">{{ __('catalog_fashion.title') }}</h1>
<!-- Products grid + Sidebar with filters -->
@ -34,16 +32,15 @@
</div>
<div class="offcanvas-body flex-column pt-2 py-lg-0 filter-sidebar">
<div class="accordion">
<!-- Genders -->
<div class="accordion-item border-0 pb-1 pb-xl-2">
<h4 class="accordion-header" id="headingGenders">
<button type="button" class="accordion-button p-0 pb-3"
data-bs-toggle="collapse" data-bs-target="#genders" aria-expanded="true"
aria-controls="genders">
<button type="button" class="accordion-button p-0 pb-3" data-bs-toggle="collapse"
data-bs-target="#genders" aria-expanded="true" aria-controls="genders">
{{ __('catalog_fashion.genders') }}
</button>
</h4>
@ -61,9 +58,8 @@
<!-- Categories -->
<div class="accordion-item border-0 pb-1 pb-xl-2">
<h4 class="accordion-header" id="headingCategories">
<button type="button" class="accordion-button p-0 pb-3"
data-bs-toggle="collapse" data-bs-target="#categories" aria-expanded="true"
aria-controls="categories">
<button type="button" class="accordion-button p-0 pb-3" data-bs-toggle="collapse"
data-bs-target="#categories" aria-expanded="true" aria-controls="categories">
{{ __('catalog_fashion.categories') }}
</button>
</h4>
@ -82,9 +78,8 @@
<!-- Price -->
<div class="accordion-item border-0 pb-1 pb-xl-2">
<h4 class="accordion-header" id="headingPrice">
<button type="button" class="accordion-button p-0 pb-3"
data-bs-toggle="collapse" data-bs-target="#price" aria-expanded="true"
aria-controls="price">
<button type="button" class="accordion-button p-0 pb-3" data-bs-toggle="collapse"
data-bs-target="#price" aria-expanded="true" aria-controls="price">
Price
</button>
</h4>
@ -97,15 +92,15 @@
<div class="range-slider-ui"></div>
<div class="d-flex align-items-center">
<div class="position-relative w-50">
<input type="number" class="form-control"
min="0" data-range-slider-min>
<input type="number" class="form-control" min="0"
data-range-slider-min>
</div>
<i class="ci-minus text-body-emphasis mx-2"></i>
<div class="position-relative w-50">
<input type="number" class="form-control"
min="0" data-range-slider-max>
<input type="number" class="form-control" min="0"
data-range-slider-max>
</div>
</div>
</div>
@ -396,11 +391,10 @@
</div> --}}
<!-- Status -->
<div class="accordion-item border-0">
{{-- <div class="accordion-item border-0">
<h4 class="accordion-header" id="headingStatus">
<button type="button" class="accordion-button p-0 pb-3"
data-bs-toggle="collapse" data-bs-target="#status" aria-expanded="true"
aria-controls="status">
<button type="button" class="accordion-button p-0 pb-3" data-bs-toggle="collapse"
data-bs-target="#status" aria-expanded="true" aria-controls="status">
Status
</button>
</h4>
@ -427,7 +421,7 @@
</div>
</div>
</div>
</div>
</div> --}}
</div>
</div>
</div>
@ -439,7 +433,8 @@
<!-- Sorting -->
<div class="d-sm-flex align-items-center justify-content-between mt-n2 mb-3 mb-sm-4">
<div class="fs-sm text-body-emphasis text-nowrap">Found <span class="fw-semibold" id="product-count">Loading...</span>
<div class="fs-sm text-body-emphasis text-nowrap">Found <span class="fw-semibold"
id="product-count">Loading...</span>
items
</div>
<div class="d-flex align-items-center text-nowrap">
@ -452,12 +447,20 @@
"containerInner": ["form-select", "border-0", "rounded-0", "px-1"]
}
}'
onchange="window.location.href='{{ route('product.index') }}?sort_by='+this.value+'&{{ http_build_query(request()->except('sort_by')) }}'">
<option value="relevance" {{ request('sort_by') == 'relevance' ? 'selected' : '' }}>{{ __('catalog_fashion.sort_relevance') }}</option>
<option value="popularity" {{ request('sort_by') == 'popularity' ? 'selected' : '' }}>{{ __('catalog_fashion.sort_popularity') }}</option>
<option value="price_low_to_high" {{ request('sort_by') == 'price_low_to_high' ? 'selected' : '' }}>{{ __('catalog_fashion.sort_price_low_to_high') }}</option>
<option value="price_high_to_low" {{ request('sort_by') == 'price_high_to_low' ? 'selected' : '' }}>{{ __('catalog_fashion.sort_price_high_to_low') }}</option>
<option value="new" {{ request('sort_by') == 'newest_arrivals' ? 'selected' : '' }}>{{ __('catalog_fashion.sort_newest_arrivals') }}</option>
onchange="window.location.href='{{ route('product.index') }}?sort_by='+this.value+'&{{ http_build_query(request()->except('sort_by')) }}'">
<option value="relevance" {{ request('sort_by') == 'relevance' ? 'selected' : '' }}>
{{ __('catalog_fashion.sort_relevance') }}</option>
<option value="popularity" {{ request('sort_by') == 'popularity' ? 'selected' : '' }}>
{{ __('catalog_fashion.sort_popularity') }}</option>
<option value="price_low_to_high"
{{ request('sort_by') == 'price_low_to_high' ? 'selected' : '' }}>
{{ __('catalog_fashion.sort_price_low_to_high') }}</option>
<option value="price_high_to_low"
{{ request('sort_by') == 'price_high_to_low' ? 'selected' : '' }}>
{{ __('catalog_fashion.sort_price_high_to_low') }}</option>
<option value="new"
{{ request('sort_by') == 'newest_arrivals' ? 'selected' : '' }}>
{{ __('catalog_fashion.sort_newest_arrivals') }}</option>
</select>
</div>
</div>
@ -466,9 +469,9 @@
<div class="row gy-4 gy-md-5 pb-4 pb-md-5" id="products-container">
<!-- Products will be loaded here via AJAX -->
</div>
{{-- <div class="col-12 col-md-8 mb-2 mb-sm-3 mb-md-0">
{{-- <div class="col-12 col-md-8 mb-2 mb-sm-3 mb-md-0">
<div
class="position-relative bg-body-tertiary text-center rounded-4 p-4 p-sm-5 py-md-4 py-xl-5">
class="position-relative text-center rounded-4 p-4 p-sm-5 py-md-4 py-xl-5">
<p class="fs-xs text-body-secondary mb-1">Sweatshirts</p>
<h2 class="h4 mb-4">Colors for your mood</h2>
<div class="swiper user-select-none mb-4"
@ -506,15 +509,15 @@
</div>
</div> --}}
</div>
<!-- Show more button -->
<a href="{{ route('product.index', array_merge(request()->except('page'), ['page' => 1])) }}" type="button" class="btn btn-lg btn-outline-secondary w-100">
Show more
<i class="ci-chevron-down fs-xl ms-2 me-n1"></i>
</a>
</div>
<!-- Show more button -->
<button type="button" class="btn btn-lg btn-outline-secondary w-100" id="show-more-btn">
Show more
<i class="ci-chevron-down fs-xl ms-2 me-n1"></i>
</button>
</div>
</div>
</section>
@ -595,200 +598,428 @@
Filters
</button>
<style>
.shimmer-wrapper {
overflow: hidden;
}
.shimmer {
position: relative;
overflow: hidden;
}
.shimmer::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.3) 50%,
rgba(255, 255, 255, 0) 100%);
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.shimmer-line {
background-color: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.shimmer-content {
position: relative;
overflow: hidden;
}
</style>
@endsection
@section('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
loadGenders();
loadCategories();
loadProducts();
// Handle sort change
const sortSelect = document.querySelector('select[onchange*="sort_by"]');
if (sortSelect) {
sortSelect.removeAttribute('onchange');
sortSelect.addEventListener('change', function() {
loadProducts({ sort_by: this.value });
});
}
// Handle price range changes
const priceInputs = document.querySelectorAll('[data-range-slider-min], [data-range-slider-max]');
priceInputs.forEach(input => {
input.addEventListener('change', function() {
const minVal = document.querySelector('[data-range-slider-min]').value;
const maxVal = document.querySelector('[data-range-slider-max]').value;
loadProducts({
price_range_start: minVal || null,
price_range_end: maxVal || null
});
});
});
// Attach initial filter event listeners
attachFilterEventListeners();
});
function removeFilter(filterKey) {
const urlParams = new URLSearchParams(window.location.search);
urlParams.delete(`filter[${filterKey}]`);
// Convert URLSearchParams to object for loadProducts
const params = {};
for (const [key, value] of urlParams.entries()) {
params[key] = value;
}
loadProducts(params);
}
function clearAllFilters() {
const urlParams = new URLSearchParams(window.location.search);
// Remove all filter parameters
for (const [key] of urlParams.entries()) {
if (key.startsWith('filter[')) {
urlParams.delete(key);
}
}
// Keep non-filter parameters
const params = {};
for (const [key, value] of urlParams.entries()) {
if (!key.startsWith('filter[')) {
params[key] = value;
}
}
loadProducts(params);
}
function loadGenders() {
const currentGenderId = new URLSearchParams(window.location.search).get('filter[gender]');
fetch(`{{ route('product.ajax.genders') }}?current_gender=${currentGenderId || ''}`, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('genders-list').innerHTML = data.genders;
// Attach event listeners to newly loaded gender links
const genderLinks = document.querySelectorAll('#genders-list a[data-gender-id]');
genderLinks.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
// Remove active class from all gender links
genderLinks.forEach(g => g.classList.remove('active', 'text-primary'));
// Add active class to clicked link
this.classList.add('active', 'text-primary');
const genderId = this.getAttribute('data-gender-id');
loadProducts({ 'filter[gender]': genderId });
// Loading state lock
let isLoading = false;
document.addEventListener('DOMContentLoaded', function() {
loadGenders();
loadCategories();
loadProducts();
// Handle sort change
const sortSelect = document.querySelector('select[onchange*="sort_by"]');
if (sortSelect) {
sortSelect.removeAttribute('onchange');
sortSelect.addEventListener('change', function() {
loadProducts({
sort_by: this.value
});
});
}
})
.catch(error => {
console.error('Error loading genders:', error);
document.getElementById('genders-list').innerHTML = '<li class="nav-item mb-1"><span class="text-danger">Error loading genders</span></li>';
});
}
function loadCategories() {
const currentCategoryId = new URLSearchParams(window.location.search).get('filter[category]');
fetch(`{{ route('product.ajax.categories') }}?current_category=${currentCategoryId || ''}`, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
// Handle price range changes
const priceInputs = document.querySelectorAll('[data-range-slider-min], [data-range-slider-max]');
priceInputs.forEach(input => {
input.addEventListener('change', function() {
debouncePriceSlider(function() {
const minVal = document.querySelector('[data-range-slider-min]').value;
const maxVal = document.querySelector('[data-range-slider-max]').value;
loadProducts({
price_range_start: minVal || null,
price_range_end: maxVal || null
});
});
});
});
// Debounce function for price slider
let priceSliderTimeout;
function debouncePriceSlider(callback, delay = 300) {
clearTimeout(priceSliderTimeout);
priceSliderTimeout = setTimeout(callback, delay);
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('categories-list').innerHTML = data.categories;
// Handle range slider UI changes
const rangeSliderUI = document.querySelector('.range-slider-ui');
if (rangeSliderUI) {
// Use MutationObserver to detect slider changes
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'childList' || mutation.type === 'attributes') {
debouncePriceSlider(function() {
const minVal = document.querySelector('[data-range-slider-min]').value;
const maxVal = document.querySelector('[data-range-slider-max]').value;
loadProducts({
price_range_start: minVal || null,
price_range_end: maxVal || null
});
});
}
});
});
// Attach event listeners to newly loaded category links
const categoryLinks = document.querySelectorAll('#categories-list a[data-category-id]');
categoryLinks.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
// Remove active class from all category links
categoryLinks.forEach(c => c.classList.remove('active', 'text-primary'));
// Add active class to clicked link
this.classList.add('active', 'text-primary');
const categoryId = this.getAttribute('data-category-id');
loadProducts({ 'filter[category]': categoryId });
observer.observe(rangeSliderUI, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class']
});
// Also handle mouseup/touchend events on the slider
rangeSliderUI.addEventListener('mouseup', function() {
debouncePriceSlider(function() {
const minVal = document.querySelector('[data-range-slider-min]').value;
const maxVal = document.querySelector('[data-range-slider-max]').value;
loadProducts({
price_range_start: minVal || null,
price_range_end: maxVal || null
});
});
});
rangeSliderUI.addEventListener('touchend', function() {
debouncePriceSlider(function() {
const minVal = document.querySelector('[data-range-slider-min]').value;
const maxVal = document.querySelector('[data-range-slider-max]').value;
loadProducts({
price_range_start: minVal || null,
price_range_end: maxVal || null
});
});
});
}
})
.catch(error => {
console.error('Error loading categories:', error);
document.getElementById('categories-list').innerHTML = '<li class="nav-item mb-1"><span class="text-danger">Error loading categories</span></li>';
});
}
function loadProducts(params = {}) {
const container = document.getElementById('products-container');
const countElement = document.getElementById('product-count');
// Show loading state
container.innerHTML = '<div class="col-12 text-center py-5"><div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div></div>';
countElement.textContent = 'Loading...';
// Get current URL parameters
const urlParams = new URLSearchParams(window.location.search);
// Add custom parameters
Object.keys(params).forEach(key => {
if (params[key] !== null) {
urlParams.set(key, params[key]);
// Attach initial filter event listeners
attachFilterEventListeners();
// Handle show more button
const showMoreBtn = document.getElementById('show-more-btn');
if (showMoreBtn) {
showMoreBtn.addEventListener('click', function() {
loadMoreProducts();
});
}
});
// Make AJAX request
fetch(`{{ route('product.ajax') }}?${urlParams.toString()}`, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
function loadMoreProducts() {
const container = document.getElementById('products-container');
const showMoreBtn = document.getElementById('show-more-btn');
// Get current page from URL or default to 2 (next page)
const urlParams = new URLSearchParams(window.location.search);
const currentPage = parseInt(urlParams.get('page')) || 1;
const nextPage = currentPage + 1;
// Show loading state
showMoreBtn.innerHTML =
'<div class="spinner-border spinner-border-sm me-2" role="status"><span class="visually-hidden">Loading...</span></div> Loading...';
showMoreBtn.disabled = true;
// Set page parameter and load products
urlParams.set('page', nextPage);
fetch(`{{ route('product.ajax') }}?${urlParams.toString()}`, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Append new products to existing container
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.products;
// Append each new product
while (tempDiv.firstChild) {
container.appendChild(tempDiv.firstChild);
}
// Update show more button
if (data.has_more) {
showMoreBtn.innerHTML = 'Show more <i class="ci-chevron-down fs-xl ms-2 me-n1"></i>';
showMoreBtn.disabled = false;
} else {
showMoreBtn.style.display = 'none';
}
// Update URL
window.history.pushState({
path: window.location.pathname + '?' + urlParams.toString()
}, '', window.location.pathname + '?' + urlParams.toString());
} else {
showMoreBtn.innerHTML = 'Show more <i class="ci-chevron-down fs-xl ms-2 me-n1"></i>';
showMoreBtn.disabled = false;
}
})
.catch(error => {
console.error('Error loading more products:', error);
showMoreBtn.innerHTML = 'Show more <i class="ci-chevron-down fs-xl ms-2 me-n1"></i>';
showMoreBtn.disabled = false;
});
}
function removeFilter(filterKey) {
const urlParams = new URLSearchParams(window.location.search);
urlParams.delete(`filter[${filterKey}]`);
const newUrl = `${window.location.pathname}?${urlParams.toString()}`;
history.replaceState(null, '', newUrl);
// remove button
const button = document.querySelector(`.remove-filter[data-filter-key="${filterKey}"]`);
if (button) {
button.remove();
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
container.innerHTML = data.products;
countElement.textContent = data.count;
// Update filters section
updateFiltersSection(data.filters);
// Update URL without page reload
const newUrl = window.location.pathname + (urlParams.toString() ? '?' + urlParams.toString() : '');
window.history.pushState({ path: newUrl }, '', newUrl);
} else {
container.innerHTML = '<div class="col-12 text-center py-5 text-danger">Error loading products</div>';
countElement.textContent = '0';
loadGenders();
loadCategories();
loadProducts(Object.fromEntries(urlParams.entries()));
}
function clearAllFilters() {
const urlParams = new URLSearchParams(window.location.search);
// Collect all filter keys first
const filterKeys = [];
for (const [key] of urlParams.entries()) {
if (key.startsWith('filter[')) {
filterKeys.push(key);
}
}
})
.catch(error => {
console.error('Error:', error);
container.innerHTML = '<div class="col-12 text-center py-5 text-danger">Error loading products</div>';
countElement.textContent = '0';
});
}
function updateFiltersSection(filters) {
const filtersContainer = document.querySelector('.filter-sidebar');
const filtersSection = filtersContainer.querySelector('.pb-4.mb-2.mb-xl-3');
if (Object.keys(filters).length > 0) {
// Build filters HTML
let filtersHtml = `
// Delete all filter keys
filterKeys.forEach(key => urlParams.delete(key));
const newUrl = `${window.location.pathname}?${urlParams.toString()}`;
history.replaceState(null, '', newUrl);
// remove all button
const buttons = document.querySelectorAll('.remove-filter');
buttons.forEach(button => {
button.remove();
});
loadGenders();
loadCategories();
loadProducts(Object.fromEntries(urlParams.entries()));
}
function loadGenders() {
const currentGenderId = new URLSearchParams(window.location.search).get('filter[gender]');
fetch(`{{ route('product.ajax.genders') }}?current_gender=${currentGenderId || ''}`, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('genders-list').innerHTML = data.genders;
// Attach event listeners to newly loaded gender links
const genderLinks = document.querySelectorAll('#genders-list a[data-gender-id]');
genderLinks.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
// Remove active class from all gender links
genderLinks.forEach(g => g.classList.remove('active', 'text-primary'));
// Add active class to clicked link
this.classList.add('active', 'text-primary');
const genderId = this.getAttribute('data-gender-id');
loadProducts({
'filter[gender]': genderId
});
});
});
}
})
.catch(error => {
console.error('Error loading genders:', error);
document.getElementById('genders-list').innerHTML =
'<li class="nav-item mb-1"><span class="text-danger">Error loading genders</span></li>';
});
}
function loadCategories() {
const currentCategoryId = new URLSearchParams(window.location.search).get('filter[category]');
fetch(`{{ route('product.ajax.categories') }}?current_category=${currentCategoryId || ''}`, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('categories-list').innerHTML = data.categories;
// Attach event listeners to newly loaded category links
const categoryLinks = document.querySelectorAll('#categories-list a[data-category-id]');
categoryLinks.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
// Remove active class from all category links
categoryLinks.forEach(c => c.classList.remove('active',
'text-primary'));
// Add active class to clicked link
this.classList.add('active', 'text-primary');
const categoryId = this.getAttribute('data-category-id');
loadProducts({
'filter[category]': categoryId
});
});
});
}
})
.catch(error => {
console.error('Error loading categories:', error);
document.getElementById('categories-list').innerHTML =
'<li class="nav-item mb-1"><span class="text-danger">Error loading categories</span></li>';
});
}
function loadProducts(params = {}) {
// Prevent multiple simultaneous calls
if (isLoading) {
return;
}
isLoading = true;
const container = document.getElementById('products-container');
const countElement = document.getElementById('product-count');
const shimmerItem = `<div class="col-6 col-md-4 mb-2 mb-sm-3 mb-md-0">
<div class="shimmer-wrapper">
<div class="shimmer">
<div class="shimmer-content rounded">
<div class="shimmer-line shimmer-image rounded mb-3" style="height: 0; padding-bottom: 100%; position: relative;">
<div style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.1); border-radius: 4px;"></div>
</div>
<div class="shimmer-line shimmer-title rounded mb-2" style="height: 20px; width: 80%;"></div>
<div class="shimmer-line shimmer-price rounded" style="height: 16px; width: 60%;"></div>
</div>
</div>
</div>
</div>`;
// Show loading state
container.innerHTML = shimmerItem.repeat(12);
countElement.textContent = 'Loading...';
// Get current URL parameters
const urlParams = new URLSearchParams(window.location.search);
// Add custom parameters
Object.keys(params).forEach(key => {
if (params[key] !== null) {
urlParams.set(key, params[key]);
}
});
// Make AJAX request
fetch(`{{ route('product.ajax') }}?${urlParams.toString()}`, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
container.innerHTML = data.products;
countElement.textContent = data.count;
// Update filters section
updateFiltersSection(data.filters);
// Update URL without page reload
const newUrl = window.location.pathname + (urlParams.toString() ? '?' + urlParams.toString() : '');
window.history.pushState({
path: newUrl
}, '', newUrl);
} else {
container.innerHTML = '<div class="col-12 text-center py-5 text-danger">Error loading products</div>';
countElement.textContent = '0';
}
})
.catch(error => {
console.error('Error:', error);
container.innerHTML = '<div class="col-12 text-center py-5 text-danger">Error loading products</div>';
countElement.textContent = '0';
})
.finally(() => {
isLoading = false;
});
}
function updateFiltersSection(filters) {
const filtersContainer = document.querySelector('.filter-sidebar');
const filtersSection = filtersContainer.querySelector('.pb-4.mb-2.mb-xl-3');
if (Object.keys(filters).length > 0) {
// Build filters HTML
let filtersHtml = `
<div class="pb-4 mb-2 mb-xl-3">
<div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="h6 mb-0">Filter</h4>
@ -799,60 +1030,57 @@
</div>
<div class="d-flex flex-wrap gap-2">
`;
Object.keys(filters).forEach(key => {
filtersHtml += `
Object.keys(filters).forEach(key => {
filtersHtml += `
<button type="button" class="btn btn-sm btn-secondary remove-filter"
data-filter-key="${key}">
<i class="ci-close fs-sm ms-n1 me-1"></i>
${filters[key]}
</button>
`;
});
filtersHtml += `
});
filtersHtml += `
</div>
</div>
`;
// Update or create filters section
if (filtersSection) {
filtersSection.outerHTML = filtersHtml;
// Update or create filters section
if (filtersSection) {
filtersSection.outerHTML = filtersHtml;
} else {
filtersContainer.insertAdjacentHTML('afterbegin', filtersHtml);
}
// Re-attach event listeners for new filter buttons
attachFilterEventListeners();
} else {
filtersContainer.insertAdjacentHTML('afterbegin', filtersHtml);
}
// Re-attach event listeners for new filter buttons
attachFilterEventListeners();
} else {
// Remove filters section if no filters
if (filtersSection) {
filtersSection.remove();
// Remove filters section if no filters
if (filtersSection) {
filtersSection.remove();
}
}
}
}
function attachFilterEventListeners() {
// Handle filter removal
const removeFilterButtons = document.querySelectorAll('.remove-filter');
removeFilterButtons.forEach(button => {
button.addEventListener('click', function() {
const filterKey = this.getAttribute('data-filter-key');
removeFilter(filterKey);
});
});
// Handle clear all filters
const clearAllButton = document.querySelector('.clear-all-filters');
if (clearAllButton) {
clearAllButton.addEventListener('click', function(e) {
e.preventDefault();
clearAllFilters();
function attachFilterEventListeners() {
// Handle filter removal
const removeFilterButtons = document.querySelectorAll('.remove-filter');
removeFilterButtons.forEach(button => {
button.addEventListener('click', function() {
const filterKey = this.getAttribute('data-filter-key');
removeFilter(filterKey);
});
});
// Handle clear all filters
const clearAllButton = document.querySelector('.clear-all-filters');
if (clearAllButton) {
clearAllButton.addEventListener('click', function(e) {
e.preventDefault();
clearAllFilters();
});
}
}
}
</script>
@endsection
@section('scripts')
@endsection