Files
2026-09-03 10:39:26 +02:00

310 lines
11 KiB
JavaScript

/* ============================================================================
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();
}
})();