Initial commit
This commit is contained in:
+309
@@ -0,0 +1,309 @@
|
||||
/* ============================================================================
|
||||
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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user