This commit is contained in:
Bayu Lukman Yusuf 2026-01-08 14:06:58 +07:00
parent c71b7ff5c2
commit c7eba16c5f
5 changed files with 314 additions and 44 deletions

View File

@ -0,0 +1,95 @@
<?php
namespace App\Http\Controllers;
use App\Models\Items;
use App\Models\StoreCategory;
use App\Models\Gender;
use Illuminate\Http\Request;
class SearchController extends Controller
{
public function search(Request $request)
{
$query = $request->get('q');
if (empty($query) || strlen($query) < 2) {
return response()->json([
'success' => true,
'results' => [],
'message' => 'Please enter at least 2 characters'
]);
}
// Search products
$products = Items::where('is_publish', true)
->where('deleted_at', null)
->where(function($q) use ($query) {
$q->where('name', 'ILIKE', "%{$query}%")
->orWhere('number', 'ILIKE', "%{$query}%")
->orWhere('description', 'ILIKE', "%{$query}%");
})
->select('id', 'name', 'number', 'slug')
->limit(8)
->get();
// Search categories
$categories = StoreCategory::where('name', 'ILIKE', "%{$query}%")
->select('id', 'name')
->limit(3)
->get();
// Search genders
$genders = Gender::where('name', 'ILIKE', "%{$query}%")
->select('id', 'name')
->limit(3)
->get();
// Format results
$results = [];
if ($products->isNotEmpty()) {
$results['products'] = $products->map(function($product) {
$price = $product->display_price;
return [
'id' => $product->id,
'name' => $product->name,
'number' => $product->number,
'slug' => $product->slug ?? $product->id,
'type' => 'product',
'route' => route('product.detail', $product->slug)
];
});
}
if ($categories->isNotEmpty()) {
$results['categories'] = $categories->map(function($category) {
return [
'id' => $category->id,
'name' => $category->name,
'slug' => $category->slug,
'type' => 'category',
'route' => route('product.index',['filter[category]'=>$category->id])
];
});
}
if ($genders->isNotEmpty()) {
$results['genders'] = $genders->map(function($gender) {
return [
'id' => $gender->id,
'name' => $gender->name,
'slug' => $gender->slug,
'type' => 'gender',
'route' => route('product.index',['filter[gender]'=>$gender->id])
];
});
}
return response()->json([
'success' => true,
'results' => $results,
'query' => $query
]);
}
}

View File

@ -39,21 +39,29 @@ class ProductRepository
$where_ids = [];
if ($price_range_start && $price_range_end) {
$params_filter = [];
$params_filter['location_id'] = $location_id;
$params_filter['price_range_start'] = $price_range_start;
$params_filter['price_range_end'] = $price_range_end;
$params_filter = [
'location_id' => $location_id,
'price_range_start' => $price_range_start,
'price_range_end' => $price_range_end,
];
$where_ids = collect($this->getProductList($params_filter))->pluck('id')->toArray();
$where_ids = collect($this->getProductList($params_filter))
->pluck('id')
->unique()
->values()
->toArray();
if (! empty($sorting_ids) && count($sorting_ids) > 0) {
$where_ids = array_values(
array_intersect($sorting_ids, $where_ids)
);
// Jika sudah ada sorting_ids → ikuti urutannya
if (!empty($sorting_ids)) {
$sorting_ids = array_values(array_intersect($sorting_ids, $where_ids));
} else {
// kalau belum ada sorting, pakai hasil filter
$sorting_ids = $where_ids;
}
}
$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,
@ -73,20 +81,18 @@ class ProductRepository
$query->orderBy('percent', 'desc');
} else {
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)) {
$cases = collect($sorting_ids)->map(function ($id, $index) {
return "WHEN items.id = {$id} THEN {$index}";
})->implode(' ');
$query->orderByRaw("
CASE
{$cases}
ELSE 999999
END
");
} elseif ($sort == 'new') {
$query->orderByRaw("case when category1 in ('CLUBS','CLUB','COMPONENT HEAD') and brand = 'PXG' then 1 else 2 end ASC");
} else {
@ -113,8 +119,8 @@ class ProductRepository
->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);
->when($sorting_ids, function ($query) use ($sorting_ids) {
$query->whereIn('items.id', $sorting_ids);
});
if ($category_id) {
@ -159,6 +165,7 @@ class ProductRepository
$order_by = 'ORDER BY items.created_at DESC NULLS LAST';
}
$location_id = $params['location_id'] ?? null;
// =========================
@ -210,13 +217,13 @@ class ProductRepository
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)
($location_id IS NOT NULL AND (d.location_id = $location_id OR d.location_id IS NULL))
OR ($location_id IS NULL AND d.location_id IS NULL)
)
ORDER BY
di.item_reference_id,
CASE
WHEN d.location_id = 22 THEN 1
WHEN d.location_id = $location_id THEN 1
ELSE 2
END,
d.created_at DESC
@ -237,13 +244,13 @@ class ProductRepository
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)
($location_id IS NOT NULL AND (d.location_id = $location_id OR d.location_id IS NULL))
OR ($location_id IS NULL AND d.location_id IS NULL)
)
ORDER BY
di.item_reference_id,
CASE
WHEN d.location_id = 22 THEN 1
WHEN d.location_id = $location_id THEN 1
ELSE 2
END,
d.created_at DESC

View File

@ -130,25 +130,18 @@
</div>
</div>
{{-- search box --}}
<div class="offcanvas offcanvas-top" id="searchBox" data-bs-backdrop="static" tabindex="-1">
<div class="offcanvas-header border-bottom p-0 py-lg-1">
<form class="container d-flex align-items-center">
<input type="search" class="form-control form-control-lg fs-lg border-0 rounded-0 py-3 ps-0"
<input type="search" id="searchInput" class="form-control form-control-lg fs-lg border-0 rounded-0 py-3 ps-0"
placeholder="Search the products" data-autofocus="offcanvas">
<button type="reset" class="btn-close fs-lg" data-bs-dismiss="offcanvas"
aria-label="Close"></button>
</form>
</div>
<div class="offcanvas-body px-0">
<div class="container text-center">
<svg class="text-body-tertiary opacity-60 mb-4" xmlns="http://www.w3.org/2000/svg" width="60"
viewBox="0 0 512 512" fill="currentColor">
<path
d="M340.115,361.412l-16.98-16.98c-34.237,29.36-78.733,47.098-127.371,47.098C87.647,391.529,0,303.883,0,195.765S87.647,0,195.765,0s195.765,87.647,195.765,195.765c0,48.638-17.738,93.134-47.097,127.371l16.98,16.98l11.94-11.94c5.881-5.881,15.415-5.881,21.296,0l112.941,112.941c5.881,5.881,5.881,15.416,0,21.296l-45.176,45.176c-5.881,5.881-15.415,5.881-21.296,0L328.176,394.648c-5.881-5.881-5.881-15.416,0-21.296L340.115,361.412z M195.765,361.412c91.484,0,165.647-74.163,165.647-165.647S287.249,30.118,195.765,30.118S30.118,104.28,30.118,195.765S104.28,361.412,195.765,361.412z M360.12,384l91.645,91.645l23.88-23.88L384,360.12L360.12,384z M233.034,233.033c5.881-5.881,15.415-5.881,21.296,0c5.881,5.881,5.881,15.416,0,21.296c-32.345,32.345-84.786,32.345-117.131,0c-5.881-5.881-5.881-15.415,0-21.296c5.881-5.881,15.416-5.881,21.296,0C179.079,253.616,212.45,253.616,233.034,233.033zM135.529,180.706c-12.475,0-22.588-10.113-22.588-22.588c0-12.475,10.113-22.588,22.588-22.588c12.475,0,22.588,10.113,22.588,22.588C158.118,170.593,148.005,180.706,135.529,180.706z M256,180.706c-12.475,0-22.588-10.113-22.588-22.588c0-12.475,10.113-22.588,22.588-22.588s22.588,10.113,22.588,22.588C278.588,170.593,268.475,180.706,256,180.706z" />
</svg>
<h6 class="mb-2">Your search results will appear here</h6>
<p class="fs-sm mb-0">Start typing in the search field above to see instant search results.</p>
</div>
<div class="offcanvas-body px-0 show_result" id="searchResults">
<!-- Search results will be loaded here -->
</div>
</div>
@ -362,3 +355,174 @@
</div>
</header>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('searchInput');
const searchResults = document.getElementById('searchResults');
let searchTimeout;
if (searchInput && searchResults) {
// Show default state initially
showDefaultSearch();
// Handle search input with debouncing
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
const query = this.value.trim();
if (query.length < 2) {
showDefaultSearch();
return;
}
// Debounce search
searchTimeout = setTimeout(() => {
performSearch(query);
}, 300);
});
// Clear search when input is cleared
searchInput.addEventListener('reset', function() {
showDefaultSearch();
});
}
function showDefaultSearch() {
searchResults.innerHTML = `
<div class="container text-center py-5">
<svg class="text-body-tertiary opacity-60 mb-4" xmlns="http://www.w3.org/2000/svg" width="60"
viewBox="0 0 512 512" fill="currentColor">
<path d="M340.115,361.412l-16.98-16.98c-34.237,29.36-78.733,47.098-127.371,47.098C87.647,391.529,0,303.883,0,195.765S87.647,0,195.765,0s195.765,87.647,195.765,195.765c0,48.638-17.738,93.134-47.097,127.371l16.98,16.98l11.94-11.94c5.881-5.881,15.415-5.881,21.296,0l112.941,112.941c5.881,5.881,5.881,15.416,0,21.296l-45.176,45.176c-5.881,5.881-15.415,5.881-21.296,0L328.176,394.648c-5.881-5.881-5.881-15.416,0-21.296L340.115,361.412z M195.765,361.412c91.484,0,165.647-74.163,165.647-165.647S287.249,30.118,195.765,30.118S30.118,104.28,30.118,195.765S104.28,361.412,195.765,361.412z M360.12,384l91.645,91.645l23.88-23.88L384,360.12L360.12,384z M233.034,233.033c5.881-5.881,15.415-5.881,21.296,0c5.881,5.881,5.881,15.416,0,21.296c-32.345,32.345-84.786,32.345-117.131,0c-5.881-5.881-5.881-15.415,0-21.296c5.881-5.881,15.416-5.881,21.296,0C179.079,253.616,212.45,253.616,233.034,233.033zM135.529,180.706c-12.475,0-22.588-10.113-22.588-22.588c0-12.475,10.113-22.588,22.588-22.588c12.475,0,22.588,10.113,22.588,22.588C158.118,170.593,148.005,180.706,135.529,180.706z M256,180.706c-12.475,0-22.588-10.113-22.588-22.588c0-12.475,10.113-22.588,22.588-22.588s22.588,10.113,22.588,22.588C278.588,170.593,268.475,180.706,256,180.706z" />
</svg>
<h6 class="mb-2">Your search results will appear here</h6>
<p class="fs-sm mb-0">Start typing in the search field above to see instant search results.</p>
</div>
`;
}
function performSearch(query) {
// Show loading state
searchResults.innerHTML = `
<div class="container text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 mb-0">Searching...</p>
</div>
`;
fetch(`{{ route('search.ajax') }}?q=${encodeURIComponent(query)}`, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
displaySearchResults(data.results, data.query);
} else {
showError('Search failed. Please try again.');
}
})
.catch(error => {
console.error('Search error:', error);
showError('Search failed. Please try again.');
});
}
function displaySearchResults(results, query) {
let html = '<div class="container py-4">';
// Products section
if (results.products && results.products.length > 0) {
html += `
<div class="mb-4">
<h6 class="text-uppercase fs-xs fw-bold mb-3">Products</h6>
<div class="row g-3">
`;
results.products.forEach(product => {
html += `
<div class="col-6 col-md-3">
<a href="${product.route}" class="text-decoration-none">
<div class="card border-0 shadow-sm">
<div class="card-body p-3">
<h6 class="card-title mb-1 text-truncate">${product.name}</h6>
</div>
</div>
</a>
</div>
`;
});
html += '</div></div>';
}
// Categories section
if (results.categories && results.categories.length > 0) {
html += `
<div class="mb-4">
<h6 class="text-uppercase fs-xs fw-bold mb-3">Categories</h6>
<div class="d-flex flex-wrap gap-2">
`;
results.categories.forEach(category => {
html += `
<a href="${category.route}"
class="btn btn-outline-secondary btn-sm">
${category.name}
</a>
`;
});
html += '</div></div>';
}
// Genders section
if (results.genders && results.genders.length > 0) {
html += `
<div class="mb-4">
<h6 class="text-uppercase fs-xs fw-bold mb-3">Genders</h6>
<div class="d-flex flex-wrap gap-2">
`;
results.genders.forEach(gender => {
html += `
<a href="${gender.route}"
class="btn btn-outline-secondary btn-sm">
${gender.name}
</a>
`;
});
html += '</div></div>';
}
// No results
if (Object.keys(results).length === 0) {
html += `
<div class="text-center py-5">
<p class="text-muted mb-0">No results found for "${query}"</p>
</div>
`;
}
html += '</div>';
searchResults.innerHTML = html;
}
function showError(message) {
searchResults.innerHTML = `
<div class="container text-center py-5">
<div class="text-danger mb-3">
<i class="ci-alert-circle fs-1"></i>
</div>
<p class="text-muted mb-0">${message}</p>
</div>
`;
}
});
</script>

View File

@ -1,4 +1,4 @@
@extends('layouts.landing', ['title' => 'Fashion Store - Catalog'])
@extends('layouts.landing', ['title' => 'Store - Catalog'])
@section('content')
<x-layout.header />

View File

@ -6,6 +6,7 @@ use App\Http\Controllers\RoutingController;
use App\Http\Controllers\LocationController;
use App\Http\Controllers\LocaleController;
use App\Http\Controllers\ProductController;
use App\Http\Controllers\SearchController;
Route::group(['prefix' => '/dummy'], function () {
Route::get('', [RoutingController::class, 'index'])->name('root');
@ -27,4 +28,7 @@ Route::get('/products',[ProductController::class, 'index'])->name('product.index
Route::get('/products/ajax',[ProductController::class, 'ajax'])->name('product.ajax');
Route::get('/products/ajax/categories',[ProductController::class, 'categories'])->name('product.ajax.categories');
Route::get('/products/ajax/genders',[ProductController::class, 'genders'])->name('product.ajax.genders');
Route::get('/product/{slug}',[ProductController::class, 'detail'])->name('product.detail');
Route::get('/product/{slug}',[ProductController::class, 'detail'])->name('product.detail');
// Search routes
Route::get('/search', [SearchController::class, 'search'])->name('search.ajax');