4 Sitoutukset 4c9b6ad853 ... f49aaf34a1

Tekijä SHA1 Viesti Päivämäärä
  Medowar f49aaf34a1 anpassung texte, Sonderzeichen 4 päivää sitten
  Medowar 98424504cc paraemetrizing pdf creation texts 4 päivää sitten
  Medowar eb7766d692 fixing frontend and backend validator not beeing aligned 4 päivää sitten
  Medowar 8498def720 small fixes 4 päivää sitten

+ 32 - 1
api/submit.php

@@ -49,6 +49,32 @@ function isMinorBirthdate(string $birthdate): bool
     return $date->diff($today)->y < 18;
     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') {
 if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
     Bootstrap::jsonResponse([
     Bootstrap::jsonResponse([
         'ok' => false,
         'ok' => false,
@@ -153,14 +179,17 @@ try {
             $store->saveDraft($email, [
             $store->saveDraft($email, [
                 'step' => 4,
                 'step' => 4,
                 'form_data' => $mergedFormData,
                 'form_data' => $mergedFormData,
-                'uploads' => $uploadResult['uploads'],
+                'uploads' => $mergedUploads,
             ]);
             ]);
 
 
+            $errorFields = array_values(array_map(static fn ($key): string => (string) $key, array_keys($errors)));
             return [
             return [
                 'ok' => false,
                 'ok' => false,
                 'already_submitted' => false,
                 'already_submitted' => false,
                 'message' => Bootstrap::appMessage('submit.validation_error'),
                 'message' => Bootstrap::appMessage('submit.validation_error'),
                 'errors' => $errors,
                 '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),
         'already_submitted' => (bool) ($submitResult['already_submitted'] ?? false),
         'message' => (string) ($submitResult['message'] ?? Bootstrap::appMessage('submit.failure')),
         'message' => (string) ($submitResult['message'] ?? Bootstrap::appMessage('submit.failure')),
         'errors' => $submitResult['errors'] ?? [],
         'errors' => $submitResult['errors'] ?? [],
+        'error_fields' => $submitResult['error_fields'] ?? [],
+        'error_details' => $submitResult['error_details'] ?? [],
     ], $status);
     ], $status);
 }
 }
 
 

+ 19 - 0
assets/css/base.css

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

+ 125 - 11
assets/js/form.js

@@ -20,6 +20,8 @@
     uploads: {},
     uploads: {},
     isSubmitting: false,
     isSubmitting: false,
     summaryMissingCount: 0,
     summaryMissingCount: 0,
+    summaryInvalidCount: 0,
+    summaryValidationErrors: {},
   };
   };
 
 
   const disclaimerSection = document.getElementById('disclaimerSection');
   const disclaimerSection = document.getElementById('disclaimerSection');
@@ -460,6 +462,8 @@
     renderUploadInfo({});
     renderUploadInfo({});
     state.currentStep = 1;
     state.currentStep = 1;
     state.summaryMissingCount = 0;
     state.summaryMissingCount = 0;
+    state.summaryInvalidCount = 0;
+    state.summaryValidationErrors = {};
     state.isSubmitting = false;
     state.isSubmitting = false;
     if (submitLabel) {
     if (submitLabel) {
       submitLabel.textContent = 'Verbindlich absenden';
       submitLabel.textContent = 'Verbindlich absenden';
@@ -976,19 +980,92 @@
 
 
     const type = String(field.type || 'text');
     const type = String(field.type || 'text');
     if (type === 'file') {
     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') {
     if (type === 'checkbox') {
-      return !isCheckboxTrue(formData[key]);
+      return !isCheckboxTrue(value);
     }
     }
 
 
     if (type === 'table') {
     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) {
   function resolveSelectLabel(field, value) {
@@ -1051,14 +1128,27 @@
     return rawValue;
     return rawValue;
   }
   }
 
 
-  function renderSummary() {
+  function renderSummary(serverErrors) {
     if (!summarySection || !summaryContent || !summaryMissingNotice) {
     if (!summarySection || !summaryContent || !summaryMissingNotice) {
       return;
       return;
     }
     }
 
 
     const formData = collectCurrentFormData();
     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();
     const fragment = document.createDocumentFragment();
     let missingCount = 0;
     let missingCount = 0;
+    let invalidCount = 0;
 
 
     const introCard = document.createElement('div');
     const introCard = document.createElement('div');
     introCard.className = 'summary-step-card';
     introCard.className = 'summary-step-card';
@@ -1096,9 +1186,15 @@
       fields.forEach((field) => {
       fields.forEach((field) => {
         const required = isFieldRequired(field, formData);
         const required = isFieldRequired(field, formData);
         const missing = isFieldMissing(field, formData);
         const missing = isFieldMissing(field, formData);
+        const fieldKey = String(field.key || '').trim();
+        const fieldError = String(mergedErrors[fieldKey] || '').trim();
+        const invalid = fieldError !== '';
         if (missing) {
         if (missing) {
           missingCount += 1;
           missingCount += 1;
         }
         }
+        if (invalid) {
+          invalidCount += 1;
+        }
 
 
         const row = document.createElement('div');
         const row = document.createElement('div');
         row.className = 'field summary-item';
         row.className = 'field summary-item';
@@ -1108,6 +1204,9 @@
         if (missing) {
         if (missing) {
           row.classList.add('summary-item-missing');
           row.classList.add('summary-item-missing');
         }
         }
+        if (invalid) {
+          row.classList.add('summary-item-invalid');
+        }
 
 
         const labelEl = document.createElement('label');
         const labelEl = document.createElement('label');
         labelEl.className = 'summary-item-label';
         labelEl.className = 'summary-item-label';
@@ -1125,6 +1224,11 @@
           missingBadge.className = 'summary-badge summary-badge-missing';
           missingBadge.className = 'summary-badge summary-badge-missing';
           missingBadge.textContent = '! Pflichtfeld fehlt';
           missingBadge.textContent = '! Pflichtfeld fehlt';
           labelEl.appendChild(missingBadge);
           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');
         const valueEl = document.createElement('div');
@@ -1145,6 +1249,12 @@
 
 
         row.appendChild(labelEl);
         row.appendChild(labelEl);
         row.appendChild(valueEl);
         row.appendChild(valueEl);
+        if (invalid) {
+          const errorEl = document.createElement('div');
+          errorEl.className = 'error';
+          errorEl.textContent = fieldError;
+          row.appendChild(errorEl);
+        }
         card.appendChild(row);
         card.appendChild(row);
       });
       });
 
 
@@ -1155,14 +1265,15 @@
     summaryContent.appendChild(fragment);
     summaryContent.appendChild(fragment);
 
 
     state.summaryMissingCount = missingCount;
     state.summaryMissingCount = missingCount;
-    if (missingCount > 0) {
+    state.summaryInvalidCount = invalidCount;
+    if (invalidCount > 0) {
       summaryMissingNotice.classList.remove('hidden');
       summaryMissingNotice.classList.remove('hidden');
       summaryMissingNotice.classList.add('summary-missing-warning');
       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 {
     } else {
       summaryMissingNotice.classList.remove('hidden');
       summaryMissingNotice.classList.remove('hidden');
       summaryMissingNotice.classList.remove('summary-missing-warning');
       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;
       return;
     }
     }
 
 
+    clearErrors();
     renderSummary();
     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;
       return;
     }
     }
 
 
@@ -1980,6 +2093,7 @@
       }
       }
       if (payload.errors) {
       if (payload.errors) {
         showErrors(payload.errors);
         showErrors(payload.errors);
+        renderSummary(payload.errors);
       }
       }
       const msg = payload.message || err.message || 'Absenden fehlgeschlagen.';
       const msg = payload.message || err.message || 'Absenden fehlgeschlagen.';
       setFeedback(msg, true);
       setFeedback(msg, true);

+ 35 - 5
config/app.sample.php

@@ -9,15 +9,15 @@ return [
     'base_url' => '/',
     'base_url' => '/',
     'contact_email' => 'kontakt@example.com',
     'contact_email' => 'kontakt@example.com',
     'start' => [
     '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' => [
     'disclaimer' => [
         'title' => 'Wichtiger Hinweis',
         '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',
         'accept_label' => 'Hinweis gelesen, weiter zum Antrag',
     ],
     ],
     'address_disclaimer' => [
     'address_disclaimer' => [
-        'text' => 'Bitte geben Sie Ihre vollstaendige Meldeadresse inklusive Hausnummer an.',
+        'text' => 'Bitte geben Sie Ihre vollständige Meldeadresse inklusive Hausnummer an.',
     ],
     ],
     'retention' => [
     'retention' => [
         'draft_days' => 14,
         'draft_days' => 14,
@@ -43,7 +43,7 @@ return [
     'admin' => [
     'admin' => [
         // Feste Zugangsdaten als Tabelle (hardcoded).
         // Feste Zugangsdaten als Tabelle (hardcoded).
         // Hash mit: php -r "echo password_hash('DEIN-PASSWORT', PASSWORD_DEFAULT), PHP_EOL;"
         // 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' => [
         'credentials' => [
             [
             [
                 'username' => 'admin',
                 'username' => 'admin',
@@ -59,6 +59,36 @@ return [
         'logs' => $root . '/storage/logs',
         'logs' => $root . '/storage/logs',
         'locks' => $root . '/storage/locks',
         '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' => [
     'api_messages' => [
         'common' => [
         'common' => [
             'method_not_allowed' => 'Method not allowed',
             'method_not_allowed' => 'Method not allowed',
@@ -78,7 +108,7 @@ return [
         'submit' => [
         'submit' => [
             'already_submitted' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
             'already_submitted' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
             'upload_error' => 'Fehler bei Uploads.',
             '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.',
             'lock_error' => 'Abschluss derzeit nicht möglich. Debug-Info: Lock konnte nicht gesetzt werden.',
             'failure' => 'Abschluss fehlgeschlagen.',
             'failure' => 'Abschluss fehlgeschlagen.',
             'success' => 'Ihr Antrag wurde erfolgreich empfangen. Bei Fragen kontaktieren Sie %contact_email%.',
             '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',
                     '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.',
                     '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',
                     'key'         => 'zusatzunterlagen',
                     'label'       => 'Zusatzunterlagen (optional)',
                     '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.
 - `username`: Klartextname fuer Login.
 - `password_hash`: Ergebnis von `password_hash(...)`.
 - `password_hash`: Ergebnis von `password_hash(...)`.
-- Keine Plaintext-Passwoerter speichern.
+- Keine Plaintext-Passwörter speichern.
 
 
 ## 2) Passwort-Hash erzeugen
 ## 2) Passwort-Hash erzeugen
 
 
@@ -45,4 +45,4 @@ Einfachster Weg:
 
 
 Hinweis:
 Hinweis:
 - Das ist kein SSO. Jede App hat weiterhin ihre eigene Session.
 - 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);
     $requiredAlways = (bool) ($field['required'] ?? false);
     $requiredConditional = isset($field['required_if']) && is_array($field['required_if']);
     $requiredConditional = isset($field['required_if']) && is_array($field['required_if']);
     $required = $requiredAlways ? 'required' : '';
     $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 = '';
     $requiredLabel = '';
     if ($requiredAlways) {
     if ($requiredAlways) {
         $requiredLabel = ' <span class="required-mark required-mark-field" aria-hidden="true">* Pflichtfeld</span>';
         $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>';
         echo '<label for="' . $labelFor . '">' . $label . $requiredLabel . '</label>';
 
 
         if ($type === 'textarea') {
         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') {
         } elseif ($type === 'select') {
             echo '<select id="' . $key . '" name="form_data[' . $key . ']" ' . $required . '>';
             echo '<select id="' . $key . '" name="form_data[' . $key . ']" ' . $required . '>';
             echo '<option value="">Bitte wählen</option>';
             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>';
             echo '<div class="upload-list" data-upload-list="' . $key . '"></div>';
         } else {
         } else {
             $inputType = htmlspecialchars($type);
             $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 (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.';
                     $errors[$key] = 'Eingabe ist zu lang.';
                 }
                 }
             }
             }
@@ -282,4 +282,17 @@ final class Validator
 
 
         return true;
         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
     private function renderMinorAdminNoticeHtml(): string
     {
     {
         return '<div style="background:#fff3cd;border:1px solid #f0c36d;padding:10px 12px;margin:12px 0">'
         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. '
             . 'Die Unterschrift eines Erziehungsberechtigten liegt digital noch nicht vor. '
             . 'Bitte die Bearbeitung erst nach Eingang des handschriftlich unterschriebenen Formulars fortsetzen.'
             . 'Bitte die Bearbeitung erst nach Eingang des handschriftlich unterschriebenen Formulars fortsetzen.'
             . '</div>';
             . '</div>';
@@ -358,7 +358,7 @@ final class Mailer
 
 
     private function renderMinorAdminNoticeText(): string
     private function renderMinorAdminNoticeText(): string
     {
     {
-        return "WICHTIGER HINWEIS (MINDERJAEHRIG)\n"
+        return "WICHTIGER HINWEIS (MINDERJÄHRIG)\n"
             . 'Die Unterschrift eines Erziehungsberechtigten liegt digital noch nicht vor. '
             . 'Die Unterschrift eines Erziehungsberechtigten liegt digital noch nicht vor. '
             . 'Bitte die Bearbeitung erst nach Eingang des handschriftlich unterschriebenen Formulars fortsetzen.';
             . '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 SubmissionFormatter $formatter;
     private FormSchema $schema;
     private FormSchema $schema;
     private string $uploadBasePath;
     private string $uploadBasePath;
+    /** @var array<string, mixed> */
+    private array $pdfTexts;
 
 
     public function __construct(SubmissionFormatter $formatter, FormSchema $schema)
     public function __construct(SubmissionFormatter $formatter, FormSchema $schema)
     {
     {
@@ -30,6 +32,7 @@ final class PdfGenerator
 
 
         $app = Bootstrap::config('app');
         $app = Bootstrap::config('app');
         $this->uploadBasePath = rtrim((string) ($app['storage']['uploads'] ?? ''), '/');
         $this->uploadBasePath = rtrim((string) ($app['storage']['uploads'] ?? ''), '/');
+        $this->pdfTexts = is_array($app['pdf_texts'] ?? null) ? $app['pdf_texts'] : [];
     }
     }
 
 
     /** @param array<string, mixed> $submission */
     /** @param array<string, mixed> $submission */
@@ -37,14 +40,14 @@ final class PdfGenerator
     {
     {
         try {
         try {
             $pdf = $this->createPdf();
             $pdf = $this->createPdf();
-            $pdf->SetTitle($this->enc('Mitgliedsantrag'), true);
+            $pdf->SetTitle($this->enc($this->pdfText('form_data.title')), true);
             $pdf->AddPage();
             $pdf->AddPage();
 
 
             $pdf->SetFont('Helvetica', 'B', 16);
             $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->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);
             $pdf->Ln(4);
 
 
             $this->renderPortrait($pdf, $submission);
             $this->renderPortrait($pdf, $submission);
@@ -58,7 +61,7 @@ final class PdfGenerator
             if ($uploads !== []) {
             if ($uploads !== []) {
                 $this->ensureSpace($pdf, 20);
                 $this->ensureSpace($pdf, 20);
                 $pdf->SetFont('Helvetica', 'B', 12);
                 $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);
                 $pdf->SetFont('Helvetica', '', 10);
                 foreach ($uploads as $group) {
                 foreach ($uploads as $group) {
                     $pdf->SetFont('Helvetica', 'B', 10);
                     $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);
             $pdf->Output('F', $tmpPath);
             return $tmpPath;
             return $tmpPath;
         } catch (\Throwable $e) {
         } catch (\Throwable $e) {
@@ -94,21 +97,21 @@ final class PdfGenerator
 
 
         try {
         try {
             $pdf = $this->createPdf();
             $pdf = $this->createPdf();
-            $pdf->SetTitle($this->enc('Einverstaendniserklaerung Minderjaehrige'), true);
+            $pdf->SetTitle($this->enc($this->pdfText('minor_signature.document_title')), true);
             $pdf->AddPage();
             $pdf->AddPage();
 
 
             $pdf->SetFont('Helvetica', 'B', 15);
             $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->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->Ln(3);
 
 
             $pdf->SetFont('Helvetica', '', 10);
             $pdf->SetFont('Helvetica', '', 10);
             $pdf->MultiCell(
             $pdf->MultiCell(
                 0,
                 0,
                 5,
                 5,
-                $this->enc('Dieses Dokument ist auszudrucken, handschriftlich zu unterschreiben und persoenlich einzureichen.')
+                $this->enc($this->pdfText('minor_signature.instruction'))
             );
             );
             $pdf->Ln(2);
             $pdf->Ln(2);
 
 
@@ -121,7 +124,7 @@ final class PdfGenerator
             if ($uploads !== []) {
             if ($uploads !== []) {
                 $this->ensureSpace($pdf, 20);
                 $this->ensureSpace($pdf, 20);
                 $pdf->SetFont('Helvetica', 'B', 12);
                 $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);
                 $pdf->SetFont('Helvetica', '', 10);
                 foreach ($uploads as $group) {
                 foreach ($uploads as $group) {
                     $pdf->SetFont('Helvetica', 'B', 10);
                     $pdf->SetFont('Helvetica', 'B', 10);
@@ -136,11 +139,11 @@ final class PdfGenerator
 
 
             $this->renderMinorSignatureSection($pdf);
             $this->renderMinorSignatureSection($pdf);
 
 
-            $tmpPath = $this->tempPath('minderjaehrige_einverstaendnis');
+            $tmpPath = $this->tempPath($this->pdfText('minor_signature.filename_prefix', 'minderjaehrige_einverstaendnis'));
             $pdf->Output('F', $tmpPath);
             $pdf->Output('F', $tmpPath);
             return $tmpPath;
             return $tmpPath;
         } catch (\Throwable $e) {
         } 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;
             return null;
         }
         }
     }
     }
@@ -160,7 +163,7 @@ final class PdfGenerator
 
 
         try {
         try {
             $pdf = $this->createPdf();
             $pdf = $this->createPdf();
-            $pdf->SetTitle($this->enc('Anlagen zum Mitgliedsantrag'), true);
+            $pdf->SetTitle($this->enc($this->pdfText('attachments.title')), true);
 
 
             foreach ($images as $img) {
             foreach ($images as $img) {
                 $pdf->AddPage();
                 $pdf->AddPage();
@@ -173,11 +176,11 @@ final class PdfGenerator
                     $this->embedImage($pdf, $imgPath);
                     $this->embedImage($pdf, $imgPath);
                 } else {
                 } else {
                     $pdf->SetFont('Helvetica', '', 10);
                     $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);
             $pdf->Output('F', $tmpPath);
             return $tmpPath;
             return $tmpPath;
         } catch (\Throwable $e) {
         } catch (\Throwable $e) {
@@ -310,12 +313,12 @@ final class PdfGenerator
         $this->ensureSpace($pdf, 55);
         $this->ensureSpace($pdf, 55);
         $pdf->Ln(6);
         $pdf->Ln(6);
         $pdf->SetFont('Helvetica', 'B', 11);
         $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->SetFont('Helvetica', '', 10);
         $pdf->MultiCell(
         $pdf->MultiCell(
             0,
             0,
             5,
             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);
         $pdf->Ln(10);
 
 
@@ -330,9 +333,9 @@ final class PdfGenerator
         $pdf->SetY($lineY + 2);
         $pdf->SetY($lineY + 2);
         $pdf->SetFont('Helvetica', '', 9);
         $pdf->SetFont('Helvetica', '', 9);
         $pdf->SetX($leftX);
         $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->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
     private function embedImage(\FPDF $pdf, string $path): void
@@ -486,8 +489,8 @@ final class PdfGenerator
         $pdf = new \FPDF('P', 'mm', 'A4');
         $pdf = new \FPDF('P', 'mm', 'A4');
         $pdf->SetAutoPageBreak(true, 20);
         $pdf->SetAutoPageBreak(true, 20);
         $pdf->SetMargins(15, 15, 15);
         $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;
         return $pdf;
     }
     }
 
 
@@ -504,6 +507,24 @@ final class PdfGenerator
         return sys_get_temp_dir() . '/antrag_' . $prefix . '_' . bin2hex(random_bytes(8)) . '.pdf';
         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.
      * Encode UTF-8 to Windows-1252 for FPDF's built-in fonts.
      * Transliterates characters outside the target charset.
      * 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 = (array) ($submission['form_data'] ?? []);
         $formData['is_minor'] = $this->deriveIsMinor($formData);
         $formData['is_minor'] = $this->deriveIsMinor($formData);
+        $submissionEmail = trim((string) ($submission['email'] ?? ''));
         $result = [];
         $result = [];
 
 
         foreach ($this->schema->getSteps() as $step) {
         foreach ($this->schema->getSteps() as $step) {
@@ -55,6 +56,10 @@ final class SubmissionFormatter
                 $fields[] = ['label' => $label, 'value' => $value];
                 $fields[] = ['label' => $label, 'value' => $value];
             }
             }
 
 
+            if ($this->isPersonalDataStep($title) && $submissionEmail !== '' && !$this->hasEmailField($fields)) {
+                $fields[] = ['label' => 'E-Mail', 'value' => $submissionEmail];
+            }
+
             if ($fields !== []) {
             if ($fields !== []) {
                 $result[] = ['title' => $title, 'fields' => $fields];
                 $result[] = ['title' => $title, 'fields' => $fields];
             }
             }
@@ -200,6 +205,23 @@ final class SubmissionFormatter
         return true;
         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 */
     /** @param array<string, mixed> $formData */
     private function deriveIsMinor(array $formData): string
     private function deriveIsMinor(array $formData): string
     {
     {