Merge remote-tracking branch 'origin/main'

This commit is contained in:
Paul Eisenbock 2026-03-18 16:10:08 +01:00
commit 5cb8badc23
6 changed files with 464 additions and 1 deletions

79
api/search_products.php Normal file
View 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);
}

View 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();
}
})();

View File

@ -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';
?>

View File

@ -161,3 +161,4 @@
}
})();
</script>

View File

@ -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");

View File

@ -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)
========================================================== */