Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
5cb8badc23
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();
|
||||
}
|
||||
})();
|
||||
|
||||
@ -11,6 +11,65 @@ error_reporting(E_ALL);
|
||||
$conn = db_connect();
|
||||
?>
|
||||
|
||||
<?php
|
||||
// ─────────────────────────────────────────────
|
||||
// Reine PHP-Suche (GET ?search=...)
|
||||
// Wenn ein Suchbegriff vorhanden ist, zeigen wir nur Suchergebnisse
|
||||
// (statt der Kategorie-Sektionen).
|
||||
// ─────────────────────────────────────────────
|
||||
$searchTerm = isset($_GET['search']) ? trim((string)$_GET['search']) : '';
|
||||
$searchLen = function_exists('mb_strlen') ? mb_strlen($searchTerm, 'UTF-8') : strlen($searchTerm);
|
||||
if ($searchTerm !== '' && $searchLen >= 2) {
|
||||
$like = addcslashes($searchTerm, "%_\\");
|
||||
$like = '%' . $like . '%';
|
||||
|
||||
$stmtSearch = $conn->prepare("
|
||||
SELECT productID, model, description, imagePath
|
||||
FROM products
|
||||
WHERE model LIKE ? OR description LIKE ?
|
||||
ORDER BY model ASC
|
||||
LIMIT 60
|
||||
");
|
||||
|
||||
if ($stmtSearch) {
|
||||
$stmtSearch->bind_param('ss', $like, $like);
|
||||
$stmtSearch->execute();
|
||||
$resultSearch = $stmtSearch->get_result();
|
||||
?>
|
||||
|
||||
<section class="product-section">
|
||||
<h2>Suchergebnisse für „<?= htmlspecialchars($searchTerm) ?>“</h2>
|
||||
|
||||
<?php if ($resultSearch->num_rows <= 0): ?>
|
||||
<p class="search-empty">Keine Produkte gefunden.</p>
|
||||
<?php else: ?>
|
||||
<div class="product-grid">
|
||||
<?php while ($product = $resultSearch->fetch_assoc()): ?>
|
||||
<?php $productId = (int)$product['productID']; ?>
|
||||
<a class="product-card" href="productpage.php?id=<?= $productId ?>">
|
||||
<img
|
||||
src="<?= !empty($product['imagePath']) ? htmlspecialchars($product['imagePath']) : 'assets/images/placeholder.png' ?>"
|
||||
alt="<?= htmlspecialchars($product['model'] ?? '') ?>">
|
||||
|
||||
<div class="product-card__content">
|
||||
<h3><?= htmlspecialchars($product['model'] ?? '') ?></h3>
|
||||
<p><?= htmlspecialchars($product['description'] ?? '') ?></p>
|
||||
</div>
|
||||
</a>
|
||||
<?php endwhile; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<?php
|
||||
$stmtSearch->close();
|
||||
}
|
||||
|
||||
// Wichtig: In Suchmodus KEINE Kategorien rendern.
|
||||
return;
|
||||
}
|
||||
?>
|
||||
|
||||
<?php
|
||||
$activeCategory = isset($_GET['category']) ? $_GET['category'] : 'all';
|
||||
?>
|
||||
|
||||
@ -161,3 +161,4 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
@ -238,8 +238,23 @@ $productId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
</div>
|
||||
|
||||
<?php
|
||||
// Unterschiedliche DB-Stände: URL-Spalte heißt je nach Schema z.B. productURL oder offerURL.
|
||||
// Wir ermitteln die existierende Spalte dynamisch, damit die Seite nicht mit "Unknown column" crasht.
|
||||
$urlColumn = '';
|
||||
$colCheck = mysqli_query($conn, "SHOW COLUMNS FROM offers LIKE 'productURL'");
|
||||
if ($colCheck && mysqli_num_rows($colCheck) > 0) {
|
||||
$urlColumn = 'productURL';
|
||||
} else {
|
||||
$colCheck2 = mysqli_query($conn, "SHOW COLUMNS FROM offers LIKE 'offerURL'");
|
||||
if ($colCheck2 && mysqli_num_rows($colCheck2) > 0) {
|
||||
$urlColumn = 'offerURL';
|
||||
}
|
||||
}
|
||||
|
||||
$urlSelect = $urlColumn !== '' ? ("offers." . $urlColumn . " AS offerURL") : "'' AS offerURL";
|
||||
|
||||
$stmt = mysqli_prepare($conn,
|
||||
"SELECT price, shippingCost, inStock, shops.name, offers.offerURL, shops.logoPath, shops.shippingTime
|
||||
"SELECT price, shippingCost, inStock, shops.name, $urlSelect, shops.logoPath, shops.shippingTime
|
||||
FROM offers
|
||||
INNER JOIN shops ON
|
||||
offers.shopID = shops.shopID WHERE offers.productID = ? ORDER BY offers.price ASC");
|
||||
|
||||
87
style.css
87
style.css
@ -258,6 +258,87 @@ a:hover {
|
||||
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 {
|
||||
margin-left: auto;
|
||||
}
|
||||
@ -646,6 +727,12 @@ a:hover {
|
||||
gap: 1.6rem;
|
||||
}
|
||||
|
||||
/* Hinweistext bei leeren Suchergebnissen */
|
||||
.search-empty {
|
||||
color: var(--text-secondary);
|
||||
margin: 0.5rem 2rem 0;
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
PRODUCT CARD (global)
|
||||
========================================================== */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user