4 Angajamente 4c9b6ad853 ... f49aaf34a1

Autor SHA1 Permisiunea de a trimite mesaje. Dacă este dezactivată, utilizatorul nu va putea trimite nici un fel de mesaj Data
  Medowar f49aaf34a1 anpassung texte, Sonderzeichen 4 zile în urmă
  Medowar 98424504cc paraemetrizing pdf creation texts 4 zile în urmă
  Medowar eb7766d692 fixing frontend and backend validator not beeing aligned 4 zile în urmă
  Medowar 8498def720 small fixes 4 zile în urmă

+ 32 - 1
api/submit.php

@@ -49,6 +49,32 @@ function isMinorBirthdate(string $birthdate): bool
     return $date->diff($today)->y < 18;
 }
 
+/**
+ * @param array<string, string> $errors
+ * @return array<int, array{key: string, label: string, message: string}>
+ */
+function buildValidationErrorDetails(array $errors, FormSchema $schema): array
+{
+    $details = [];
+    $allFields = $schema->getAllFields();
+
+    foreach ($errors as $key => $message) {
+        $field = $allFields[$key] ?? [];
+        $label = '';
+        if (is_array($field)) {
+            $label = trim((string) ($field['label'] ?? ''));
+        }
+
+        $details[] = [
+            'key' => (string) $key,
+            'label' => $label !== '' ? $label : (string) $key,
+            'message' => (string) $message,
+        ];
+    }
+
+    return $details;
+}
+
 if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
     Bootstrap::jsonResponse([
         'ok' => false,
@@ -153,14 +179,17 @@ try {
             $store->saveDraft($email, [
                 'step' => 4,
                 'form_data' => $mergedFormData,
-                'uploads' => $uploadResult['uploads'],
+                'uploads' => $mergedUploads,
             ]);
 
+            $errorFields = array_values(array_map(static fn ($key): string => (string) $key, array_keys($errors)));
             return [
                 'ok' => false,
                 'already_submitted' => false,
                 'message' => Bootstrap::appMessage('submit.validation_error'),
                 'errors' => $errors,
+                'error_fields' => $errorFields,
+                'error_details' => buildValidationErrorDetails($errors, $schema),
             ];
         }
 
@@ -191,6 +220,8 @@ if (($submitResult['ok'] ?? false) !== true) {
         'already_submitted' => (bool) ($submitResult['already_submitted'] ?? false),
         'message' => (string) ($submitResult['message'] ?? Bootstrap::appMessage('submit.failure')),
         'errors' => $submitResult['errors'] ?? [],
+        'error_fields' => $submitResult['error_fields'] ?? [],
+        'error_details' => $submitResult['error_details'] ?? [],
     ], $status);
 }
 

+ 19 - 0
assets/css/base.css

@@ -712,6 +712,11 @@ small {
   background: transparent;
 }
 
+.summary-item-invalid {
+  border: 0;
+  background: transparent;
+}
+
 .summary-item-label {
   font-weight: 600;
   margin-bottom: 0.5rem;
@@ -756,6 +761,15 @@ small {
   background: rgba(193, 18, 31, 0.1);
 }
 
+.summary-item-invalid .summary-item-label {
+  color: #ffd3d7;
+}
+
+.summary-item-invalid .summary-item-value {
+  border-color: rgba(193, 18, 31, 0.75);
+  background: rgba(193, 18, 31, 0.1);
+}
+
 .summary-badge {
   display: inline-block;
   margin-left: 0.4rem;
@@ -780,6 +794,11 @@ small {
   color: #ffd3d7;
 }
 
+.summary-badge-invalid {
+  background: rgba(193, 18, 31, 0.24);
+  color: #ffd3d7;
+}
+
 .btn-spinner {
   display: inline-block;
   width: 14px;

+ 125 - 11
assets/js/form.js

@@ -20,6 +20,8 @@
     uploads: {},
     isSubmitting: false,
     summaryMissingCount: 0,
+    summaryInvalidCount: 0,
+    summaryValidationErrors: {},
   };
 
   const disclaimerSection = document.getElementById('disclaimerSection');
@@ -460,6 +462,8 @@
     renderUploadInfo({});
     state.currentStep = 1;
     state.summaryMissingCount = 0;
+    state.summaryInvalidCount = 0;
+    state.summaryValidationErrors = {};
     state.isSubmitting = false;
     if (submitLabel) {
       submitLabel.textContent = 'Verbindlich absenden';
@@ -976,19 +980,92 @@
 
     const type = String(field.type || 'text');
     if (type === 'file') {
-      const uploadItems = state.uploads[key];
-      return !Array.isArray(uploadItems) || uploadItems.length === 0;
+      return !hasFileValue(key);
     }
 
+    return isFormValueEmpty(formData[key], type, field);
+  }
+
+  function isFormValueEmpty(value, type, field) {
     if (type === 'checkbox') {
-      return !isCheckboxTrue(formData[key]);
+      return !isCheckboxTrue(value);
     }
 
     if (type === 'table') {
-      return isTableCsvEmpty(formData[key], field);
+      return isTableCsvEmpty(value, field);
     }
 
-    return String(formData[key] || '').trim() === '';
+    return String(value || '').trim() === '';
+  }
+
+  function validationStringLength(value) {
+    return Array.from(String(value || '')).length;
+  }
+
+  function validateFormData(formData) {
+    const errors = {};
+
+    fieldsByKey.forEach((field, key) => {
+      const type = String((field && field.type) || 'text');
+      if (!isFieldVisible(field, formData)) {
+        return;
+      }
+
+      const required = isFieldRequired(field, formData);
+      if (type === 'file') {
+        if (required && !hasFileValue(key)) {
+          errors[key] = 'Dieses Upload-Feld ist erforderlich.';
+        }
+        return;
+      }
+
+      const value = formData[key];
+      const emptyValue = isFormValueEmpty(value, type, field);
+      if (required && emptyValue) {
+        errors[key] = 'Dieses Feld ist erforderlich.';
+        return;
+      }
+
+      if (emptyValue) {
+        return;
+      }
+
+      const scalarValue = String(value || '').trim();
+
+      if (type === 'email' && !isValidEmail(scalarValue)) {
+        errors[key] = 'Bitte eine gültige E-Mail-Adresse eingeben.';
+        return;
+      }
+
+      if (type === 'select' && Array.isArray(field.options)) {
+        const selected = scalarValue;
+        let validSelection = false;
+
+        field.options.forEach((option) => {
+          if (validSelection || !option || typeof option !== 'object') {
+            return;
+          }
+
+          if (String(option.value || '') !== selected) {
+            return;
+          }
+
+          validSelection = isOptionVisible(option, formData);
+        });
+
+        if (!validSelection) {
+          errors[key] = 'Ungültige Auswahl.';
+          return;
+        }
+      }
+
+      const maxLength = Number(field.max_length);
+      if (Number.isInteger(maxLength) && maxLength > 0 && validationStringLength(scalarValue) > maxLength) {
+        errors[key] = 'Eingabe ist zu lang.';
+      }
+    });
+
+    return errors;
   }
 
   function resolveSelectLabel(field, value) {
@@ -1051,14 +1128,27 @@
     return rawValue;
   }
 
-  function renderSummary() {
+  function renderSummary(serverErrors) {
     if (!summarySection || !summaryContent || !summaryMissingNotice) {
       return;
     }
 
     const formData = collectCurrentFormData();
+    const clientErrors = validateFormData(formData);
+    const mergedErrors = { ...clientErrors };
+    if (serverErrors && typeof serverErrors === 'object') {
+      Object.keys(serverErrors).forEach((key) => {
+        const message = String(serverErrors[key] || '').trim();
+        if (message !== '') {
+          mergedErrors[key] = message;
+        }
+      });
+    }
+    state.summaryValidationErrors = mergedErrors;
+
     const fragment = document.createDocumentFragment();
     let missingCount = 0;
+    let invalidCount = 0;
 
     const introCard = document.createElement('div');
     introCard.className = 'summary-step-card';
@@ -1096,9 +1186,15 @@
       fields.forEach((field) => {
         const required = isFieldRequired(field, formData);
         const missing = isFieldMissing(field, formData);
+        const fieldKey = String(field.key || '').trim();
+        const fieldError = String(mergedErrors[fieldKey] || '').trim();
+        const invalid = fieldError !== '';
         if (missing) {
           missingCount += 1;
         }
+        if (invalid) {
+          invalidCount += 1;
+        }
 
         const row = document.createElement('div');
         row.className = 'field summary-item';
@@ -1108,6 +1204,9 @@
         if (missing) {
           row.classList.add('summary-item-missing');
         }
+        if (invalid) {
+          row.classList.add('summary-item-invalid');
+        }
 
         const labelEl = document.createElement('label');
         labelEl.className = 'summary-item-label';
@@ -1125,6 +1224,11 @@
           missingBadge.className = 'summary-badge summary-badge-missing';
           missingBadge.textContent = '! Pflichtfeld fehlt';
           labelEl.appendChild(missingBadge);
+        } else if (invalid) {
+          const invalidBadge = document.createElement('span');
+          invalidBadge.className = 'summary-badge summary-badge-invalid';
+          invalidBadge.textContent = '! Ungültiger Wert';
+          labelEl.appendChild(invalidBadge);
         }
 
         const valueEl = document.createElement('div');
@@ -1145,6 +1249,12 @@
 
         row.appendChild(labelEl);
         row.appendChild(valueEl);
+        if (invalid) {
+          const errorEl = document.createElement('div');
+          errorEl.className = 'error';
+          errorEl.textContent = fieldError;
+          row.appendChild(errorEl);
+        }
         card.appendChild(row);
       });
 
@@ -1155,14 +1265,15 @@
     summaryContent.appendChild(fragment);
 
     state.summaryMissingCount = missingCount;
-    if (missingCount > 0) {
+    state.summaryInvalidCount = invalidCount;
+    if (invalidCount > 0) {
       summaryMissingNotice.classList.remove('hidden');
       summaryMissingNotice.classList.add('summary-missing-warning');
-      summaryMissingNotice.textContent = '! Es fehlen noch ' + String(missingCount) + ' Pflichtfelder. Bitte korrigieren Sie die rot markierten Einträge.';
+      summaryMissingNotice.textContent = '! Es gibt noch ' + String(invalidCount) + ' ungültige oder fehlende Felder. Bitte korrigieren Sie die rot markierten Einträge.';
     } else {
       summaryMissingNotice.classList.remove('hidden');
       summaryMissingNotice.classList.remove('summary-missing-warning');
-      summaryMissingNotice.textContent = 'Alle Pflichtfelder sind ausgefüllt.';
+      summaryMissingNotice.textContent = 'Alle Pflichtfelder sind ausgefüllt und die Angaben sind gültig.';
     }
   }
 
@@ -1963,9 +2074,11 @@
       return;
     }
 
+    clearErrors();
     renderSummary();
-    if (state.summaryMissingCount > 0) {
-      setFeedback('Bitte zuerst alle rot markierten Pflichtfelder ausfüllen.', true);
+    if (state.summaryInvalidCount > 0) {
+      showErrors(state.summaryValidationErrors);
+      setFeedback('Bitte zuerst alle rot markierten Pflichtfelder und ungültigen Angaben korrigieren.', true);
       return;
     }
 
@@ -1980,6 +2093,7 @@
       }
       if (payload.errors) {
         showErrors(payload.errors);
+        renderSummary(payload.errors);
       }
       const msg = payload.message || err.message || 'Absenden fehlgeschlagen.';
       setFeedback(msg, true);

+ 35 - 5
config/app.sample.php

@@ -9,15 +9,15 @@ return [
     'base_url' => '/',
     'contact_email' => 'kontakt@example.com',
     'start' => [
-        'intro_text' => 'Bitte E-Mail eingeben. Bestehende Entwürfe werden automatisch geladen.',
+        'intro_text' => 'Zum Start des Mitgliedsantrags bitte E-Mail eingeben. Bestehende Entwürfe werden automatisch geladen. Die eingegebene E-Mail Adresse wird gleichzeitig als Kontakt-Adresse für die Mitgliedschaft verwendet.',
     ],
     'disclaimer' => [
         'title' => 'Wichtiger Hinweis',
-        'text' => "Bitte lesen Sie diesen Hinweis vor Beginn sorgfältig.\n\nMit dem Fortfahren bestaetigen Sie, dass Ihre Angaben vollstaendig und wahrheitsgemäß sind.\nIhre Daten werden ausschliesslich zur Bearbeitung des Mitgliedsantrags verwendet.",
+        'text' => "Bitte lesen Sie diesen Hinweis vor Beginn sorgfältig.\n\nDieses Formular dient dem Beitritt zur Freiwilligen Feuerwehr Freising.\n Füllen Sie dieses Formular nur aus, wenn Sie bereits mindestens einmal die Übungen als Beitrittskandidat besucht haben.\n Mit dem Fortfahren bestätigen Sie, dass Ihre Angaben vollständig und wahrheitsgemäß sind.\nIhre Daten werden ausschließlich zur Bearbeitung des Mitgliedsantrags verwendet.",
         'accept_label' => 'Hinweis gelesen, weiter zum Antrag',
     ],
     'address_disclaimer' => [
-        'text' => 'Bitte geben Sie Ihre vollstaendige Meldeadresse inklusive Hausnummer an.',
+        'text' => 'Bitte geben Sie Ihre vollständige Meldeadresse inklusive Hausnummer an.',
     ],
     'retention' => [
         'draft_days' => 14,
@@ -43,7 +43,7 @@ return [
     'admin' => [
         // Feste Zugangsdaten als Tabelle (hardcoded).
         // Hash mit: php -r "echo password_hash('DEIN-PASSWORT', PASSWORD_DEFAULT), PHP_EOL;"
-        // Alternativ: Online Tool: https://bcrypt-generator.com/
+        // Alternativ: Online Tool: https://wutools.com/dev/encoding/bcrypt-hash-generator
         'credentials' => [
             [
                 'username' => 'admin',
@@ -59,6 +59,36 @@ return [
         'logs' => $root . '/storage/logs',
         'locks' => $root . '/storage/locks',
     ],
+    'pdf_texts' => [
+        'metadata' => [
+            'creator' => 'Feuerwehr Freising - Mitgliedsantrag',
+            'author' => 'Feuerwehr Freising',
+        ],
+        'common' => [
+            'submitted_prefix' => 'Eingereicht: ',
+            'email_prefix' => 'E-Mail: ',
+            'uploads_heading' => 'Hochgeladene Dateien',
+            'missing_image' => '[Bild konnte nicht geladen werden]',
+        ],
+        'form_data' => [
+            'title' => 'Mitgliedsantrag',
+            'filename_prefix' => 'antragsdaten',
+        ],
+        'minor_signature' => [
+            'document_title' => 'Einverständniserklärung Minderjährige',
+            'heading' => 'Einverständniserklärung fuer Minderjährige',
+            'instruction' => 'Dieses Dokument ist auszudrucken, handschriftlich zu unterschreiben und persönlich einzureichen.',
+            'filename_prefix' => 'minderjaehrige_einverstaendnis',
+            'signature_heading' => 'Unterschriften',
+            'signature_confirmation' => 'Hiermit bestätigen Antragsteller/in und Erziehungsberechtigte/r die Richtigkeit der oben aufgefuehrten Angaben.',
+            'signature_minor_label' => 'Antragsteller/in (minderjährig)',
+            'signature_guardian_label' => 'Erziehungsberechtigte/r (Eltern)',
+        ],
+        'attachments' => [
+            'title' => 'Anlagen zum Mitgliedsantrag',
+            'filename_prefix' => 'anlagen',
+        ],
+    ],
     'api_messages' => [
         'common' => [
             'method_not_allowed' => 'Method not allowed',
@@ -78,7 +108,7 @@ return [
         'submit' => [
             'already_submitted' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
             'upload_error' => 'Fehler bei Uploads.',
-            'validation_error' => 'Bitte Pflichtfelder prüfen. Nicht alle Pflichtfeler sind ausgefüllt oder ungültige Werte vorhanden.',
+            'validation_error' => 'Bitte Pflichtfelder prüfen. Nicht alle Pflichtfelder sind ausgefüllt oder ungültige Werte vorhanden.',
             'lock_error' => 'Abschluss derzeit nicht möglich. Debug-Info: Lock konnte nicht gesetzt werden.',
             'failure' => 'Abschluss fehlgeschlagen.',
             'success' => 'Ihr Antrag wurde erfolgreich empfangen. Bei Fragen kontaktieren Sie %contact_email%.',

+ 0 - 12
config/form_schema.php

@@ -455,18 +455,6 @@ return [
                     'accept'      => '.pdf,.jpg,.jpeg,.png',
                     'description' => 'Nachweise deiner Feuerwehr-Qualifikationen. Bitte Nachweise aller Lehrgänge und Beförderungen. Ohne Nachweis können wir die Qualifikation nicht anerkennen. Ein gutes Foto der Nachweise ist ausreichend.',
                 ],
-                [
-                    'key'         => 'einverstaendniserklaerung',
-                    'label'       => 'Einverständniserklärung Erziehungsberechtigte',
-                    'type'        => 'file',
-                    'required'    => false,
-                    'visible_if'  => [
-                        'field'  => 'mitgliedsart',
-                        'equals' => 'Jugend',
-                    ],
-                    'accept'      => '.pdf,.jpg,.jpeg,.png',
-                    'description' => 'Wird bei Mitgliedsart Jugend angezeigt.',
-                ],
                 [
                     'key'         => 'zusatzunterlagen',
                     'label'       => 'Zusatzunterlagen (optional)',

+ 2 - 2
docs/auth_integration.md

@@ -18,7 +18,7 @@ Diese Anwendung nutzt eine statische Credential-Tabelle in `config/app.local.php
 
 - `username`: Klartextname fuer Login.
 - `password_hash`: Ergebnis von `password_hash(...)`.
-- Keine Plaintext-Passwoerter speichern.
+- Keine Plaintext-Passwörter speichern.
 
 ## 2) Passwort-Hash erzeugen
 
@@ -45,4 +45,4 @@ Einfachster Weg:
 
 Hinweis:
 - Das ist kein SSO. Jede App hat weiterhin ihre eigene Session.
-- Fuer spaeteres SSO kann dieselbe Credential-Tabelle als gemeinsame Basis dienen.
+- Fuer späteres SSO kann dieselbe Credential-Tabelle als gemeinsame Basis dienen.

+ 6 - 2
index.php

@@ -52,6 +52,10 @@ function renderField(array $field, string $addressDisclaimerText): void
     $requiredAlways = (bool) ($field['required'] ?? false);
     $requiredConditional = isset($field['required_if']) && is_array($field['required_if']);
     $required = $requiredAlways ? 'required' : '';
+    $maxLengthAttr = '';
+    if (isset($field['max_length']) && is_int($field['max_length']) && $field['max_length'] > 0) {
+        $maxLengthAttr = ' maxlength="' . (string) $field['max_length'] . '"';
+    }
     $requiredLabel = '';
     if ($requiredAlways) {
         $requiredLabel = ' <span class="required-mark required-mark-field" aria-hidden="true">* Pflichtfeld</span>';
@@ -75,7 +79,7 @@ function renderField(array $field, string $addressDisclaimerText): void
         echo '<label for="' . $labelFor . '">' . $label . $requiredLabel . '</label>';
 
         if ($type === 'textarea') {
-            echo '<textarea id="' . $key . '" name="form_data[' . $key . ']" ' . $required . '></textarea>';
+            echo '<textarea id="' . $key . '" name="form_data[' . $key . ']" ' . $required . $maxLengthAttr . '></textarea>';
         } elseif ($type === 'select') {
             echo '<select id="' . $key . '" name="form_data[' . $key . ']" ' . $required . '>';
             echo '<option value="">Bitte wählen</option>';
@@ -179,7 +183,7 @@ function renderField(array $field, string $addressDisclaimerText): void
             echo '<div class="upload-list" data-upload-list="' . $key . '"></div>';
         } else {
             $inputType = htmlspecialchars($type);
-            echo '<input id="' . $key . '" type="' . $inputType . '" name="form_data[' . $key . ']" ' . $required . '>';
+            echo '<input id="' . $key . '" type="' . $inputType . '" name="form_data[' . $key . ']" ' . $required . $maxLengthAttr . '>';
         }
     }
 

+ 14 - 1
src/form/validator.php

@@ -77,7 +77,7 @@ final class Validator
             }
 
             if (isset($field['max_length']) && is_int($field['max_length'])) {
-                if (strlen((string) $value) > $field['max_length']) {
+                if ($this->validationStringLength((string) $value) > $field['max_length']) {
                     $errors[$key] = 'Eingabe ist zu lang.';
                 }
             }
@@ -282,4 +282,17 @@ final class Validator
 
         return true;
     }
+
+    private function validationStringLength(string $value): int
+    {
+        if (function_exists('mb_strlen')) {
+            return mb_strlen($value, 'UTF-8');
+        }
+
+        if (preg_match_all('/./us', $value, $matches) === false) {
+            return strlen($value);
+        }
+
+        return count($matches[0] ?? []);
+    }
 }

+ 2 - 2
src/mail/mailer.php

@@ -350,7 +350,7 @@ final class Mailer
     private function renderMinorAdminNoticeHtml(): string
     {
         return '<div style="background:#fff3cd;border:1px solid #f0c36d;padding:10px 12px;margin:12px 0">'
-            . '<strong>Wichtiger Hinweis (Minderjaehrig):</strong> '
+            . '<strong>Wichtiger Hinweis (Minderjährig):</strong> '
             . 'Die Unterschrift eines Erziehungsberechtigten liegt digital noch nicht vor. '
             . 'Bitte die Bearbeitung erst nach Eingang des handschriftlich unterschriebenen Formulars fortsetzen.'
             . '</div>';
@@ -358,7 +358,7 @@ final class Mailer
 
     private function renderMinorAdminNoticeText(): string
     {
-        return "WICHTIGER HINWEIS (MINDERJAEHRIG)\n"
+        return "WICHTIGER HINWEIS (MINDERJÄHRIG)\n"
             . 'Die Unterschrift eines Erziehungsberechtigten liegt digital noch nicht vor. '
             . 'Bitte die Bearbeitung erst nach Eingang des handschriftlich unterschriebenen Formulars fortsetzen.';
     }

+ 44 - 23
src/mail/pdfgenerator.php

@@ -22,6 +22,8 @@ final class PdfGenerator
     private SubmissionFormatter $formatter;
     private FormSchema $schema;
     private string $uploadBasePath;
+    /** @var array<string, mixed> */
+    private array $pdfTexts;
 
     public function __construct(SubmissionFormatter $formatter, FormSchema $schema)
     {
@@ -30,6 +32,7 @@ final class PdfGenerator
 
         $app = Bootstrap::config('app');
         $this->uploadBasePath = rtrim((string) ($app['storage']['uploads'] ?? ''), '/');
+        $this->pdfTexts = is_array($app['pdf_texts'] ?? null) ? $app['pdf_texts'] : [];
     }
 
     /** @param array<string, mixed> $submission */
@@ -37,14 +40,14 @@ final class PdfGenerator
     {
         try {
             $pdf = $this->createPdf();
-            $pdf->SetTitle($this->enc('Mitgliedsantrag'), true);
+            $pdf->SetTitle($this->enc($this->pdfText('form_data.title')), true);
             $pdf->AddPage();
 
             $pdf->SetFont('Helvetica', 'B', 16);
-            $pdf->Cell(0, 10, $this->enc('Mitgliedsantrag'), 0, 1);
+            $pdf->Cell(0, 10, $this->enc($this->pdfText('form_data.title')), 0, 1);
             $pdf->SetFont('Helvetica', '', 9);
-            $pdf->Cell(0, 5, $this->enc('Eingereicht: ' . $this->formatTimestamp($submission)), 0, 1);
-            $pdf->Cell(0, 5, $this->enc('E-Mail: ' . (string) ($submission['email'] ?? '')), 0, 1);
+            $pdf->Cell(0, 5, $this->enc($this->pdfText('common.submitted_prefix') . $this->formatTimestamp($submission)), 0, 1);
+            $pdf->Cell(0, 5, $this->enc($this->pdfText('common.email_prefix') . (string) ($submission['email'] ?? '')), 0, 1);
             $pdf->Ln(4);
 
             $this->renderPortrait($pdf, $submission);
@@ -58,7 +61,7 @@ final class PdfGenerator
             if ($uploads !== []) {
                 $this->ensureSpace($pdf, 20);
                 $pdf->SetFont('Helvetica', 'B', 12);
-                $pdf->Cell(0, 8, $this->enc('Hochgeladene Dateien'), 0, 1);
+                $pdf->Cell(0, 8, $this->enc($this->pdfText('common.uploads_heading')), 0, 1);
                 $pdf->SetFont('Helvetica', '', 10);
                 foreach ($uploads as $group) {
                     $pdf->SetFont('Helvetica', 'B', 10);
@@ -71,7 +74,7 @@ final class PdfGenerator
                 }
             }
 
-            $tmpPath = $this->tempPath('antragsdaten');
+            $tmpPath = $this->tempPath($this->pdfText('form_data.filename_prefix', 'antragsdaten'));
             $pdf->Output('F', $tmpPath);
             return $tmpPath;
         } catch (\Throwable $e) {
@@ -94,21 +97,21 @@ final class PdfGenerator
 
         try {
             $pdf = $this->createPdf();
-            $pdf->SetTitle($this->enc('Einverstaendniserklaerung Minderjaehrige'), true);
+            $pdf->SetTitle($this->enc($this->pdfText('minor_signature.document_title')), true);
             $pdf->AddPage();
 
             $pdf->SetFont('Helvetica', 'B', 15);
-            $pdf->Cell(0, 10, $this->enc('Einverstaendniserklaerung fuer Minderjaehrige'), 0, 1);
+            $pdf->Cell(0, 10, $this->enc($this->pdfText('minor_signature.heading')), 0, 1);
             $pdf->SetFont('Helvetica', '', 9);
-            $pdf->Cell(0, 5, $this->enc('Eingereicht: ' . $this->formatTimestamp($submission)), 0, 1);
-            $pdf->Cell(0, 5, $this->enc('E-Mail: ' . (string) ($submission['email'] ?? '')), 0, 1);
+            $pdf->Cell(0, 5, $this->enc($this->pdfText('common.submitted_prefix') . $this->formatTimestamp($submission)), 0, 1);
+            $pdf->Cell(0, 5, $this->enc($this->pdfText('common.email_prefix') . (string) ($submission['email'] ?? '')), 0, 1);
             $pdf->Ln(3);
 
             $pdf->SetFont('Helvetica', '', 10);
             $pdf->MultiCell(
                 0,
                 5,
-                $this->enc('Dieses Dokument ist auszudrucken, handschriftlich zu unterschreiben und persoenlich einzureichen.')
+                $this->enc($this->pdfText('minor_signature.instruction'))
             );
             $pdf->Ln(2);
 
@@ -121,7 +124,7 @@ final class PdfGenerator
             if ($uploads !== []) {
                 $this->ensureSpace($pdf, 20);
                 $pdf->SetFont('Helvetica', 'B', 12);
-                $pdf->Cell(0, 8, $this->enc('Hochgeladene Dateien'), 0, 1);
+                $pdf->Cell(0, 8, $this->enc($this->pdfText('common.uploads_heading')), 0, 1);
                 $pdf->SetFont('Helvetica', '', 10);
                 foreach ($uploads as $group) {
                     $pdf->SetFont('Helvetica', 'B', 10);
@@ -136,11 +139,11 @@ final class PdfGenerator
 
             $this->renderMinorSignatureSection($pdf);
 
-            $tmpPath = $this->tempPath('minderjaehrige_einverstaendnis');
+            $tmpPath = $this->tempPath($this->pdfText('minor_signature.filename_prefix', 'minderjaehrige_einverstaendnis'));
             $pdf->Output('F', $tmpPath);
             return $tmpPath;
         } catch (\Throwable $e) {
-            Bootstrap::log('mail', 'PDF-Erstellung (Minderjaehrigen-Erklaerung) fehlgeschlagen: ' . $e->getMessage());
+            Bootstrap::log('mail', 'PDF-Erstellung (Minderjährigen-Erklärung) fehlgeschlagen: ' . $e->getMessage());
             return null;
         }
     }
@@ -160,7 +163,7 @@ final class PdfGenerator
 
         try {
             $pdf = $this->createPdf();
-            $pdf->SetTitle($this->enc('Anlagen zum Mitgliedsantrag'), true);
+            $pdf->SetTitle($this->enc($this->pdfText('attachments.title')), true);
 
             foreach ($images as $img) {
                 $pdf->AddPage();
@@ -173,11 +176,11 @@ final class PdfGenerator
                     $this->embedImage($pdf, $imgPath);
                 } else {
                     $pdf->SetFont('Helvetica', '', 10);
-                    $pdf->Cell(0, 10, $this->enc('[Bild konnte nicht geladen werden]'), 0, 1);
+                    $pdf->Cell(0, 10, $this->enc($this->pdfText('common.missing_image')), 0, 1);
                 }
             }
 
-            $tmpPath = $this->tempPath('anlagen');
+            $tmpPath = $this->tempPath($this->pdfText('attachments.filename_prefix', 'anlagen'));
             $pdf->Output('F', $tmpPath);
             return $tmpPath;
         } catch (\Throwable $e) {
@@ -310,12 +313,12 @@ final class PdfGenerator
         $this->ensureSpace($pdf, 55);
         $pdf->Ln(6);
         $pdf->SetFont('Helvetica', 'B', 11);
-        $pdf->Cell(0, 8, $this->enc('Unterschriften'), 0, 1);
+        $pdf->Cell(0, 8, $this->enc($this->pdfText('minor_signature.signature_heading')), 0, 1);
         $pdf->SetFont('Helvetica', '', 10);
         $pdf->MultiCell(
             0,
             5,
-            $this->enc('Hiermit bestaetigen Antragsteller/in und Erziehungsberechtigte/r die Richtigkeit der oben aufgefuehrten Angaben.')
+            $this->enc($this->pdfText('minor_signature.signature_confirmation'))
         );
         $pdf->Ln(10);
 
@@ -330,9 +333,9 @@ final class PdfGenerator
         $pdf->SetY($lineY + 2);
         $pdf->SetFont('Helvetica', '', 9);
         $pdf->SetX($leftX);
-        $pdf->Cell($lineWidth, 5, $this->enc('Antragsteller/in (minderjaehrig)'), 0, 0);
+        $pdf->Cell($lineWidth, 5, $this->enc($this->pdfText('minor_signature.signature_minor_label')), 0, 0);
         $pdf->SetX($rightX);
-        $pdf->Cell($lineWidth, 5, $this->enc('Erziehungsberechtigte/r (Eltern)'), 0, 1);
+        $pdf->Cell($lineWidth, 5, $this->enc($this->pdfText('minor_signature.signature_guardian_label')), 0, 1);
     }
 
     private function embedImage(\FPDF $pdf, string $path): void
@@ -486,8 +489,8 @@ final class PdfGenerator
         $pdf = new \FPDF('P', 'mm', 'A4');
         $pdf->SetAutoPageBreak(true, 20);
         $pdf->SetMargins(15, 15, 15);
-        $pdf->SetCreator($this->enc('Feuerwehr Freising - Mitgliedsantrag'), true);
-        $pdf->SetAuthor($this->enc('Feuerwehr Freising'), true);
+        $pdf->SetCreator($this->enc($this->pdfText('metadata.creator')), true);
+        $pdf->SetAuthor($this->enc($this->pdfText('metadata.author')), true);
         return $pdf;
     }
 
@@ -504,6 +507,24 @@ final class PdfGenerator
         return sys_get_temp_dir() . '/antrag_' . $prefix . '_' . bin2hex(random_bytes(8)) . '.pdf';
     }
 
+    private function pdfText(string $path, string $fallback = ''): string
+    {
+        $value = $this->pdfTexts;
+        foreach (explode('.', $path) as $segment) {
+            if (!is_array($value) || !array_key_exists($segment, $value)) {
+                return $fallback;
+            }
+            $value = $value[$segment];
+        }
+
+        if (!is_string($value)) {
+            return $fallback;
+        }
+
+        $text = trim($value);
+        return $text !== '' ? $text : $fallback;
+    }
+
     /**
      * Encode UTF-8 to Windows-1252 for FPDF's built-in fonts.
      * Transliterates characters outside the target charset.

+ 22 - 0
src/mail/submissionformatter.php

@@ -27,6 +27,7 @@ final class SubmissionFormatter
     {
         $formData = (array) ($submission['form_data'] ?? []);
         $formData['is_minor'] = $this->deriveIsMinor($formData);
+        $submissionEmail = trim((string) ($submission['email'] ?? ''));
         $result = [];
 
         foreach ($this->schema->getSteps() as $step) {
@@ -55,6 +56,10 @@ final class SubmissionFormatter
                 $fields[] = ['label' => $label, 'value' => $value];
             }
 
+            if ($this->isPersonalDataStep($title) && $submissionEmail !== '' && !$this->hasEmailField($fields)) {
+                $fields[] = ['label' => 'E-Mail', 'value' => $submissionEmail];
+            }
+
             if ($fields !== []) {
                 $result[] = ['title' => $title, 'fields' => $fields];
             }
@@ -200,6 +205,23 @@ final class SubmissionFormatter
         return true;
     }
 
+    private function isPersonalDataStep(string $title): bool
+    {
+        return trim($title) === 'Persönliche Daten';
+    }
+
+    /** @param array<int, array{label: string, value: string}> $fields */
+    private function hasEmailField(array $fields): bool
+    {
+        foreach ($fields as $field) {
+            if (strtolower(trim((string) ($field['label'] ?? ''))) === 'e-mail') {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
     /** @param array<string, mixed> $formData */
     private function deriveIsMinor(array $formData): string
     {