3 Commits fb1916772d ... 7b7b0e5794

Autore SHA1 Messaggio Data
  Medowar 7b7b0e5794 more fixes 3 settimane fa
  Medowar 4af27d13b3 fixing pdf generation 3 settimane fa
  Medowar b32d0c040c fixing word wrap 3 settimane fa
8 ha cambiato i file con 563 aggiunte e 77 eliminazioni
  1. 1 0
      .gitignore
  2. 5 2
      .htaccess
  3. 2 2
      admin/products.php
  4. 15 0
      assets/css/style.css
  5. 108 2
      data/orders.json
  6. 105 0
      docs/MAIL_PROCESS.md
  7. 312 71
      includes/functions.php
  8. 15 0
      product.php

+ 1 - 0
.gitignore

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

+ 5 - 2
.htaccess

@@ -14,8 +14,11 @@ Options -Indexes
     # Block hidden files/folders except ACME challenge path.
     RewriteRule "(^|/)\.(?!well-known/)" - [F]
 
-    # Deny direct access to writable data directory except uploads.
-    RewriteRule ^data/(?!uploads/)(?:/|$) - [F,L]
+    # Allow public access to uploaded product images only.
+    RewriteRule ^data/uploads/[^/]+\.(?:jpe?g|png|webp|gif)$ - [L,NC]
+
+    # Deny direct access to the rest of the writable data directory.
+    RewriteRule ^data(?:/|$) - [F,L]
 </IfModule>
 
 <IfModule mod_authz_core.c>

+ 2 - 2
admin/products.php

@@ -462,7 +462,7 @@ function updateAvailabilityFields() {
         <table class="responsive-table">
             <thead>
                 <tr>
-                    <th>ID</th>
+                    <th class="product-id-column">ID</th>
                     <th>Name</th>
                     <th>Kategorien</th>
                     <th>Größen</th>
@@ -473,7 +473,7 @@ function updateAvailabilityFields() {
             <tbody>
                 <?php foreach ($products as $product): ?>
                     <tr>
-                        <td data-label="ID"><?php echo (int) $product[
+                        <td data-label="ID" class="product-id-column"><?php echo (int) $product[
                             "id"
                         ]; ?></td>
                         <td data-label="Name"><?php echo escape(

+ 15 - 0
assets/css/style.css

@@ -438,6 +438,14 @@ table tr:hover {
     white-space: normal;
 }
 
+.table-responsive th.product-id-column,
+.table-responsive td.product-id-column {
+    min-width: 3.5rem;
+    white-space: nowrap;
+    word-break: normal;
+    overflow-wrap: normal;
+}
+
 /* Admin: use more horizontal space so tables fit */
 body.admin-page .container {
     max-width: none;
@@ -835,6 +843,13 @@ body.admin-page .container {
     margin: 1.5rem 0 2rem;
 }
 
+.product-category-list {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.4rem;
+    margin: -0.25rem 0 1.2rem;
+}
+
 .product-description {
     margin-top: 0.5rem;
     line-height: 1.8;

+ 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()`.

+ 312 - 71
includes/functions.php

@@ -2207,49 +2207,38 @@ function sendEmail($to, $subject, $message, $isHtml = true, $attachments = [])
     );
 }
 
-function buildOrderPdfLines($order)
+function pdfEncodeWinAnsi($text)
 {
-    $lines = [
-        SITE_FULL_NAME,
-        SITE_DEPARTMENT_NAME,
-        "",
-        SITE_SERVICE_HEADER,
-        "Bestellnummer: " . $order["id"],
-        "Erstellt am: " . formatDate($order["created_at"]),
-        "Name: " . $order["customer_name"],
-        "E-Mail: " . $order["customer_email"],
-        "Organisation: " . $order["organization_label"],
-        "",
-        "Artikel:",
-    ];
+    $text = str_replace("\r", "", (string) $text);
 
-    foreach ($order["items"] as $item) {
-        $line = "- " . $item["product_name"];
-        if ($item["size"] !== "") {
-            $line .= " | Größe: " . $item["size"];
-        }
-        if ($item["availability_label"] !== "") {
-            $line .=
-                " | Hinweis: " .
-                preg_replace("/\s+/", " ", $item["availability_label"]);
-        }
-        $lines[] = $line;
+    if (!function_exists("iconv")) {
+        return preg_replace('/[^\x09\x0A\x20-\x7E]/', "?", $text);
+    }
+
+    $chars = preg_split("//u", $text, -1, PREG_SPLIT_NO_EMPTY);
+    if (!is_array($chars)) {
+        $fallback = @iconv("UTF-8", "Windows-1252//TRANSLIT//IGNORE", $text);
+        return is_string($fallback) ? $fallback : $text;
     }
 
-    $lines[] = "";
-    $lines[] = "Kommentar:";
-    if ($order["comment"] !== "") {
-        foreach (
-            preg_split('/\r\n|\r|\n/', $order["comment"])
-            as $commentLine
-        ) {
-            $lines[] = $commentLine;
+    $result = "";
+    foreach ($chars as $char) {
+        $converted = @iconv("UTF-8", "Windows-1252", $char);
+        if ($converted !== false) {
+            $result .= $converted;
+            continue;
         }
-    } else {
-        $lines[] = "Kein Kommentar";
+
+        $fallback = @iconv("UTF-8", "Windows-1252//TRANSLIT", $char);
+        if ($fallback !== false && $fallback !== "") {
+            $result .= $fallback;
+            continue;
+        }
+
+        $result .= "?";
     }
 
-    return $lines;
+    return $result;
 }
 
 function pdfEscapeText($text)
@@ -2257,65 +2246,317 @@ function pdfEscapeText($text)
     $text = str_replace("\\", "\\\\", $text);
     $text = str_replace("(", "\(", $text);
     $text = str_replace(")", "\)", $text);
-    $text = str_replace("\r", "", $text);
+    return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', "", $text);
+}
 
-    if (function_exists("iconv")) {
-        $converted = @iconv("UTF-8", "Windows-1252//TRANSLIT//IGNORE", $text);
-        if (is_string($converted)) {
-            $text = $converted;
+function pdfWrapAnsiText($text, $maxChars)
+{
+    $lines = [];
+    $paragraphs = explode("\n", str_replace("\r", "", (string) $text));
+
+    foreach ($paragraphs as $paragraph) {
+        $normalized = trim(preg_replace("/\s+/", " ", $paragraph));
+        if ($normalized === "") {
+            $lines[] = "";
+            continue;
+        }
+
+        $words = explode(" ", $normalized);
+        $current = "";
+
+        foreach ($words as $word) {
+            if ($word === "") {
+                continue;
+            }
+
+            if (strlen($word) > $maxChars) {
+                if ($current !== "") {
+                    $lines[] = $current;
+                    $current = "";
+                }
+
+                $parts = str_split($word, $maxChars);
+                $lastIndex = count($parts) - 1;
+                for ($i = 0; $i < $lastIndex; $i++) {
+                    $lines[] = $parts[$i];
+                }
+                $current = $parts[$lastIndex];
+                continue;
+            }
+
+            $candidate = $current === "" ? $word : $current . " " . $word;
+            if (strlen($candidate) <= $maxChars) {
+                $current = $candidate;
+            } else {
+                if ($current !== "") {
+                    $lines[] = $current;
+                }
+                $current = $word;
+            }
+        }
+
+        if ($current !== "") {
+            $lines[] = $current;
         }
     }
 
-    return $text;
+    if (empty($lines)) {
+        $lines[] = "";
+    }
+
+    return $lines;
 }
 
 function generateOrderPdf($order)
 {
-    $lines = buildOrderPdfLines($order);
-    $content = "BT\n/F1 12 Tf\n14 TL\n50 790 Td\n";
-    $first = true;
+    $pageWidth = 595;
+    $pageHeight = 842;
+    $leftMargin = 45;
+    $topY = 800;
+    $bottomY = 60;
+    $lineHeight = 14;
 
-    foreach ($lines as $line) {
-        if (!$first) {
-            $content .= "T*\n";
+    $pages = [];
+    $pageContent = "";
+    $y = $topY;
+    $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, $encodedText, $fontSize = 12) use (&$pageContent) {
+        $pageContent .=
+            "BT\n/F1 " .
+            $fontSize .
+            " Tf\n1 0 0 1 " .
+            number_format($x, 2, ".", "") .
+            " " .
+            number_format($y, 2, ".", "") .
+            " Tm\n(" .
+            pdfEscapeText($encodedText) .
+            ") Tj\nET\n";
+    };
+
+    $startPage = function () use (
+        &$pages,
+        &$pageContent,
+        &$y,
+        &$pageNumber,
+        $topY,
+        $leftMargin,
+        $lineHeight,
+        $siteName,
+        $orderId,
+        $createdAt,
+        $writeText,
+        $encodeText
+    ) {
+        if ($pageContent !== "") {
+            $pages[] = $pageContent;
         }
-        $first = false;
-        $content .= "(" . pdfEscapeText($line) . ") Tj\n";
+
+        $pageNumber++;
+        $pageContent = "";
+        $y = $topY;
+
+        foreach (pdfWrapAnsiText($siteName, 70) as $index => $line) {
+            $writeText($leftMargin, $y, $line, $index === 0 ? 15 : 11);
+            $y -= $index === 0 ? 19 : $lineHeight;
+        }
+
+        $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) {
+        if ($y - $requiredHeight < $bottomY) {
+            $startPage();
+        }
+    };
+
+    $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,
+        $leftMargin,
+        $writeText,
+        $ensureSpace,
+        $encodeText
+    ) {
+        $ensureSpace(24);
+        $writeText($leftMargin, $y, $encodeText($titleText), 13);
+        $y -= 18;
+    };
+
+    $startPage();
+
+    $writeSectionTitle("Gehört zu");
+    $writeWrapped("Name: " . $customerName, 80);
+    $writeWrapped("Organisation: " . $organization, 80);
+    $writeWrapped("E-Mail: " . $customerEmail, 80);
+    $y -= 6;
+
+    $writeSectionTitle("Artikelliste");
+
+    $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"]),
+            );
+
+            $writeWrapped($encodeText($itemNumber . ". ") . $itemName, 78);
+
+            if ($sizeLabel !== "") {
+                $writeWrapped($encodeText("   Größe: ") . $sizeLabel, 76);
+            }
+
+            if ($hintLabel !== "") {
+                $writeWrapped($encodeText("   Hinweis: ") . $hintLabel, 76);
+            }
+
+            $y -= 4;
+            $itemNumber++;
+        }
+    }
+
+    $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);
+        }
+    }
+
+    $y -= 6;
+    $writeSectionTitle("Lagerbearbeitung");
+
+    $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);
     }
 
-    $content .= "ET";
-    $length = strlen($content);
+    if ($pageContent !== "") {
+        $pages[] = $pageContent;
+    }
+
+    if (empty($pages)) {
+        $pages[] = "";
+    }
 
+    $fontObjectNumber = 3;
     $objects = [];
-    $objects[] = "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n";
-    $objects[] = "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n";
-    $objects[] =
-        "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n";
-    $objects[] =
-        "4 0 obj\n<< /Length " .
-        $length .
-        " >>\nstream\n" .
-        $content .
-        "\nendstream\nendobj\n";
-    $objects[] =
-        "5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n";
+    $objects[1] = "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n";
+    $objects[
+        $fontObjectNumber
+    ] = "3 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>\nendobj\n";
+
+    $kids = [];
+    $nextObjectNumber = 4;
+    foreach ($pages as $pageStream) {
+        $pageObjectNumber = $nextObjectNumber++;
+        $contentObjectNumber = $nextObjectNumber++;
+
+        $kids[] = $pageObjectNumber . " 0 R";
+        $objects[$pageObjectNumber] =
+            $pageObjectNumber .
+            " 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 " .
+            $pageWidth .
+            " " .
+            $pageHeight .
+            "] /Contents " .
+            $contentObjectNumber .
+            " 0 R /Resources << /Font << /F1 " .
+            $fontObjectNumber .
+            " 0 R >> >> >>\nendobj\n";
+
+        $objects[$contentObjectNumber] =
+            $contentObjectNumber .
+            " 0 obj\n<< /Length " .
+            strlen($pageStream) .
+            " >>\nstream\n" .
+            $pageStream .
+            "\nendstream\nendobj\n";
+    }
+
+    $objects[2] =
+        "2 0 obj\n<< /Type /Pages /Kids [" .
+        implode(" ", $kids) .
+        "] /Count " .
+        count($kids) .
+        " >>\nendobj\n";
+
+    ksort($objects);
 
     $pdf = "%PDF-1.4\n";
     $offsets = [0];
-    foreach ($objects as $object) {
-        $offsets[] = strlen($pdf);
-        $pdf .= $object;
+    foreach ($objects as $number => $objectContent) {
+        $offsets[$number] = strlen($pdf);
+        $pdf .= $objectContent;
     }
 
+    $lastObjectNumber = (int) max(array_keys($objects));
     $xrefOffset = strlen($pdf);
-    $pdf .= "xref\n0 " . (count($objects) + 1) . "\n";
+    $pdf .= "xref\n0 " . ($lastObjectNumber + 1) . "\n";
     $pdf .= "0000000000 65535 f \n";
 
-    for ($i = 1; $i <= count($objects); $i++) {
-        $pdf .= sprintf("%010d 00000 n \n", $offsets[$i]);
+    for ($i = 1; $i <= $lastObjectNumber; $i++) {
+        if (isset($offsets[$i])) {
+            $pdf .= sprintf("%010d 00000 n \n", $offsets[$i]);
+        } else {
+            $pdf .= "0000000000 00000 f \n";
+        }
     }
 
-    $pdf .= "trailer\n<< /Size " . (count($objects) + 1) . " /Root 1 0 R >>\n";
+    $pdf .= "trailer\n<< /Size " . ($lastObjectNumber + 1) . " /Root 1 0 R >>\n";
     $pdf .= "startxref\n" . $xrefOffset . "\n%%EOF";
 
     return $pdf;

+ 15 - 0
product.php

@@ -12,6 +12,7 @@ if ($product === null) {
 
 $pageTitle = $product["name"];
 $sizes = getProductSizes($product);
+$productCategoryIds = getProductCategoryIds($product);
 
 if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['add_to_cart'])) {
     // Validate CSRF token
@@ -87,6 +88,20 @@ include __DIR__ . "/includes/header.php";
 
     <div class="product-copy">
         <h1><?php echo escape($product["name"]); ?></h1>
+        <?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">
             <h3>Beschreibung</h3>