index.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. <?php
  2. declare(strict_types=1);
  3. use App\App\Bootstrap;
  4. use App\Form\FormSchema;
  5. use App\Security\Csrf;
  6. require __DIR__ . '/src/autoload.php';
  7. Bootstrap::init();
  8. $schema = new FormSchema();
  9. $steps = $schema->getSteps();
  10. $csrf = Csrf::token();
  11. $app = Bootstrap::config('app');
  12. $disclaimerConfigRaw = $app['disclaimer'] ?? [];
  13. if (is_string($disclaimerConfigRaw)) {
  14. $disclaimerConfig = ['text' => $disclaimerConfigRaw];
  15. } elseif (is_array($disclaimerConfigRaw)) {
  16. $disclaimerConfig = $disclaimerConfigRaw;
  17. } else {
  18. $disclaimerConfig = [];
  19. }
  20. $disclaimerTitle = (string) ($disclaimerConfig['title'] ?? 'Hinweis');
  21. $disclaimerText = (string) ($disclaimerConfig['text'] ?? '');
  22. $disclaimerAcceptLabel = (string) ($disclaimerConfig['accept_label'] ?? 'Hinweis gelesen, weiter');
  23. $startConfigRaw = $app['start'] ?? [];
  24. if (is_array($startConfigRaw)) {
  25. $startConfig = $startConfigRaw;
  26. } else {
  27. $startConfig = [];
  28. }
  29. $startIntroText = (string) ($startConfig['intro_text'] ?? 'Bitte E-Mail eingeben. Bestehende Entwürfe werden automatisch geladen.');
  30. $addressDisclaimerConfigRaw = $app['address_disclaimer'] ?? ($app['address_disclaimer_text'] ?? '');
  31. if (is_string($addressDisclaimerConfigRaw)) {
  32. $addressDisclaimerText = $addressDisclaimerConfigRaw;
  33. } elseif (is_array($addressDisclaimerConfigRaw)) {
  34. $addressDisclaimerText = (string) ($addressDisclaimerConfigRaw['text'] ?? '');
  35. } else {
  36. $addressDisclaimerText = '';
  37. }
  38. $impressumUrl = trim((string) ($app['impressum_url'] ?? ''));
  39. $datenschutzUrl = trim((string) ($app['datenschutz_url'] ?? ''));
  40. $baseUrl = Bootstrap::baseUrl();
  41. /** @param array<string, mixed> $field */
  42. function renderField(array $field, string $addressDisclaimerText): void
  43. {
  44. $keyRaw = (string) ($field['key'] ?? '');
  45. $key = htmlspecialchars($keyRaw);
  46. $label = htmlspecialchars((string) $field['label']);
  47. $type = (string) ($field['type'] ?? 'text');
  48. $requiredAlways = (bool) ($field['required'] ?? false);
  49. $requiredConditional = isset($field['required_if']) && is_array($field['required_if']);
  50. $required = $requiredAlways ? 'required' : '';
  51. $maxLengthAttr = '';
  52. if (isset($field['max_length']) && is_int($field['max_length']) && $field['max_length'] > 0) {
  53. $maxLengthAttr = ' maxlength="' . (string) $field['max_length'] . '"';
  54. }
  55. $requiredLabel = '';
  56. if ($requiredAlways) {
  57. $requiredLabel = ' <span class="required-mark required-mark-field" aria-hidden="true">* Pflichtfeld</span>';
  58. } elseif ($requiredConditional) {
  59. $requiredLabel = ' <span class="required-mark required-mark-field" aria-hidden="true">* Pflichtfeld</span>';
  60. }
  61. $fieldClass = 'field';
  62. if ($requiredAlways || $requiredConditional) {
  63. $fieldClass .= ' mandatory-field';
  64. }
  65. if ($requiredAlways) {
  66. $fieldClass .= ' mandatory-field-hard';
  67. }
  68. echo '<div class="' . $fieldClass . '" data-field="' . $key . '">';
  69. if ($type === 'checkbox') {
  70. echo '<label class="checkbox-label"><input type="checkbox" name="form_data[' . $key . ']" value="1" ' . $required . '> ' . $label . $requiredLabel . '</label>';
  71. } else {
  72. $labelFor = $type === 'table' ? htmlspecialchars($keyRaw . '__r0__c0') : $key;
  73. echo '<label for="' . $labelFor . '">' . $label . $requiredLabel . '</label>';
  74. if ($type === 'textarea') {
  75. echo '<textarea id="' . $key . '" name="form_data[' . $key . ']" ' . $required . $maxLengthAttr . '></textarea>';
  76. } elseif ($type === 'select') {
  77. echo '<select id="' . $key . '" name="form_data[' . $key . ']" ' . $required . '>';
  78. echo '<option value="">Bitte wählen</option>';
  79. foreach (($field['options'] ?? []) as $option) {
  80. if (!is_array($option)) {
  81. continue;
  82. }
  83. $value = htmlspecialchars((string) ($option['value'] ?? ''));
  84. $optLabel = htmlspecialchars((string) ($option['label'] ?? ''));
  85. echo '<option value="' . $value . '">' . $optLabel . '</option>';
  86. }
  87. echo '</select>';
  88. } elseif ($type === 'table') {
  89. $rows = (int) ($field['rows'] ?? 4);
  90. if ($rows < 1) {
  91. $rows = 1;
  92. } elseif ($rows > 50) {
  93. $rows = 50;
  94. }
  95. $columns = [];
  96. if (isset($field['columns']) && is_array($field['columns'])) {
  97. foreach ($field['columns'] as $index => $column) {
  98. if (!is_array($column)) {
  99. continue;
  100. }
  101. $columnLabelRaw = trim((string) ($column['label'] ?? ''));
  102. if ($columnLabelRaw === '') {
  103. $columnLabelRaw = 'Spalte ' . ($index + 1);
  104. }
  105. $columnTypeRaw = strtolower(trim((string) ($column['type'] ?? 'text')));
  106. $columnType = in_array($columnTypeRaw, ['text', 'date', 'number', 'email', 'tel'], true) ? $columnTypeRaw : 'text';
  107. $columns[] = [
  108. 'label' => $columnLabelRaw,
  109. 'type' => $columnType,
  110. 'placeholder' => (string) ($column['placeholder'] ?? ''),
  111. ];
  112. }
  113. }
  114. if (empty($columns)) {
  115. $columns = [
  116. ['label' => 'Spalte 1', 'type' => 'text', 'placeholder' => ''],
  117. ['label' => 'Spalte 2', 'type' => 'text', 'placeholder' => ''],
  118. ['label' => 'Spalte 3', 'type' => 'text', 'placeholder' => ''],
  119. ];
  120. }
  121. echo '<input id="' . $key . '" type="hidden" name="form_data[' . $key . ']">';
  122. echo '<div class="table-input-wrapper">';
  123. echo '<div class="table-responsive">';
  124. echo '<table class="form-table-input" data-table-field="1" data-table-key="' . $key . '" data-table-rows="' . (string) $rows . '">';
  125. echo '<thead><tr>';
  126. foreach ($columns as $column) {
  127. echo '<th>' . htmlspecialchars($column['label']) . '</th>';
  128. }
  129. echo '</tr></thead>';
  130. echo '<tbody>';
  131. for ($row = 0; $row < $rows; $row++) {
  132. echo '<tr>';
  133. foreach ($columns as $columnIndex => $column) {
  134. $cellId = $keyRaw . '__r' . $row . '__c' . $columnIndex;
  135. $cellIdEscaped = htmlspecialchars($cellId);
  136. $placeholder = trim((string) ($column['placeholder'] ?? ''));
  137. $placeholderEscaped = htmlspecialchars($placeholder);
  138. $ariaLabel = htmlspecialchars($column['label'] . ' Zeile ' . ($row + 1));
  139. echo '<td>';
  140. echo '<input id="' . $cellIdEscaped . '" class="table-cell-input" type="' . htmlspecialchars((string) $column['type']) . '" data-table-cell="1" data-table-key="' . $key . '" data-row-index="' . (string) $row . '" data-col-index="' . (string) $columnIndex . '" aria-label="' . $ariaLabel . '" autocomplete="off"';
  141. if ($placeholder !== '') {
  142. echo ' placeholder="' . $placeholderEscaped . '"';
  143. }
  144. echo '>';
  145. echo '</td>';
  146. }
  147. echo '</tr>';
  148. }
  149. echo '</tbody>';
  150. echo '</table>';
  151. echo '</div>';
  152. echo '</div>';
  153. } elseif ($type === 'file') {
  154. $accept = htmlspecialchars((string) ($field['accept'] ?? ''));
  155. $description = trim((string) ($field['description'] ?? ''));
  156. $fileInputId = $key . '_file';
  157. $cameraInputId = $key . '_camera';
  158. echo '<div class="upload-control" data-upload-key="' . $key . '">';
  159. echo '<div class="upload-actions">';
  160. echo '<label class="upload-action-btn" for="' . $fileInputId . '">Datei auswählen</label>';
  161. echo '<label class="upload-action-btn upload-action-btn-camera" for="' . $cameraInputId . '">Foto aufnehmen</label>';
  162. echo '</div>';
  163. if ($description !== '') {
  164. echo '<small class="hint">' . nl2br(htmlspecialchars($description)) . '</small>';
  165. }
  166. echo '<input id="' . $fileInputId . '" class="upload-native-input" type="file" name="' . $key . '" accept="' . $accept . '">';
  167. echo '<input id="' . $cameraInputId . '" class="upload-native-input" type="file" name="' . $key . '__camera" accept="image/*" capture="environment">';
  168. echo '<p class="upload-selected" data-upload-selected="' . $key . '">Keine Datei gewählt</p>';
  169. echo '</div>';
  170. echo '<div class="upload-list" data-upload-list="' . $key . '"></div>';
  171. } else {
  172. $inputType = htmlspecialchars($type);
  173. echo '<input id="' . $key . '" type="' . $inputType . '" name="form_data[' . $key . ']" ' . $required . $maxLengthAttr . '>';
  174. }
  175. }
  176. $subtext = trim((string) ($field['subtext'] ?? ''));
  177. if ($subtext !== '') {
  178. echo '<small class="hint">' . nl2br(htmlspecialchars($subtext)) . '</small>';
  179. }
  180. if ($keyRaw === 'strasse' && trim($addressDisclaimerText) !== '') {
  181. echo '<div class="address-disclaimer">' . nl2br(htmlspecialchars($addressDisclaimerText)) . '</div>';
  182. }
  183. if (isset($field['required_if']) && is_array($field['required_if'])) {
  184. $depField = htmlspecialchars((string) ($field['required_if']['field'] ?? ''));
  185. $depValue = htmlspecialchars((string) ($field['required_if']['equals'] ?? ''));
  186. }
  187. echo '<div class="error" data-error-for="' . $key . '"></div>';
  188. echo '</div>';
  189. }
  190. ?><!doctype html>
  191. <html lang="de">
  192. <head>
  193. <meta charset="utf-8">
  194. <meta name="viewport" content="width=device-width, initial-scale=1">
  195. <title><?= htmlspecialchars((string) $app['project_name']) ?></title>
  196. <link rel="stylesheet" href="<?= htmlspecialchars(Bootstrap::url('assets/css/tokens.css')) ?>">
  197. <link rel="stylesheet" href="<?= htmlspecialchars(Bootstrap::url('assets/css/base.css')) ?>">
  198. </head>
  199. <body>
  200. <header class="site-header">
  201. <div class="container header-inner">
  202. <a class="brand" href="<?= htmlspecialchars(Bootstrap::url('index.php')) ?>">
  203. <img class="brand-logo" src="<?= htmlspecialchars(Bootstrap::url('assets/images/feuerwehr-logo-invers.webp')) ?>" alt="Feuerwehr Logo">
  204. <div>
  205. <div class="brand-title"><?= htmlspecialchars((string) $app['project_name']) ?></div>
  206. <div class="brand-subtitle">Feuerwehr Freising</div>
  207. </div>
  208. </a>
  209. </div>
  210. </header>
  211. <main class="container">
  212. <h1>Digitaler Mitgliedsantrag Feuerwehrverein</h1>
  213. <section id="disclaimerSection" class="card hidden">
  214. <h2><?= htmlspecialchars($disclaimerTitle) ?></h2>
  215. <p class="disclaimer-text"><?= nl2br(htmlspecialchars($disclaimerText)) ?></p>
  216. <div class="field disclaimer-ack-field">
  217. <label class="checkbox-label">
  218. <input id="disclaimerReadCheckbox" type="checkbox" value="1">
  219. Ich habe den Hinweis gelesen und verstanden.
  220. </label>
  221. <div id="disclaimerReadError" class="error"></div>
  222. </div>
  223. <button id="acceptDisclaimerBtn" type="button" class="btn" disabled><?= htmlspecialchars($disclaimerAcceptLabel) ?></button>
  224. </section>
  225. <section id="wizardSection" class="card hidden">
  226. <h2>Mitgliedsantrag</h2>
  227. <p class="required-legend"><span class="required-mark" aria-hidden="true">*</span> Pflichtfeld</p>
  228. <div id="progress" class="progress"></div>
  229. <form id="applicationForm" enctype="multipart/form-data" novalidate>
  230. <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
  231. <input type="hidden" id="applicationEmail" name="email" value="">
  232. <div class="hp-field" aria-hidden="true">
  233. <label for="applicationWebsite">Website</label>
  234. <input id="applicationWebsite" type="text" name="website" autocomplete="off" tabindex="-1">
  235. </div>
  236. <?php foreach ($steps as $index => $step): ?>
  237. <section class="step hidden" data-step="<?= $index + 1 ?>">
  238. <h3>Schritt <?= $index + 1 ?>: <?= htmlspecialchars((string) ($step['title'] ?? '')) ?></h3>
  239. <p><?= htmlspecialchars((string) ($step['description'] ?? '')) ?></p>
  240. <?php foreach (($step['fields'] ?? []) as $field): ?>
  241. <?php if (is_array($field)) { renderField($field, $addressDisclaimerText); } ?>
  242. <?php endforeach; ?>
  243. </section>
  244. <?php endforeach; ?>
  245. <section id="summarySection" class="step-summary hidden">
  246. <h3>Zusammenfassung</h3>
  247. <p>Bitte prüfen Sie alle Angaben vor dem verbindlichen Absenden.</p>
  248. <div id="summaryMissingNotice" class="summary-missing-note hidden" role="status" aria-live="polite"></div>
  249. <div id="summaryContent" class="summary-content"></div>
  250. </section>
  251. <div class="wizard-actions">
  252. <button type="button" id="prevBtn" class="btn btn-secondary">Zurück</button>
  253. <button type="button" id="nextBtn" class="btn">Weiter</button>
  254. <button type="button" id="submitBtn" class="btn hidden">
  255. <span data-submit-label>Verbindlich absenden</span>
  256. <span id="submitSpinner" class="btn-spinner hidden" aria-hidden="true"></span>
  257. </button>
  258. </div>
  259. <p id="feedbackMessage" class="status-text" role="status" aria-live="polite"></p>
  260. </form>
  261. </section>
  262. <section id="startSection" class="card">
  263. <h2>Status</h2>
  264. <p id="startIntroText"><?= htmlspecialchars($startIntroText) ?></p>
  265. <form id="startForm" novalidate>
  266. <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
  267. <div class="hp-field" aria-hidden="true">
  268. <label for="website">Website</label>
  269. <input id="website" type="text" name="website" autocomplete="off" tabindex="-1">
  270. </div>
  271. <div class="field" id="startEmailField">
  272. <label for="startEmail">E-Mail <span class="required-mark required-mark-field-start" aria-hidden="true">* Pflichtfeld</span></label>
  273. <input id="startEmail" type="email" name="email" required inputmode="email" autocomplete="email">
  274. <div id="startEmailError" class="error"></div>
  275. </div>
  276. <div class="inline-actions" id="startActions">
  277. <button id="startSubmitBtn" type="submit" class="btn">Code senden</button>
  278. </div>
  279. <section id="otpSection" class="hidden" aria-live="polite">
  280. <p id="otpInfoText" class="status-text"></p>
  281. <div class="field" id="startOtpField">
  282. <label for="startOtp">Sicherheitscode <span class="required-mark required-mark-field-start" aria-hidden="true">* Pflichtfeld</span></label>
  283. <input id="startOtp" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="[0-9]{6}">
  284. <div id="startOtpError" class="error"></div>
  285. </div>
  286. <div class="inline-actions" id="otpActions">
  287. <button id="verifyOtpBtn" type="button" class="btn">Code bestätigen</button>
  288. <button id="resendOtpBtn" type="button" class="btn btn-secondary">Code erneut senden</button>
  289. </div>
  290. <p id="otpCooldownMessage" class="status-text"></p>
  291. </section>
  292. <div id="compactStatusBox" class="compact-status hidden">
  293. <p><strong>E-Mail:</strong> <span id="statusEmailValue">-</span></p>
  294. <p><strong>Speicherstatus:</strong> <span id="draftStatusValue">Noch nicht gespeichert</span></p>
  295. <button id="resetDataBtn" type="button" class="btn btn-small">Gespeicherte Daten löschen und neu starten</button>
  296. </div>
  297. <p id="startFeedbackMessage" class="status-text" role="status" aria-live="polite"></p>
  298. </form>
  299. </section>
  300. </main>
  301. <?php if ($impressumUrl !== '' || $datenschutzUrl !== ''): ?>
  302. <footer class="site-footer">
  303. <div class="container site-footer-inner">
  304. <?php if ($impressumUrl !== ''): ?>
  305. <a class="site-footer-link" href="<?= htmlspecialchars($impressumUrl) ?>">Impressum</a>
  306. <?php endif; ?>
  307. <?php if ($impressumUrl !== '' && $datenschutzUrl !== ''): ?>
  308. <span class="site-footer-separator" aria-hidden="true">|</span>
  309. <?php endif; ?>
  310. <?php if ($datenschutzUrl !== ''): ?>
  311. <a class="site-footer-link" href="<?= htmlspecialchars($datenschutzUrl) ?>">Datenschutz</a>
  312. <?php endif; ?>
  313. </div>
  314. </footer>
  315. <?php endif; ?>
  316. <script>
  317. window.APP_BOOT = {
  318. steps: <?= json_encode($steps, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
  319. csrf: <?= json_encode($csrf, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
  320. contactEmail: <?= json_encode((string) ($app['contact_email'] ?? ''), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
  321. baseUrl: <?= json_encode($baseUrl, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
  322. verification: <?= json_encode((array) ($app['verification'] ?? []), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>
  323. };
  324. </script>
  325. <script src="<?= htmlspecialchars(Bootstrap::url('assets/js/form.js')) ?>"></script>
  326. </body>
  327. </html>