Forráskód Böngészése

changing category-behaviour to be configurable in the admin-ui.
Allow products to have multiple categories.

Medowar 1 hónapja
szülő
commit
787c98eddc
10 módosított fájl, 843 hozzáadás és 250 törlés
  1. 195 0
      admin/categories.php
  2. 1 0
      admin/index.php
  3. 228 206
      admin/products.php
  4. 23 0
      assets/css/style.css
  5. 1 0
      config.sample.php
  6. 1 1
      data/admins.json
  7. 20 0
      data/categories.json
  8. 353 25
      includes/functions.php
  9. 12 6
      index.php
  10. 9 12
      product.php

+ 195 - 0
admin/categories.php

@@ -0,0 +1,195 @@
+<?php
+require_once __DIR__ . '/../config.php';
+require_once __DIR__ . '/../includes/functions.php';
+
+// Check admin login
+if (!isset($_SESSION['admin_logged_in']) || !$_SESSION['admin_logged_in']) {
+    header('Location: login.php');
+    exit;
+}
+
+$pageTitle = 'Kategorien verwalten';
+$message = '';
+$messageType = '';
+$categories = getCategories();
+$products = getProducts();
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    if (isset($_POST['add_category'])) {
+        $label = normalizeCategoryLabel($_POST['label'] ?? '');
+
+        if (!isValidCategoryLabel($label)) {
+            $message = 'Bitte geben Sie einen Kategorienamen mit maximal 80 Zeichen ein.';
+            $messageType = 'error';
+        } else {
+            $categoryId = generateCategoryIdFromLabel($label, $categories);
+            $categories[] = [
+                'id' => $categoryId,
+                'label' => $label
+            ];
+            saveCategories($categories);
+            $message = 'Kategorie wurde erfolgreich angelegt.';
+            $messageType = 'success';
+        }
+    }
+
+    if (isset($_POST['update_category'])) {
+        $categoryId = normalizeCategoryId($_POST['category_id'] ?? '');
+        $label = normalizeCategoryLabel($_POST['label'] ?? '');
+        $found = false;
+
+        if (!isValidCategoryLabel($label)) {
+            $message = 'Bitte geben Sie einen Kategorienamen mit maximal 80 Zeichen ein.';
+            $messageType = 'error';
+        } else {
+            foreach ($categories as &$category) {
+                if ($category['id'] === $categoryId) {
+                    $category['label'] = $label;
+                    $found = true;
+                    break;
+                }
+            }
+            unset($category);
+
+            if (!$found) {
+                $message = 'Kategorie nicht gefunden.';
+                $messageType = 'error';
+            } else {
+                saveCategories($categories);
+                $message = 'Kategorie wurde erfolgreich aktualisiert.';
+                $messageType = 'success';
+            }
+        }
+    }
+
+    if (isset($_POST['delete_category'])) {
+        $categoryId = normalizeCategoryId($_POST['category_id'] ?? '');
+        $found = false;
+
+        if (isCategoryInUse($categoryId)) {
+            $message = 'Diese Kategorie wird noch von Produkten verwendet und kann nicht gelöscht werden.';
+            $messageType = 'error';
+        } else {
+            $categories = array_values(array_filter($categories, function ($category) use ($categoryId, &$found) {
+                if ($category['id'] === $categoryId) {
+                    $found = true;
+                    return false;
+                }
+                return true;
+            }));
+
+            if (!$found) {
+                $message = 'Kategorie nicht gefunden.';
+                $messageType = 'error';
+            } else {
+                saveCategories($categories);
+                $message = 'Kategorie wurde gelöscht.';
+                $messageType = 'success';
+            }
+        }
+    }
+
+    $categories = getCategories();
+    $products = getProducts();
+}
+
+$editCategoryId = normalizeCategoryId($_GET['edit'] ?? '');
+$editingCategory = null;
+if ($editCategoryId !== '') {
+    $editingCategory = getCategoryById($editCategoryId);
+    if ($editingCategory === null && $message === '') {
+        $message = 'Ausgewählte Kategorie wurde nicht gefunden.';
+        $messageType = 'error';
+    }
+}
+
+$bodyClass = 'admin-page';
+include __DIR__ . '/../includes/header.php';
+?>
+
+<div class="admin-header">
+    <h2>Kategorien verwalten</h2>
+    <div>
+        <a href="index.php" class="btn btn-secondary">Zurück zum Dashboard</a>
+    </div>
+</div>
+
+<?php if ($message !== ''): ?>
+    <div class="alert alert-<?php echo $messageType; ?>">
+        <?php echo htmlspecialchars($message); ?>
+    </div>
+<?php endif; ?>
+
+<?php if ($editingCategory !== null): ?>
+    <div class="panel" style="padding: 2rem;">
+        <h3>Kategorie bearbeiten</h3>
+        <form method="POST">
+            <input type="hidden" name="category_id" value="<?php echo htmlspecialchars($editingCategory['id']); ?>">
+            <div class="form-group">
+                <label for="category_id_display">ID</label>
+                <input type="text" id="category_id_display" value="<?php echo htmlspecialchars($editingCategory['id']); ?>" readonly>
+                <small>Die ID sollte gleichbleiben, damit Produktzuordnungen und Filter-URLs gültig bleiben.</small>
+            </div>
+            <div class="form-group">
+                <label for="label_edit">Name *</label>
+                <input type="text" id="label_edit" name="label" maxlength="80" required value="<?php echo htmlspecialchars($editingCategory['label']); ?>">
+            </div>
+            <button type="submit" name="update_category" class="btn">Kategorie aktualisieren</button>
+            <a href="categories.php" class="btn btn-secondary">Abbrechen</a>
+        </form>
+    </div>
+<?php else: ?>
+    <div class="panel" style="padding: 2rem;">
+        <h3>Neue Kategorie anlegen</h3>
+        <form method="POST">
+            <div class="form-group">
+                <label for="label">Name *</label>
+                <input type="text" id="label" name="label" maxlength="80" required placeholder="z.B. Accessoires">
+                <small>Die technische ID wird einmalig automatisch aus dem Namen erzeugt.</small>
+            </div>
+            <button type="submit" name="add_category" class="btn">Kategorie anlegen</button>
+        </form>
+    </div>
+<?php endif; ?>
+
+<div class="panel">
+    <h3>Kategorien</h3>
+    <div class="table-responsive">
+        <table class="responsive-table">
+            <thead>
+                <tr>
+                    <th>Name</th>
+                    <th>ID</th>
+                    <th>Produkte</th>
+                    <th>Aktionen</th>
+                </tr>
+            </thead>
+            <tbody>
+                <?php foreach ($categories as $category): ?>
+                    <?php
+                    $productCount = 0;
+                    foreach ($products as $product) {
+                        if (productHasCategory($product, $category['id'])) {
+                            $productCount++;
+                        }
+                    }
+                    ?>
+                    <tr>
+                        <td data-label="Name"><?php echo htmlspecialchars($category['label']); ?></td>
+                        <td data-label="ID"><code><?php echo htmlspecialchars($category['id']); ?></code></td>
+                        <td data-label="Produkte"><?php echo $productCount; ?></td>
+                        <td data-label="Aktionen">
+                            <a href="categories.php?edit=<?php echo urlencode($category['id']); ?>" class="btn btn-small btn-secondary">Bearbeiten</a>
+                            <form method="POST" style="display: inline;" onsubmit="return confirm('Kategorie wirklich löschen?');">
+                                <input type="hidden" name="category_id" value="<?php echo htmlspecialchars($category['id']); ?>">
+                                <button type="submit" name="delete_category" class="btn btn-small">Löschen</button>
+                            </form>
+                        </td>
+                    </tr>
+                <?php endforeach; ?>
+            </tbody>
+        </table>
+    </div>
+</div>
+
+<?php include __DIR__ . '/../includes/footer.php'; ?>

+ 1 - 0
admin/index.php

@@ -57,6 +57,7 @@ include __DIR__ . '/../includes/header.php';
             <summary class="btn btn-secondary">Verwaltung</summary>
             <div class="admin-actions-menu">
                 <a href="products.php">Produkte verwalten</a>
+                <a href="categories.php">Kategorien verwalten</a>
                 <a href="faq.php">FAQ bearbeiten</a>
                 <a href="admins.php">Admins verwalten</a>
                 <a href="login.php?logout=1">Abmelden</a>

+ 228 - 206
admin/products.php

@@ -11,6 +11,7 @@ if (!isset($_SESSION['admin_logged_in']) || !$_SESSION['admin_logged_in']) {
 $pageTitle = 'Produkte verwalten';
 $message = '';
 $messageType = '';
+$categories = getCategories();
 
 function handleImageUpload($fileInputName = 'image_file') {
     if (!isset($_FILES[$fileInputName]) || $_FILES[$fileInputName]['error'] === UPLOAD_ERR_NO_FILE) {
@@ -42,10 +43,11 @@ function handleImageUpload($fileInputName = 'image_file') {
     }
 
     $safeBaseName = preg_replace('/[^a-zA-Z0-9_-]/', '-', pathinfo($originalName, PATHINFO_FILENAME));
-    $safeBaseName = trim($safeBaseName, '-');
+    $safeBaseName = trim((string) $safeBaseName, '-');
     if ($safeBaseName === '') {
         $safeBaseName = 'bild';
     }
+
     $targetFilename = $safeBaseName . '.' . $extension;
     $targetPath = $imagesDir . '/' . $targetFilename;
     $counter = 1;
@@ -62,111 +64,151 @@ function handleImageUpload($fileInputName = 'image_file') {
     return ['success' => true, 'filename' => $targetFilename];
 }
 
+function isValidProductCategoryInput($categoryId) {
+    return getCategoryById($categoryId) !== null;
+}
+
+function getSubmittedProductCategoryIds($submittedValues) {
+    $selectedCategoryIds = normalizeProductCategoryIds($submittedValues['categories'] ?? []);
+    $validCategoryIds = [];
+
+    foreach ($selectedCategoryIds as $categoryId) {
+        if (isValidProductCategoryInput($categoryId)) {
+            $validCategoryIds[] = $categoryId;
+        }
+    }
+
+    return $validCategoryIds;
+}
+
+function buildProductSizeStock($sizesInput, $submittedValues = [], $existingValues = []) {
+    $sizes = getProductSizes(['sizes' => (string) $sizesInput]);
+    $stockBySize = [];
+
+    foreach ($sizes as $size) {
+        $stockKey = 'stock_' . str_replace([' ', ','], '_', $size);
+        if (isset($submittedValues[$stockKey])) {
+            $stockBySize[$size] = max(0, (int) $submittedValues[$stockKey]);
+        } elseif (isset($existingValues[$size])) {
+            $stockBySize[$size] = max(0, (int) $existingValues[$size]);
+        } else {
+            $stockBySize[$size] = 0;
+        }
+    }
+
+    return [
+        'sizes' => implode(',', $sizes),
+        'stock_by_size' => $stockBySize
+    ];
+}
+
 // Handle product operations
 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
     $products = getProducts();
-    
-    if (isset($_POST['add_product'])) {
+
+    if (empty($categories)) {
+        $message = 'Es ist keine Kategorie vorhanden. Bitte zuerst Kategorien anlegen.';
+        $messageType = 'error';
+    } elseif (isset($_POST['add_product'])) {
         $uploadResult = handleImageUpload();
         if (!$uploadResult['success']) {
             $message = $uploadResult['message'];
             $messageType = 'error';
         } else {
-        // Generate new ID
-        $newId = 1;
-        if (!empty($products)) {
-            $ids = array_column($products, 'id');
-            $newId = max($ids) + 1;
-        }
-        
-        $newProduct = [
-            'id' => $newId,
-            'name' => sanitize($_POST['name']),
-            'description' => sanitize($_POST['description']),
-            'price' => (float)$_POST['price'],
-            'category' => sanitize($_POST['category']),
-            'image' => $uploadResult['filename'] !== null ? $uploadResult['filename'] : sanitize($_POST['image'])
-        ];
-        
-        // Handle stock - per size for apparel, general for merch
-        if ($newProduct['category'] === 'apparel' && !empty($_POST['sizes'])) {
-            $sizes = sanitize($_POST['sizes']);
-            $newProduct['sizes'] = $sizes; // Store as comma-separated string
-            
-            // Initialize stock_by_size
-            $sizeArray = array_map('trim', explode(',', $sizes));
-            $newProduct['stock_by_size'] = [];
-            foreach ($sizeArray as $size) {
-                $stockKey = 'stock_' . str_replace([' ', ','], '_', $size);
-                $newProduct['stock_by_size'][$size] = isset($_POST[$stockKey]) ? (int)$_POST[$stockKey] : 0;
+            $categoryIds = getSubmittedProductCategoryIds($_POST);
+            $sizeData = buildProductSizeStock($_POST['sizes'] ?? '', $_POST);
+
+            if (empty($categoryIds)) {
+                $message = 'Bitte wählen Sie mindestens eine gültige Kategorie aus.';
+                $messageType = 'error';
+            } elseif ($sizeData['sizes'] === '') {
+                $message = 'Bitte geben Sie mindestens eine Größe ein.';
+                $messageType = 'error';
+            } else {
+                $newId = 1;
+                if (!empty($products)) {
+                    $ids = array_column($products, 'id');
+                    $newId = max($ids) + 1;
+                }
+
+                $products[] = [
+                    'id' => $newId,
+                    'name' => sanitize($_POST['name']),
+                    'description' => sanitize($_POST['description']),
+                    'price' => (float) ($_POST['price'] ?? 0),
+                    'categories' => $categoryIds,
+                    'image' => $uploadResult['filename'] !== null ? $uploadResult['filename'] : sanitize($_POST['image']),
+                    'sizes' => $sizeData['sizes'],
+                    'stock_by_size' => $sizeData['stock_by_size']
+                ];
+
+                saveProducts($products);
+                $message = 'Produkt erfolgreich hinzugefügt.';
+                $messageType = 'success';
             }
-        } else {
-            $newProduct['stock'] = (int)$_POST['stock'];
-        }
-        
-        $products[] = $newProduct;
-        saveProducts($products);
-        $message = 'Produkt erfolgreich hinzugefügt.';
-        $messageType = 'success';
         }
     }
-    
+
     if (isset($_POST['update_product'])) {
         $uploadResult = handleImageUpload();
         if (!$uploadResult['success']) {
             $message = $uploadResult['message'];
             $messageType = 'error';
         } else {
-        $productId = (int)$_POST['product_id'];
-        foreach ($products as &$product) {
-            if ($product['id'] == $productId) {
-                $product['name'] = sanitize($_POST['name']);
-                $product['description'] = sanitize($_POST['description']);
-                $product['price'] = (float)$_POST['price'];
-                $product['category'] = sanitize($_POST['category']);
-                $product['image'] = $uploadResult['filename'] !== null ? $uploadResult['filename'] : sanitize($_POST['image']);
-                
-                // Update stock - per size for apparel, general for merch
-                if ($product['category'] === 'apparel') {
-                    if (!empty($_POST['sizes'])) {
-                        $product['sizes'] = sanitize($_POST['sizes']);
-                        
-                        // Update stock_by_size
-                        $sizeArray = array_map('trim', explode(',', $product['sizes']));
-                        if (!isset($product['stock_by_size']) || !is_array($product['stock_by_size'])) {
-                            $product['stock_by_size'] = [];
-                        }
-                        foreach ($sizeArray as $size) {
-                            $stockKey = 'stock_' . str_replace([' ', ','], '_', $size);
-                            $product['stock_by_size'][$size] = isset($_POST[$stockKey]) ? (int)$_POST[$stockKey] : (isset($product['stock_by_size'][$size]) ? $product['stock_by_size'][$size] : 0);
-                        }
-                        // Remove sizes that are no longer in the list
-                        $product['stock_by_size'] = array_intersect_key($product['stock_by_size'], array_flip($sizeArray));
-                        unset($product['stock']); // Remove general stock for apparel
-                    } else {
-                        unset($product['sizes']);
-                        unset($product['stock_by_size']);
+            $productId = (int) ($_POST['product_id'] ?? 0);
+            $categoryIds = getSubmittedProductCategoryIds($_POST);
+
+            $existingProduct = null;
+            foreach ($products as $product) {
+                if ($product['id'] === $productId) {
+                    $existingProduct = $product;
+                    break;
+                }
+            }
+
+            $existingStockBySize = isset($existingProduct['stock_by_size']) && is_array($existingProduct['stock_by_size'])
+                ? $existingProduct['stock_by_size']
+                : [];
+            $sizeData = buildProductSizeStock($_POST['sizes'] ?? '', $_POST, $existingStockBySize);
+
+            if (empty($categoryIds)) {
+                $message = 'Bitte wählen Sie mindestens eine gültige Kategorie aus.';
+                $messageType = 'error';
+            } elseif ($existingProduct === null) {
+                $message = 'Produkt nicht gefunden.';
+                $messageType = 'error';
+            } elseif ($sizeData['sizes'] === '') {
+                $message = 'Bitte geben Sie mindestens eine Größe ein.';
+                $messageType = 'error';
+            } else {
+                foreach ($products as &$product) {
+                    if ($product['id'] === $productId) {
+                        $product['name'] = sanitize($_POST['name']);
+                        $product['description'] = sanitize($_POST['description']);
+                        $product['price'] = (float) ($_POST['price'] ?? 0);
+                        $product['categories'] = $categoryIds;
+                        $product['image'] = $uploadResult['filename'] !== null ? $uploadResult['filename'] : sanitize($_POST['image']);
+                        $product['sizes'] = $sizeData['sizes'];
+                        $product['stock_by_size'] = $sizeData['stock_by_size'];
+                        unset($product['stock']);
+                        break;
                     }
-                } else {
-                    $product['stock'] = (int)$_POST['stock'];
-                    unset($product['sizes']);
-                    unset($product['stock_by_size']);
                 }
-                break;
+                unset($product);
+
+                saveProducts($products);
+                $message = 'Produkt erfolgreich aktualisiert.';
+                $messageType = 'success';
             }
         }
-        saveProducts($products);
-        $message = 'Produkt erfolgreich aktualisiert.';
-        $messageType = 'success';
-        }
     }
-    
+
     if (isset($_POST['delete_product'])) {
-        $productId = (int)$_POST['product_id'];
-        $products = array_filter($products, function($product) use ($productId) {
-            return $product['id'] != $productId;
+        $productId = (int) ($_POST['product_id'] ?? 0);
+        $products = array_filter($products, function ($product) use ($productId) {
+            return $product['id'] !== $productId;
         });
-        $products = array_values($products); // Re-index
+        $products = array_values($products);
         saveProducts($products);
         $message = 'Produkt erfolgreich gelöscht.';
         $messageType = 'success';
@@ -177,7 +219,7 @@ $products = getProducts();
 $editingProduct = null;
 
 if (isset($_GET['edit'])) {
-    $editingProduct = getProductById((int)$_GET['edit']);
+    $editingProduct = getProductById((int) $_GET['edit']);
 }
 
 $bodyClass = 'admin-page';
@@ -191,7 +233,7 @@ include __DIR__ . '/../includes/header.php';
     </div>
 </div>
 
-<?php if ($message): ?>
+<?php if ($message !== ''): ?>
     <div class="alert alert-<?php echo $messageType; ?>">
         <?php echo htmlspecialchars($message); ?>
     </div>
@@ -215,33 +257,32 @@ include __DIR__ . '/../includes/header.php';
                 <input type="number" id="price" name="price" step="0.01" min="0" required value="<?php echo $editingProduct['price']; ?>">
             </div>
             <div class="form-group">
-                <label for="category">Kategorie *</label>
-                <select id="category" name="category" required onchange="toggleStockFields()">
-                    <option value="apparel" <?php echo $editingProduct['category'] === 'apparel' ? 'selected' : ''; ?>>Bekleidung</option>
-                    <option value="merch" <?php echo $editingProduct['category'] === 'merch' ? 'selected' : ''; ?>>Merchandise</option>
+                <label for="categories">Kategorien *</label>
+                <select id="categories" name="categories[]" multiple size="<?php echo max(3, min(8, count($categories))); ?>" required>
+                    <?php foreach ($categories as $category): ?>
+                        <option value="<?php echo htmlspecialchars($category['id']); ?>" <?php echo in_array($category['id'], getProductCategoryIds($editingProduct), true) ? 'selected' : ''; ?>>
+                            <?php echo htmlspecialchars($category['label']); ?>
+                        </option>
+                    <?php endforeach; ?>
                 </select>
+                <small>Mehrfachauswahl mit Strg/Cmd oder Umschalt. Auf Touch-Geräten können mehrere Einträge nacheinander markiert werden.</small>
             </div>
-            <div class="form-group" id="stock-group" style="<?php echo $editingProduct['category'] === 'merch' ? '' : 'display: none;'; ?>">
-                <label for="stock">Lagerbestand *</label>
-                <input type="number" id="stock" name="stock" min="0" value="<?php echo isset($editingProduct['stock']) ? (int)$editingProduct['stock'] : 0; ?>">
-            </div>
-            <div class="form-group" id="sizes-group" style="<?php echo $editingProduct['category'] === 'apparel' ? '' : 'display: none;'; ?>">
-                <label for="sizes">Größen (kommagetrennt, z.B. S,M,L,XL,XXL)</label>
-                <input type="text" id="sizes" name="sizes" value="<?php echo isset($editingProduct['sizes']) ? htmlspecialchars($editingProduct['sizes']) : ''; ?>" placeholder="S,M,L,XL" onchange="updateSizeStockFields()">
+            <div class="form-group">
+                <label for="sizes">Größen (kommagetrennt, z.B. Standard oder S,M,L,XL) *</label>
+                <input type="text" id="sizes" name="sizes" required value="<?php echo htmlspecialchars($editingProduct['sizes'] ?? ''); ?>" placeholder="Standard" oninput="updateSizeStockFields()">
+                <small>Alle Produkte nutzen größenbasierten Bestand. Für Artikel ohne Varianten z.B. <code>Standard</code> oder <code>Einheitsgröße</code> verwenden.</small>
             </div>
-            <div id="size-stock-group" style="<?php echo $editingProduct['category'] === 'apparel' ? '' : 'display: none;'; ?>">
-                <?php if ($editingProduct['category'] === 'apparel' && !empty($editingProduct['sizes'])): ?>
-                    <?php 
-                    $sizes = array_map('trim', explode(',', $editingProduct['sizes']));
-                    $stockBySize = isset($editingProduct['stock_by_size']) && is_array($editingProduct['stock_by_size']) ? $editingProduct['stock_by_size'] : [];
-                    foreach ($sizes as $size): 
-                    ?>
-                        <div class="form-group">
-                            <label>Lagerbestand für Größe "<?php echo htmlspecialchars($size); ?>" *</label>
-                            <input type="number" name="stock_<?php echo htmlspecialchars(str_replace([' ', ','], '_', $size)); ?>" min="0" value="<?php echo isset($stockBySize[$size]) ? (int)$stockBySize[$size] : 0; ?>" required>
-                        </div>
-                    <?php endforeach; ?>
-                <?php endif; ?>
+            <div id="size-stock-group">
+                <?php
+                $sizes = getProductSizes($editingProduct);
+                $stockBySize = isset($editingProduct['stock_by_size']) && is_array($editingProduct['stock_by_size']) ? $editingProduct['stock_by_size'] : [];
+                foreach ($sizes as $size):
+                ?>
+                    <div class="form-group">
+                        <label>Lagerbestand für Größe "<?php echo htmlspecialchars($size); ?>" *</label>
+                        <input type="number" name="stock_<?php echo htmlspecialchars(str_replace([' ', ','], '_', $size)); ?>" min="0" value="<?php echo isset($stockBySize[$size]) ? (int) $stockBySize[$size] : 0; ?>" required>
+                    </div>
+                <?php endforeach; ?>
             </div>
             <div class="form-group">
                 <label for="image">Bilddateiname (z.B. tshirt.jpg)</label>
@@ -253,37 +294,28 @@ include __DIR__ . '/../includes/header.php';
                 <small>Upload nach <code>assets/images</code>; ersetzt den Dateinamen oben automatisch.</small>
             </div>
             <script>
-                function toggleStockFields() {
-                    const category = document.getElementById('category').value;
-                    const stockGroup = document.getElementById('stock-group');
-                    const sizesGroup = document.getElementById('sizes-group');
-                    const sizeStockGroup = document.getElementById('size-stock-group');
-                    
-                    if (category === 'apparel') {
-                        stockGroup.style.display = 'none';
-                        sizesGroup.style.display = 'block';
-                        sizeStockGroup.style.display = 'block';
-                        updateSizeStockFields();
-                    } else {
-                        stockGroup.style.display = 'block';
-                        sizesGroup.style.display = 'none';
-                        sizeStockGroup.style.display = 'none';
-                    }
-                }
-                
                 function updateSizeStockFields() {
                     const sizesInput = document.getElementById('sizes');
                     const sizeStockGroup = document.getElementById('size-stock-group');
-                    const sizes = sizesInput.value.split(',').map(s => s.trim()).filter(s => s);
-                    
+                    const currentValues = {};
+
+                    sizeStockGroup.querySelectorAll('input[type="number"]').forEach((input) => {
+                        currentValues[input.name] = input.value;
+                    });
+
+                    const sizes = sizesInput.value.split(',').map((size) => size.trim()).filter((size, index, all) => size && all.indexOf(size) === index);
                     let html = '';
-                    sizes.forEach(size => {
+
+                    sizes.forEach((size) => {
                         const safeName = size.replace(/[ ,]/g, '_');
+                        const fieldName = 'stock_' + safeName;
+                        const currentValue = Object.prototype.hasOwnProperty.call(currentValues, fieldName) ? currentValues[fieldName] : '0';
                         html += '<div class="form-group">';
-                        html += '<label>Lagerbestand für Größe "' + size + '" *</label>';
-                        html += '<input type="number" name="stock_' + safeName + '" min="0" value="0" required>';
+                        html += '<label>Lagerbestand für Größe "' + size.replace(/"/g, '&quot;') + '" *</label>';
+                        html += '<input type="number" name="' + fieldName + '" min="0" value="' + currentValue + '" required>';
                         html += '</div>';
                     });
+
                     sizeStockGroup.innerHTML = html;
                 }
             </script>
@@ -308,21 +340,22 @@ include __DIR__ . '/../includes/header.php';
                 <input type="number" id="price" name="price" step="0.01" min="0" required>
             </div>
             <div class="form-group">
-                <label for="category">Kategorie *</label>
-                <select id="category" name="category" required onchange="toggleStockFields()">
-                    <option value="apparel">Bekleidung</option>
-                    <option value="merch">Merchandise</option>
+                <label for="categories">Kategorien *</label>
+                <select id="categories" name="categories[]" multiple size="<?php echo max(3, min(8, count($categories))); ?>" required>
+                    <?php foreach ($categories as $category): ?>
+                        <option value="<?php echo htmlspecialchars($category['id']); ?>">
+                            <?php echo htmlspecialchars($category['label']); ?>
+                        </option>
+                    <?php endforeach; ?>
                 </select>
+                <small>Mehrfachauswahl mit Strg/Cmd oder Umschalt. Auf Touch-Geräten können mehrere Einträge nacheinander markiert werden.</small>
             </div>
-            <div class="form-group" id="stock-group" style="display: none;">
-                <label for="stock">Lagerbestand *</label>
-                <input type="number" id="stock" name="stock" min="0" value="0">
-            </div>
-            <div class="form-group" id="sizes-group" style="display: none;">
-                <label for="sizes">Größen (kommagetrennt, z.B. S,M,L,XL,XXL)</label>
-                <input type="text" id="sizes" name="sizes" placeholder="S,M,L,XL" onchange="updateSizeStockFields()">
+            <div class="form-group">
+                <label for="sizes">Größen (kommagetrennt, z.B. Standard oder S,M,L,XL) *</label>
+                <input type="text" id="sizes" name="sizes" required placeholder="Standard" oninput="updateSizeStockFields()">
+                <small>Alle Produkte nutzen größenbasierten Bestand. Für Artikel ohne Varianten z.B. <code>Standard</code> oder <code>Einheitsgröße</code> verwenden.</small>
             </div>
-            <div id="size-stock-group" style="display: none;"></div>
+            <div id="size-stock-group"></div>
             <div class="form-group">
                 <label for="image">Bilddateiname (z.B. tshirt.jpg)</label>
                 <input type="text" id="image" name="image">
@@ -333,37 +366,28 @@ include __DIR__ . '/../includes/header.php';
                 <small>Upload nach <code>assets/images</code>; Dateiname wird automatisch übernommen.</small>
             </div>
             <script>
-                function toggleStockFields() {
-                    const category = document.getElementById('category').value;
-                    const stockGroup = document.getElementById('stock-group');
-                    const sizesGroup = document.getElementById('sizes-group');
-                    const sizeStockGroup = document.getElementById('size-stock-group');
-                    
-                    if (category === 'apparel') {
-                        stockGroup.style.display = 'none';
-                        sizesGroup.style.display = 'block';
-                        sizeStockGroup.style.display = 'block';
-                        updateSizeStockFields();
-                    } else {
-                        stockGroup.style.display = 'block';
-                        sizesGroup.style.display = 'none';
-                        sizeStockGroup.style.display = 'none';
-                    }
-                }
-                
                 function updateSizeStockFields() {
                     const sizesInput = document.getElementById('sizes');
                     const sizeStockGroup = document.getElementById('size-stock-group');
-                    const sizes = sizesInput.value.split(',').map(s => s.trim()).filter(s => s);
-                    
+                    const currentValues = {};
+
+                    sizeStockGroup.querySelectorAll('input[type="number"]').forEach((input) => {
+                        currentValues[input.name] = input.value;
+                    });
+
+                    const sizes = sizesInput.value.split(',').map((size) => size.trim()).filter((size, index, all) => size && all.indexOf(size) === index);
                     let html = '';
-                    sizes.forEach(size => {
+
+                    sizes.forEach((size) => {
                         const safeName = size.replace(/[ ,]/g, '_');
+                        const fieldName = 'stock_' + safeName;
+                        const currentValue = Object.prototype.hasOwnProperty.call(currentValues, fieldName) ? currentValues[fieldName] : '0';
                         html += '<div class="form-group">';
-                        html += '<label>Lagerbestand für Größe "' + size + '" *</label>';
-                        html += '<input type="number" name="stock_' + safeName + '" min="0" value="0" required>';
+                        html += '<label>Lagerbestand für Größe "' + size.replace(/"/g, '&quot;') + '" *</label>';
+                        html += '<input type="number" name="' + fieldName + '" min="0" value="' + currentValue + '" required>';
                         html += '</div>';
                     });
+
                     sizeStockGroup.innerHTML = html;
                 }
             </script>
@@ -376,48 +400,46 @@ include __DIR__ . '/../includes/header.php';
 <?php if (empty($products)): ?>
     <p>Keine Produkte vorhanden.</p>
 <?php else: ?>
-    <table class="responsive-table">
-        <thead>
-            <tr>
-                <th>ID</th>
-                <th>Name</th>
-                <th>Kategorie</th>
-                <th>Preis</th>
-                <th>Lagerbestand</th>
-                <th>Aktionen</th>
-            </tr>
-        </thead>
-        <tbody>
-            <?php foreach ($products as $product): ?>
+    <div class="table-responsive">
+        <table class="responsive-table">
+            <thead>
                 <tr>
-                <td data-label="ID"><?php echo $product['id']; ?></td>
-                <td data-label="Name"><?php echo htmlspecialchars($product['name']); ?></td>
-                <td data-label="Kategorie"><?php echo $product['category'] === 'apparel' ? 'Bekleidung' : 'Merchandise'; ?></td>
-                <td data-label="Preis"><?php echo formatPrice($product['price']); ?></td>
-                <td data-label="Lagerbestand">
-                    <?php 
-                    if ($product['category'] === 'apparel' && isset($product['stock_by_size']) && is_array($product['stock_by_size'])) {
-                        $stockInfo = [];
-                        foreach ($product['stock_by_size'] as $size => $stock) {
-                            $stockInfo[] = "$size: $stock";
-                        }
-                        echo implode(', ', $stockInfo) ?: '0';
-                    } else {
-                        echo isset($product['stock']) ? (int)$product['stock'] : 0;
-                    }
-                    ?>
-                </td>
-                    <td data-label="Aktionen">
-                        <a href="?edit=<?php echo $product['id']; ?>" class="btn btn-small">Bearbeiten</a>
-                        <form method="POST" style="display: inline;" onsubmit="return confirm('Möchten Sie dieses Produkt wirklich löschen?');">
-                            <input type="hidden" name="product_id" value="<?php echo $product['id']; ?>">
-                            <button type="submit" name="delete_product" class="btn btn-secondary btn-small">Löschen</button>
-                        </form>
-                    </td>
+                    <th>ID</th>
+                    <th>Name</th>
+                    <th>Kategorien</th>
+                    <th>Preis</th>
+                    <th>Lagerbestand</th>
+                    <th>Aktionen</th>
                 </tr>
-            <?php endforeach; ?>
-        </tbody>
-    </table>
+            </thead>
+            <tbody>
+                <?php foreach ($products as $product): ?>
+                    <tr>
+                        <td data-label="ID"><?php echo $product['id']; ?></td>
+                        <td data-label="Name"><?php echo htmlspecialchars($product['name']); ?></td>
+                        <td data-label="Kategorien"><?php echo htmlspecialchars(implode(', ', getCategoryLabels(getProductCategoryIds($product)))); ?></td>
+                        <td data-label="Preis"><?php echo formatPrice($product['price']); ?></td>
+                        <td data-label="Lagerbestand">
+                            <?php
+                            $stockInfo = [];
+                            foreach (($product['stock_by_size'] ?? []) as $size => $stock) {
+                                $stockInfo[] = $size . ': ' . (int) $stock;
+                            }
+                            echo !empty($stockInfo) ? htmlspecialchars(implode(', ', $stockInfo)) : '0';
+                            ?>
+                        </td>
+                        <td data-label="Aktionen">
+                            <a href="?edit=<?php echo $product['id']; ?>" class="btn btn-small">Bearbeiten</a>
+                            <form method="POST" style="display: inline;" onsubmit="return confirm('Möchten Sie dieses Produkt wirklich löschen?');">
+                                <input type="hidden" name="product_id" value="<?php echo $product['id']; ?>">
+                                <button type="submit" name="delete_product" class="btn btn-secondary btn-small">Löschen</button>
+                            </form>
+                        </td>
+                    </tr>
+                <?php endforeach; ?>
+            </tbody>
+        </table>
+    </div>
 <?php endif; ?>
 
 <?php include __DIR__ . '/../includes/footer.php'; ?>

+ 23 - 0
assets/css/style.css

@@ -362,6 +362,22 @@ body.admin-page .container {
     margin-top: 0.4rem;
 }
 
+.category-filter-bar {
+    display: flex;
+    gap: 0.75rem;
+    margin: 1.5rem 0;
+    overflow-x: auto;
+    flex-wrap: nowrap;
+    white-space: nowrap;
+    padding-bottom: 0.25rem;
+    -webkit-overflow-scrolling: touch;
+}
+
+.category-filter-bar .btn {
+    flex: 0 0 auto;
+    white-space: nowrap;
+}
+
 /* Footer */
 footer {
     background-color: var(--brand-primary);
@@ -779,6 +795,13 @@ footer {
         white-space: normal;
     }
 
+    .category-filter-bar {
+        margin-left: -1rem;
+        margin-right: -1rem;
+        padding-left: 1rem;
+        padding-right: 1rem;
+    }
+
     /* Admin tables: stack rows into blocks on narrow screens */
     body.admin-page table.responsive-table {
         border: 0;

+ 1 - 0
config.sample.php

@@ -57,6 +57,7 @@ define('DATA_DIR', __DIR__ . '/data/');
 define('PRODUCTS_FILE', DATA_DIR . 'products.json');
 define('RESERVATIONS_FILE', DATA_DIR . 'reservations.json');
 define('ADMINS_FILE', DATA_DIR . 'admins.json');
+define('CATEGORIES_FILE', DATA_DIR . 'categories.json');
 define('FAQ_FILE', DATA_DIR . 'faq.json');
 
 // Session settings

+ 1 - 1
data/admins.json

@@ -11,4 +11,4 @@
             "email": "admin.josef@mailpit.medowar.de"
         }
     }
-}
+}

+ 20 - 0
data/categories.json

@@ -0,0 +1,20 @@
+{
+    "categories": [
+        {
+            "id": "apparel",
+            "label": "Bekleidung"
+        },
+        {
+            "id": "mannlich",
+            "label": "Männlich"
+        },
+        {
+            "id": "unisex",
+            "label": "unisex"
+        },
+        {
+            "id": "weiblich",
+            "label": "weiblich"
+        }
+    ]
+}

+ 353 - 25
includes/functions.php

@@ -239,12 +239,336 @@ function saveAdminUsers($admins) {
     saveAdminAccounts($normalizedAccounts);
 }
 
+/**
+ * Get default category records.
+ */
+function getDefaultCategories() {
+    return [
+        ['id' => 'apparel', 'label' => 'Bekleidung'],
+        ['id' => 'merch', 'label' => 'Merchandise']
+    ];
+}
+
+/**
+ * Normalize category id input to a stable slug.
+ */
+function normalizeCategoryId($id) {
+    $id = trim((string) $id);
+    if ($id === '') {
+        return '';
+    }
+
+    if (function_exists('iconv')) {
+        $converted = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $id);
+        if (is_string($converted) && $converted !== '') {
+            $id = $converted;
+        }
+    }
+
+    $id = strtolower($id);
+    $id = preg_replace('/[^a-z0-9]+/', '-', $id);
+    $id = trim((string) $id, '-');
+
+    return $id;
+}
+
+/**
+ * Normalize category label input.
+ */
+function normalizeCategoryLabel($label) {
+    return trim((string) $label);
+}
+
+/**
+ * Validate category label.
+ */
+function isValidCategoryLabel($label) {
+    $label = normalizeCategoryLabel($label);
+    if ($label === '') {
+        return false;
+    }
+
+    $length = function_exists('mb_strlen') ? mb_strlen($label) : strlen($label);
+    return $length <= 80;
+}
+
+/**
+ * Normalize category records from storage.
+ */
+function normalizeCategories($categories) {
+    $normalized = [];
+
+    if (!is_array($categories)) {
+        $categories = [];
+    }
+
+    foreach ($categories as $category) {
+        if (!is_array($category)) {
+            continue;
+        }
+
+        $id = normalizeCategoryId($category['id'] ?? '');
+        $label = normalizeCategoryLabel($category['label'] ?? '');
+        if ($id === '' || !isValidCategoryLabel($label)) {
+            continue;
+        }
+
+        $normalized[$id] = [
+            'id' => $id,
+            'label' => $label
+        ];
+    }
+
+    if (empty($normalized)) {
+        foreach (getDefaultCategories() as $category) {
+            $normalized[$category['id']] = $category;
+        }
+    }
+
+    uasort($normalized, function ($left, $right) {
+        return strcasecmp($left['label'], $right['label']);
+    });
+
+    return array_values($normalized);
+}
+
+/**
+ * Get all categories.
+ */
+function getCategories() {
+    $data = readJsonFile(CATEGORIES_FILE);
+    $categories = isset($data['categories']) ? $data['categories'] : [];
+    return normalizeCategories($categories);
+}
+
+/**
+ * Get category by id.
+ */
+function getCategoryById($categoryId) {
+    $categoryId = normalizeCategoryId($categoryId);
+    foreach (getCategories() as $category) {
+        if ($category['id'] === $categoryId) {
+            return $category;
+        }
+    }
+
+    return null;
+}
+
+/**
+ * Get category label by id with fallback to raw id.
+ */
+function getCategoryLabel($categoryId) {
+    $category = getCategoryById($categoryId);
+    if ($category !== null) {
+        return $category['label'];
+    }
+
+    $categoryId = trim((string) $categoryId);
+    return $categoryId !== '' ? $categoryId : 'Unbekannt';
+}
+
+/**
+ * Get category labels by ids.
+ */
+function getCategoryLabels($categoryIds) {
+    $labels = [];
+    foreach (normalizeProductCategoryIds($categoryIds) as $categoryId) {
+        $labels[] = getCategoryLabel($categoryId);
+    }
+
+    return $labels;
+}
+
+/**
+ * Save categories.
+ */
+function saveCategories($categories) {
+    writeJsonFile(CATEGORIES_FILE, ['categories' => normalizeCategories($categories)]);
+}
+
+/**
+ * Generate a unique category id from a label.
+ */
+function generateCategoryIdFromLabel($label, $existingCategories = []) {
+    $baseId = normalizeCategoryId($label);
+    if ($baseId === '') {
+        $baseId = 'category';
+    }
+
+    $usedIds = [];
+    foreach (normalizeCategories($existingCategories) as $category) {
+        $usedIds[$category['id']] = true;
+    }
+
+    $candidate = $baseId;
+    $counter = 2;
+    while (isset($usedIds[$candidate])) {
+        $candidate = $baseId . '-' . $counter;
+        $counter++;
+    }
+
+    return $candidate;
+}
+
+/**
+ * Check whether any product uses a category id.
+ */
+function isCategoryInUse($categoryId) {
+    $categoryId = normalizeCategoryId($categoryId);
+    foreach (getProducts() as $product) {
+        if (productHasCategory($product, $categoryId)) {
+            return true;
+        }
+    }
+
+    return false;
+}
+
+/**
+ * Normalize product category ids from legacy or current storage.
+ */
+function normalizeProductCategoryIds($categoryValue) {
+    if (is_array($categoryValue)) {
+        $rawCategoryIds = $categoryValue;
+    } elseif ($categoryValue === null || $categoryValue === '') {
+        $rawCategoryIds = [];
+    } else {
+        $rawCategoryIds = [$categoryValue];
+    }
+
+    $normalized = [];
+    foreach ($rawCategoryIds as $categoryId) {
+        $categoryId = normalizeCategoryId($categoryId);
+        if ($categoryId === '') {
+            continue;
+        }
+        $normalized[$categoryId] = $categoryId;
+    }
+
+    return array_values($normalized);
+}
+
+/**
+ * Get normalized category ids for a product.
+ */
+function getProductCategoryIds($product) {
+    if (isset($product['categories'])) {
+        return normalizeProductCategoryIds($product['categories']);
+    }
+
+    return normalizeProductCategoryIds($product['category'] ?? []);
+}
+
+/**
+ * Determine whether a product is assigned to a category.
+ */
+function productHasCategory($product, $categoryId) {
+    $categoryId = normalizeCategoryId($categoryId);
+    if ($categoryId === '') {
+        return false;
+    }
+
+    return in_array($categoryId, getProductCategoryIds($product), true);
+}
+
+/**
+ * Parse product sizes into a normalized array.
+ */
+function getProductSizes($product) {
+    if (isset($product['sizes']) && is_array($product['sizes'])) {
+        $sizes = $product['sizes'];
+    } elseif (isset($product['sizes']) && is_string($product['sizes'])) {
+        $sizes = explode(',', $product['sizes']);
+    } else {
+        $sizes = [];
+    }
+
+    $normalized = [];
+    foreach ($sizes as $size) {
+        $size = trim((string) $size);
+        if ($size === '') {
+            continue;
+        }
+        $normalized[$size] = $size;
+    }
+
+    return array_values($normalized);
+}
+
+/**
+ * Determine whether a product uses size-based stock.
+ */
+function productUsesSizeStock($product) {
+    return !empty(getProductSizes($product));
+}
+
+/**
+ * Normalize a single product record for backwards compatibility.
+ */
+function normalizeProductRecord($product, $defaultCategoryId = '') {
+    if (!is_array($product)) {
+        return null;
+    }
+
+    $product['id'] = isset($product['id']) ? (int) $product['id'] : 0;
+    $product['name'] = isset($product['name']) ? trim((string) $product['name']) : '';
+    $product['description'] = isset($product['description']) ? trim((string) $product['description']) : '';
+    $product['price'] = isset($product['price']) ? (float) $product['price'] : 0.0;
+    $product['image'] = isset($product['image']) ? trim((string) $product['image']) : '';
+
+    $categoryIds = getProductCategoryIds($product);
+    if (empty($categoryIds) && $defaultCategoryId !== '') {
+        $categoryIds = [$defaultCategoryId];
+    }
+    $product['categories'] = $categoryIds;
+    unset($product['category']);
+
+    $sizes = getProductSizes($product);
+    $stockBySize = isset($product['stock_by_size']) && is_array($product['stock_by_size']) ? $product['stock_by_size'] : [];
+
+    if (empty($sizes) && array_key_exists('stock', $product)) {
+        $sizes = ['Standard'];
+        $stockBySize = ['Standard' => (int) $product['stock']];
+    }
+
+    if (!empty($sizes)) {
+        $normalizedStockBySize = [];
+        foreach ($sizes as $size) {
+            $normalizedStockBySize[$size] = isset($stockBySize[$size]) ? max(0, (int) $stockBySize[$size]) : 0;
+        }
+
+        $product['sizes'] = implode(',', $sizes);
+        $product['stock_by_size'] = $normalizedStockBySize;
+        unset($product['stock']);
+    } else {
+        $product['stock'] = isset($product['stock']) ? max(0, (int) $product['stock']) : 0;
+        unset($product['stock_by_size']);
+        unset($product['sizes']);
+    }
+
+    return $product;
+}
+
 /**
  * Get all products
  */
 function getProducts() {
     $data = readJsonFile(PRODUCTS_FILE);
-    return isset($data['products']) ? $data['products'] : [];
+    $rawProducts = isset($data['products']) && is_array($data['products']) ? $data['products'] : [];
+    $categories = getCategories();
+    $defaultCategoryId = !empty($categories) ? $categories[0]['id'] : 'apparel';
+    $products = [];
+
+    foreach ($rawProducts as $product) {
+        $normalizedProduct = normalizeProductRecord($product, $defaultCategoryId);
+        if ($normalizedProduct === null) {
+            continue;
+        }
+        $products[] = $normalizedProduct;
+    }
+
+    return $products;
 }
 
 /**
@@ -264,7 +588,19 @@ function getProductById($id) {
  * Save products
  */
 function saveProducts($products) {
-    $data = ['products' => $products];
+    $categories = getCategories();
+    $defaultCategoryId = !empty($categories) ? $categories[0]['id'] : 'apparel';
+    $normalizedProducts = [];
+
+    foreach ($products as $product) {
+        $normalizedProduct = normalizeProductRecord($product, $defaultCategoryId);
+        if ($normalizedProduct === null) {
+            continue;
+        }
+        $normalizedProducts[] = $normalizedProduct;
+    }
+
+    $data = ['products' => $normalizedProducts];
     writeJsonFile(PRODUCTS_FILE, $data);
 }
 
@@ -732,50 +1068,46 @@ function generateReservationId() {
 }
 
 /**
- * Check if product has enough stock
- * For apparel: checks stock for specific size
- * For merch: checks general stock
+ * Check if product has enough stock.
  */
 function checkStock($productId, $quantity, $size = null) {
     $product = getProductById($productId);
     if (!$product) {
         return false;
     }
-    
-    // For apparel with sizes, check stock per size
-    if ($product['category'] === 'apparel' && !empty($product['sizes']) && $size !== null) {
+
+    if (productUsesSizeStock($product) && $size !== null) {
         if (!isset($product['stock_by_size']) || !is_array($product['stock_by_size'])) {
             return false;
         }
-        $sizeStock = isset($product['stock_by_size'][$size]) ? (int)$product['stock_by_size'][$size] : 0;
+        $sizeStock = isset($product['stock_by_size'][$size]) ? (int) $product['stock_by_size'][$size] : 0;
         return $sizeStock >= $quantity;
     }
-    
-    // For merch or apparel without size-specific stock, use general stock
-    $stock = isset($product['stock']) ? (int)$product['stock'] : 0;
+
+    $stock = isset($product['stock']) ? (int) $product['stock'] : 0;
     return $stock >= $quantity;
 }
 
 /**
- * Get stock for a product (per size for apparel, general for merch)
+ * Get stock for a product.
  */
 function getStock($product, $size = null) {
-    if ($product['category'] === 'apparel' && !empty($product['sizes']) && $size !== null) {
+    if (productUsesSizeStock($product) && $size !== null) {
         if (isset($product['stock_by_size']) && is_array($product['stock_by_size'])) {
-            return isset($product['stock_by_size'][$size]) ? (int)$product['stock_by_size'][$size] : 0;
+            return isset($product['stock_by_size'][$size]) ? (int) $product['stock_by_size'][$size] : 0;
         }
     }
-    return isset($product['stock']) ? (int)$product['stock'] : 0;
+    return isset($product['stock']) ? (int) $product['stock'] : 0;
 }
 
 /**
- * Get total stock for a product (sum of all sizes for apparel)
+ * Get total stock for a product.
  */
 function getTotalStock($product) {
-    if ($product['category'] === 'apparel' && isset($product['stock_by_size']) && is_array($product['stock_by_size'])) {
+    if (productUsesSizeStock($product) && isset($product['stock_by_size']) && is_array($product['stock_by_size'])) {
         return array_sum($product['stock_by_size']);
     }
-    return isset($product['stock']) ? (int)$product['stock'] : 0;
+    return isset($product['stock']) ? (int) $product['stock'] : 0;
 }
 
 /**
@@ -785,8 +1117,7 @@ function allocateStock($productId, $quantity, $size = null) {
     $products = getProducts();
     foreach ($products as &$product) {
         if ($product['id'] == $productId) {
-            // For apparel with sizes, allocate per size
-            if ($product['category'] === 'apparel' && !empty($product['sizes']) && $size !== null) {
+            if (productUsesSizeStock($product) && $size !== null) {
                 if (!isset($product['stock_by_size']) || !is_array($product['stock_by_size'])) {
                     $product['stock_by_size'] = [];
                 }
@@ -798,7 +1129,6 @@ function allocateStock($productId, $quantity, $size = null) {
                     $product['stock_by_size'][$size] = 0;
                 }
             } else {
-                // For merch or general stock
                 if (!isset($product['stock'])) {
                     $product['stock'] = 0;
                 }
@@ -820,8 +1150,7 @@ function releaseStock($productId, $quantity, $size = null) {
     $products = getProducts();
     foreach ($products as &$product) {
         if ($product['id'] == $productId) {
-            // For apparel with sizes, release per size
-            if ($product['category'] === 'apparel' && !empty($product['sizes']) && $size !== null) {
+            if (productUsesSizeStock($product) && $size !== null) {
                 if (!isset($product['stock_by_size']) || !is_array($product['stock_by_size'])) {
                     $product['stock_by_size'] = [];
                 }
@@ -830,7 +1159,6 @@ function releaseStock($productId, $quantity, $size = null) {
                 }
                 $product['stock_by_size'][$size] += $quantity;
             } else {
-                // For merch or general stock
                 if (!isset($product['stock'])) {
                     $product['stock'] = 0;
                 }

+ 12 - 6
index.php

@@ -4,13 +4,16 @@ require_once __DIR__ . '/includes/functions.php';
 
 $pageTitle = 'Startseite';
 $products = getProducts();
+$categories = getCategories();
 
 // Filter by category if provided
-$category = isset($_GET['category']) ? sanitize($_GET['category']) : '';
-if ($category) {
+$category = isset($_GET['category']) ? normalizeCategoryId($_GET['category']) : '';
+if ($category !== '' && getCategoryById($category) !== null) {
     $products = array_filter($products, function($product) use ($category) {
-        return $product['category'] === $category;
+        return productHasCategory($product, $category);
     });
+} else {
+    $category = '';
 }
 
 include __DIR__ . '/includes/header.php';
@@ -24,10 +27,13 @@ include __DIR__ . '/includes/header.php';
     <?php endforeach; ?>
 </div>
 
-<div style="margin: 1.5rem 0;">
+<div class="category-filter-bar" aria-label="Produktkategorien">
     <a href="?category=" class="btn btn-small <?php echo $category === '' ? '' : 'btn-secondary'; ?>">Alle</a>
-    <a href="?category=apparel" class="btn btn-small <?php echo $category === 'apparel' ? '' : 'btn-secondary'; ?>">Bekleidung</a>
-    <a href="?category=merch" class="btn btn-small <?php echo $category === 'merch' ? '' : 'btn-secondary'; ?>">Merchandise</a>
+    <?php foreach ($categories as $categoryOption): ?>
+        <a href="?category=<?php echo urlencode($categoryOption['id']); ?>" class="btn btn-small <?php echo $category === $categoryOption['id'] ? '' : 'btn-secondary'; ?>">
+            <?php echo htmlspecialchars($categoryOption['label']); ?>
+        </a>
+    <?php endforeach; ?>
 </div>
 
 <?php if (empty($products)): ?>

+ 9 - 12
product.php

@@ -11,14 +11,14 @@ if (!$product) {
 }
 
 $pageTitle = $product['name'];
+$usesSizeStock = productUsesSizeStock($product);
 
 // Handle add to cart
 if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_to_cart'])) {
     $quantity = isset($_POST['quantity']) ? (int)$_POST['quantity'] : 1;
     $size = isset($_POST['size']) ? sanitize($_POST['size']) : '';
     
-    // For apparel, size is required
-    if ($product['category'] === 'apparel' && empty($size)) {
+    if ($usesSizeStock && empty($size)) {
         $error = 'Bitte wählen Sie eine Größe aus.';
     } elseif ($quantity < 1) {
         $error = 'Menge muss mindestens 1 sein.';
@@ -28,9 +28,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_to_cart'])) {
             $_SESSION['cart'] = [];
         }
         
-        // For apparel, check if same product with same size already in cart
         $found = false;
-        if ($product['category'] === 'apparel') {
+        if ($usesSizeStock) {
             foreach ($_SESSION['cart'] as &$item) {
                 if ($item['product_id'] == $productId && isset($item['size']) && $item['size'] === $size) {
                     $item['quantity'] += $quantity;
@@ -53,7 +52,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_to_cart'])) {
                 'product_id' => $productId,
                 'quantity' => $quantity
             ];
-            if ($product['category'] === 'apparel' && !empty($size)) {
+            if ($usesSizeStock && !empty($size)) {
                 $cartItem['size'] = $size;
             }
             $_SESSION['cart'][] = $cartItem;
@@ -94,13 +93,12 @@ include __DIR__ . '/includes/header.php';
         ?>
         <div class="stock <?php echo $hasStock ? 'in-stock' : 'out-of-stock'; ?>" style="font-size: 1.1rem; margin: 1rem 0;">
             <?php if ($hasStock): ?>
-                <?php if ($product['category'] === 'apparel' && isset($product['stock_by_size']) && is_array($product['stock_by_size'])): ?>
+                <?php if ($usesSizeStock && isset($product['stock_by_size']) && is_array($product['stock_by_size'])): ?>
                     Verfügbar:
                     <?php 
-                    $sizes = is_array($product['sizes']) ? $product['sizes'] : explode(',', $product['sizes']);
+                    $sizes = getProductSizes($product);
                     $stockInfo = [];
                     foreach ($sizes as $sizeOption) {
-                        $sizeOption = trim($sizeOption);
                         $sizeStock = isset($product['stock_by_size'][$sizeOption]) ? (int)$product['stock_by_size'][$sizeOption] : 0;
                         if ($sizeStock > 0) {
                             $stockInfo[] = "$sizeOption: $sizeStock";
@@ -125,16 +123,15 @@ include __DIR__ . '/includes/header.php';
         </div>
         
         <form method="POST" style="margin-top: 2rem;">
-            <?php if ($product['category'] === 'apparel' && !empty($product['sizes'])): ?>
+            <?php if ($usesSizeStock): ?>
                 <div class="form-group">
                     <label for="size">Größe *</label>
                     <select id="size" name="size" required style="width: 100%;" onchange="updateMaxQuantity()">
                         <option value="">Bitte wählen</option>
                         <?php 
-                        $sizes = is_array($product['sizes']) ? $product['sizes'] : explode(',', $product['sizes']);
+                        $sizes = getProductSizes($product);
                         $stockBySize = isset($product['stock_by_size']) && is_array($product['stock_by_size']) ? $product['stock_by_size'] : [];
                         foreach ($sizes as $sizeOption): 
-                            $sizeOption = trim($sizeOption);
                             $sizeStock = isset($stockBySize[$sizeOption]) ? (int)$stockBySize[$sizeOption] : 0;
                         ?>
                             <option value="<?php echo htmlspecialchars($sizeOption); ?>" data-stock="<?php echo $sizeStock; ?>">
@@ -155,7 +152,7 @@ include __DIR__ . '/includes/header.php';
             <?php endif; ?>
             <button type="submit" name="add_to_cart" class="btn" style="width: 100%;">In den Warenkorb</button>
         </form>
-        <?php if ($product['category'] === 'apparel' && !empty($product['sizes'])): ?>
+        <?php if ($usesSizeStock): ?>
         <script>
             function updateMaxQuantity() {
                 const sizeSelect = document.getElementById('size');