浏览代码

start form config, some fixes and adjustments

Medowar 1 月之前
父节点
当前提交
814bec60ce
共有 5 个文件被更改,包括 862 次插入111 次删除
  1. 189 7
      assets/css/base.css
  2. 578 55
      assets/js/form.js
  3. 3 0
      config/app.sample.php
  4. 7 14
      config/form_schema.php
  5. 85 35
      index.php

+ 189 - 7
assets/css/base.css

@@ -168,6 +168,30 @@ label {
   font-weight: 600;
 }
 
+.required-mark {
+  display: inline-block;
+  margin-left: 0.35rem;
+  padding: 0.05rem 0.35rem;
+  border-radius: 999px;
+  background: rgba(193, 18, 31, 0.18);
+  color: #ffd0d4;
+  font-size: 0.75rem;
+  font-weight: 700;
+  letter-spacing: 0.01em;
+  vertical-align: middle;
+}
+
+.required-mark-conditional {
+  background: rgba(202, 195, 0, 0.18);
+  color: #fff6a8;
+}
+
+.required-legend {
+  margin: 0 0 0.9rem;
+  color: var(--brand-muted);
+  font-size: 0.9rem;
+}
+
 .checkbox-label {
   display: inline-flex;
   align-items: center;
@@ -301,6 +325,26 @@ small {
   font-size: 0.85rem;
 }
 
+.mandatory-field {
+  padding-left: 0.55rem;
+  border-left: 3px solid rgba(202, 195, 0, 0.65);
+}
+
+.mandatory-field-hard {
+  border-left-color: rgba(193, 18, 31, 0.75);
+}
+
+.address-disclaimer {
+  margin-top: 0.45rem;
+  padding: 0.65rem 0.75rem;
+  border: 1px solid var(--brand-border);
+  border-left: 4px solid var(--brand-accent);
+  border-radius: 6px;
+  background: rgba(202, 195, 0, 0.08);
+  color: var(--brand-text);
+  font-size: 0.9rem;
+}
+
 .hidden,
 .hp-field {
   display: none;
@@ -377,20 +421,155 @@ small {
   color: var(--brand-muted);
 }
 
+.disclaimer-ack-field {
+  margin: 1rem 0;
+}
+
 .compact-status {
-  margin-top: 1rem;
-  padding: 1rem;
+  margin-top: 0.75rem;
+  padding: 0.65rem 0.75rem;
   border: 1px solid var(--brand-border);
-  border-radius: 8px;
+  border-radius: 6px;
   background: var(--brand-surface-alt);
+  font-size: 0.92rem;
 }
 
 .compact-status p {
-  margin: 0 0 0.35rem;
+  margin: 0 0 0.3rem;
 }
 
 .compact-status p:last-of-type {
-  margin-bottom: 1rem;
+  margin-bottom: 0.65rem;
+}
+
+.step-summary {
+  border: 1px solid var(--brand-border);
+  border-radius: 8px;
+  padding: 1rem;
+  background: var(--brand-surface-alt);
+}
+
+.summary-missing-note {
+  margin-bottom: 0.9rem;
+  padding: 0.65rem 0.75rem;
+  border: 1px solid rgba(202, 195, 0, 0.55);
+  border-radius: 6px;
+  background: rgba(202, 195, 0, 0.1);
+  color: #fff1a1;
+  font-weight: 600;
+}
+
+.summary-missing-note.summary-missing-warning {
+  border-color: rgba(193, 18, 31, 0.7);
+  background: rgba(193, 18, 31, 0.14);
+  color: #ffd3d7;
+}
+
+.summary-content {
+  display: grid;
+  gap: 0.85rem;
+}
+
+.summary-step-card {
+  border: 1px solid var(--brand-border);
+  border-radius: 8px;
+  padding: 0.75rem;
+  background: var(--brand-surface);
+}
+
+.summary-step-card h4 {
+  margin: 0 0 0.65rem;
+  font-size: 1rem;
+}
+
+.summary-item {
+  margin-top: 0.55rem;
+  padding: 0.55rem 0.65rem;
+  border: 1px solid var(--brand-border);
+  border-radius: 6px;
+  background: var(--brand-surface-alt);
+}
+
+.summary-item:first-of-type {
+  margin-top: 0;
+}
+
+.summary-item-required {
+  border-left: 4px solid rgba(202, 195, 0, 0.65);
+}
+
+.summary-item-missing {
+  border-color: rgba(193, 18, 31, 0.75);
+  border-left: 4px solid rgba(193, 18, 31, 0.95);
+  background: rgba(193, 18, 31, 0.1);
+}
+
+.summary-item-label {
+  font-weight: 700;
+  margin-bottom: 0.25rem;
+}
+
+.summary-item-value {
+  font-size: 0.95rem;
+  color: var(--brand-text);
+}
+
+.summary-item-value-empty {
+  color: var(--brand-muted);
+}
+
+.summary-item-value-missing {
+  color: #ffd0d4;
+  font-weight: 700;
+}
+
+.summary-badge {
+  display: inline-block;
+  margin-left: 0.4rem;
+  margin-top: 0.25rem;
+  padding: 0.05rem 0.35rem;
+  border-radius: 999px;
+  font-size: 0.75rem;
+  font-weight: 700;
+}
+
+.summary-badge-required {
+  background: rgba(202, 195, 0, 0.2);
+  color: #fff6a8;
+}
+
+.summary-badge-missing {
+  background: rgba(193, 18, 31, 0.24);
+  color: #ffd3d7;
+}
+
+.btn-spinner {
+  display: inline-block;
+  width: 14px;
+  height: 14px;
+  margin-left: 0.45rem;
+  border: 2px solid rgba(255, 255, 255, 0.5);
+  border-top-color: #ffffff;
+  border-radius: 50%;
+  animation: spinner-rotate 0.8s linear infinite;
+  vertical-align: -2px;
+}
+
+.btn-spinner.hidden {
+  display: none;
+}
+
+.btn.is-loading {
+  pointer-events: none;
+}
+
+@keyframes spinner-rotate {
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg);
+  }
 }
 
 .alert {
@@ -649,8 +828,11 @@ tr:hover td {
 }
 
 #startSection.compact-mode {
-  padding-top: 1rem;
-  padding-bottom: 1rem;
+  max-width: 640px;
+  margin-left: auto;
+  margin-right: auto;
+  padding-top: 0.8rem;
+  padding-bottom: 0.8rem;
 }
 
 @media (max-width: 768px) {

+ 578 - 55
assets/js/form.js

@@ -1,24 +1,33 @@
 (function () {
   const EMAIL_STORAGE_KEY = 'ff_member_form_email_v1';
-  const DISCLAIMER_ACCEPTED_KEY = 'ff_member_form_disclaimer_accepted_v1';
   const boot = window.APP_BOOT || { steps: [], csrf: '', contactEmail: '' };
   const baseUrl = String(boot.baseUrl || '').replace(/\/+$/, '');
+  const schemaSteps = Array.isArray(boot.steps) ? boot.steps : [];
 
   const state = {
     email: '',
     currentStep: 1,
-    totalSteps: boot.steps.length,
+    totalSteps: schemaSteps.length,
+    summaryStep: schemaSteps.length + 1,
     autosaveId: null,
+    uploads: {},
+    isSubmitting: false,
+    summaryMissingCount: 0,
   };
 
   const disclaimerSection = document.getElementById('disclaimerSection');
+  const disclaimerReadCheckbox = document.getElementById('disclaimerReadCheckbox');
+  const disclaimerReadError = document.getElementById('disclaimerReadError');
   const acceptDisclaimerBtn = document.getElementById('acceptDisclaimerBtn');
+
   const startSection = document.getElementById('startSection');
   const startForm = document.getElementById('startForm');
   const startIntroText = document.getElementById('startIntroText');
   const startEmailField = document.getElementById('startEmailField');
   const startActions = document.getElementById('startActions');
   const startEmailInput = document.getElementById('startEmail');
+  const startEmailError = document.getElementById('startEmailError');
+  const startSubmitBtn = document.getElementById('startSubmitBtn');
   const resetDataBtn = document.getElementById('resetDataBtn');
   const compactStatusBox = document.getElementById('compactStatusBox');
   const statusEmailValue = document.getElementById('statusEmailValue');
@@ -32,7 +41,35 @@
   const prevBtn = document.getElementById('prevBtn');
   const nextBtn = document.getElementById('nextBtn');
   const submitBtn = document.getElementById('submitBtn');
+  const submitSpinner = document.getElementById('submitSpinner');
+  const submitLabel = submitBtn ? submitBtn.querySelector('[data-submit-label]') : null;
+  const summarySection = document.getElementById('summarySection');
+  const summaryContent = document.getElementById('summaryContent');
+  const summaryMissingNotice = document.getElementById('summaryMissingNotice');
   const stepElements = Array.from(document.querySelectorAll('.step'));
+  const startEmailRequiredMark = document.querySelector('#startEmailField .required-mark-field-start');
+  const fieldContainersByKey = new Map();
+  const fieldsByKey = new Map();
+
+  document.querySelectorAll('.field[data-field]').forEach((container) => {
+    const key = String(container.getAttribute('data-field') || '').trim();
+    if (key !== '') {
+      fieldContainersByKey.set(key, container);
+    }
+  });
+
+  schemaSteps.forEach((step) => {
+    const fields = Array.isArray(step.fields) ? step.fields : [];
+    fields.forEach((field) => {
+      if (!field || typeof field !== 'object') {
+        return;
+      }
+      const key = String(field.key || '').trim();
+      if (key !== '') {
+        fieldsByKey.set(key, field);
+      }
+    });
+  });
 
   function appUrl(path) {
     const normalizedPath = String(path || '').replace(/^\/+/, '');
@@ -49,6 +86,29 @@
     draftStatusValue.classList.toggle('error-text', Boolean(isError));
   }
 
+  function setStartEmailError(text) {
+    if (!startEmailError) {
+      return;
+    }
+    startEmailError.textContent = text || '';
+  }
+
+  function setDisclaimerError(text) {
+    if (!disclaimerReadError) {
+      return;
+    }
+    disclaimerReadError.textContent = text || '';
+  }
+
+  function updateStartEmailRequiredMarker() {
+    if (!startEmailRequiredMark) {
+      return;
+    }
+
+    const email = normalizeEmail(startEmailInput.value || '');
+    startEmailRequiredMark.classList.toggle('hidden', isValidEmail(email));
+  }
+
   function formatTimestamp(isoDate) {
     if (!isoDate) {
       return '';
@@ -94,19 +154,15 @@
     }
   }
 
-  function hasAcceptedDisclaimer() {
-    try {
-      return localStorage.getItem(DISCLAIMER_ACCEPTED_KEY) === '1';
-    } catch (_err) {
-      return false;
+  function updateDisclaimerAcceptanceState() {
+    if (!acceptDisclaimerBtn || !disclaimerReadCheckbox) {
+      return;
     }
-  }
 
-  function setDisclaimerAccepted() {
-    try {
-      localStorage.setItem(DISCLAIMER_ACCEPTED_KEY, '1');
-    } catch (_err) {
-      // ignore localStorage errors
+    const accepted = disclaimerReadCheckbox.checked;
+    acceptDisclaimerBtn.disabled = !accepted;
+    if (accepted) {
+      setDisclaimerError('');
     }
   }
 
@@ -135,6 +191,7 @@
     startEmailInput.value = email;
     startEmailInput.readOnly = true;
     startEmailInput.setAttribute('aria-readonly', 'true');
+    updateStartEmailRequiredMarker();
     rememberEmail(email);
     enterCompactStatus(email);
   }
@@ -146,7 +203,9 @@
     startEmailInput.removeAttribute('aria-readonly');
     if (clearInput) {
       startEmailInput.value = '';
+      setStartEmailError('');
     }
+    updateStartEmailRequiredMarker();
     forgetRememberedEmail();
     leaveCompactStatus();
   }
@@ -193,9 +252,16 @@
     clearErrors();
     renderUploadInfo({});
     state.currentStep = 1;
-    submitBtn.disabled = false;
-    nextBtn.disabled = false;
-    prevBtn.disabled = false;
+    state.summaryMissingCount = 0;
+    state.isSubmitting = false;
+    if (submitLabel) {
+      submitLabel.textContent = 'Verbindlich absenden';
+    }
+    if (submitSpinner) {
+      submitSpinner.classList.add('hidden');
+    }
+    submitBtn.classList.remove('is-loading');
+    refreshRequiredMarkers();
     updateProgress();
     Array.from(document.querySelectorAll('[data-upload-key]')).forEach((control) => {
       const fieldKey = control.getAttribute('data-upload-key');
@@ -205,22 +271,354 @@
     });
   }
 
+  function parseFieldKey(name) {
+    const match = /^form_data\[(.+)\]$/.exec(String(name || ''));
+    return match ? match[1] : '';
+  }
+
+  function isCheckboxTrue(value) {
+    return ['1', 'on', 'true', true, 1].includes(value);
+  }
+
+  function collectCurrentFormData() {
+    const data = {};
+
+    Array.from(applicationForm.elements).forEach((el) => {
+      if (!el.name) {
+        return;
+      }
+
+      const key = parseFieldKey(el.name);
+      if (!key) {
+        return;
+      }
+
+      if (el.type === 'checkbox') {
+        data[key] = el.checked ? '1' : '0';
+      } else {
+        data[key] = el.value || '';
+      }
+    });
+
+    return data;
+  }
+
+  function hasFileValue(fieldKey) {
+    const fileInput = applicationForm.querySelector('[name="' + fieldKey + '"]');
+    const cameraInput = applicationForm.querySelector('[name="' + fieldKey + '__camera"]');
+    const hasLocalSelection =
+      (fileInput && fileInput.files && fileInput.files.length > 0) ||
+      (cameraInput && cameraInput.files && cameraInput.files.length > 0);
+
+    if (hasLocalSelection) {
+      return true;
+    }
+
+    const storedUploads = state.uploads[fieldKey];
+    return Array.isArray(storedUploads) && storedUploads.length > 0;
+  }
+
+  function isFieldCompleted(field, formData) {
+    const key = String(field.key || '').trim();
+    const type = String(field.type || 'text');
+    if (key === '') {
+      return false;
+    }
+
+    if (type === 'file') {
+      return hasFileValue(key);
+    }
+
+    if (type === 'checkbox') {
+      return isCheckboxTrue(formData[key]);
+    }
+
+    return String(formData[key] || '').trim() !== '';
+  }
+
+  function refreshRequiredMarkers() {
+    const formData = collectCurrentFormData();
+
+    fieldsByKey.forEach((field, key) => {
+      const container = fieldContainersByKey.get(key);
+      if (!container) {
+        return;
+      }
+
+      const markers = container.querySelectorAll('.required-mark-field');
+      if (!markers.length) {
+        return;
+      }
+
+      const requiredNow = isFieldRequired(field, formData);
+      const completed = isFieldCompleted(field, formData);
+      const hideMarker = !requiredNow || completed;
+
+      markers.forEach((marker) => {
+        marker.classList.toggle('hidden', hideMarker);
+      });
+    });
+  }
+
+  function initRequiredMarkerTracking() {
+    Array.from(applicationForm.elements).forEach((el) => {
+      if (!el.name || !el.name.startsWith('form_data[')) {
+        return;
+      }
+      el.addEventListener('blur', refreshRequiredMarkers);
+      el.addEventListener('change', refreshRequiredMarkers);
+    });
+  }
+
+  function isFieldRequired(field, formData) {
+    if (!field || typeof field !== 'object') {
+      return false;
+    }
+
+    if (field.required === true) {
+      return true;
+    }
+
+    if (!field.required_if || typeof field.required_if !== 'object') {
+      return false;
+    }
+
+    const depField = String(field.required_if.field || '').trim();
+    const depValue = String(field.required_if.equals || '');
+    if (!depField) {
+      return false;
+    }
+
+    return String(formData[depField] || '') === depValue;
+  }
+
+  function isFieldMissing(field, formData) {
+    if (!isFieldRequired(field, formData)) {
+      return false;
+    }
+
+    const key = String(field.key || '').trim();
+    if (!key) {
+      return false;
+    }
+
+    const type = String(field.type || 'text');
+    if (type === 'file') {
+      const uploadItems = state.uploads[key];
+      return !Array.isArray(uploadItems) || uploadItems.length === 0;
+    }
+
+    if (type === 'checkbox') {
+      return !isCheckboxTrue(formData[key]);
+    }
+
+    return String(formData[key] || '').trim() === '';
+  }
+
+  function resolveSelectLabel(field, value) {
+    if (!Array.isArray(field.options)) {
+      return value;
+    }
+
+    const matched = field.options.find((option) => {
+      return option && String(option.value || '') === String(value);
+    });
+
+    if (!matched) {
+      return value;
+    }
+
+    return String(matched.label || value);
+  }
+
+  function fieldDisplayValue(field, formData) {
+    const key = String(field.key || '').trim();
+    const type = String(field.type || 'text');
+
+    if (!key) {
+      return '';
+    }
+
+    if (type === 'file') {
+      const uploadItems = state.uploads[key];
+      if (!Array.isArray(uploadItems) || uploadItems.length === 0) {
+        return '';
+      }
+      return uploadItems
+        .map((item) => {
+          if (!item || typeof item !== 'object') {
+            return '';
+          }
+          return String(item.original_filename || item.stored_filename || '').trim();
+        })
+        .filter(Boolean)
+        .join(', ');
+    }
+
+    if (type === 'checkbox') {
+      return isCheckboxTrue(formData[key]) ? 'Ja' : 'Nein';
+    }
+
+    const rawValue = String(formData[key] || '').trim();
+    if (rawValue === '') {
+      return '';
+    }
+
+    if (type === 'select') {
+      return resolveSelectLabel(field, rawValue);
+    }
+
+    return rawValue;
+  }
+
+  function renderSummary() {
+    if (!summarySection || !summaryContent || !summaryMissingNotice) {
+      return;
+    }
+
+    const formData = collectCurrentFormData();
+    const fragment = document.createDocumentFragment();
+    let missingCount = 0;
+
+    const introCard = document.createElement('div');
+    introCard.className = 'summary-step-card';
+    const introTitle = document.createElement('h4');
+    introTitle.textContent = 'Startdaten';
+    introCard.appendChild(introTitle);
+
+    const emailRow = document.createElement('div');
+    emailRow.className = 'summary-item';
+    const emailLabel = document.createElement('div');
+    emailLabel.className = 'summary-item-label';
+    emailLabel.textContent = 'E-Mail';
+    const emailValue = document.createElement('div');
+    emailValue.className = 'summary-item-value';
+    emailValue.textContent = state.email || 'Nicht gesetzt';
+    emailRow.appendChild(emailLabel);
+    emailRow.appendChild(emailValue);
+    introCard.appendChild(emailRow);
+    fragment.appendChild(introCard);
+
+    schemaSteps.forEach((step, stepIndex) => {
+      const fields = Array.isArray(step.fields) ? step.fields.filter((field) => field && typeof field === 'object') : [];
+      if (fields.length === 0) {
+        return;
+      }
+
+      const card = document.createElement('div');
+      card.className = 'summary-step-card';
+
+      const title = document.createElement('h4');
+      title.textContent = 'Schritt ' + String(stepIndex + 1) + ': ' + String(step.title || '');
+      card.appendChild(title);
+
+      fields.forEach((field) => {
+        const required = isFieldRequired(field, formData);
+        const missing = isFieldMissing(field, formData);
+        if (missing) {
+          missingCount += 1;
+        }
+
+        const row = document.createElement('div');
+        row.className = 'summary-item';
+        if (required) {
+          row.classList.add('summary-item-required');
+        }
+        if (missing) {
+          row.classList.add('summary-item-missing');
+        }
+
+        const labelEl = document.createElement('div');
+        labelEl.className = 'summary-item-label';
+        labelEl.textContent = String(field.label || field.key || 'Feld');
+
+        if (required) {
+          const requiredBadge = document.createElement('span');
+          requiredBadge.className = 'summary-badge summary-badge-required';
+          requiredBadge.textContent = 'Pflichtfeld';
+          labelEl.appendChild(requiredBadge);
+        }
+
+        if (missing) {
+          const missingBadge = document.createElement('span');
+          missingBadge.className = 'summary-badge summary-badge-missing';
+          missingBadge.textContent = '! Pflichtfeld fehlt';
+          labelEl.appendChild(missingBadge);
+        }
+
+        const valueEl = document.createElement('div');
+        valueEl.className = 'summary-item-value';
+        const value = fieldDisplayValue(field, formData);
+        if (value !== '') {
+          valueEl.textContent = value;
+        } else {
+          valueEl.textContent = String(field.type || '') === 'file' ? 'Keine Datei hochgeladen' : 'Nicht ausgefüllt';
+          valueEl.classList.add('summary-item-value-empty');
+          if (missing) {
+            valueEl.classList.add('summary-item-value-missing');
+          }
+        }
+
+        row.appendChild(labelEl);
+        row.appendChild(valueEl);
+        card.appendChild(row);
+      });
+
+      fragment.appendChild(card);
+    });
+
+    summaryContent.innerHTML = '';
+    summaryContent.appendChild(fragment);
+
+    state.summaryMissingCount = missingCount;
+    if (missingCount > 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.';
+    } else {
+      summaryMissingNotice.classList.remove('hidden');
+      summaryMissingNotice.classList.remove('summary-missing-warning');
+      summaryMissingNotice.textContent = 'Alle Pflichtfelder sind ausgefüllt.';
+    }
+  }
+
   function updateProgress() {
-    progress.textContent = 'Schritt ' + state.currentStep + ' von ' + state.totalSteps;
+    const isSummary = state.currentStep === state.summaryStep;
+
+    if (isSummary) {
+      progress.textContent = 'Zusammenfassung (Schritt ' + String(state.summaryStep) + ' von ' + String(state.summaryStep) + ')';
+    } else {
+      progress.textContent = 'Schritt ' + String(state.currentStep) + ' von ' + String(state.totalSteps);
+    }
+
     stepElements.forEach((el) => {
       const step = Number(el.getAttribute('data-step'));
       el.classList.toggle('hidden', step !== state.currentStep);
     });
 
-    prevBtn.disabled = state.currentStep === 1;
-    nextBtn.classList.toggle('hidden', state.currentStep === state.totalSteps);
-    submitBtn.classList.toggle('hidden', state.currentStep !== state.totalSteps);
+    if (summarySection) {
+      summarySection.classList.toggle('hidden', !isSummary);
+      if (isSummary) {
+        renderSummary();
+      }
+    }
+
+    prevBtn.disabled = state.isSubmitting || state.currentStep === 1;
+
+    nextBtn.textContent = state.currentStep === state.totalSteps ? 'Zur Zusammenfassung' : 'Weiter';
+    nextBtn.classList.toggle('hidden', state.currentStep >= state.summaryStep);
+    nextBtn.disabled = state.isSubmitting;
+
+    submitBtn.classList.toggle('hidden', !isSummary);
+    submitBtn.disabled = state.isSubmitting;
   }
 
   function fillFormData(data) {
     Object.keys(data || {}).forEach((key) => {
       const field = applicationForm.querySelector('[name="form_data[' + key + ']"]');
-      if (!field) return;
+      if (!field) {
+        return;
+      }
 
       if (field.type === 'checkbox') {
         field.checked = ['1', 'on', 'true', true].includes(data[key]);
@@ -228,24 +626,30 @@
         field.value = data[key] || '';
       }
     });
+    refreshRequiredMarkers();
   }
 
   function renderUploadInfo(uploads) {
+    state.uploads = uploads && typeof uploads === 'object' ? uploads : {};
+
     document.querySelectorAll('[data-upload-list]').forEach((el) => {
       el.innerHTML = '';
     });
 
-    Object.keys(uploads || {}).forEach((field) => {
+    Object.keys(state.uploads).forEach((field) => {
       const target = document.querySelector('[data-upload-list="' + field + '"]');
-      if (!target || !Array.isArray(uploads[field])) return;
+      if (!target || !Array.isArray(state.uploads[field])) {
+        return;
+      }
 
-      uploads[field].forEach((item) => {
+      state.uploads[field].forEach((item) => {
         const div = document.createElement('div');
         div.className = 'upload-item';
         div.textContent = item.original_filename + ' (' + item.uploaded_at + ')';
         target.appendChild(div);
       });
     });
+    refreshRequiredMarkers();
   }
 
   function updateUploadSelectionText(fieldKey) {
@@ -266,6 +670,7 @@
     }
 
     target.textContent = label;
+    refreshRequiredMarkers();
   }
 
   async function triggerInstantUpload() {
@@ -277,6 +682,10 @@
     try {
       await saveDraft(true);
       setFeedback('', false);
+      refreshRequiredMarkers();
+      if (state.currentStep === state.summaryStep) {
+        renderSummary();
+      }
     } catch (err) {
       const msg = (err.payload && err.payload.message) || err.message || 'Upload fehlgeschlagen.';
       setDraftStatus('Upload fehlgeschlagen', true);
@@ -346,12 +755,16 @@
     const fd = new FormData();
     fd.append('csrf', boot.csrf);
     fd.append('email', state.email);
-    fd.append('step', String(state.currentStep));
+    fd.append('step', String(Math.min(state.currentStep, state.totalSteps)));
     fd.append('website', '');
 
     Array.from(applicationForm.elements).forEach((el) => {
-      if (!el.name) return;
-      if (!el.name.startsWith('form_data[')) return;
+      if (!el.name) {
+        return;
+      }
+      if (!el.name.startsWith('form_data[')) {
+        return;
+      }
 
       if (el.type === 'checkbox') {
         fd.append(el.name, el.checked ? '1' : '0');
@@ -419,26 +832,84 @@
     return response;
   }
 
+  function setSubmitting(isSubmitting) {
+    state.isSubmitting = isSubmitting;
+    submitBtn.classList.toggle('is-loading', isSubmitting);
+    if (submitSpinner) {
+      submitSpinner.classList.toggle('hidden', !isSubmitting);
+    }
+    if (submitLabel) {
+      submitLabel.textContent = isSubmitting ? 'Wird gesendet ...' : 'Verbindlich absenden';
+    }
+    updateProgress();
+  }
+
   async function submitApplication() {
-    const payload = collectPayload(true);
-    const response = await postForm(appUrl('api/submit.php'), payload);
-    clearErrors();
-    setDraftStatus('Abgeschlossen', false);
-    setFeedback('Antrag erfolgreich abgeschlossen. Vielen Dank.', false);
-    submitBtn.disabled = true;
-    nextBtn.disabled = true;
-    prevBtn.disabled = true;
-    return response;
+    setSubmitting(true);
+    setFeedback('Absenden gestartet ...', false);
+
+    try {
+      const payload = collectPayload(true);
+      const response = await postForm(appUrl('api/submit.php'), payload);
+      clearErrors();
+      setDraftStatus('Abgeschlossen', false);
+      setFeedback('Antrag erfolgreich abgeschlossen. Vielen Dank.', false);
+      setSubmitting(false);
+
+      submitBtn.disabled = true;
+      nextBtn.disabled = true;
+      prevBtn.disabled = true;
+      if (submitLabel) {
+        submitLabel.textContent = 'Abgesendet';
+      }
+      return response;
+    } catch (err) {
+      setSubmitting(false);
+      throw err;
+    }
+  }
+
+  function validateStartEmail(showError) {
+    const email = normalizeEmail(startEmailInput.value || '');
+    const valid = isValidEmail(email);
+
+    startEmailInput.value = email;
+    if (valid) {
+      setStartEmailError('');
+      startEmailInput.setCustomValidity('');
+      updateStartEmailRequiredMarker();
+      return true;
+    }
+
+    const message = 'Bitte eine gültige E-Mail-Adresse eingeben.';
+    startEmailInput.setCustomValidity(message);
+    if (showError) {
+      setStartEmailError(message);
+      setFeedback(message, true);
+    }
+    updateStartEmailRequiredMarker();
+    return false;
   }
 
   async function startProcess(rawEmail) {
     const email = normalizeEmail(rawEmail);
     if (!isValidEmail(email)) {
-      setFeedback('Bitte eine gültige E-Mail-Adresse eingeben.', true);
+      const message = 'Bitte eine gültige E-Mail-Adresse eingeben.';
+      setStartEmailError(message);
+      setFeedback(message, true);
       startEmailInput.focus();
       return;
     }
 
+    startEmailInput.value = email;
+    setStartEmailError('');
+    startEmailInput.setCustomValidity('');
+    updateStartEmailRequiredMarker();
+
+    if (startSubmitBtn) {
+      startSubmitBtn.disabled = true;
+    }
+
     try {
       const result = await loadDraft(email);
       lockEmail(email);
@@ -469,14 +940,46 @@
     } catch (err) {
       const msg = (err.payload && err.payload.message) || err.message || 'Laden fehlgeschlagen.';
       setFeedback(msg, true);
+    } finally {
+      if (startSubmitBtn) {
+        startSubmitBtn.disabled = false;
+      }
     }
   }
 
   startForm.addEventListener('submit', async (event) => {
     event.preventDefault();
+    if (!validateStartEmail(true)) {
+      startEmailInput.focus();
+      return;
+    }
     await startProcess(startEmailInput.value || '');
   });
 
+  startEmailInput.addEventListener('input', () => {
+    if (startEmailInput.readOnly) {
+      return;
+    }
+    if (startEmailInput.value.trim() === '') {
+      setStartEmailError('');
+      startEmailInput.setCustomValidity('');
+      updateStartEmailRequiredMarker();
+      return;
+    }
+    validateStartEmail(false);
+  });
+
+  startEmailInput.addEventListener('blur', () => {
+    if (startEmailInput.readOnly) {
+      return;
+    }
+    if (startEmailInput.value.trim() === '') {
+      updateStartEmailRequiredMarker();
+      return;
+    }
+    validateStartEmail(true);
+  });
+
   resetDataBtn.addEventListener('click', async () => {
     if (!state.email) {
       setFeedback('Keine aktive E-Mail vorhanden.', true);
@@ -503,10 +1006,14 @@
   });
 
   prevBtn.addEventListener('click', async () => {
-    if (state.currentStep <= 1) return;
+    if (state.currentStep <= 1 || state.isSubmitting) {
+      return;
+    }
 
     try {
-      await saveDraft(false);
+      if (state.currentStep <= state.totalSteps) {
+        await saveDraft(false);
+      }
       state.currentStep -= 1;
       updateProgress();
     } catch (err) {
@@ -516,7 +1023,9 @@
   });
 
   nextBtn.addEventListener('click', async () => {
-    if (state.currentStep >= state.totalSteps) return;
+    if (state.currentStep >= state.summaryStep || state.isSubmitting) {
+      return;
+    }
 
     try {
       await saveDraft(false);
@@ -529,6 +1038,16 @@
   });
 
   submitBtn.addEventListener('click', async () => {
+    if (state.isSubmitting) {
+      return;
+    }
+
+    renderSummary();
+    if (state.summaryMissingCount > 0) {
+      setFeedback('Bitte zuerst alle rot markierten Pflichtfelder ausfüllen.', true);
+      return;
+    }
+
     try {
       await submitApplication();
     } catch (err) {
@@ -550,31 +1069,35 @@
     startSection.classList.remove('hidden');
 
     const rememberedEmail = normalizeEmail(getRememberedEmail());
-    if (rememberedEmail !== '') {
-      if (isValidEmail(rememberedEmail)) {
-        startEmailInput.value = rememberedEmail;
-        setFeedback('', false);
-        startProcess(rememberedEmail);
-      } else {
-        forgetRememberedEmail();
-      }
+    if (rememberedEmail !== '' && isValidEmail(rememberedEmail)) {
+      startEmailInput.value = rememberedEmail;
+      setFeedback('', false);
+      startProcess(rememberedEmail);
     }
   }
 
+  if (disclaimerReadCheckbox) {
+    disclaimerReadCheckbox.addEventListener('change', updateDisclaimerAcceptanceState);
+  }
+
   if (acceptDisclaimerBtn) {
     acceptDisclaimerBtn.addEventListener('click', () => {
-      setDisclaimerAccepted();
+      if (!disclaimerReadCheckbox || !disclaimerReadCheckbox.checked) {
+        setDisclaimerError('Bitte lesen und bestätigen Sie den Hinweis.');
+        return;
+      }
+      setDisclaimerError('');
       initializeAfterDisclaimer();
     });
   }
 
-  if (hasAcceptedDisclaimer()) {
-    initializeAfterDisclaimer();
-  } else {
-    disclaimerSection.classList.remove('hidden');
-    startSection.classList.add('hidden');
-  }
+  updateDisclaimerAcceptanceState();
+  disclaimerSection.classList.remove('hidden');
+  startSection.classList.add('hidden');
 
   initUploadControls();
+  initRequiredMarkerTracking();
+  refreshRequiredMarkers();
+  updateStartEmailRequiredMarker();
   updateProgress();
 })();

+ 3 - 0
config/app.sample.php

@@ -13,6 +13,9 @@ return [
         'text' => "Bitte lesen Sie diesen Hinweis vor Beginn sorgfaeltig.\n\nMit dem Fortfahren bestaetigen Sie, dass Ihre Angaben vollstaendig und wahrheitsgemaess sind.\nIhre Daten werden ausschliesslich zur Bearbeitung des Mitgliedsantrags verwendet.",
         'accept_label' => 'Hinweis gelesen, weiter zum Antrag',
     ],
+    'address_disclaimer' => [
+        'text' => 'Bitte geben Sie Ihre vollstaendige Meldeadresse inklusive Hausnummer an.',
+    ],
     'retention' => [
         'draft_days' => 14,
         'submission_days' => 90,

+ 7 - 14
config/form_schema.php

@@ -12,9 +12,11 @@ return [
                 ['key' => 'nachname', 'label' => 'Nachname', 'type' => 'text', 'required' => true, 'max_length' => 100],
                 ['key' => 'geburtsdatum', 'label' => 'Geburtsdatum', 'type' => 'date', 'required' => true],
                 ['key' => 'strasse', 'label' => 'Straße und Hausnummer', 'type' => 'text', 'required' => true, 'max_length' => 200],
-                ['key' => 'plz', 'label' => 'PLZ', 'type' => 'text', 'required' => true, 'max_length' => 10],
+                ['key' => 'plz', 'label' => 'PLZ', 'type' => 'number', 'required' => true, 'max_length' => 5],
                 ['key' => 'ort', 'label' => 'Ort', 'type' => 'text', 'required' => true, 'max_length' => 100],
-                ['key' => 'telefon', 'label' => 'Telefon', 'type' => 'text', 'required' => true, 'max_length' => 50],
+                ['key' => 'telefon', 'label' => 'Telefon (Mobil)', 'type' => 'number', 'required' => true, 'max_length' => 15],
+                ['key' => 'job', 'label' => 'Beruf, Tätigkeit(auch Schüler/Student)', 'type' => 'text', 'required' => true, 'max_length' => 100],
+                ['key' => 'pate', 'label' => 'Pate (optional)', 'type' => 'text', 'required' => false, 'max_length' => 100],
             ],
         ],
         [
@@ -26,20 +28,10 @@ return [
                     ['value' => 'Jugend', 'label' => 'Jugend'],
                     ['value' => 'Foerdernd', 'label' => 'Fördernd'],
                 ]],
-                ['key' => 'abteilung', 'label' => 'Abteilung', 'type' => 'select', 'required' => true, 'options' => [
-                    ['value' => 'Einsatz', 'label' => 'Einsatzabteilung'],
-                    ['value' => 'Jugend', 'label' => 'Jugendfeuerwehr'],
-                    ['value' => 'Verein', 'label' => 'Vereinsmitglied'],
-                ]],
-                ['key' => 'ist_minderjaehrig', 'label' => 'Sind Sie unter 18 Jahren?', 'type' => 'select', 'required' => true, 'options' => [
-                    ['value' => 'nein', 'label' => 'Nein'],
-                    ['value' => 'ja', 'label' => 'Ja'],
-                ]],
                 ['key' => 'qualifikation_vorhanden', 'label' => 'Feuerwehr-Qualifikationen vorhanden?', 'type' => 'select', 'required' => true, 'options' => [
                     ['value' => 'nein', 'label' => 'Nein'],
                     ['value' => 'ja', 'label' => 'Ja'],
                 ]],
-                ['key' => 'bemerkung', 'label' => 'Bemerkung (optional)', 'type' => 'textarea', 'required' => false, 'max_length' => 1000],
             ],
         ],
         [
@@ -57,8 +49,9 @@ return [
             'title' => 'Einwilligung & Abschluss',
             'description' => 'Bitte bestätigen Sie die Angaben und reichen Sie den Antrag ein.',
             'fields' => [
-                ['key' => 'einwilligung_datenschutz', 'label' => 'Ich stimme der Verarbeitung meiner Daten zu.', 'type' => 'checkbox', 'required' => true],
-                ['key' => 'einwilligung_ordnung', 'label' => 'Ich erkenne die Satzung und Ordnung des Vereins an.', 'type' => 'checkbox', 'required' => true],
+                ['key' => 'freier_kommentar', 'label' => 'Freier Kommentar (optional)', 'type' => 'textarea', 'required' => false, 'max_length' => 2000],
+                ['key' => 'einwilligung_datenschutz', 'label' => 'Ich stimme der Verarbeitung meiner Daten zu.', 'type' => 'checkbox', 'required' => true, 'subtext' => 'Ich willige ein, dass der oben genannte Verein als verantwortliche Stelle, die im Aufnahmeantrag erhobenen personenbezogenen Daten ausschließlich zum Zwecke der Mitgliederverwaltung und der Übermittlung von Vereinsinformationen durch den Verein, insbesondere der Weitergabe an die Stadt Freising als Träger der kommunalen Feuerwehr und für alle in der Satzung genannten Zwecke verarbeitet und genutzt werden. Eine Übermittlung von Daten an Dritte außerhalb des Vereins findet nur im Rahmen der in der Satzung festgelegten Zwecke statt. Diese Datenübermittlungen sind notwendig zum Zwecke des Vereins. Eine Datennutzung für Werbezwecke findet nicht statt. Bei Beendigung der Mitgliedschaft werden die personenbezogenen Daten gelöscht, soweit sie nicht entsprechend der gesetzlichen Vorgaben aufbewahrt werden müssen. Jedes Mitglied hat im Rahmen der Vorgaben der Datenschutzgrundverordnung (DSGVO) und des Bundesdatenschutzgesetzes n.F. (DSAnpUG EU) das Recht auf Auskunft über die personenbezogenen Daten, die zu seiner Person bei der verantwortlichen Stelle gespeichert sind. Außerdem hat das Mitglied, im Falle von fehlerhaften Daten, ein Korrekturrecht.  Beschwerdestelle ist das Bayerische Landesamt für Datenschutzaufsicht (BayLDA) Promenade 27 91522 Ansbach '],
+                ['key' => 'einwilligung_bildrechte', 'label' => 'Einverständniserklärung zur Veröffentlichung von Fotos und Filmaufnahmen', 'type' => 'checkbox', 'required' => false, 'subtext' => 'Ich willige ein, dass im Rahmen von Veranstaltungen und Einsätzen angefertigte Foto- und Filmaufnahmen für Veröffentlichungen, Berichte, in Printmedien, Neuen Medien und auf der Internetseite des Vereines und seinen übergeordneten Verbänden unentgeltlich verwendet werden dürfen. Eine Verwendung der Aufnahmen für andere als die beschriebenen Zwecke oder ein Inverkehrbringen durch Überlassung der Aufnahme an Dritte außer der Dachorganisation des Vereins ist unzulässig. Diese Einwilligung ist freiwillig. Durch eine nicht erteilte Einwilligung entstehen mir als Mitglied keine Nachteile. Die Einwilligung kann jederzeit mit Wirkung für die Zukunft widerrufen werden.'],
             ],
         ],
     ],

+ 85 - 35
index.php

@@ -25,22 +25,46 @@ if (is_string($disclaimerConfigRaw)) {
 $disclaimerTitle = (string) ($disclaimerConfig['title'] ?? 'Hinweis');
 $disclaimerText = (string) ($disclaimerConfig['text'] ?? '');
 $disclaimerAcceptLabel = (string) ($disclaimerConfig['accept_label'] ?? 'Hinweis gelesen, weiter');
+$addressDisclaimerConfigRaw = $app['address_disclaimer'] ?? ($app['address_disclaimer_text'] ?? '');
+if (is_string($addressDisclaimerConfigRaw)) {
+    $addressDisclaimerText = $addressDisclaimerConfigRaw;
+} elseif (is_array($addressDisclaimerConfigRaw)) {
+    $addressDisclaimerText = (string) ($addressDisclaimerConfigRaw['text'] ?? '');
+} else {
+    $addressDisclaimerText = '';
+}
 $baseUrl = Bootstrap::baseUrl();
 
 /** @param array<string, mixed> $field */
-function renderField(array $field): void
+function renderField(array $field, string $addressDisclaimerText): void
 {
-    $key = htmlspecialchars((string) $field['key']);
+    $keyRaw = (string) ($field['key'] ?? '');
+    $key = htmlspecialchars($keyRaw);
     $label = htmlspecialchars((string) $field['label']);
     $type = (string) ($field['type'] ?? 'text');
-    $required = ((bool) ($field['required'] ?? false)) ? 'required' : '';
+    $requiredAlways = (bool) ($field['required'] ?? false);
+    $requiredConditional = isset($field['required_if']) && is_array($field['required_if']);
+    $required = $requiredAlways ? 'required' : '';
+    $requiredLabel = '';
+    if ($requiredAlways) {
+        $requiredLabel = ' <span class="required-mark required-mark-field" aria-hidden="true">* Pflichtfeld</span>';
+    } elseif ($requiredConditional) {
+        $requiredLabel = ' <span class="required-mark required-mark-field required-mark-conditional" aria-hidden="true">* Bedingt Pflicht</span>';
+    }
+    $fieldClass = 'field';
+    if ($requiredAlways || $requiredConditional) {
+        $fieldClass .= ' mandatory-field';
+    }
+    if ($requiredAlways) {
+        $fieldClass .= ' mandatory-field-hard';
+    }
 
-    echo '<div class="field" data-field="' . $key . '">';
+    echo '<div class="' . $fieldClass . '" data-field="' . $key . '">';
 
     if ($type === 'checkbox') {
-        echo '<label class="checkbox-label"><input type="checkbox" name="form_data[' . $key . ']" value="1" ' . $required . '> ' . $label . '</label>';
+        echo '<label class="checkbox-label"><input type="checkbox" name="form_data[' . $key . ']" value="1" ' . $required . '> ' . $label . $requiredLabel . '</label>';
     } else {
-        echo '<label for="' . $key . '">' . $label . '</label>';
+        echo '<label for="' . $key . '">' . $label . $requiredLabel . '</label>';
 
         if ($type === 'textarea') {
             echo '<textarea id="' . $key . '" name="form_data[' . $key . ']" ' . $required . '></textarea>';
@@ -76,10 +100,17 @@ function renderField(array $field): void
         }
     }
 
+    $subtext = trim((string) ($field['subtext'] ?? ''));
+    if ($subtext !== '') {
+        echo '<small class="hint">' . nl2br(htmlspecialchars($subtext)) . '</small>';
+    }
+    if ($keyRaw === 'strasse' && trim($addressDisclaimerText) !== '') {
+        echo '<div class="address-disclaimer">' . nl2br(htmlspecialchars($addressDisclaimerText)) . '</div>';
+    }
     if (isset($field['required_if']) && is_array($field['required_if'])) {
         $depField = htmlspecialchars((string) ($field['required_if']['field'] ?? ''));
         $depValue = htmlspecialchars((string) ($field['required_if']['equals'] ?? ''));
-        echo '<small class="hint">Pflicht, wenn ' . $depField . ' = ' . $depValue . '.</small>';
+        echo '<small class="hint">Bedingtes Pflichtfeld, wenn ' . $depField . ' = ' . $depValue . '.</small>';
     }
 
     echo '<div class="error" data-error-for="' . $key . '"></div>';
@@ -112,36 +143,19 @@ function renderField(array $field): void
     <section id="disclaimerSection" class="card">
         <h2><?= htmlspecialchars($disclaimerTitle) ?></h2>
         <p class="disclaimer-text"><?= nl2br(htmlspecialchars($disclaimerText)) ?></p>
-        <button id="acceptDisclaimerBtn" type="button" class="btn"><?= htmlspecialchars($disclaimerAcceptLabel) ?></button>
-    </section>
-
-    <section id="startSection" class="card hidden">
-        <h2>Start</h2>
-        <p id="startIntroText">Bitte E-Mail eingeben. Bestehende Entwürfe werden automatisch geladen.</p>
-        <form id="startForm" novalidate>
-            <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
-            <div class="hp-field" aria-hidden="true">
-                <label for="website">Website</label>
-                <input id="website" type="text" name="website" autocomplete="off" tabindex="-1">
-            </div>
-            <div class="field" id="startEmailField">
-                <label for="startEmail">E-Mail</label>
-                <input id="startEmail" type="email" name="email" required inputmode="email" autocomplete="email">
-            </div>
-            <div class="inline-actions" id="startActions">
-                <button id="startSubmitBtn" type="submit" class="btn">Formular laden</button>
-            </div>
-            <div id="compactStatusBox" class="compact-status hidden">
-                <p><strong>E-Mail:</strong> <span id="statusEmailValue">-</span></p>
-                <p><strong>Speicherstatus:</strong> <span id="draftStatusValue">Noch nicht gespeichert</span></p>
-                <button id="resetDataBtn" type="button" class="btn btn-secondary btn-small">Gespeicherte Daten löschen und neu starten</button>
-            </div>
-            <p id="feedbackMessage" class="status-text" role="status" aria-live="polite"></p>
-        </form>
+        <div class="field disclaimer-ack-field">
+            <label class="checkbox-label">
+                <input id="disclaimerReadCheckbox" type="checkbox" value="1">
+                Ich habe den Hinweis gelesen und verstanden.
+            </label>
+            <div id="disclaimerReadError" class="error"></div>
+        </div>
+        <button id="acceptDisclaimerBtn" type="button" class="btn" disabled><?= htmlspecialchars($disclaimerAcceptLabel) ?></button>
     </section>
 
     <section id="wizardSection" class="card hidden">
         <h2>Mitgliedsantrag</h2>
+        <p class="required-legend"><span class="required-mark" aria-hidden="true">*</span> Pflichtfeld</p>
         <div id="progress" class="progress"></div>
         <form id="applicationForm" enctype="multipart/form-data" novalidate>
             <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
@@ -154,18 +168,54 @@ function renderField(array $field): void
                     <p><?= htmlspecialchars((string) ($step['description'] ?? '')) ?></p>
 
                     <?php foreach (($step['fields'] ?? []) as $field): ?>
-                        <?php if (is_array($field)) { renderField($field); } ?>
+                        <?php if (is_array($field)) { renderField($field, $addressDisclaimerText); } ?>
                     <?php endforeach; ?>
                 </section>
             <?php endforeach; ?>
 
+            <section id="summarySection" class="step-summary hidden">
+                <h3>Zusammenfassung</h3>
+                <p>Bitte prüfen Sie alle Angaben vor dem verbindlichen Absenden.</p>
+                <div id="summaryMissingNotice" class="summary-missing-note hidden" role="status" aria-live="polite"></div>
+                <div id="summaryContent" class="summary-content"></div>
+            </section>
+
             <div class="wizard-actions">
                 <button type="button" id="prevBtn" class="btn btn-secondary">Zurück</button>
                 <button type="button" id="nextBtn" class="btn">Weiter</button>
-                <button type="button" id="submitBtn" class="btn hidden">Verbindlich absenden</button>
+                <button type="button" id="submitBtn" class="btn hidden">
+                    <span data-submit-label>Verbindlich absenden</span>
+                    <span id="submitSpinner" class="btn-spinner hidden" aria-hidden="true"></span>
+                </button>
             </div>
         </form>
     </section>
+
+    <section id="startSection" class="card hidden">
+        <h2>Start</h2>
+        <p id="startIntroText">Bitte E-Mail eingeben. Bestehende Entwürfe werden automatisch geladen.</p>
+        <form id="startForm" novalidate>
+            <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
+            <div class="hp-field" aria-hidden="true">
+                <label for="website">Website</label>
+                <input id="website" type="text" name="website" autocomplete="off" tabindex="-1">
+            </div>
+            <div class="field" id="startEmailField">
+                <label for="startEmail">E-Mail <span class="required-mark required-mark-field-start" aria-hidden="true">* Pflichtfeld</span></label>
+                <input id="startEmail" type="email" name="email" required inputmode="email" autocomplete="email">
+                <div id="startEmailError" class="error"></div>
+            </div>
+            <div class="inline-actions" id="startActions">
+                <button id="startSubmitBtn" type="submit" class="btn">Formular laden</button>
+            </div>
+            <div id="compactStatusBox" class="compact-status hidden">
+                <p><strong>E-Mail:</strong> <span id="statusEmailValue">-</span></p>
+                <p><strong>Speicherstatus:</strong> <span id="draftStatusValue">Noch nicht gespeichert</span></p>
+                <button id="resetDataBtn" type="button" class="btn btn-small">Gespeicherte Daten löschen und neu starten</button>
+            </div>
+            <p id="feedbackMessage" class="status-text" role="status" aria-live="polite"></p>
+        </form>
+    </section>
 </main>
 <script>
 window.APP_BOOT = {