feat: implement search autocomplete feature with dropdown
This commit is contained in:
parent
6aa7c4a764
commit
82c1fc63e8
79
api/search_products.php
Normal file
79
api/search_products.php
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../lib/bootstrap.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$conn = db_connect();
|
||||||
|
|
||||||
|
// Query
|
||||||
|
$q = isset($_GET['q']) ? (string)$_GET['q'] : '';
|
||||||
|
$q = trim($q);
|
||||||
|
|
||||||
|
// Limit
|
||||||
|
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 8;
|
||||||
|
if ($limit < 1) {
|
||||||
|
$limit = 1;
|
||||||
|
}
|
||||||
|
if ($limit > 15) {
|
||||||
|
$limit = 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimum query length to reduce load/noise
|
||||||
|
if (mb_strlen($q, 'UTF-8') < 2) {
|
||||||
|
echo json_encode(['items' => []], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape LIKE wildcards (% _), then add %...%
|
||||||
|
$like = addcslashes($q, "%_\\");
|
||||||
|
$like = '%' . $like . '%';
|
||||||
|
|
||||||
|
// Simple search: model + description
|
||||||
|
$sql = "
|
||||||
|
SELECT p.productID, p.model, p.description, p.imagePath
|
||||||
|
FROM products p
|
||||||
|
WHERE (p.model LIKE ? OR p.description LIKE ?)
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN p.model LIKE ? THEN 0 ELSE 1 END,
|
||||||
|
p.model ASC
|
||||||
|
LIMIT ?
|
||||||
|
";
|
||||||
|
|
||||||
|
$stmt = $conn->prepare($sql);
|
||||||
|
if (!$stmt) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'DB-Query konnte nicht vorbereitet werden.'], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->bind_param('sssi', $like, $like, $like, $limit);
|
||||||
|
$stmt->execute();
|
||||||
|
$res = $stmt->get_result();
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
while ($row = $res->fetch_assoc()) {
|
||||||
|
$id = (int)($row['productID'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items[] = [
|
||||||
|
'id' => $id,
|
||||||
|
'model' => (string)($row['model'] ?? ''),
|
||||||
|
'description' => (string)($row['description'] ?? ''),
|
||||||
|
'imagePath' => (string)($row['imagePath'] ?? ''),
|
||||||
|
'url' => 'productpage.php?id=' . $id,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['items' => $items], JSON_UNESCAPED_UNICODE);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'Serverfehler'], JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
|
||||||
222
assets/js/search-autocomplete.js
Normal file
222
assets/js/search-autocomplete.js
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function debounce(fn, delay) {
|
||||||
|
var t;
|
||||||
|
return function () {
|
||||||
|
var ctx = this;
|
||||||
|
var args = arguments;
|
||||||
|
clearTimeout(t);
|
||||||
|
t = setTimeout(function () { fn.apply(ctx, args); }, delay);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDropdownEl() {
|
||||||
|
var el = document.createElement('div');
|
||||||
|
el.className = 'searchDropdown';
|
||||||
|
el.hidden = true;
|
||||||
|
el.setAttribute('role', 'listbox');
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachToInput(input) {
|
||||||
|
if (!input || input.dataset.autocompleteBound === '1') return;
|
||||||
|
input.dataset.autocompleteBound = '1';
|
||||||
|
|
||||||
|
// wrapper for positioning
|
||||||
|
var wrapper = input.closest('.nav__searchField');
|
||||||
|
if (!wrapper) return;
|
||||||
|
wrapper.classList.add('nav__searchField--hasDropdown');
|
||||||
|
|
||||||
|
// dropdown
|
||||||
|
var dropdown = createDropdownEl();
|
||||||
|
wrapper.appendChild(dropdown);
|
||||||
|
|
||||||
|
var activeIndex = -1;
|
||||||
|
var items = [];
|
||||||
|
var abortController = null;
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
dropdown.hidden = true;
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
dropdown.classList.remove('is-open');
|
||||||
|
activeIndex = -1;
|
||||||
|
items = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
dropdown.hidden = false;
|
||||||
|
dropdown.classList.add('is-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text) {
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'searchDropdown__status';
|
||||||
|
row.textContent = text;
|
||||||
|
dropdown.appendChild(row);
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(list) {
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
items = list || [];
|
||||||
|
activeIndex = -1;
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
setStatus('Keine Produkte gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
(function (idx) {
|
||||||
|
var it = items[idx];
|
||||||
|
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.className = 'searchDropdown__item';
|
||||||
|
a.href = it.url;
|
||||||
|
a.setAttribute('role', 'option');
|
||||||
|
a.tabIndex = -1;
|
||||||
|
|
||||||
|
var img = document.createElement('img');
|
||||||
|
img.className = 'searchDropdown__img';
|
||||||
|
img.alt = '';
|
||||||
|
img.loading = 'lazy';
|
||||||
|
img.decoding = 'async';
|
||||||
|
img.src = it.imagePath && it.imagePath.length ? it.imagePath : 'assets/images/placeholder.png';
|
||||||
|
|
||||||
|
var meta = document.createElement('div');
|
||||||
|
meta.className = 'searchDropdown__meta';
|
||||||
|
|
||||||
|
var title = document.createElement('div');
|
||||||
|
title.className = 'searchDropdown__title';
|
||||||
|
title.textContent = it.model || '';
|
||||||
|
|
||||||
|
var desc = document.createElement('div');
|
||||||
|
desc.className = 'searchDropdown__desc';
|
||||||
|
desc.textContent = it.description || '';
|
||||||
|
|
||||||
|
meta.appendChild(title);
|
||||||
|
meta.appendChild(desc);
|
||||||
|
|
||||||
|
a.appendChild(img);
|
||||||
|
a.appendChild(meta);
|
||||||
|
|
||||||
|
a.addEventListener('mouseenter', function () {
|
||||||
|
setActive(idx);
|
||||||
|
});
|
||||||
|
|
||||||
|
dropdown.appendChild(a);
|
||||||
|
})(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActive(idx) {
|
||||||
|
var children = dropdown.querySelectorAll('.searchDropdown__item');
|
||||||
|
for (var i = 0; i < children.length; i++) {
|
||||||
|
children[i].classList.toggle('is-active', i === idx);
|
||||||
|
}
|
||||||
|
activeIndex = idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchResults(q) {
|
||||||
|
if (abortController) {
|
||||||
|
abortController.abort();
|
||||||
|
}
|
||||||
|
abortController = new AbortController();
|
||||||
|
|
||||||
|
var url = 'api/search_products.php?q=' + encodeURIComponent(q) + '&limit=8';
|
||||||
|
|
||||||
|
setStatus('Suche…');
|
||||||
|
|
||||||
|
fetch(url, { signal: abortController.signal, headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
render((data && data.items) ? data.items : []);
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
if (err && err.name === 'AbortError') return;
|
||||||
|
setStatus('Fehler bei der Suche.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var debounced = debounce(function () {
|
||||||
|
var q = (input.value || '').trim();
|
||||||
|
if (q.length < 2) {
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchResults(q);
|
||||||
|
}, 180);
|
||||||
|
|
||||||
|
input.addEventListener('input', debounced);
|
||||||
|
|
||||||
|
input.addEventListener('focus', function () {
|
||||||
|
var q = (input.value || '').trim();
|
||||||
|
if (q.length >= 2) {
|
||||||
|
fetchResults(q);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('keydown', function (e) {
|
||||||
|
if (dropdown.hidden) return;
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var max = items.length - 1;
|
||||||
|
if (max < 0) return;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
var next = activeIndex + 1;
|
||||||
|
if (next > max) next = 0;
|
||||||
|
setActive(next);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
var prev = activeIndex - 1;
|
||||||
|
if (prev < 0) prev = max;
|
||||||
|
setActive(prev);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
if (activeIndex >= 0) {
|
||||||
|
var links = dropdown.querySelectorAll('.searchDropdown__item');
|
||||||
|
if (links[activeIndex]) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.location.href = links[activeIndex].href;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (wrapper.contains(e.target)) return;
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// prevent blur-close when clicking inside dropdown until navigation happens
|
||||||
|
dropdown.addEventListener('mousedown', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
var inputs = document.querySelectorAll('input.nav__searchInput[name="search"]');
|
||||||
|
for (var i = 0; i < inputs.length; i++) {
|
||||||
|
attachToInput(inputs[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
@ -161,3 +161,7 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Produktsuche (Autocomplete Dropdown) -->
|
||||||
|
<script src="assets/js/search-autocomplete.js" defer></script>
|
||||||
|
|
||||||
|
|||||||
81
style.css
81
style.css
@ -258,6 +258,87 @@ a:hover {
|
|||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================================
|
||||||
|
SEARCH AUTOCOMPLETE DROPDOWN
|
||||||
|
========================================================== */
|
||||||
|
.nav__searchField--hasDropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchDropdown {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 10px);
|
||||||
|
z-index: 1200;
|
||||||
|
background: rgba(21, 25, 35, 0.96);
|
||||||
|
backdrop-filter: blur(18px) saturate(160%);
|
||||||
|
-webkit-backdrop-filter: blur(18px) saturate(160%);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchDropdown__status {
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchDropdown__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
border-top: 1px solid rgba(94, 96, 117, 0.18);
|
||||||
|
transition: background var(--transition-fast), transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchDropdown__item:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchDropdown__item:hover,
|
||||||
|
.searchDropdown__item.is-active {
|
||||||
|
background: rgba(39, 74, 151, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchDropdown__img {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid rgba(94, 96, 117, 0.25);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchDropdown__meta {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchDropdown__title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchDropdown__desc {
|
||||||
|
font-size: 0.86rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.nav__inner.container {
|
.nav__inner.container {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user