Files
shopApiNg/index.html

1187 lines
38 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Category Tree Viewer</title>
<!-- Resource hints to optimize loading -->
<link rel="preload" href="/socket.io/socket.io.js" as="script">
<link rel="preconnect" href="/api">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 2rem;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
padding: 2rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
h1 {
color: #667eea;
margin-bottom: 2rem;
font-size: 2.5rem;
text-align: center;
}
.tree {
list-style: none;
}
.tree ul {
list-style: none;
padding-left: 2rem;
}
.category {
margin: 0.5rem 0;
padding: 0.75rem;
background: white;
border-radius: 8px;
border-left: 4px solid #667eea;
transition: all 0.3s ease;
}
.category-header.has-children {
cursor: pointer;
}
.category:hover {
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
}
.category-header {
display: flex;
align-items: center;
gap: 1rem;
}
.category-image {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 6px;
background: #f0f0f0;
}
.category-info {
flex: 1;
}
.category-name {
font-weight: 600;
color: #333;
font-size: 1.1rem;
}
.category-count {
color: #666;
font-size: 0.9rem;
margin-top: 0.25rem;
}
.toggle {
color: #667eea;
font-weight: bold;
margin-right: 0.5rem;
user-select: none;
}
.children {
margin-top: 0.5rem;
}
.children.hidden {
display: none;
}
.loading {
text-align: center;
padding: 3rem;
color: #667eea;
font-size: 1.2rem;
}
.error {
background: #fee;
color: #c33;
padding: 1rem;
border-radius: 8px;
text-align: center;
}
.category-products {
margin-top: 0.5rem;
padding-left: 4rem;
font-size: 0.9rem;
color: #555;
}
.product-item {
padding: 0.5rem 0;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
gap: 0.75rem;
}
.product-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.product-meta {
font-size: 0.8rem;
color: #888;
display: flex;
gap: 0.5rem;
}
.product-price {
font-weight: bold;
color: #2d3748;
font-size: 0.95rem;
}
.product-image {
width: 32px;
height: 32px;
object-fit: cover;
border-radius: 4px;
background: #f0f0f0;
}
.product-item:last-child {
border-bottom: none;
}
.product-item.more {
cursor: pointer;
color: #667eea;
transition: color 0.2s;
}
.product-item.more:hover {
color: #764ba2;
text-decoration: underline;
}
.search-wrapper {
position: relative;
margin-bottom: 1.5rem;
}
#filter-input {
width: 100%;
padding: 0.75rem 2.5rem 0.75rem 1rem;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 1rem;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
#filter-input:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
outline: none;
}
#clear-btn {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #999;
font-size: 1.25rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
transition: all 0.2s ease;
opacity: 0;
pointer-events: none;
}
#clear-btn.visible {
opacity: 1;
pointer-events: auto;
}
#clear-btn:hover {
color: #667eea;
background: rgba(102, 126, 234, 0.1);
}
#version-reload-btn {
position: fixed;
bottom: 20px;
right: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px 20px;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
transition: all 0.3s ease;
display: none;
z-index: 1000;
}
#version-reload-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.5);
}
#version-reload-btn.show {
display: block;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateY(100px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Mobile Responsive Styles */
@media (max-width: 768px) {
body {
padding: 1rem;
}
.container {
padding: 1rem;
border-radius: 12px;
}
h1 {
font-size: 1.75rem;
margin-bottom: 1rem;
}
.search-wrapper {
margin-bottom: 1rem;
}
#filter-input {
padding: 0.625rem 2.25rem 0.625rem 0.875rem;
font-size: 0.95rem;
}
#clear-btn {
font-size: 1.1rem;
right: 0.375rem;
}
.tree ul {
padding-left: 1rem;
}
.category {
margin: 0.375rem 0;
padding: 0.625rem;
border-left-width: 3px;
}
.category-header {
gap: 0.75rem;
}
.category-image {
width: 40px;
height: 40px;
}
.category-name {
font-size: 1rem;
}
.category-count {
font-size: 0.85rem;
}
.toggle {
font-size: 0.9rem;
margin-right: 0.375rem;
}
.category-products {
padding-left: 2.5rem;
font-size: 0.85rem;
}
.product-image {
width: 28px;
height: 28px;
}
.product-item {
padding: 0.375rem 0;
}
#version-reload-btn {
bottom: 15px;
right: 15px;
padding: 10px 16px;
font-size: 0.85rem;
}
/* Ensure touch targets are at least 44px */
.category-header.has-children {
min-height: 44px;
}
}
@media (max-width: 480px) {
body {
padding: 0.5rem;
}
.container {
padding: 0.75rem;
border-radius: 8px;
}
h1 {
font-size: 1.5rem;
margin-bottom: 0.75rem;
}
#filter-input {
padding: 0.5rem 2rem 0.5rem 0.75rem;
font-size: 0.9rem;
}
#clear-btn {
font-size: 1rem;
right: 0.25rem;
}
.tree ul {
padding-left: 0.75rem;
}
.category {
margin: 0.25rem 0;
padding: 0.5rem;
}
.category-header {
gap: 0.5rem;
}
.category-image {
width: 36px;
height: 36px;
}
.category-name {
font-size: 0.95rem;
}
.category-count {
font-size: 0.8rem;
}
.category-products {
padding-left: 2rem;
font-size: 0.8rem;
}
.product-image {
width: 24px;
height: 24px;
}
#version-reload-btn {
bottom: 10px;
right: 10px;
padding: 8px 14px;
font-size: 0.8rem;
}
}
/* Product Detail Popper */
.product-popper {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
z-index: 1000;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
display: none;
}
.product-popper.visible {
display: block;
animation: fadeIn 0.2s ease-out;
}
.popper-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
display: none;
backdrop-filter: blur(2px);
}
.popper-overlay.visible {
display: block;
animation: fadeIn 0.2s ease-out;
}
.popper-close {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666;
padding: 0.5rem;
line-height: 1;
border-radius: 50%;
transition: background 0.2s;
}
.popper-close:hover {
background: #f0f0f0;
color: #333;
}
.popper-content h3 {
margin-bottom: 1rem;
color: #2d3748;
font-size: 1.25rem;
}
.popper-content {
line-height: 1.6;
color: #4a5568;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.product-name-link {
cursor: pointer;
color: #4a5568;
transition: color 0.2s;
text-decoration: none;
}
.product-name-link:hover {
color: #667eea;
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="search-wrapper">
<input type="text" id="filter-input" placeholder="🔍" autocomplete="off">
<button id="clear-btn" title="Clear search"></button>
</div>
<div id="tree-container">
<div class="loading">Loading categories...</div>
</div>
</div>
<button id="version-reload-btn" onclick="location.reload()">New version - [reload]</button>
<!-- Product Detail Popper -->
<div id="popper-overlay" class="popper-overlay"></div>
<div id="product-popper" class="product-popper">
<button class="popper-close" onclick="closePopper()">×</button>
<div id="popper-content" class="popper-content"></div>
</div>
<script src="/socket.io/socket.io.js" async></script>
<script>
// Initialize socket when io is available (async)
function initSocket() {
if (typeof io === 'undefined') {
// Socket.io not loaded yet, try again soon
setTimeout(initSocket, 50);
return;
}
const socket = io({
transports: ['websocket']
});
// Version checking
const clientEtag = document.querySelector('meta[name="app-version"]')?.content;
const reloadBtn = document.getElementById('version-reload-btn');
// Socket Events
socket.on('connect', () => {
console.log('🔌 Connected to server via WebSocket');
// Check version on connect and reconnect
if (clientEtag) {
socket.emit('checkVersion', clientEtag);
}
});
socket.on('versionMismatch', ({ serverEtag }) => {
console.log('⚠️ New version available on server');
if (reloadBtn) {
reloadBtn.classList.add('show');
}
});
socket.on('categoriesUpdated', () => {
console.log('🔄 Categories updated, reloading tree...');
loadCategories();
});
socket.on('categoryProductsUpdated', ({ id }) => {
console.log(`🔄 Products for category ${id} updated, reloading...`);
updateCategoryProducts(id);
});
// Debounce function
function debounce(func, wait) {
let timeout;
return function (...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
const debouncedSearch = debounce((value) => {
if (value.trim().length >= 3) {
socket.emit('search', value);
} else {
// Clear matches and collapse all categories if less than 3 chars
resetMatches(state.categories);
resetExpansion(state.categories);
collapseAllProducts(state.categories);
render();
}
}, 300);
// Event Listeners
filterInput.addEventListener('input', (e) => {
const value = e.target.value;
state.filter = value;
// Toggle clear button visibility
if (value) {
clearBtn.classList.add('visible');
} else {
clearBtn.classList.remove('visible');
}
if (value.trim().length >= 3) {
debouncedSearch(value);
} else {
// Clear matches and collapse all categories
resetMatches(state.categories);
resetExpansion(state.categories);
collapseAllProducts(state.categories);
render();
}
});
socket.on('searchResults', ({ query, matches }) => {
if (query !== state.filter) return;
const matchSet = new Set(matches);
// Reset expansion and matches
resetExpansion(state.categories);
// Mark matches and expand
markMatches(state.categories, matchSet);
render();
});
}
// Start socket initialization (async, non-blocking)
initSocket();
// State management
const state = {
categories: [],
filter: '',
// We store expansion state directly on category objects
};
// DOM Elements
const container = document.getElementById('tree-container');
const filterInput = document.getElementById('filter-input');
const clearBtn = document.getElementById('clear-btn');
// Auto-focus search field on page load
window.addEventListener('load', () => {
filterInput.focus();
});
// Clear button functionality
clearBtn.addEventListener('click', () => {
filterInput.value = '';
state.filter = '';
clearBtn.classList.remove('visible');
// Clear matches and collapse all categories
resetMatches(state.categories);
resetExpansion(state.categories);
collapseAllProducts(state.categories);
render();
// Refocus the input
filterInput.focus();
});
function resetMatches(nodes) {
nodes.forEach(node => {
node._hasMatch = false;
if (node.children) resetMatches(node.children);
});
}
function markMatches(nodes, matchSet) {
let anyMatch = false;
nodes.forEach(node => {
const isDirectMatch = matchSet.has(node.kKategorie);
let childMatch = false;
if (node.children) {
childMatch = markMatches(node.children, matchSet);
}
if (isDirectMatch || childMatch) {
node._hasMatch = true;
node._isDirectMatch = isDirectMatch; // Track if this node itself matches
anyMatch = true;
// Expand if children have matches
if (childMatch) {
node.isChildrenExpanded = true;
}
// Expand if direct match (to show products)
if (isDirectMatch) {
node.isChildrenExpanded = true;
node.isExpanded = true;
// Trigger load if needed
if (node.products === null) fetchProducts(node);
}
} else {
node._hasMatch = false;
node._isDirectMatch = false;
}
});
return anyMatch;
}
function resetExpansion(nodes) {
nodes.forEach(node => {
node.isChildrenExpanded = false;
node.isExpanded = false;
if (node.children) resetExpansion(node.children);
});
}
function collapseAllProducts(nodes) {
nodes.forEach(node => {
node.isExpanded = false;
if (node.children) collapseAllProducts(node.children);
});
}
// Initial Load
loadCategories();
async function loadCategories() {
try {
const response = await fetch('/api/categories');
if (!response.ok) throw new Error('Failed to load categories');
state.categories = await response.json();
// Initialize and fetch products for all categories
initCategories(state.categories);
render();
} catch (err) {
container.innerHTML = `<div class="error">❌ ${err.message}</div>`;
}
}
function initCategories(nodes) {
nodes.forEach(node => {
// Initialize state properties
node.products = null; // null means loading
node.isExpanded = false;
node.isChildrenExpanded = false; // New state for subcategory expansion
// Fetch products
// fetchProducts(node); // Lazy load instead
if (node.children) {
initCategories(node.children);
}
});
}
function fetchProducts(node) {
fetch(`/api/categories/${node.kKategorie}/products`)
.then(res => res.ok ? res.json() : [])
.then(products => {
node.products = products;
render(); // Re-render on data arrival
})
.catch(() => {
node.products = [];
render();
});
}
function renderProducts(products) {
if (!products) return '<div class="loading">Loading products...</div>';
if (products.length === 0) return '<div class="category-products">No products found.</div>';
const items = products.map(p => {
const image = p.images && p.images.length > 0
? `<img src="/api/images/${p.images[0]}/thumbnail" class="product-image" loading="lazy">`
: '<div class="product-image"></div>';
// Calculate Gross Price
let priceDisplay = '';
if (p.fVKNetto !== undefined && p.fVKNetto !== null) {
const net = parseFloat(p.fVKNetto);
const rate = p.fSteuersatz ? parseFloat(p.fSteuersatz) : 19.0; // Default to 19% if missing
const gross = net * (1 + rate / 100);
priceDisplay = `<div class="product-price">${gross.toFixed(2)} €</div>`;
console.log('Product:', p.cName, 'Net:', net, 'Rate:', rate, 'Gross:', gross.toFixed(2));
} else {
console.log('Product missing price:', p.cName, 'fVKNetto:', p.fVKNetto);
}
// Meta info
const metaParts = [];
if (p.cArtNr) metaParts.push(`Art: ${p.cArtNr}`);
if (p.cBarcode) metaParts.push(`EAN: ${p.cBarcode}`);
const metaHtml = metaParts.length > 0 ? `<div class="product-meta">${metaParts.join(' • ')}</div>` : '';
return `
<div class="product-item">
${image}
<div class="product-details">
<div>
<a class="product-name-link" onclick="showProductDetails(${p.kArtikel}, '${p.cName.replace(/'/g, "\\'")}')">${p.cName}</a>
</div>
${metaHtml}
</div>
${priceDisplay}
</div>
`;
}).join('');
return `<div class="category-products">${items}</div>`;
}
function updateCategoryProducts(id) {
const findAndReload = (nodes) => {
for (const node of nodes) {
if (node.kKategorie === id) {
node.products = null; // Set to loading
render();
fetchProducts(node);
return true;
}
if (node.children && findAndReload(node.children)) return true;
}
return false;
};
findAndReload(state.categories);
}
// Filtering Logic
function filterTree(nodes, query) {
if (!query.trim() || query.trim().length < 3) return nodes; // Return original structure if no filter or short query
return nodes.map(node => {
// Only keep if marked as having a match
if (node._hasMatch) {
// Filter children
const matchingChildren = node.children ? filterTree(node.children, query) : [];
// Filter products if loaded
let matchingProducts = node.products;
if (matchingProducts) {
const words = query.toLowerCase().split(/\s+/).filter(w => w);
matchingProducts = matchingProducts.filter(p => {
const name = p.cName.toLowerCase();
return words.every(w => name.includes(w));
});
// Limit product results
if (matchingProducts.length > 21) {
matchingProducts = matchingProducts.slice(0, 21);
}
}
return {
...node,
products: matchingProducts,
children: matchingChildren,
_original: node
};
}
return null;
}).filter(n => n !== null);
}
// Rendering
function render() {
const filteredCategories = filterTree(state.categories, state.filter);
container.innerHTML = '';
if (state.filter && filteredCategories.length === 0) {
container.innerHTML = '<div class="loading" style="color: #666">No matching articles found.</div>';
return;
}
const ul = document.createElement('ul');
ul.className = 'tree';
filteredCategories.forEach(cat => {
ul.appendChild(renderCategory(cat));
});
container.appendChild(ul);
}
function countSubcategoryArticles(children) {
let total = 0;
children.forEach(child => {
// Count articles in this child
const childCount = child.products ? child.products.length : child.articleCount;
total += childCount;
// Recursively count articles in nested subcategories
if (child.children && child.children.length > 0) {
total += countSubcategoryArticles(child.children);
}
});
return total;
}
function renderCategory(category) {
// category might be a filtered copy or original.
// If it's a copy, it has _original pointing to the real state node.
const realNode = category._original || category;
const li = document.createElement('li');
const div = document.createElement('div');
div.className = 'category';
const header = document.createElement('div');
header.className = 'category-header';
// Toggle for children or products
const hasChildren = category.children && category.children.length > 0;
const hasProducts = category.articleCount > 0 || (category.products && category.products.length > 0);
if (hasChildren || hasProducts) {
header.classList.add('has-children');
const toggle = document.createElement('span');
toggle.className = 'toggle';
toggle.textContent = realNode.isChildrenExpanded ? '▼' : '▶';
header.appendChild(toggle);
header.onclick = (e) => {
e.stopPropagation();
realNode.isChildrenExpanded = !realNode.isChildrenExpanded;
// Lazy load products if expanding and not yet loaded
if (realNode.isChildrenExpanded && realNode.products === null) {
fetchProducts(realNode);
}
render();
};
}
// Image
if (category.kBild) {
const img = document.createElement('img');
img.className = 'category-image';
img.src = `/img/cat/${category.kBild}.avif`;
img.alt = category.cName;
img.loading = 'lazy';
img.onerror = () => img.style.display = 'none';
header.appendChild(img);
}
// Info
const info = document.createElement('div');
info.className = 'category-info';
const name = document.createElement('div');
name.className = 'category-name';
name.textContent = category.cName;
info.appendChild(name);
const count = document.createElement('div');
count.className = 'category-count';
// When filtering, show actual matching product count
// If this category is not a direct match (only matches due to subcategories),
// and products aren't loaded, show 0 instead of total articleCount
let productCount;
if (category.products !== null && category.products !== undefined) {
// Products are loaded, use the filtered count
productCount = category.products.length;
} else if (state.filter && !realNode._isDirectMatch) {
// Filtering is active and this is not a direct match, so show 0
productCount = 0;
} else {
// No filter or is a direct match, use API count
productCount = category.articleCount;
}
let countText = `${productCount} articles`;
// Always show subcategory count if children exist (User requirement)
// Use filtered children length
if (category.children && category.children.length > 0) {
const subcategoryArticleCount = countSubcategoryArticles(category.children);
countText += `, ${category.children.length} subcategories`;
if (subcategoryArticleCount > 0) {
countText += ` with ${subcategoryArticleCount} more articles`;
}
}
count.textContent = countText;
info.appendChild(count);
header.appendChild(info);
div.appendChild(header);
// Products
const productsDiv = document.createElement('div');
productsDiv.className = 'category-products';
// Only show products if:
// 1. Not filtering (show all), OR
// 2. This category is a direct match (not just expanded due to child matches)
const shouldShowProducts = !state.filter || realNode._isDirectMatch;
if (realNode.isChildrenExpanded && shouldShowProducts) {
if (category.products === null) {
productsDiv.innerHTML = '<small>Loading products...</small>';
productsDiv.style.display = 'block';
} else if (category.products.length > 0) {
productsDiv.style.display = 'block';
const ul = document.createElement('ul');
ul.style.listStyle = 'none';
const limit = realNode.isExpanded ? 20 : 3;
const displayProducts = category.products.slice(0, limit);
displayProducts.forEach(p => {
const li = document.createElement('li');
li.className = 'product-item';
// Image
if (p.images && p.images.length > 0) {
const img = document.createElement('img');
img.className = 'product-image';
img.src = `/img/prod/${p.images[0]}.avif`;
img.alt = p.cName;
img.loading = 'lazy';
img.onerror = () => img.style.display = 'none';
li.appendChild(img);
}
// Product details container
const detailsDiv = document.createElement('div');
detailsDiv.className = 'product-details';
// Product name
const nameDiv = document.createElement('div');
const nameLink = document.createElement('span');
nameLink.className = 'product-name-link';
nameLink.textContent = p.cName;
nameLink.onclick = (e) => {
e.stopPropagation();
showProductDetails(p.kArtikel, p.cName);
};
nameDiv.appendChild(nameLink);
detailsDiv.appendChild(nameDiv);
// Meta info (Article number and barcode)
const metaParts = [];
if (p.cArtNr) metaParts.push(`Art: ${p.cArtNr}`);
if (p.cBarcode) metaParts.push(`EAN: ${p.cBarcode}`);
if (metaParts.length > 0) {
const metaDiv = document.createElement('div');
metaDiv.className = 'product-meta';
metaDiv.textContent = metaParts.join(' • ');
detailsDiv.appendChild(metaDiv);
}
li.appendChild(detailsDiv);
// Price
if (p.fVKNetto !== undefined && p.fVKNetto !== null) {
const net = parseFloat(p.fVKNetto);
const rate = p.fSteuersatz ? parseFloat(p.fSteuersatz) : 19.0;
const gross = net * (1 + rate / 100);
const priceDiv = document.createElement('div');
priceDiv.className = 'product-price';
priceDiv.textContent = `${gross.toFixed(2)}`;
li.appendChild(priceDiv);
}
ul.appendChild(li);
});
// Show "more" if expanded and there are more than 20, OR if collapsed and there are more than 3
if (realNode.isExpanded && category.products.length > 20) {
const more = document.createElement('li');
more.className = 'product-item more';
more.style.fontStyle = 'italic';
more.textContent = `(more)`;
// Prevent click from collapsing
more.onclick = (e) => e.stopPropagation();
ul.appendChild(more);
} else if (!realNode.isExpanded && category.products.length > 3) {
const more = document.createElement('li');
more.className = 'product-item more';
more.style.fontStyle = 'italic';
more.textContent = `...and ${category.products.length - 3} more`;
more.onclick = (e) => {
e.stopPropagation();
// Collapse others
collapseAllProducts(state.categories);
realNode.isExpanded = true;
render();
};
ul.appendChild(more);
}
productsDiv.appendChild(ul);
} else {
productsDiv.style.display = 'none';
}
} else {
productsDiv.style.display = 'none';
}
div.appendChild(productsDiv);
// Children
if (category.children && category.children.length > 0 && realNode.isChildrenExpanded) {
const childrenUl = document.createElement('ul');
childrenUl.className = 'children';
category.children.forEach(child => {
childrenUl.appendChild(renderCategory(child));
});
div.appendChild(childrenUl);
}
li.appendChild(div);
return li;
}
function collapseAllProducts(nodes) {
nodes.forEach(node => {
node.isExpanded = false;
if (node.children) collapseAllProducts(node.children);
});
}
// Product Details Popper Logic
const popperOverlay = document.getElementById('popper-overlay');
const productPopper = document.getElementById('product-popper');
const popperContent = document.getElementById('popper-content');
function closePopper() {
popperOverlay.classList.remove('visible');
productPopper.classList.remove('visible');
popperContent.innerHTML = '';
}
popperOverlay.addEventListener('click', closePopper);
// Close on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closePopper();
});
async function showProductDetails(id) {
// Show loading state
popperContent.innerHTML = '<div class="loading">Loading details...</div>';
popperOverlay.classList.add('visible');
productPopper.classList.add('visible');
try {
const response = await fetch(`/api/products/${id}/details`);
if (!response.ok) throw new Error('Failed to load details');
const data = await response.json();
// The description is in cBeschreibung
popperContent.innerHTML = data.cBeschreibung || 'No description available.';
} catch (err) {
console.error('Error fetching product details:', err);
popperContent.innerHTML = `<div class="error">❌ Failed to load details: ${err.message}</div>`;
}
}
</script>
</body>
</html>