/* ============================================================================ SoftSPM - obsługa formularzy Suwak antybotowy, zgoda marketingowa i wysyłka przez api/send.php. Czysty JavaScript, bez zależności. ========================================================================= */ (function () { 'use strict'; var DICT = { pl: { slide: 'Przesuń, aby zweryfikować', verified: 'Zweryfikowano', checking: 'Sprawdzanie...', needSlider: 'Najpierw przesuń suwak, aby potwierdzić, że nie jesteś robotem.', needConsent: 'Zaznacz zgodę na kontakt, aby wysłać zgłoszenie.', sending: 'Wysyłanie...', sendLabel: 'Wyślij', tokenError: 'Nie udało się przygotować formularza. Odśwież stronę.', network: 'Brak połączenia z serwerem. Spróbuj ponownie lub napisz na info@softspm.com.', generic: 'Nie udało się wysłać wiadomości. Napisz na info@softspm.com.', handleLabel: 'Przesuń suwak w prawo, aby potwierdzić, że nie jesteś robotem' }, en: { slide: 'Slide to verify', verified: 'Verified', checking: 'Checking...', needSlider: 'Please slide the control first to confirm you are not a robot.', needConsent: 'Please tick the consent box to send the message.', sending: 'Sending...', sendLabel: 'Send', tokenError: 'The form could not be prepared. Please refresh the page.', network: 'No connection to the server. Try again or write to info@softspm.com.', generic: 'The message could not be sent. Please write to info@softspm.com.', handleLabel: 'Slide the control to the right to confirm you are not a robot' } }; var lang = (document.documentElement.getAttribute('lang') || 'pl').slice(0, 2).toLowerCase(); var t = DICT[lang] || DICT.pl; /* -- suwak weryfikacyjny -------------------------------------------------- */ function Slider(root, onVerified) { var track = root.querySelector('[data-verify-track]'); var fill = root.querySelector('[data-verify-fill]'); var handle = root.querySelector('[data-verify-handle]'); var hint = root.querySelector('[data-verify-hint]'); if (!track || !handle) { return null; } var pos = 0; var done = false; var busy = false; var pointerId = null; handle.setAttribute('aria-label', t.handleLabel); function limit() { return Math.max(0, track.clientWidth - handle.offsetWidth); } function place(x) { var max = limit(); pos = Math.max(0, Math.min(max, x)); handle.style.left = pos + 'px'; if (fill) { fill.style.width = (pos + handle.offsetWidth) + 'px'; } handle.setAttribute('aria-valuenow', max > 0 ? String(Math.round((pos / max) * 100)) : '0'); } function reset() { done = false; busy = false; root.classList.remove('is-verified'); handle.setAttribute('aria-valuetext', t.slide); if (hint) { hint.textContent = t.slide; } place(0); } function complete() { if (done || busy) { return; } busy = true; place(limit()); if (hint) { hint.textContent = t.checking; } onVerified(function (ok) { busy = false; if (ok) { done = true; root.classList.add('is-verified'); handle.setAttribute('aria-valuetext', t.verified); if (hint) { hint.textContent = t.verified; } } else { reset(); } }); } handle.addEventListener('pointerdown', function (event) { if (done || busy) { return; } pointerId = event.pointerId; handle.setPointerCapture(pointerId); root.classList.add('is-dragging'); var offset = event.clientX - handle.getBoundingClientRect().left; function move(moveEvent) { place(moveEvent.clientX - track.getBoundingClientRect().left - offset); } function up() { handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', up); handle.removeEventListener('pointercancel', up); root.classList.remove('is-dragging'); if (pointerId !== null && handle.hasPointerCapture && handle.hasPointerCapture(pointerId)) { handle.releasePointerCapture(pointerId); } pointerId = null; if (pos >= limit() - 2) { complete(); } else { place(0); } } handle.addEventListener('pointermove', move); handle.addEventListener('pointerup', up); handle.addEventListener('pointercancel', up); event.preventDefault(); }); // Obsługa klawiaturą - strzałkami do końca albo klawiszem End. handle.addEventListener('keydown', function (event) { if (done || busy) { return; } var step = limit() / 4; var key = event.key; if (key === 'ArrowRight' || key === 'ArrowUp') { place(pos + step); } else if (key === 'ArrowLeft' || key === 'ArrowDown') { place(pos - step); } else if (key === 'End') { place(limit()); } else if (key === 'Home') { place(0); } else { return; } event.preventDefault(); if (pos >= limit() - 1) { complete(); } }); window.addEventListener('resize', function () { place(done ? limit() : pos); }); reset(); return { isVerified: function () { return done; }, focus: function () { handle.focus(); }, reset: reset }; } /* -- pojedynczy formularz ------------------------------------------------- */ function initForm(form) { var id = form.getAttribute('data-form'); var endpoint = form.getAttribute('data-endpoint') || 'api/send.php'; var status = form.querySelector('[data-form-status]'); var button = form.querySelector('button[type="submit"]'); var consent = form.querySelector('[data-consent]'); var verifyRoot = form.querySelector('[data-verify]'); var buttonLabel = button ? button.textContent : t.sendLabel; var token = null; var proof = null; var sending = false; function setStatus(text, kind) { if (!status) { return; } status.textContent = text || ''; status.className = 'form-status' + (kind ? ' is-' + kind : ''); } function request(params, done) { var url = endpoint + '?' + params; var xhrDone = false; fetch(url, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } }) .then(function (response) { return response.json().catch(function () { return {}; }); }) .then(function (data) { xhrDone = true; done(data && data.ok ? data : null); }) .catch(function () { if (!xhrDone) { done(null); } }); } // Token pobieramy dopiero, gdy ktoś zacznie korzystać z formularza. // Dzięki temu serwer mierzy realny czas wypełniania. // // Kolejka pending jest istotna: zdarzenie na suwaku potrafi zawołać tę // funkcję dwa razy w tej samej chwili. Bez niej powstałyby dwa tokeny, // a potwierdzenie dotyczyłoby innego niż ten wysłany razem z formularzem. var pending = null; function ensureToken(done) { if (token) { done(true); return; } if (pending) { pending.push(done); return; } pending = [done]; request('action=token&form=' + encodeURIComponent(id), function (data) { var queue = pending; pending = null; if (data && data.token) { token = data.token; } for (var i = 0; i < queue.length; i++) { queue[i](!!token); } }); } var slider = verifyRoot ? Slider(verifyRoot, function (finish) { ensureToken(function (ok) { if (!ok) { setStatus(t.tokenError, 'error'); finish(false); return; } request('action=confirm&form=' + encodeURIComponent(id) + '&token=' + encodeURIComponent(token), function (data) { if (data && data.proof) { proof = data.proof; setStatus('', null); finish(true); } else { setStatus(t.tokenError, 'error'); finish(false); } }); }); }) : null; // Token bierzemy przy pierwszym dotknięciu formularza - wtedy serwer mierzy // realny czas wypełniania, a nie moment przesunięcia suwaka. var wake = function () { ensureToken(function () {}); }; form.addEventListener('focusin', wake); if (verifyRoot) { verifyRoot.addEventListener('pointerdown', wake); verifyRoot.addEventListener('keydown', wake); } form.addEventListener('submit', function (event) { event.preventDefault(); if (sending) { return; } if (slider && !slider.isVerified()) { setStatus(t.needSlider, 'error'); slider.focus(); return; } if (consent && !consent.checked) { setStatus(t.needConsent, 'error'); consent.focus(); return; } if (!token || !proof) { setStatus(t.tokenError, 'error'); return; } var payload = { form: id, token: token, proof: proof, zgoda: true, page: window.location.href }; var fields = form.querySelectorAll('input[name], select[name], textarea[name]'); for (var i = 0; i < fields.length; i++) { var field = fields[i]; if (field.type === 'checkbox' || field.type === 'radio' || field.type === 'submit') { continue; } payload[field.name] = field.value; } sending = true; setStatus(t.sending, 'busy'); if (button) { button.disabled = true; button.textContent = t.sending; } fetch(endpoint, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(payload) }) .then(function (response) { return response.json() .catch(function () { return { ok: false }; }) .then(function (data) { return { status: response.status, data: data }; }); }) .then(function (result) { sending = false; if (button) { button.disabled = false; button.textContent = buttonLabel; } if (result.data && result.data.ok) { form.reset(); token = null; proof = null; if (slider) { slider.reset(); } setStatus(result.data.message || '', 'ok'); } else { setStatus((result.data && result.data.message) || t.generic, 'error'); // Token jest jednorazowy i serwer mógł go już zużyć, więc po każdym // błędzie bierzemy nowy i prosimy o ponowne przesunięcie suwaka. token = null; proof = null; if (slider) { slider.reset(); } } }) .catch(function () { sending = false; if (button) { button.disabled = false; button.textContent = buttonLabel; } setStatus(t.network, 'error'); }); }); } function boot() { var forms = document.querySelectorAll('form[data-form]'); for (var i = 0; i < forms.length; i++) { initForm(forms[i]); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } })();