Browse Source

more fixes

Medowar 3 weeks ago
parent
commit
7b7b0e5794
6 changed files with 337 additions and 188 deletions
  1. 1 0
      .gitignore
  2. 4 3
      assets/css/style.css
  3. 108 2
      data/orders.json
  4. 105 0
      docs/MAIL_PROCESS.md
  5. 105 178
      includes/functions.php
  6. 14 5
      product.php

+ 1 - 0
.gitignore

@@ -2,3 +2,4 @@ config.php
 .codex
 data/reservations.php
 data/logs/
+data/orders.json

+ 4 - 3
assets/css/style.css

@@ -843,10 +843,11 @@ body.admin-page .container {
     margin: 1.5rem 0 2rem;
 }
 
-.product-category-text {
+.product-category-list {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.4rem;
     margin: -0.25rem 0 1.2rem;
-    font-size: 0.96rem;
-    color: var(--brand-muted);
 }
 
 .product-description {

+ 108 - 2
data/orders.json

@@ -1,3 +1,109 @@
 {
-    "orders": []
-}
+    "orders": [
+        {
+            "id": "FWFS-2026-001",
+            "customer_name": "uojsndfoin",
+            "customer_email": "inbx@mailpit.medowar.de",
+            "organization_id": "feuerwehr-freising",
+            "organization_label": "Feuerwehr Freising TEST",
+            "comment": "",
+            "items": [
+                {
+                    "product_id": 10,
+                    "product_name": "Beil",
+                    "size": "Universalgröße",
+                    "availability_label": "",
+                    "is_processed": true
+                },
+                {
+                    "product_id": 17,
+                    "product_name": "Allwetterjacke",
+                    "size": "M",
+                    "availability_label": "",
+                    "is_processed": false
+                }
+            ],
+            "status": "cancelled",
+            "confirmation_status": "not_required",
+            "confirmation_token": "",
+            "confirmation_expires_at": "",
+            "confirmed_at": "2026-05-11 22:14:17",
+            "created_at": "2026-05-11 22:14:17",
+            "updated_at": "2026-05-11 22:15:23",
+            "cancelled_at": "2026-05-11 22:15:23",
+            "cancelled_by": "admin",
+            "cancellation_reason": "test",
+            "admin_notified_at": ""
+        },
+        {
+            "id": "FWFS-2026-002",
+            "customer_name": "asdad",
+            "customer_email": "jbisbf@mailpit.medowar.de",
+            "organization_id": "feuerwehr-freising",
+            "organization_label": "Feuerwehr Freising TEST",
+            "comment": "",
+            "items": [
+                {
+                    "product_id": 4,
+                    "product_name": "Fleece",
+                    "size": "M",
+                    "availability_label": "",
+                    "is_processed": true
+                },
+                {
+                    "product_id": 5,
+                    "product_name": "Handschuhe AT",
+                    "size": "9",
+                    "availability_label": "",
+                    "is_processed": true
+                }
+            ],
+            "status": "processed",
+            "confirmation_status": "not_required",
+            "confirmation_token": "",
+            "confirmation_expires_at": "",
+            "confirmed_at": "2026-05-11 22:14:38",
+            "created_at": "2026-05-11 22:14:38",
+            "updated_at": "2026-05-11 22:14:58",
+            "cancelled_at": "",
+            "cancelled_by": "",
+            "cancellation_reason": "",
+            "admin_notified_at": ""
+        },
+        {
+            "id": "FWFS-2026-003",
+            "customer_name": "sfsdfsd",
+            "customer_email": "fsdfsdf@123.de",
+            "organization_id": "feuerwehr-freising",
+            "organization_label": "Feuerwehr Freising TEST",
+            "comment": "",
+            "items": [
+                {
+                    "product_id": 10,
+                    "product_name": "Beil",
+                    "size": "Universalgröße",
+                    "availability_label": "",
+                    "is_processed": false
+                },
+                {
+                    "product_id": 4,
+                    "product_name": "Fleece",
+                    "size": "M",
+                    "availability_label": "",
+                    "is_processed": false
+                }
+            ],
+            "status": "open",
+            "confirmation_status": "not_required",
+            "confirmation_token": "",
+            "confirmation_expires_at": "",
+            "confirmed_at": "2026-05-11 22:17:07",
+            "created_at": "2026-05-11 22:17:07",
+            "updated_at": "2026-05-11 22:17:07",
+            "cancelled_at": "",
+            "cancelled_by": "",
+            "cancellation_reason": "",
+            "admin_notified_at": ""
+        }
+    ]
+}

+ 105 - 0
docs/MAIL_PROCESS.md

@@ -0,0 +1,105 @@
+# Mail-Prozess
+
+## Überblick
+
+Der Versand aller Bestellmails ist zentral in `includes/functions.php` implementiert und wird synchron innerhalb der jeweiligen HTTP-Requests ausgeführt.
+
+- Kernfunktion für Versand: `sendEmail(...)`
+- Fachliche Mail-Trigger: `createOrder(...)` und `confirmOrderByToken(...)`
+- Laufzeit-Einstellungen: `getSystemSettings()` aus `data/settings.json`
+- Startwerte/Fallbacks: `config.php`
+
+Hinweis:
+
+- Es gibt keine Queue, keinen Hintergrundjob und keine Retry-Logik. Versand erfolgt direkt per PHP-`mail()`.
+
+## Relevante Einstellungen
+
+Die folgenden Parameter steuern den Mailfluss:
+
+| Schlüssel | Quelle | Wirkung |
+|---|---|---|
+| `order_recipient_email` | Admin-Einstellungen (`data/settings.json`) | Empfänger für interne Bestellmail |
+| `order_confirmation_required` | Admin-Einstellungen | Erzwingt Bestätigungsmail vor interner Weiterleitung |
+| `order_confirmation_expiry_days` | Admin-Einstellungen | Gültigkeit des Bestätigungslinks |
+| `attach_order_pdf_to_admin_email` | Admin-Einstellungen | Hängt PDF an interne Bestellmail an |
+| `FROM_EMAIL`, `FROM_NAME` | `config.php` | Absender/Anzeigename für alle ausgehenden Mails |
+| `SITE_URL` | `config.php` | Basis für Bestätigungslink in Mails |
+
+## Mail-Typen und Auslöser
+
+### 1) Bestätigung an Besteller
+
+- Funktion: `sendOrderConfirmationRequestEmail($order)`
+- Trigger: direkt nach `createOrder(...)`, wenn `order_confirmation_required = true`
+- Empfänger: `order.customer_email`
+- Inhalt: Bestellzusammenfassung + Button/Link auf `order-confirm.php?token=...`
+
+### 2) Eingangsbestätigung an Besteller (ohne Pflichtbestätigung)
+
+- Funktion: `sendOrderCreatedCustomerEmail($order)`
+- Trigger: direkt nach `createOrder(...)`, wenn `order_confirmation_required = false`
+- Empfänger: `order.customer_email`
+- Inhalt: Bestellung erfasst und intern weitergeleitet
+
+### 3) Bestätigungsinfo an Besteller (nach Klick auf Token-Link)
+
+- Funktion: `sendOrderConfirmedCustomerEmail($order)`
+- Trigger: nach erfolgreicher Token-Bestätigung in `confirmOrderByToken(...)`
+- Empfänger: `order.customer_email`
+- Inhalt: Bestellung bestätigt und intern weitergeleitet
+
+### 4) Interne Bestellmail
+
+- Funktion: `sendConfirmedOrderAdminNotification($order)`
+- Trigger:
+  - direkt nach `createOrder(...)`, wenn `order_confirmation_required = false`
+  - nach erfolgreicher Token-Bestätigung in `confirmOrderByToken(...)`, wenn vorher `pending`
+- Empfänger: `getOrderRecipientEmail()` (normalisiert/validiert)
+- Inhalt: HTML-Bestellzusammenfassung
+- Optional: PDF-Anhang `bestellung-<order-id>.pdf` bei aktivem `attach_order_pdf_to_admin_email`
+
+## Ablauf nach Konfiguration
+
+### Fall A: Bestätigung erforderlich
+
+1. `createOrder(...)` speichert Bestellung mit `confirmation_status = pending`.
+2. Besteller erhält Bestätigungsmail mit Token-Link.
+3. Klick auf Link ruft `order-confirm.php` auf und startet `confirmOrderByToken(...)`.
+4. Bei gültigem, nicht abgelaufenem Token:
+   - `confirmation_status` wird auf `confirmed` gesetzt,
+   - interne Bestellmail wird versendet,
+   - Besteller erhält Bestätigungsinfo.
+
+### Fall B: Keine Bestätigung erforderlich
+
+1. `createOrder(...)` speichert Bestellung mit `confirmation_status = not_required` und `confirmed_at`.
+2. Interne Bestellmail wird sofort versendet.
+3. Besteller erhält direkt Eingangsbestätigung.
+
+## Ablauf bei Fristablauf
+
+- Offene Bestätigungen (`pending`) wechseln nach Frist auf `expired`.
+- Der Status wird in `refreshOrderState(...)` berechnet und über `expirePendingOrders()` beim Aufruf von `admin/index.php`, `admin/orders.php` und `order-confirm.php` fortgeschrieben.
+- Für `expired` gibt es keine zusätzliche Mail.
+
+## Technische Versanddetails
+
+- Ohne Anhang: HTML- oder Textmail direkt über `mail($to, $subject, $message, $headers)`.
+- Mit Anhang: `multipart/mixed` mit Base64-kodierten Attachments.
+- Standard-Header:
+  - `From: <FROM_NAME> <FROM_EMAIL>`
+  - `Reply-To: FROM_EMAIL`
+  - `X-Mailer: PHP/<version>`
+- Bestätigungslink wird über `buildAbsoluteUrl(...)` erzeugt:
+  - absolute `SITE_URL` wird direkt verwendet,
+  - bei relativer `SITE_URL` wird aus Request-Kontext (`HTTP_HOST`, HTTPS) eine absolute URL gebaut.
+
+## Fehlerverhalten und Nachvollziehbarkeit
+
+- Rückgaben von `sendEmail(...)` werden nur teilweise ausgewertet:
+  - Bei interner Bestellmail wird bei Erfolg `admin_notified_at` gesetzt.
+  - Schlägt die interne Mail fehl, bleibt `admin_notified_at` leer.
+  - Fehlschläge von Bestellermails blockieren den Bestellprozess nicht.
+- Es gibt keine automatische Wiederholung fehlgeschlagener Mails.
+- Betriebsvoraussetzung bleibt eine funktionierende Server-Mailkonfiguration für PHP-`mail()`.

+ 105 - 178
includes/functions.php

@@ -2312,27 +2312,28 @@ function generateOrderPdf($order)
     $pageWidth = 595;
     $pageHeight = 842;
     $leftMargin = 45;
-    $rightMargin = 45;
     $topY = 800;
-    $bottomY = 55;
+    $bottomY = 60;
     $lineHeight = 14;
 
-    $orderId = pdfEncodeWinAnsi($order["id"]);
-    $createdAt = pdfEncodeWinAnsi(formatDate($order["created_at"]));
-    $customerName = pdfEncodeWinAnsi($order["customer_name"]);
-    $customerEmail = pdfEncodeWinAnsi($order["customer_email"]);
-    $organization = pdfEncodeWinAnsi($order["organization_label"]);
-    $comment = pdfEncodeWinAnsi($order["comment"]);
-    $labelBelongsTo = pdfEncodeWinAnsi("Gehört zu");
-    $labelSize = pdfEncodeWinAnsi("Größe");
-    $labelFullyIssued = pdfEncodeWinAnsi("Vollständig ausgegeben");
-
     $pages = [];
     $pageContent = "";
     $y = $topY;
-    $isFirstPage = true;
+    $pageNumber = 0;
+
+    $encodeText = function ($text) {
+        return pdfEncodeWinAnsi((string) $text);
+    };
+
+    $siteName = $encodeText(SITE_FULL_NAME);
+    $orderId = $encodeText($order["id"]);
+    $createdAt = $encodeText(formatDate($order["created_at"]));
+    $customerName = $encodeText($order["customer_name"]);
+    $customerEmail = $encodeText($order["customer_email"]);
+    $organization = $encodeText($order["organization_label"]);
+    $commentRaw = (string) $order["comment"];
 
-    $writeText = function ($x, $y, $text, $fontSize = 12) use (&$pageContent) {
+    $writeText = function ($x, $y, $encodedText, $fontSize = 12) use (&$pageContent) {
         $pageContent .=
             "BT\n/F1 " .
             $fontSize .
@@ -2341,102 +2342,45 @@ function generateOrderPdf($order)
             " " .
             number_format($y, 2, ".", "") .
             " Tm\n(" .
-            pdfEscapeText($text) .
+            pdfEscapeText($encodedText) .
             ") Tj\nET\n";
     };
 
-    $drawLine = function ($x1, $y1, $x2, $y2) use (&$pageContent) {
-        $pageContent .=
-            number_format($x1, 2, ".", "") .
-            " " .
-            number_format($y1, 2, ".", "") .
-            " m " .
-            number_format($x2, 2, ".", "") .
-            " " .
-            number_format($y2, 2, ".", "") .
-            " l S\n";
-    };
-
-    $drawRect = function ($x, $y, $width, $height) use (&$pageContent) {
-        $pageContent .=
-            number_format($x, 2, ".", "") .
-            " " .
-            number_format($y, 2, ".", "") .
-            " " .
-            number_format($width, 2, ".", "") .
-            " " .
-            number_format($height, 2, ".", "") .
-            " re S\n";
-    };
-
     $startPage = function () use (
         &$pages,
         &$pageContent,
         &$y,
-        &$isFirstPage,
+        &$pageNumber,
         $topY,
         $leftMargin,
-        $pageWidth,
-        $rightMargin,
+        $lineHeight,
+        $siteName,
         $orderId,
         $createdAt,
-        $customerName,
-        $customerEmail,
-        $organization,
-        $labelBelongsTo,
         $writeText,
-        $drawLine
+        $encodeText
     ) {
         if ($pageContent !== "") {
             $pages[] = $pageContent;
         }
 
+        $pageNumber++;
         $pageContent = "";
         $y = $topY;
 
-        $leftTitle = pdfEncodeWinAnsi(SITE_FULL_NAME);
-        $leftDepartment = pdfEncodeWinAnsi(SITE_DEPARTMENT_NAME);
-        $leftService = pdfEncodeWinAnsi(SITE_SERVICE_HEADER);
-
-        $writeText($leftMargin, $y, $leftTitle, 16);
-        $y -= 18;
-        $writeText($leftMargin, $y, $leftDepartment, 11);
-        $y -= 14;
-        $writeText($leftMargin, $y, $leftService, 11);
-
-        $writeText(350, $topY, "BESTELLUNG", 11);
-        $writeText(350, $topY - 22, "ID " . $orderId, 20);
-        $writeText(350, $topY - 44, "Erstellt am: " . $createdAt, 11);
-
-        $y = $topY - 62;
-        $drawLine($leftMargin, $y, $pageWidth - $rightMargin, $y);
-        $y -= 18;
-
-        if ($isFirstPage) {
-            $writeText($leftMargin, $y, $labelBelongsTo, 11);
-            $y -= 18;
-
-            $belongsFields = [
-                "Name: " . $customerName,
-                "Organisation: " . $organization,
-                "E-Mail: " . $customerEmail,
-            ];
-
-            foreach ($belongsFields as $fieldLine) {
-                foreach (pdfWrapAnsiText($fieldLine, 70) as $wrappedLine) {
-                    $writeText($leftMargin, $y, $wrappedLine, 12);
-                    $y -= 16;
-                }
-            }
+        foreach (pdfWrapAnsiText($siteName, 70) as $index => $line) {
+            $writeText($leftMargin, $y, $line, $index === 0 ? 15 : 11);
+            $y -= $index === 0 ? 19 : $lineHeight;
+        }
 
-            $y -= 2;
-            $drawLine($leftMargin, $y, $pageWidth - $rightMargin, $y);
-            $y -= 18;
-            $isFirstPage = false;
-        } else {
-            $writeText($leftMargin, $y, "Fortsetzung Bestellung " . $orderId, 11);
-            $y -= 20;
+        $headerLine = "Bestellung: " . $orderId;
+        if ($pageNumber > 1) {
+            $headerLine .= " | Seite " . $encodeText($pageNumber);
         }
+        $writeText($leftMargin, $y, $headerLine, 12);
+        $y -= 17;
+        $writeText($leftMargin, $y, "Erstellt am: " . $createdAt, 11);
+        $y -= 20;
     };
 
     $ensureSpace = function ($requiredHeight) use (&$y, $bottomY, $startPage) {
@@ -2445,117 +2389,100 @@ function generateOrderPdf($order)
         }
     };
 
-    $drawItemTableHeader = function () use (
+    $writeWrapped = function (
+        $encodedText,
+        $maxChars,
+        $fontSize = 11,
+        $x = null
+    ) use (&$y, $lineHeight, $leftMargin, $writeText, $ensureSpace) {
+        $targetX = $x === null ? $leftMargin : $x;
+        $lines = pdfWrapAnsiText($encodedText, $maxChars);
+        foreach ($lines as $line) {
+            $ensureSpace($lineHeight);
+            $writeText($targetX, $y, $line, $fontSize);
+            $y -= $lineHeight;
+        }
+    };
+
+    $writeSectionTitle = function ($titleText) use (
         &$y,
-        $lineHeight,
         $leftMargin,
         $writeText,
-        $labelSize,
-        $drawLine,
-        $pageWidth,
-        $rightMargin
+        $ensureSpace,
+        $encodeText
     ) {
-        $writeText($leftMargin, $y, "Nr.", 11);
-        $writeText(75, $y, "Artikel", 11);
-        $writeText(340, $y, $labelSize, 11);
-        $writeText(430, $y, "Hinweis", 11);
-        $y -= $lineHeight;
-        $drawLine($leftMargin, $y + 4, $pageWidth - $rightMargin, $y + 4);
+        $ensureSpace(24);
+        $writeText($leftMargin, $y, $encodeText($titleText), 13);
+        $y -= 18;
     };
 
     $startPage();
-    $ensureSpace(36);
-    $writeText($leftMargin, $y, "Artikelliste", 13);
-    $y -= 20;
-    $drawItemTableHeader();
 
-    $itemNumber = 1;
-    foreach ($order["items"] as $item) {
-        $itemName = pdfEncodeWinAnsi($item["product_name"]);
-        $sizeLabel = pdfEncodeWinAnsi($item["size"]);
-        $hintLabel = pdfEncodeWinAnsi(
-            preg_replace("/\s+/", " ", (string) $item["availability_label"]),
-        );
+    $writeSectionTitle("Gehört zu");
+    $writeWrapped("Name: " . $customerName, 80);
+    $writeWrapped("Organisation: " . $organization, 80);
+    $writeWrapped("E-Mail: " . $customerEmail, 80);
+    $y -= 6;
 
-        $itemLines = pdfWrapAnsiText($itemName, 40);
-        $sizeLines = pdfWrapAnsiText($sizeLabel, 12);
-        $hintLines = pdfWrapAnsiText($hintLabel, 22);
+    $writeSectionTitle("Artikelliste");
 
-        $lineCount = max(count($itemLines), count($sizeLines), count($hintLines));
-        $rowHeight = $lineCount * $lineHeight + 4;
-
-        if ($y - $rowHeight < $bottomY) {
-            $startPage();
-            $ensureSpace(30);
-            $writeText($leftMargin, $y, "Artikelliste (Fortsetzung)", 13);
-            $y -= 20;
-            $drawItemTableHeader();
-        }
+    $itemNumber = 1;
+    if (empty($order["items"])) {
+        $writeWrapped($encodeText("Keine Artikel"), 80);
+    } else {
+        foreach ($order["items"] as $item) {
+            $itemName = $encodeText($item["product_name"]);
+            $sizeLabel = $encodeText($item["size"]);
+            $hintLabel = $encodeText(
+                preg_replace("/\s+/", " ", (string) $item["availability_label"]),
+            );
 
-        $rowTop = $y;
-        $writeText($leftMargin, $rowTop, (string) $itemNumber, 11);
+            $writeWrapped($encodeText($itemNumber . ". ") . $itemName, 78);
 
-        for ($i = 0; $i < $lineCount; $i++) {
-            if (isset($itemLines[$i])) {
-                $writeText(75, $rowTop - $i * $lineHeight, $itemLines[$i], 11);
+            if ($sizeLabel !== "") {
+                $writeWrapped($encodeText("   Größe: ") . $sizeLabel, 76);
             }
-            if (isset($sizeLines[$i])) {
-                $writeText(340, $rowTop - $i * $lineHeight, $sizeLines[$i], 11);
-            }
-            if (isset($hintLines[$i])) {
-                $writeText(430, $rowTop - $i * $lineHeight, $hintLines[$i], 11);
+
+            if ($hintLabel !== "") {
+                $writeWrapped($encodeText("   Hinweis: ") . $hintLabel, 76);
             }
-        }
 
-        $y -= $rowHeight;
-        $drawLine($leftMargin, $y + 4, $pageWidth - $rightMargin, $y + 4);
-        $y -= 4;
-        $itemNumber++;
+            $y -= 4;
+            $itemNumber++;
+        }
     }
 
-    $commentSource = trim($comment) !== "" ? $comment : "Kein Kommentar";
-    $commentLines = [];
-    foreach (preg_split('/\r\n|\r|\n/', $commentSource) as $commentPart) {
-        $wrapped = pdfWrapAnsiText($commentPart, 92);
-        foreach ($wrapped as $line) {
-            $commentLines[] = $line;
+    $y -= 4;
+    $writeSectionTitle("Kommentar");
+
+    if (trim($commentRaw) === "") {
+        $writeWrapped($encodeText("Kein Kommentar"), 80);
+    } else {
+        foreach (preg_split('/\r\n|\r|\n/', $commentRaw) as $commentLine) {
+            $writeWrapped($encodeText($commentLine), 82);
         }
     }
 
-    $ensureSpace(40);
-    $y -= 8;
-    $writeText($leftMargin, $y, "Kommentar", 13);
-    $y -= 18;
+    $y -= 6;
+    $writeSectionTitle("Lagerbearbeitung");
 
-    foreach ($commentLines as $line) {
-        if ($y - $lineHeight < $bottomY) {
-            $startPage();
-            $ensureSpace(34);
-            $writeText($leftMargin, $y, "Kommentar (Fortsetzung)", 13);
-            $y -= 18;
-        }
-        $writeText($leftMargin, $y, $line, 11);
-        $y -= $lineHeight;
-    }
-
-    $footerHeight = 96;
-    $ensureSpace($footerHeight + 10);
-    $y -= 12;
-    $drawLine($leftMargin, $y + 8, $pageWidth - $rightMargin, $y + 8);
-    $writeText($leftMargin, $y, "Lagerbearbeitung", 13);
-    $y -= 18;
-    $writeText($leftMargin, $y, "Ausgegeben am: ____________________", 11);
-    $y -= 20;
-    $writeText($leftMargin, $y, "Ausgegeben durch: ____________________", 11);
-    $y -= 20;
-    $writeText($leftMargin, $y, "Unterschrift: ________________________", 11);
-    $y -= 22;
-
-    $drawRect($leftMargin, $y - 8, 10, 10);
-    $writeText($leftMargin + 16, $y, $labelFullyIssued, 11);
-    $y -= 18;
-    $drawRect($leftMargin, $y - 8, 10, 10);
-    $writeText($leftMargin + 16, $y, "Teilweise ausgegeben", 11);
+    $warehouseLines = [
+        "Ausgegeben am: ________________________",
+        "Ausgegeben durch: _____________________",
+        "Unterschrift: _________________________",
+        "",
+        "[ ] Vollständig ausgegeben",
+        "[ ] Teilweise ausgegeben",
+    ];
+
+    foreach ($warehouseLines as $line) {
+        if ($line === "") {
+            $ensureSpace($lineHeight);
+            $y -= $lineHeight;
+            continue;
+        }
+        $writeWrapped($encodeText($line), 80);
+    }
 
     if ($pageContent !== "") {
         $pages[] = $pageContent;

+ 14 - 5
product.php

@@ -12,7 +12,7 @@ if ($product === null) {
 
 $pageTitle = $product["name"];
 $sizes = getProductSizes($product);
-$categoryLabels = getCategoryLabels(getProductCategoryIds($product));
+$productCategoryIds = getProductCategoryIds($product);
 
 if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['add_to_cart'])) {
     // Validate CSRF token
@@ -88,10 +88,19 @@ include __DIR__ . "/includes/header.php";
 
     <div class="product-copy">
         <h1><?php echo escape($product["name"]); ?></h1>
-        <?php if (!empty($categoryLabels)): ?>
-            <p class="product-category-text">
-                Kategorie: <?php echo escape(implode(", ", $categoryLabels)); ?>
-            </p>
+        <?php if (!empty($productCategoryIds)): ?>
+            <div class="product-category-list" 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; ?>
 
         <div class="product-description-block">