form.js 17 KB

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