310 lines
11 KiB
JavaScript
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();
|
|
}
|
|
})();
|