4 Commity c5e5b1b7f7 ... fb1916772d

Autor SHA1 Wiadomość Data
  Medowar fb1916772d adding colored tiles to products on the main page/Overview 3 tygodni temu
  Medowar 81d35f32cf . 3 tygodni temu
  Medowar 19b12fb381 adding url support for faq 3 tygodni temu
  Medowar 62e136fce3 fixing styling of admin config checkboxes, adding docs for order process 3 tygodni temu
10 zmienionych plików z 288 dodań i 44 usunięć
  1. 2 1
      .gitignore
  2. 17 4
      admin/faq.php
  3. 2 2
      admin/settings.php
  4. 40 0
      assets/css/style.css
  5. 1 1
      data/faq.json
  6. 2 2
      data/products.json
  7. 2 1
      data/settings.json
  8. 76 0
      docs/ORDER_PROCESS.md
  9. 125 32
      includes/functions.php
  10. 21 1
      index.php

+ 2 - 1
.gitignore

@@ -1,3 +1,4 @@
 config.php
 .codex
-data/reservations.php
+data/reservations.php
+data/logs/

+ 17 - 4
admin/faq.php

@@ -11,6 +11,7 @@ if (!isset($_SESSION['admin_logged_in']) || !$_SESSION['admin_logged_in']) {
 $pageTitle = "FAQ bearbeiten";
 $message = "";
 $messageType = "";
+$settings = getSystemSettings();
 
 if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['save_faq'])) {
     // Validate CSRF token
@@ -19,18 +20,24 @@ if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['save_faq'])) {
         $messageType = "error";
     } else {
         $content = isset($_POST['content']) ? (string) $_POST['content'] : "";
-        if (saveFaqContent($content)) {
+        $settings["startpage_intro_text"] = isset($_POST['startpage_intro_text'])
+            ? (string) $_POST['startpage_intro_text']
+            : "";
+
+        if (saveFaqContent($content) && saveSystemSettings($settings)) {
             logAccess("Admin updated FAQ content");
-            $message = "FAQ-Inhalt wurde gespeichert.";
+            $message = "FAQ-Inhalt und Startseitentext wurden gespeichert.";
             $messageType = "success";
         } else {
-            $message = "FAQ-Inhalt konnte nicht gespeichert werden.";
+            $message = "FAQ-Inhalt und/oder Startseitentext konnten nicht gespeichert werden.";
             $messageType = "error";
         }
     }
 }
 
 $faqContent = getFaqContent();
+$settings = getSystemSettings();
+$startpageIntroText = (string) ($settings["startpage_intro_text"] ?? "");
 
 $bodyClass = "admin-page";
 include __DIR__ . "/../includes/header.php";
@@ -51,7 +58,7 @@ include __DIR__ . "/../includes/header.php";
 
 <div class="panel panel-lg">
     <p class="mb-2">
-        Unterstützte Markdown-Syntax: <code>#</code>, <code>##</code>, <code>###</code>, <code>**fett**</code>, <code>*kursiv*</code>, Listen mit <code>-</code> oder <code>1.</code>
+        Unterstützte Markdown-Syntax: <code>#</code>, <code>##</code>, <code>###</code>, <code>**fett**</code>, <code>*kursiv*</code>, Listen mit <code>-</code> oder <code>1.</code>, Links mit <code>[Text](https://example.com)</code>
     </p>
 
     <form method="POST">
@@ -62,6 +69,12 @@ include __DIR__ . "/../includes/header.php";
                 $faqContent,
             ); ?></textarea>
         </div>
+        <div class="form-group">
+            <label for="startpage_intro_text">Startseitentext</label>
+            <textarea id="startpage_intro_text" name="startpage_intro_text" rows="6"><?php echo htmlspecialchars(
+                $startpageIntroText,
+            ); ?></textarea>
+        </div>
         <button type="submit" name="save_faq" class="btn">Speichern</button>
     </form>
 </div>

+ 2 - 2
admin/settings.php

@@ -70,7 +70,7 @@ include __DIR__ . "/../includes/header.php";
         </div>
 
         <div class="form-group">
-            <label>
+            <label class="checkbox-label">
                 <input type="checkbox" name="order_confirmation_required" value="1" <?php echo !empty(
                     $settings["order_confirmation_required"]
                 )
@@ -88,7 +88,7 @@ include __DIR__ . "/../includes/header.php";
         </div>
 
         <div class="form-group">
-            <label>
+            <label class="checkbox-label">
                 <input type="checkbox" name="attach_order_pdf_to_admin_email" value="1" <?php echo !empty(
                     $settings["attach_order_pdf_to_admin_email"]
                 )

+ 40 - 0
assets/css/style.css

@@ -224,6 +224,8 @@ code {
 }
 
 .product-card {
+    display: flex;
+    flex-direction: column;
     background: var(--brand-surface);
     border-radius: 28px;
     overflow: hidden;
@@ -246,6 +248,9 @@ code {
 }
 
 .product-card-content {
+    display: flex;
+    flex: 1;
+    flex-direction: column;
     padding: 1.5rem;
 }
 
@@ -258,6 +263,24 @@ code {
     text-decoration: none;
 }
 
+.product-card-categories {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.4rem;
+    margin-top: auto;
+}
+
+.category-chip {
+    display: inline-flex;
+    align-items: center;
+    padding: 0.2rem 0.55rem;
+    border: 1px solid transparent;
+    border-radius: 8px;
+    font-size: 0.75rem;
+    line-height: 1.2;
+    font-weight: 600;
+}
+
 .product-card .price {
     font-size: 1.5rem;
     font-weight: 600;
@@ -307,6 +330,23 @@ code {
     color: var(--brand-text);
 }
 
+.form-group input[type="checkbox"] {
+    width: auto;
+    padding: 0;
+    border: 0;
+    border-radius: 0;
+    background: transparent;
+    margin: 0.15rem 0 0;
+    flex-shrink: 0;
+}
+
+.form-group label.checkbox-label {
+    display: flex;
+    align-items: flex-start;
+    gap: 0.55rem;
+    margin-bottom: 0;
+}
+
 ::placeholder {
     color: var(--brand-muted);
 }

+ 1 - 1
data/faq.json

@@ -1,3 +1,3 @@
 {
-    "content": "TODO\r\n\r\n## Ansprechpartner\r\n\r\nTechnische Probleme: Josef Straßl\r\n"
+    "content": "TODO erweitern\r\n\r\n# Uniformen\r\n\r\n## Kleiderordnung\r\nDie Kleiderordnung des LFV Bayern findest du hier: [Kleiderordnung LFV Bayern e.V.](https://www.lfv-bayern.de/media/filer_public/7b/16/7b164f73-f12f-4f2b-b0a6-36876b14fa9b/210401_auftreten-oeffentlichkeit_2021_akt.pdf)\r\n\r\n## Hose & Schuhe\r\n\r\nHose und Schuhe für die Uniformen sind nicht im Formular verfügbar und muss selbst beschafft werden. Details dazu in der Kleiderordnung des LFV.\r\n\r\n## Bestickung \r\n\r\nDie Bestickung mit Ärmelabzeichen und Rangabzeichen wird durch blablabla\r\nWenn passt, wieder zurück, damit es über die Schneiderei angenäht werden kann, inkl. Funktionsabzeichen\r\n\r\n\r\n# Häufig gestellte Fragen\r\n\r\n**Was passiert, wenn etwas nicht lagernd ist?**\r\nWenn ein Teil deiner Bestellung nicht lagernd ist, dann bekommst du zuerst die Teile, die im Lager vorhanden sind. Die nicht vorhandenen Artikel werden beim Hersteller bestellt und bei Eintreffen an dich ausgeliefert.\r\n\r\n**Wie lange dauert die Bestellung, wenn ein Produkt nicht lagernd ist?**\r\nDazu können wir keine generelle Aussage treffen, in der Regel aber 4-12 Wochen. Um den Status deiner Bestellung zu erfragen, wende dich an xxx@freising.de. Gib dabei bitte das Produkt an und wann du die Bestellung aufgegeben hast.\r\n\r\n**Kann ich nur ein Ärmelabzeichen haben? Ich sammel diese?**\r\nNein.\r\n\r\n\r\n## Ansprechpartner Formular\r\n\r\nFeuerwehrtechnik: Josef Kammerloher\r\nIT: Josef Straßl\r\n"
 }

+ 2 - 2
data/products.json

@@ -165,8 +165,7 @@
             "description": "",
             "image": "",
             "categories": [
-                "uniform",
-                "jugend"
+                "uniform"
             ],
             "sizes": "36,36 Slim,37,37 Slim,38,38 Slim,39,39 Slim,40,40 Slim,41,41 Slim,42,42 Slim,43,43 Slim,44,44 Slim,45,45 Slim,46,46 Slim,47,47 Slim,48,48 Slim,49,49 Slim,50,50 Slim",
             "availability_labels": {
@@ -409,6 +408,7 @@
             "description": "",
             "image": "",
             "categories": [
+                "jugend",
                 "psa-agt",
                 "psa-nicht-agt"
             ],

+ 2 - 1
data/settings.json

@@ -3,6 +3,7 @@
         "order_recipient_email": "bestellungen@mailpit.medowar.de",
         "order_confirmation_required": false,
         "order_confirmation_expiry_days": 7,
-        "attach_order_pdf_to_admin_email": true
+        "attach_order_pdf_to_admin_email": true,
+        "startpage_intro_text": "Dieses System dient der internen Bestellung persönlicher Schutzausrüstung der Stadt Freising.\r\nDie Bearbeitung erfolgt durch die Gerätewarte der Feuerwehr Freising."
     }
 }

+ 76 - 0
docs/ORDER_PROCESS.md

@@ -0,0 +1,76 @@
+# Order Process
+
+## Überblick
+
+Das System bildet keinen klassischen E-Commerce-Checkout ab, sondern einen internen PSA-Anforderungsprozess mit optionaler E-Mail-Bestätigung vor der internen Weiterleitung.
+
+- Einstieg: `index.php` -> `product.php` -> `cart.php` -> `checkout.php`
+- Persistenz: `data/orders.json`
+- Kernlogik: `includes/functions.php`
+- Interne Bearbeitung: `admin/orders.php`
+- Einstellungen für Bestätigung/Weiterleitung: `admin/settings.php`
+
+Hinweis:
+
+- `config.php` liefert Startwerte, wirksam im Betrieb sind die normalisierten Einstellungen aus `data/settings.json` (über `getSystemSettings()`).
+
+## Ablauf (Endnutzer)
+
+1. Produkt wird in `product.php` in den Warenkorb gelegt (optional mit Größe).
+2. Der Warenkorb (`cart.php`) enthält pro Produkt nur einen Eintrag; erneutes Hinzufügen aktualisiert ggf. die Größe statt eine Menge zu erhöhen.
+3. Checkout (`checkout.php`) erfasst:
+   - Name
+   - E-Mail
+   - Organisation (Pflicht, muss aktiv sein)
+   - Kommentar (optional)
+4. `createOrder(...)` erzeugt eine Bestellnummer (`ORDER_PREFIX-JAHR-LFDNR`, z. B. `FWFS-2026-001`) und speichert die Bestellung.
+5. Nach dem Speichern wird der Warenkorb geleert und auf `order-success.php` weitergeleitet.
+
+## Potenzielle Freigaben / Bestätigungen
+
+### 1) Organisation als Vorbedingung
+
+Eine Bestellung ist nur möglich, wenn die gewählte Organisation existiert und als aktiv markiert ist. Inaktive/ungültige Organisationen blockieren den Abschluss.
+
+### 2) Optionale E-Mail-Bestätigung durch Besteller
+
+Die Einstellung `order_confirmation_required` steuert, ob eine Bestätigung nötig ist:
+
+- **Aktiviert**:
+  - Status beim Anlegen: `confirmation_status = pending`
+  - Es wird eine Bestätigungs-Mail mit Token-Link (`order-confirm.php?token=...`) versendet.
+  - Interne Weiterleitung an die Empfängeradresse erfolgt erst nach erfolgreicher Bestätigung.
+  - Frist über `order_confirmation_expiry_days`; danach `confirmation_status = expired`.
+- **Deaktiviert**:
+  - Status beim Anlegen: `confirmation_status = not_required`
+  - Bestellung wird direkt intern weitergeleitet.
+
+### 3) Keine separate Admin-Freigabe vor Eingang
+
+Es gibt keinen expliziten Admin-Approve-Schritt, der den Eingang einer Bestellung freischaltet. Die formale Freigabelogik ist die optionale E-Mail-Bestätigung durch den Besteller.
+
+## Interner Prozess nach Eingang
+
+In `admin/orders.php` werden Bestellungen nach Status geführt und manuell bearbeitet:
+
+- `open`: keine Position bearbeitet
+- `partial`: mindestens eine Position bearbeitet
+- `processed`: alle Positionen bearbeitet
+- `cancelled`: Bestellung storniert
+- zusätzlich `pending`/`expired` über `confirmation_status`
+
+Regeln:
+
+- Positionsbearbeitung ist gesperrt, solange Bestellung `pending` oder `expired` ist.
+- Stornierung ist jederzeit möglich (solange noch nicht storniert).
+- Zeitstempel für interne Weiterleitung (`admin_notified_at`) wird gesetzt, wenn die Admin-Benachrichtigungsmail erfolgreich versendet wurde.
+
+## Abweichungen zum Standard-Webshop
+
+- Kein Payment-Schritt (kein Warenwert, keine Zahlungsarten, keine Zahlungsfreigabe).
+- Keine Mengenlogik im Warenkorb (pro Produkt nur ein Eintrag, keine Stückzahl).
+- Keine Lieferadresse / kein Versandprozess / kein Fulfillment-Tracking.
+- Keine Endnutzer-Bestellhistorie oder Kundenkonto-Workflow.
+- Optionaler Double-Opt-in-ähnlicher Schritt per E-Mail-Bestätigung vor interner Weiterleitung.
+- Bearbeitung ist positionsbasiert im Admin (operativer Abarbeitungsstatus statt klassischer Versandstatus).
+- Legacy-Routen `reservation.php` und `orders.php` (Frontend) sowie `admin/reservations.php` und `admin/backorders.php` leiten auf die Bestellverwaltung um und sind kein eigener Prozess mehr.

+ 125 - 32
includes/functions.php

@@ -69,18 +69,18 @@ function escape($value)
  */
 function generateCsrfToken()
 {
-    if (empty($_SESSION['csrf_token'])) {
-        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+    if (empty($_SESSION["csrf_token"])) {
+        $_SESSION["csrf_token"] = bin2hex(random_bytes(32));
     }
-    return $_SESSION['csrf_token'];
+    return $_SESSION["csrf_token"];
 }
 
 function validateCsrfToken($token)
 {
-    if (empty($_SESSION['csrf_token'])) {
+    if (empty($_SESSION["csrf_token"])) {
         return false;
     }
-    return hash_equals($_SESSION['csrf_token'], $token);
+    return hash_equals($_SESSION["csrf_token"], $token);
 }
 
 function csrfField()
@@ -130,7 +130,7 @@ function setFlashMessage($key, $type, $message)
         return;
     }
 
-    $_SESSION['flash_messages'][$key] = [
+    $_SESSION["flash_messages"][$key] = [
         "type" => $type,
         "message" => $message,
     ];
@@ -143,7 +143,7 @@ function consumeFlashMessage($key)
         return null;
     }
 
-    $messages = $_SESSION['flash_messages'] ?? [];
+    $messages = $_SESSION["flash_messages"] ?? [];
     if (
         !is_array($messages) ||
         !isset($messages[$key]) ||
@@ -153,7 +153,7 @@ function consumeFlashMessage($key)
     }
 
     $message = $messages[$key];
-    unset($_SESSION['flash_messages'][$key]);
+    unset($_SESSION["flash_messages"][$key]);
 
     $type = trim((string) ($message["type"] ?? ""));
     $text = trim((string) ($message["message"] ?? ""));
@@ -447,6 +447,39 @@ function getCategoryLabels($categoryIds)
     return $labels;
 }
 
+function getCategoryChipPalette($categoryId)
+{
+    $categoryId = normalizeCategoryId($categoryId);
+    if ($categoryId === "") {
+        return [
+            "background" => "#ebe8df",
+            "border" => "#d0c8b5",
+            "text" => "#4b4b4b",
+        ];
+    }
+
+    $hash = crc32($categoryId);
+    if ($hash < 0) {
+        $hash = $hash * -1;
+    }
+
+    // Spread hues across a distinct red -> blue range.
+    $hueSteps = [0, 16, 32, 48, 66, 84, 104, 128, 152, 176, 200, 224];
+    $hue = $hueSteps[$hash % count($hueSteps)];
+    $saturation = 44 + (($hash >> 8) % 10);
+    $lightness = 84 + (($hash >> 16) % 7);
+
+    $background = "hsl(" . $hue . ", " . $saturation . "%, " . $lightness . "%)";
+    $border = "hsl(" . $hue . ", " . ($saturation + 8) . "%, " . ($lightness - 16) . "%)";
+    $text = "hsl(" . $hue . ", " . ($saturation + 18) . "%, 24%)";
+
+    return [
+        "background" => $background,
+        "border" => $border,
+        "text" => $text,
+    ];
+}
+
 function generateCategoryIdFromLabel($label, $existingCategories = [])
 {
     $baseId = normalizeCategoryId($label);
@@ -658,7 +691,9 @@ function saveProducts($products)
         }
     }
 
-    return writeJsonFile(PRODUCTS_FILE, ["products" => array_values($normalized)]);
+    return writeJsonFile(PRODUCTS_FILE, [
+        "products" => array_values($normalized),
+    ]);
 }
 
 function getFaqFilePath(): string
@@ -702,6 +737,24 @@ function saveFaqContent(string $markdown): bool
 function renderFaqInlineMarkdown(string $text): string
 {
     $escaped = escape($text);
+    $escaped = preg_replace_callback(
+        '/\[([^\]]+)\]\(([^)\s]+)\)/',
+        function ($matches) {
+            $label = $matches[1];
+            $url = trim(html_entity_decode($matches[2], ENT_QUOTES, "UTF-8"));
+            if ($url === "") {
+                return $matches[0];
+            }
+
+            $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
+            if (!in_array($scheme, ["http", "https", "mailto"], true)) {
+                return $matches[0];
+            }
+
+            return '<a href="' . escape($url) . '">' . $label . "</a>";
+        },
+        $escaped,
+    );
     $escaped = preg_replace(
         "/\*\*(.+?)\*\*/s",
         '<strong>$1</strong>',
@@ -944,6 +997,19 @@ function generateOrganizationIdFromLabel($label, $existingOrganizations = [])
 
 function getDefaultSystemSettings()
 {
+    $startpageIntroText = "";
+    if (defined("DISCLAIMER_LINES") && is_array(DISCLAIMER_LINES)) {
+        $lines = array_filter(
+            array_map(function ($line) {
+                return trim((string) $line);
+            }, DISCLAIMER_LINES),
+            function ($line) {
+                return $line !== "";
+            },
+        );
+        $startpageIntroText = implode("\n", $lines);
+    }
+
     return [
         "order_recipient_email" => defined("ORDER_RECIPIENT_EMAIL")
             ? ORDER_RECIPIENT_EMAIL
@@ -961,6 +1027,7 @@ function getDefaultSystemSettings()
         )
             ? (bool) ATTACH_ORDER_PDF_TO_ADMIN_EMAIL
             : true,
+        "startpage_intro_text" => $startpageIntroText,
     ];
 }
 
@@ -986,6 +1053,10 @@ function normalizeSystemSettings($settings)
         $expiryDays = 7;
     }
 
+    $startpageIntroText = trim(
+        (string) ($settings["startpage_intro_text"] ?? $defaults["startpage_intro_text"]),
+    );
+
     return [
         "order_recipient_email" => $recipientEmail,
         "order_confirmation_required" => !empty(
@@ -995,6 +1066,7 @@ function normalizeSystemSettings($settings)
         "attach_order_pdf_to_admin_email" => !empty(
             $settings["attach_order_pdf_to_admin_email"]
         ),
+        "startpage_intro_text" => $startpageIntroText,
     ];
 }
 
@@ -1035,6 +1107,29 @@ function shouldAttachOrderPdfToAdminEmail()
     return !empty($settings["attach_order_pdf_to_admin_email"]);
 }
 
+function getStartpageIntroLines()
+{
+    $settings = getSystemSettings();
+    $text = trim((string) ($settings["startpage_intro_text"] ?? ""));
+    if ($text === "") {
+        return [];
+    }
+
+    $lines = preg_split('/\R+/', $text) ?: [];
+    $lines = array_values(
+        array_filter(
+            array_map(function ($line) {
+                return trim((string) $line);
+            }, $lines),
+            function ($line) {
+                return $line !== "";
+            },
+        ),
+    );
+
+    return $lines;
+}
+
 function normalizeOrderItem($item)
 {
     if (!is_array($item)) {
@@ -1350,7 +1445,7 @@ function buildAbsoluteUrl($path)
     }
 
     $scheme = isHttpsRequest() ? "https" : "http";
-    $host = $_SERVER['HTTP_HOST'] ?? "";
+    $host = $_SERVER["HTTP_HOST"] ?? "";
     if ($host === "") {
         return $path;
     }
@@ -1361,20 +1456,20 @@ function buildAbsoluteUrl($path)
 function isHttpsRequest(): bool
 {
     if (
-        !empty($_SERVER['HTTPS']) &&
-        strtolower((string) $_SERVER['HTTPS']) !== "off"
+        !empty($_SERVER["HTTPS"]) &&
+        strtolower((string) $_SERVER["HTTPS"]) !== "off"
     ) {
         return true;
     }
     if (
-        !empty($_SERVER['HTTP_X_FORWARDED_PROTO']) &&
-        strtolower((string) $_SERVER['HTTP_X_FORWARDED_PROTO']) === "https"
+        !empty($_SERVER["HTTP_X_FORWARDED_PROTO"]) &&
+        strtolower((string) $_SERVER["HTTP_X_FORWARDED_PROTO"]) === "https"
     ) {
         return true;
     }
     if (
-        !empty($_SERVER['SERVER_PORT']) &&
-        (int) $_SERVER['SERVER_PORT'] === 443
+        !empty($_SERVER["SERVER_PORT"]) &&
+        (int) $_SERVER["SERVER_PORT"] === 443
     ) {
         return true;
     }
@@ -1708,7 +1803,7 @@ function formatDate($dateString)
 
 function getCart()
 {
-    $cart = $_SESSION['cart'] ?? [];
+    $cart = $_SESSION["cart"] ?? [];
     if (!is_array($cart)) {
         $cart = [];
     }
@@ -1742,8 +1837,8 @@ function getCart()
         ];
     }
 
-    $_SESSION['cart'] = array_values($normalized);
-    return $_SESSION['cart'];
+    $_SESSION["cart"] = array_values($normalized);
+    return $_SESSION["cart"];
 }
 
 function addCartItem($productId, $size = "")
@@ -1786,7 +1881,7 @@ function addCartItem($productId, $size = "")
         }
 
         $cart[$index]["size"] = $size;
-        $_SESSION['cart'] = array_values($cart);
+        $_SESSION["cart"] = array_values($cart);
 
         return [
             "success" => true,
@@ -1801,7 +1896,7 @@ function addCartItem($productId, $size = "")
         "size" => $size,
     ];
 
-    $_SESSION['cart'] = array_values($cart);
+    $_SESSION["cart"] = array_values($cart);
     return [
         "success" => true,
         "status" => "added",
@@ -1814,13 +1909,13 @@ function removeCartItemByIndex($index)
     $cart = getCart();
     if (isset($cart[$index])) {
         unset($cart[$index]);
-        $_SESSION['cart'] = array_values($cart);
+        $_SESSION["cart"] = array_values($cart);
     }
 }
 
 function clearCart()
 {
-    $_SESSION['cart'] = [];
+    $_SESSION["cart"] = [];
 }
 
 function getCartItemsDetailed()
@@ -2028,9 +2123,7 @@ function sendConfirmedOrderAdminNotification($order)
 
     $subject = SITE_SERVICE_NAME . ": Neue Bestellung - " . $order["id"];
     $intro =
-        "<p>Eine neue Bestellung im " .
-        escape(SITE_SERVICE_NAME) .
-        " der Stadt Freising wurde freigegeben und muss bearbeitet werden.</p>";
+        "<p>Eine neue PSA-Bestellung wurde freigegeben und muss bearbeitet werden.</p>";
     $message = buildOrderSummaryHtml($order, "Neue PSA-Bestellung", $intro);
 
     $attachments = [];
@@ -2341,9 +2434,9 @@ function logError($message, $context = [], $level = "ERROR")
         "level" => $level,
         "message" => $message,
         "context" => $context,
-        "ip" => $_SERVER['REMOTE_ADDR'] ?? "unknown",
-        "user_agent" => $_SERVER['HTTP_USER_AGENT'] ?? "unknown",
-        "request_uri" => $_SERVER['REQUEST_URI'] ?? "unknown",
+        "ip" => $_SERVER["REMOTE_ADDR"] ?? "unknown",
+        "user_agent" => $_SERVER["HTTP_USER_AGENT"] ?? "unknown",
+        "request_uri" => $_SERVER["REQUEST_URI"] ?? "unknown",
         "session_id" => session_id()
             ? substr(session_id(), 0, 8) . "..."
             : "none",
@@ -2368,9 +2461,9 @@ function logAccess($message, $context = [])
         "timestamp" => date("Y-m-d H:i:s.u"),
         "message" => $message,
         "context" => $context,
-        "ip" => $_SERVER['REMOTE_ADDR'] ?? "unknown",
-        "request_method" => $_SERVER['REQUEST_METHOD'] ?? "unknown",
-        "request_uri" => $_SERVER['REQUEST_URI'] ?? "unknown",
+        "ip" => $_SERVER["REMOTE_ADDR"] ?? "unknown",
+        "request_method" => $_SERVER["REQUEST_METHOD"] ?? "unknown",
+        "request_uri" => $_SERVER["REQUEST_URI"] ?? "unknown",
     ];
 
     $logLine = json_encode($entry, JSON_UNESCAPED_UNICODE) . PHP_EOL;

+ 21 - 1
index.php

@@ -5,6 +5,7 @@ require_once __DIR__ . "/includes/functions.php";
 $pageTitle = "Startseite";
 $products = getProducts();
 $categories = getCategories();
+$startpageIntroLines = getStartpageIntroLines();
 
 $category = isset($_GET['category'])
     ? normalizeCategoryId($_GET['category'])
@@ -25,7 +26,7 @@ include __DIR__ . "/includes/header.php";
 <h2><?php echo escape(SITE_SERVICE_HEADER); ?></h2>
 
 <div class="disclaimer-box">
-    <?php foreach (DISCLAIMER_LINES as $line): ?>
+    <?php foreach ($startpageIntroLines as $line): ?>
         <p><?php echo escape($line); ?></p>
     <?php endforeach; ?>
 </div>
@@ -77,6 +78,25 @@ $categoryOption["id"]
                     ]; ?>" class="product-card-title-link"><?php echo escape(
     $product["name"],
 ); ?></a></h3>
+                    <?php $productCategoryIds = getProductCategoryIds($product); ?>
+                    <?php if (!empty($productCategoryIds)): ?>
+                        <div class="product-card-categories" aria-label="Kategorien">
+                            <?php foreach ($productCategoryIds as $productCategoryId): ?>
+                                <?php $chipPalette = getCategoryChipPalette(
+                                    $productCategoryId,
+                                ); ?>
+                                <span class="category-chip" style="background-color: <?php echo escape(
+                                    $chipPalette["background"],
+                                ); ?>; border-color: <?php echo escape(
+    $chipPalette["border"],
+); ?>; color: <?php echo escape($chipPalette["text"]); ?>;">
+                                    <?php echo escape(
+                                        getCategoryLabel($productCategoryId),
+                                    ); ?>
+                                </span>
+                            <?php endforeach; ?>
+                        </div>
+                    <?php endif; ?>
                     <a href="product.php?id=<?php echo (int) $product[
                         "id"
                     ]; ?>" class="btn btn-block mt-2">Details ansehen</a>