form.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574
  1. (function () {
  2. const EMAIL_STORAGE_KEY = 'ff_member_form_email_v1';
  3. const DISCLAIMER_ACCEPTED_KEY = 'ff_member_form_disclaimer_accepted_v1';
  4. const boot = window.APP_BOOT || { steps: [], csrf: '', contactEmail: '' };
  5. const state = {
  6. email: '',
  7. currentStep: 1,
  8. totalSteps: boot.steps.length,
  9. autosaveId: null,
  10. };
  11. const disclaimerSection = document.getElementById('disclaimerSection');
  12. const acceptDisclaimerBtn = document.getElementById('acceptDisclaimerBtn');
  13. const startSection = document.getElementById('startSection');
  14. const startForm = document.getElementById('startForm');
  15. const startIntroText = document.getElementById('startIntroText');
  16. const startEmailField = document.getElementById('startEmailField');
  17. const startActions = document.getElementById('startActions');
  18. const startEmailInput = document.getElementById('startEmail');
  19. const resetDataBtn = document.getElementById('resetDataBtn');
  20. const compactStatusBox = document.getElementById('compactStatusBox');
  21. const statusEmailValue = document.getElementById('statusEmailValue');
  22. const draftStatusValue = document.getElementById('draftStatusValue');
  23. const feedbackMessage = document.getElementById('feedbackMessage');
  24. const wizardSection = document.getElementById('wizardSection');
  25. const applicationForm = document.getElementById('applicationForm');
  26. const applicationEmail = document.getElementById('applicationEmail');
  27. const progress = document.getElementById('progress');
  28. const prevBtn = document.getElementById('prevBtn');
  29. const nextBtn = document.getElementById('nextBtn');
  30. const submitBtn = document.getElementById('submitBtn');
  31. const stepElements = Array.from(document.querySelectorAll('.step'));
  32. function setFeedback(text, isError) {
  33. feedbackMessage.textContent = text || '';
  34. feedbackMessage.classList.toggle('error-text', Boolean(isError));
  35. }
  36. function setDraftStatus(text, isError) {
  37. draftStatusValue.textContent = text || '';
  38. draftStatusValue.classList.toggle('error-text', Boolean(isError));
  39. }
  40. function formatTimestamp(isoDate) {
  41. if (!isoDate) {
  42. return '';
  43. }
  44. const date = new Date(isoDate);
  45. if (Number.isNaN(date.getTime())) {
  46. return String(isoDate);
  47. }
  48. return date.toLocaleString('de-DE');
  49. }
  50. function normalizeEmail(email) {
  51. return (email || '').trim().toLowerCase();
  52. }
  53. function isValidEmail(email) {
  54. return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  55. }
  56. function rememberEmail(email) {
  57. try {
  58. localStorage.setItem(EMAIL_STORAGE_KEY, email);
  59. } catch (_err) {
  60. // ignore localStorage errors
  61. }
  62. }
  63. function getRememberedEmail() {
  64. try {
  65. return localStorage.getItem(EMAIL_STORAGE_KEY) || '';
  66. } catch (_err) {
  67. return '';
  68. }
  69. }
  70. function forgetRememberedEmail() {
  71. try {
  72. localStorage.removeItem(EMAIL_STORAGE_KEY);
  73. } catch (_err) {
  74. // ignore localStorage errors
  75. }
  76. }
  77. function hasAcceptedDisclaimer() {
  78. try {
  79. return localStorage.getItem(DISCLAIMER_ACCEPTED_KEY) === '1';
  80. } catch (_err) {
  81. return false;
  82. }
  83. }
  84. function setDisclaimerAccepted() {
  85. try {
  86. localStorage.setItem(DISCLAIMER_ACCEPTED_KEY, '1');
  87. } catch (_err) {
  88. // ignore localStorage errors
  89. }
  90. }
  91. function enterCompactStatus(email) {
  92. statusEmailValue.textContent = email;
  93. startSection.classList.add('compact-mode');
  94. compactStatusBox.classList.remove('hidden');
  95. startIntroText.classList.add('hidden');
  96. startEmailField.classList.add('hidden');
  97. startActions.classList.add('hidden');
  98. }
  99. function leaveCompactStatus() {
  100. startSection.classList.remove('compact-mode');
  101. compactStatusBox.classList.add('hidden');
  102. startIntroText.classList.remove('hidden');
  103. startEmailField.classList.remove('hidden');
  104. startActions.classList.remove('hidden');
  105. statusEmailValue.textContent = '-';
  106. setDraftStatus('Noch nicht gespeichert', false);
  107. }
  108. function lockEmail(email) {
  109. state.email = email;
  110. applicationEmail.value = email;
  111. startEmailInput.value = email;
  112. startEmailInput.readOnly = true;
  113. startEmailInput.setAttribute('aria-readonly', 'true');
  114. rememberEmail(email);
  115. enterCompactStatus(email);
  116. }
  117. function unlockEmail(clearInput) {
  118. state.email = '';
  119. applicationEmail.value = '';
  120. startEmailInput.readOnly = false;
  121. startEmailInput.removeAttribute('aria-readonly');
  122. if (clearInput) {
  123. startEmailInput.value = '';
  124. }
  125. forgetRememberedEmail();
  126. leaveCompactStatus();
  127. }
  128. function stopAutosave() {
  129. if (state.autosaveId) {
  130. clearInterval(state.autosaveId);
  131. state.autosaveId = null;
  132. }
  133. }
  134. function startAutosave() {
  135. stopAutosave();
  136. state.autosaveId = setInterval(async () => {
  137. if (!state.email || wizardSection.classList.contains('hidden')) {
  138. return;
  139. }
  140. try {
  141. await saveDraft(false);
  142. } catch (_err) {
  143. // visible on next manual action
  144. }
  145. }, 15000);
  146. }
  147. function clearErrors() {
  148. document.querySelectorAll('[data-error-for]').forEach((el) => {
  149. el.textContent = '';
  150. });
  151. }
  152. function showErrors(errors) {
  153. clearErrors();
  154. Object.keys(errors || {}).forEach((key) => {
  155. const el = document.querySelector('[data-error-for="' + key + '"]');
  156. if (el) {
  157. el.textContent = errors[key];
  158. }
  159. });
  160. }
  161. function clearWizardData() {
  162. applicationForm.reset();
  163. clearErrors();
  164. renderUploadInfo({});
  165. state.currentStep = 1;
  166. submitBtn.disabled = false;
  167. nextBtn.disabled = false;
  168. prevBtn.disabled = false;
  169. updateProgress();
  170. Array.from(document.querySelectorAll('[data-upload-key]')).forEach((control) => {
  171. const fieldKey = control.getAttribute('data-upload-key');
  172. if (fieldKey) {
  173. updateUploadSelectionText(fieldKey);
  174. }
  175. });
  176. }
  177. function updateProgress() {
  178. progress.textContent = 'Schritt ' + state.currentStep + ' von ' + state.totalSteps;
  179. stepElements.forEach((el) => {
  180. const step = Number(el.getAttribute('data-step'));
  181. el.classList.toggle('hidden', step !== state.currentStep);
  182. });
  183. prevBtn.disabled = state.currentStep === 1;
  184. nextBtn.classList.toggle('hidden', state.currentStep === state.totalSteps);
  185. submitBtn.classList.toggle('hidden', state.currentStep !== state.totalSteps);
  186. }
  187. function fillFormData(data) {
  188. Object.keys(data || {}).forEach((key) => {
  189. const field = applicationForm.querySelector('[name="form_data[' + key + ']"]');
  190. if (!field) return;
  191. if (field.type === 'checkbox') {
  192. field.checked = ['1', 'on', 'true', true].includes(data[key]);
  193. } else {
  194. field.value = data[key] || '';
  195. }
  196. });
  197. }
  198. function renderUploadInfo(uploads) {
  199. document.querySelectorAll('[data-upload-list]').forEach((el) => {
  200. el.innerHTML = '';
  201. });
  202. Object.keys(uploads || {}).forEach((field) => {
  203. const target = document.querySelector('[data-upload-list="' + field + '"]');
  204. if (!target || !Array.isArray(uploads[field])) return;
  205. uploads[field].forEach((item) => {
  206. const div = document.createElement('div');
  207. div.className = 'upload-item';
  208. div.textContent = item.original_filename + ' (' + item.uploaded_at + ')';
  209. target.appendChild(div);
  210. });
  211. });
  212. }
  213. function updateUploadSelectionText(fieldKey) {
  214. const target = document.querySelector('[data-upload-selected="' + fieldKey + '"]');
  215. if (!target) {
  216. return;
  217. }
  218. const fileInput = applicationForm.querySelector('[name="' + fieldKey + '"]');
  219. const cameraInput = applicationForm.querySelector('[name="' + fieldKey + '__camera"]');
  220. let label = 'Keine Datei gewählt';
  221. if (fileInput && fileInput.files && fileInput.files[0]) {
  222. label = 'Ausgewählt: ' + fileInput.files[0].name;
  223. }
  224. if (cameraInput && cameraInput.files && cameraInput.files[0]) {
  225. label = 'Ausgewählt: ' + cameraInput.files[0].name + ' (Foto)';
  226. }
  227. target.textContent = label;
  228. }
  229. async function triggerInstantUpload() {
  230. if (!state.email || wizardSection.classList.contains('hidden')) {
  231. return;
  232. }
  233. setDraftStatus('Datei wird hochgeladen ...', false);
  234. try {
  235. await saveDraft(true);
  236. setFeedback('', false);
  237. } catch (err) {
  238. const msg = (err.payload && err.payload.message) || err.message || 'Upload fehlgeschlagen.';
  239. setDraftStatus('Upload fehlgeschlagen', true);
  240. setFeedback(msg, true);
  241. }
  242. }
  243. function initUploadControls() {
  244. const controls = Array.from(document.querySelectorAll('[data-upload-key]'));
  245. controls.forEach((control) => {
  246. const fieldKey = control.getAttribute('data-upload-key') || '';
  247. if (!fieldKey) {
  248. return;
  249. }
  250. const fileInput = applicationForm.querySelector('[name="' + fieldKey + '"]');
  251. const cameraInput = applicationForm.querySelector('[name="' + fieldKey + '__camera"]');
  252. if (fileInput) {
  253. fileInput.addEventListener('change', async () => {
  254. if (fileInput.files && fileInput.files[0] && cameraInput) {
  255. cameraInput.value = '';
  256. }
  257. updateUploadSelectionText(fieldKey);
  258. if (fileInput.files && fileInput.files[0]) {
  259. await triggerInstantUpload();
  260. }
  261. });
  262. }
  263. if (cameraInput) {
  264. cameraInput.addEventListener('change', async () => {
  265. if (cameraInput.files && cameraInput.files[0] && fileInput) {
  266. fileInput.value = '';
  267. }
  268. updateUploadSelectionText(fieldKey);
  269. if (cameraInput.files && cameraInput.files[0]) {
  270. await triggerInstantUpload();
  271. }
  272. });
  273. }
  274. updateUploadSelectionText(fieldKey);
  275. });
  276. }
  277. async function postForm(url, formData) {
  278. const response = await fetch(url, {
  279. method: 'POST',
  280. body: formData,
  281. credentials: 'same-origin',
  282. headers: { 'X-Requested-With': 'XMLHttpRequest' },
  283. });
  284. const payload = await response.json();
  285. if (!response.ok || payload.ok === false) {
  286. const err = new Error(payload.message || 'Anfrage fehlgeschlagen');
  287. err.payload = payload;
  288. throw err;
  289. }
  290. return payload;
  291. }
  292. function collectPayload(includeFiles) {
  293. const fd = new FormData();
  294. fd.append('csrf', boot.csrf);
  295. fd.append('email', state.email);
  296. fd.append('step', String(state.currentStep));
  297. fd.append('website', '');
  298. Array.from(applicationForm.elements).forEach((el) => {
  299. if (!el.name) return;
  300. if (!el.name.startsWith('form_data[')) return;
  301. if (el.type === 'checkbox') {
  302. fd.append(el.name, el.checked ? '1' : '0');
  303. } else {
  304. fd.append(el.name, el.value || '');
  305. }
  306. });
  307. if (includeFiles) {
  308. Array.from(applicationForm.querySelectorAll('input[type="file"]')).forEach((input) => {
  309. if (input.files && input.files[0]) {
  310. fd.append(input.name, input.files[0]);
  311. }
  312. });
  313. }
  314. return fd;
  315. }
  316. async function loadDraft(email) {
  317. const fd = new FormData();
  318. fd.append('csrf', boot.csrf);
  319. fd.append('email', email);
  320. fd.append('website', '');
  321. return postForm('/api/load-draft.php', fd);
  322. }
  323. async function resetSavedData(email) {
  324. const fd = new FormData();
  325. fd.append('csrf', boot.csrf);
  326. fd.append('email', email);
  327. fd.append('website', '');
  328. return postForm('/api/reset.php', fd);
  329. }
  330. async function saveDraft(includeFiles) {
  331. const payload = collectPayload(includeFiles);
  332. const response = await postForm('/api/save-draft.php', payload);
  333. if (response.upload_errors && Object.keys(response.upload_errors).length > 0) {
  334. showErrors(response.upload_errors);
  335. setDraftStatus('Uploadfehler', true);
  336. setFeedback('Einige Dateien konnten nicht gespeichert werden.', true);
  337. } else {
  338. const ts = formatTimestamp(response.updated_at);
  339. setDraftStatus('Gespeichert: ' + (ts || 'gerade eben'), false);
  340. }
  341. if (response.uploads) {
  342. renderUploadInfo(response.uploads);
  343. }
  344. if (includeFiles) {
  345. Array.from(applicationForm.querySelectorAll('input[type="file"]')).forEach((input) => {
  346. input.value = '';
  347. });
  348. Array.from(document.querySelectorAll('[data-upload-key]')).forEach((control) => {
  349. const fieldKey = control.getAttribute('data-upload-key');
  350. if (fieldKey) {
  351. updateUploadSelectionText(fieldKey);
  352. }
  353. });
  354. }
  355. return response;
  356. }
  357. async function submitApplication() {
  358. const payload = collectPayload(true);
  359. const response = await postForm('/api/submit.php', payload);
  360. clearErrors();
  361. setDraftStatus('Abgeschlossen', false);
  362. setFeedback('Antrag erfolgreich abgeschlossen. Vielen Dank.', false);
  363. submitBtn.disabled = true;
  364. nextBtn.disabled = true;
  365. prevBtn.disabled = true;
  366. return response;
  367. }
  368. async function startProcess(rawEmail) {
  369. const email = normalizeEmail(rawEmail);
  370. if (!isValidEmail(email)) {
  371. setFeedback('Bitte eine gültige E-Mail-Adresse eingeben.', true);
  372. startEmailInput.focus();
  373. return;
  374. }
  375. try {
  376. const result = await loadDraft(email);
  377. lockEmail(email);
  378. if (result.already_submitted) {
  379. wizardSection.classList.add('hidden');
  380. setDraftStatus('Antrag bereits abgeschlossen', false);
  381. setFeedback(boot.contactEmail ? 'Kontakt: ' + boot.contactEmail : '', false);
  382. stopAutosave();
  383. return;
  384. }
  385. wizardSection.classList.remove('hidden');
  386. fillFormData(result.data || {});
  387. renderUploadInfo(result.uploads || {});
  388. state.currentStep = Math.min(Math.max(Number(result.step || 1), 1), state.totalSteps);
  389. updateProgress();
  390. startAutosave();
  391. const loadedAt = formatTimestamp(result.updated_at);
  392. if (loadedAt) {
  393. setDraftStatus('Entwurf geladen: ' + loadedAt, false);
  394. } else {
  395. setDraftStatus('Neuer Entwurf gestartet', false);
  396. }
  397. setFeedback('', false);
  398. } catch (err) {
  399. const msg = (err.payload && err.payload.message) || err.message || 'Laden fehlgeschlagen.';
  400. setFeedback(msg, true);
  401. }
  402. }
  403. startForm.addEventListener('submit', async (event) => {
  404. event.preventDefault();
  405. await startProcess(startEmailInput.value || '');
  406. });
  407. resetDataBtn.addEventListener('click', async () => {
  408. if (!state.email) {
  409. setFeedback('Keine aktive E-Mail vorhanden.', true);
  410. return;
  411. }
  412. const confirmed = window.confirm('Alle gespeicherten Daten zu dieser E-Mail endgültig löschen und neu starten?');
  413. if (!confirmed) {
  414. return;
  415. }
  416. try {
  417. await resetSavedData(state.email);
  418. stopAutosave();
  419. wizardSection.classList.add('hidden');
  420. clearWizardData();
  421. unlockEmail(true);
  422. setFeedback('Alle gespeicherten Daten wurden gelöscht. Sie können neu starten.', false);
  423. startEmailInput.focus();
  424. } catch (err) {
  425. const msg = (err.payload && err.payload.message) || err.message || 'Löschen fehlgeschlagen.';
  426. setFeedback(msg, true);
  427. }
  428. });
  429. prevBtn.addEventListener('click', async () => {
  430. if (state.currentStep <= 1) return;
  431. try {
  432. await saveDraft(false);
  433. state.currentStep -= 1;
  434. updateProgress();
  435. } catch (err) {
  436. const msg = (err.payload && err.payload.message) || err.message;
  437. setFeedback(msg, true);
  438. }
  439. });
  440. nextBtn.addEventListener('click', async () => {
  441. if (state.currentStep >= state.totalSteps) return;
  442. try {
  443. await saveDraft(false);
  444. state.currentStep += 1;
  445. updateProgress();
  446. } catch (err) {
  447. const msg = (err.payload && err.payload.message) || err.message;
  448. setFeedback(msg, true);
  449. }
  450. });
  451. submitBtn.addEventListener('click', async () => {
  452. try {
  453. await submitApplication();
  454. } catch (err) {
  455. const payload = err.payload || {};
  456. if (payload.errors) {
  457. showErrors(payload.errors);
  458. }
  459. const msg = payload.message || err.message || 'Absenden fehlgeschlagen.';
  460. setFeedback(msg, true);
  461. if (payload.already_submitted) {
  462. wizardSection.classList.add('hidden');
  463. setDraftStatus('Antrag bereits abgeschlossen', false);
  464. }
  465. }
  466. });
  467. function initializeAfterDisclaimer() {
  468. disclaimerSection.classList.add('hidden');
  469. startSection.classList.remove('hidden');
  470. const rememberedEmail = normalizeEmail(getRememberedEmail());
  471. if (rememberedEmail !== '') {
  472. if (isValidEmail(rememberedEmail)) {
  473. startEmailInput.value = rememberedEmail;
  474. setFeedback('', false);
  475. startProcess(rememberedEmail);
  476. } else {
  477. forgetRememberedEmail();
  478. }
  479. }
  480. }
  481. if (acceptDisclaimerBtn) {
  482. acceptDisclaimerBtn.addEventListener('click', () => {
  483. setDisclaimerAccepted();
  484. initializeAfterDisclaimer();
  485. });
  486. }
  487. if (hasAcceptedDisclaimer()) {
  488. initializeAfterDisclaimer();
  489. } else {
  490. disclaimerSection.classList.remove('hidden');
  491. startSection.classList.add('hidden');
  492. }
  493. initUploadControls();
  494. updateProgress();
  495. })();