Formularz kontaktowy
Poniższy tekst będzie w zasadzie podsumowaniem tego, co sobie powiedzieliśmy w poprzednich rozdziałach. Naszym celem będzie stworzenie w pełni działającego dynamicznego formularza kontaktowego.
HTML i CSS
Zaczynamy od prostego kodu HTML i CSS, który przewijał nam się już w poprzednich dyskusjach:
Pokaż HTMLPokaż CSS
Wygląd formularza możesz zobaczyć tutaj.
Do naszego formularza wybierzemy sposób z umieszczeniem komunikatów błędów w dataset pól.
Tak jak to robiliśmy w tamtym rozdziale - każde wymagane pole w naszym formularzu będzie miało dodatkowy atrybut data-error-text
, który zawiera tekst wyświetlany w razie błędu. W razie jeżeli nie wiesz o czym mówimy, zapraszam do lektury.
Początkowy skrypt
Żeby na siłę nie powtarzać czynności, zacznijmy od punktu, na którym skończyliśmy w poprzednim rozdziale, to jest na podpięciu sprawdzania pól w czasie pisania, ale i przy próbie wysyłki formularza:
const form = document.querySelector("#contactForm");
const inputs = form.querySelectorAll("[required]");
//wyłączam validationApi by nie przeszkadzało
form.setAttribute("novalidate", true);
//dodaję dynamiczną walidację pól
for (const el of inputs) {
el.addEventListener("input", e => markFieldAsError(e.target, !e.target.checkValidity()));
}
function toggleErrorField(field, show) {
const errorText = field.nextElementSibling;
if (errorText !== null) {
if (errorText.classList.contains("form-error-text")) {
errorText.style.display = show ? "block" : "none";
errorText.setAttribute('aria-hidden', show);
}
}
}
function markFieldAsError(field, show) {
if (show) {
field.classList.add("field-error");
} else {
field.classList.remove("field-error");
toggleErrorField(field, false);
}
}
Samą wysyłkę napiszemy jako oddzielną funkcję:
function checkElements() {
let formErrors = false;
for (const el of inputs) {
markFieldAsError(el, false);
toggleErrorField(el, false);
if (!el.checkValidity()) {
markFieldAsError(el, true);
toggleErrorField(el, true);
formErrors = true;
}
}
return formErrors;
}
function submitForm(e) {
let formErrors = checkElements();
if (!formErrors) {
//form.submit();
//dane będziemy wysyłać dynamicznie!
}
}
form.addEventListener("submit", e => {
e.preventDefault();
submitForm()
});
Sprawdź formularz w akcji
Wysyłka formularza
Żeby wysłać dane, musimy pobrać wszystkie dane z formularza. Wystarczy do tego użyć formData:
...
function checkElements() {
...
}
function submitForm() {
let formErrors = checkElements();
if (!formErrors) {
const formData = new FormData(form);
}
}
Przed samym wysłaniem danych wyłączmy przycisk submit poprzez dodanie do niego atrybutu disabled
. Dzięki temu zniecierpliwiony użytkownik nie będzie mógł klikać w przycisk wysyłania aż do czasu zakończenia poprzedniej wysyłki.
Dodatkowo powinniśmy pokazać jakiś wskaźnik wczytywania. Możemy to zrobić za pomocą prostej animacji:
.loading {
position: relative;
pointer-events: none;
opacity:0.5;
}
.loading::after {
position: absolute;
left: 50%;
top: 50%;
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid rgba(0, 0, 0, 0.2);
border-right-color: rgba(0,0,0,0.7);
transform: translate(-50%, -50%) rotate(0deg);
content:"";
animation: rotateSingleLoading 0.3s infinite linear;
z-index: 10;
}
@keyframes rotateSingleLoading {
0% {
transform: translate(-50%, -50%) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(360deg);
}
}
const loadingTest = document.querySelector("#loadingTest")
loadingTest.addEventListener("click", e => {
loadingTest.classList.add("loading");
loadingTest.disabled = true;
});
Do samej wysyłki zastosujemy fetch. Napiszmy do tego oddzielną funkcję, która zwróci nam odpowiednie dane:
const form = document.querySelector("#contactForm");
const url = "https://skrypt-na-serwerze.php";
...
function makeRequest(data) {
return fetch(url, {
method : "post"
body: data
})
.then(res => {
if (res.ok) {
return res.json()
} else {
return Promise.reject(`${res.status}: ${res.statusText}`);
}
});
}
function submitForm() {
let formErrors = checkElements();
if (!formErrors) {
const formData = new FormData(form);
const submit = form.querySelector("[type=submit]");
submit.disabled = true;
submit.classList.add("loading");
makeRequest(formData)
.then(res => {
//tutaj odpowiedź
})
.catch(err => {
//tutaj błędy
})
.finally(() => { //gdy zakończy się połączenie chcemy włączyć przycisk submit
submit.disabled = false;
submit.classList.remove("loading");
});
}
}
Po stronie serwera
Formularz wysłaliśmy na serwer. Do naszych celów użyjemy PHP, ale podobne działanie można utworzyć w każdej innej technologii serwerowej:
<?php
$mailToSend = "twoj-mail@lorem.pl";
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$name = $_POST["name"];
$email = $_POST["email"];
$message = $_POST["message"];
$errors = [];
$return = [];
if (empty($name)) { //jeżeli pusta wartość
array_push($errors, "name");
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { //sprawdzamy czy email ma zły wzór
array_push($errors, "email");
}
if (empty($message)) {
array_push($errors, "message");
}
if (count($errors) > 0) {
$return["errors"] = $errors;
} else {
//każde wysłanie wiadomości musi być poprzedzone ustawieniem nagłówków
$headers = "MIME-Version: 1.0" . "\r\n";
$headers .= "Content-type: text/html; charset=UTF-8". "\r\n";
$headers .= "From: ".$email."\r\n";
$headers .= "Reply-to: ".$email;
$message = "
<html>
<head>
<meta charset=\"utf-8\">
</head>
<body>
<div> Imię: $name</div>
<div> Email: <a href=\"mailto:$email\">$email</a> </div>
<div> Wiadomość: </div>
<div> $message </div>
</body>
</html>";
if (mail($mailToSend, "Wiadomość ze strony - " . date("d-m-Y"), $message, $headers)) {
$return["status"] = "success";
} else {
$return["status"] = "error";
}
}
header("Content-Type: application/json");
echo json_encode($return);
}
Jest to prosty skrypt, który kolejno sprawdza przesłane pola. Jeżeli dane są poprawne, skrypt spróbuje wysłać maila.
Zwrócone do Javascript dane mogą być w 3 postaciach:
{status: "ok"}
- gdy wszystko poszło dobrze i wiadomość została wysłana{status: "error"}
- dane z formularza były poprawne, ale z jakiegoś powodu nie udało się wysłać wiadomości{"errors" : ["name", "email", "message"] }
- tablica zawierająca nazwy pól, które zostały wysłane z formularza z błędnymi danymi
Czasami mogą się pojawić problemy z wysyłką maila z naszego serwera. Może to być spowodowane wieloma czynnikami. Niektóre serwery wymagają by nagłówek From
wskazywał na maila, który mamy podpięty pod dany serwer. Inne wymagają dodatkowych parametrów.
A i nie zawsze będziemy korzystać przecież z PHP. Może w naszym przypadku lepiej sprawdzi się nodemailer? A nawet czasami nie potrzeba w ogóle skryptów na serwerze. Zawsze możemy skorzystać z usług darmowych dostawców statycznych formularzy, które to serwisy wyślą takie maile za nas. Dobre zestawienie znajdziesz pod adresem https://css-tricks.com/a-comparison-of-static-form-providers/
Tutaj znajdziesz też wersję dla Node.js. Korzysta ona z express, oraz z NodeMailera.
Odpowiedź z serwera
Serwer dostał dane, obsłużył je i zwraca nam odpowiedź w 3 wariantach.
Gdy w odpowiedzi dostaniemy zmienną errors
, zakładamy, że będzie to tablica z nazwami błędnie wypełnionych pól. Każdą wartość tej tablicy (linie 17-18) zmieniamy za pomocą map na tablicę selektorów CSS np. [name="email"]
, [name="message"]
. Następnie taką tablicę łączymy za pomocą join(","), dzięki czemu uzyskujemy zapis:
const selectors = res.errors.map(el => `[name="${el}"]`);
const fieldsWithErrors = form.querySelectorAll(ret.errors.join(","));
//powyższe jest równoznaczne z
const fieldsWithErrors = form.querySelectorAll(`[name="name"],[name="email"],[name="message"]...`)
Zastosujmy powyższą logikę w naszej funkcji i odpalmy ją w funkcji wysyłającej formularz:
function makeRequest(data) {
return fetch(url, {
method,
body: data
})
.then(res => {
if (res.ok) {
return res.json()
} else {
return Promise.reject(`${res.status}: ${res.statusText}`);
}
});
}
function afterSubmit(res) {
if (res.errors) { //błędne pola
const selectors = res.errors.map(el => `[name="${el}"]`);
const fieldsWithErrors = form.querySelectorAll(selectors.join(","));
for (const el of fieldsWithErrors) {
markFieldAsError(el, true);
toggleErrorField(el, true);
}
} else { //pola są ok - sprawdzamy status wysyłki
if (res.status === "ok") {
//wyświetlamy komunikat powodzenia
}
if (res.status === "error") {
//komunikat błędu np gdy nie dało się wysłać wiadomości
}
}
}
function submitForm() {
let formErrors = checkElements();
if (!formErrors) {
const formData = new FormData(form);
const submit = form.querySelector("[type=submit]");
submit.disabled = true;
submit.classList.add("loading");
makeRequest(formData)
.then(res => {
afterSubmit(res);
})
.catch(err => {
//tutaj błędy
})
.finally(() => {
submit.disabled = false;
submit.classList.remove("loading");
});
}
}
Odpowiedź z serwera, błąd serwera
Pozostały nam do obsługi odpowiedzi, gdy status równy jest "success" i "error".
Dla "error", możemy dodać do formularza tekst z ogólnym komunikatem błędu:
function showSubmitError(text) {
//jeżeli istnieje komunikat o błędzie wysyłki
//np. generowany przy poprzednim wysyłaniu formularza
//usuwamy go, by nie duplikować tych komunikatów
const statusError = document.querySelector(".send-error");
if (statusError) statusError.remove();
const div = document.createElement("div");
div.classList.add("send-error");
div.innerText = "Wysłanie wiadomości się nie powiodło";
submit.parentElement.appendChild(div);
}
function afterSubmit(res) {
if (res.errors) { //błędne pola
const selectors = res.errors.map(el => `[name="${el}"]`);
const fieldsWithErrors = form.querySelectorAll(selectors.join(","));
for (const el of fieldsWithErrors) {
markFieldAsError(el, true);
toggleErrorField(el, true);
}
} else { //pola są ok - sprawdzamy status wysyłki
if (res.status === "ok") {
//wyświetlamy komunikat powodzenia, cieszymy sie
}
if (res.status === "error") {
showSubmitError(res.status);
}
}
}
Tą samą funkcję możemy dodatkowo użyć, by wyświetlić błędy dla samego fetch (np. gdy występuje brak połączenia, albo adres wysyłki jest błędny):
function submitForm() {
let formErrors = checkElements();
if (!formErrors) {
const formData = new FormData(form);
const submit = form.querySelector("[type=submit]");
submit.disabled = true;
submit.classList.add("loading");
makeRequest(formData)
.then(res => {
afterSubmit(res);
})
.catch(err => {
showSubmitError(err);
})
.finally(() => {
submit.disabled = false;
submit.classList.remove("loading");
});
}
}
Odpowiedź z serwera, wreszcie pozytywnie
Zajmijmy się teraz pozytywną odpowiedzią. Możemy tutaj oczywiście wstawiać dynamicznie tekst tak samo, jak powyżej w przypadku błędów. Ja zazwyczaj jednak po pozytywnej wysyłce zastępuję cały formularz nową zawartością. Dzięki temu będzie on trochę bardziej odporny na różne ataki robotów lub ludzi, którzy lubią wysyłać formularze za pomocą np. setInterval
:
function showSubmitSuccess() {
const div = document.createElement("div");
div.classList.add("form-send-success");
div.innerText = "Wysłanie wiadomości się nie powiodło";
form.parentElement.insertBefore(div, form);
div.innerHTML = `
<strong>Wiadomość została wysłana</strong>
<span>Dziękujemy za kontakt. Postaramy się odpowiedzieć jak najszybciej</span>
`;
form.remove();
}
function afterSubmit(res) {
if (res.errors) {
const selectors = res.errors.map(el => `[name="${el}"]`);
const fieldsWithErrors = form.querySelectorAll(selectors.join(","));
for (const el of fieldsWithErrors) {
markFieldAsError(el, true);
toggleErrorField(el, true);
}
} else {
if (res.status === "ok") {
showSubmitSuccess();
}
if (res.status === "error") {
showSubmitError(res.status);
}
}
}
Prosty antyspam
Powyżej stworzyliśmy prosty w pełni dynamiczny formularz kontaktowy. Problem z takimi formularzami jest taki, że bardzo lubią je roboty rozsyłające spam. Aby im przeszkodzić, musimy jakoś zabezpieczyć nasze dzieło. Jedną ze łatwiejszych metod jest tak zwany honeypot. Metoda ta polega na dodaniu do formularza dodatkowego pola, które ukrywamy za pomocą styli:
<span class="form-row honey-row">
<label for="honey">Jeżeli jesteś człowiekiem, nie wypełniaj tego pola</label>
<input type="text" name="honey">
</span>
.form .honey-row {
display:none;
}
Większość robotów ignoruje style, ani nie rozumie języka pisanego, więc wypełnią to pole. Wystarczy teraz po stronie serwera sprawdzić, czy pole to ma wartość. Jak ma, olewamy całą sprawę, ewentualnie pokrzepiając robota miłymi słowami wiadomości w formie sukcesu:
<?php
$mailToSend = "twoj-email@lorem.pl";
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$name = $_POST["name"];
$email = $_POST["email"];
$message = $_POST["email"];
$antiSpam = $_POST["honey"];
$errors = Array();
$return = Array();
if (empty($antiSpam)) {
//tutaj cała reszta skryptu który mieliśmy wcześniej (od 23 do 49)
if (count($errors) > 0) {
$return["errors"] = $errors;
} else {
...
}
} else {
$return["status"] = "ok";
}
header("Content-Type: application/json");
echo json_encode($return);
}
Recaptcha
Powyższy sposób nie jest idealny i bardzo łatwo można go obejść. Głupi robot może i wypełni powyższe pole, ale użytkownik nie musi. Wystarczy, że raz wyśle formularz i sprawdzi jak wygląda takie zapytanie.
Mimo wszystko w realnych projektach warto skorzystać z Recaptchy. Wersja 3 jest niewidoczna dla użytkownika (nie trzeba klikać jakiś dziwnych obrazków), a i jest bardzo łatwa do zaimplementowania.
Po pierwsze musimy wejść na stronę https://developers.google.com/recaptcha. Następnie na górze strony wchodzimy w Recaptcha v3 admin console. Po wejściu w link musimy podać etykietę (nazwa porządkowa), wybrać wersję recaptchy (v3) oraz domenę (adres) naszej strony. Po przejściu dalej dostaniemy 2 klucze. Jeden do użycia w kodzie html/js strony, a drugi do użycia po stronie serwera (np. w kodzie php).
Wystarczy potem postępować zgodnie z punktami z tej odpowiedzi.
Przykładowa implementacja tego rozwiązania w naszym kodzie może mieć postać:
function submitForm() {
let formErrors = checkElements();
if (!formErrors) {
//tutaj wysyłka
const formData = new FormData(form);
const submit = form.querySelector("[type=submit]");
submit.disabled = true;
submit.classList.add("loading");
grecaptcha.ready(() => {
grecaptcha.execute(recaptchaKey, {action: 'submit'}).then(token => {
formData.append("token", token);
makeRequest(formData).then(res => {
afterSubmit(res);
})
.catch(err => {
showSubmitError(err);
})
.finally(() => {
inputSubmit.disabled = false;
inputSubmit.classList.remove("loading");
});
});
});
}
}
form.addEventListener("submit", submit);
Po stronie serwera:
<?php
$mailToSend = "twoj-email@lorem.pl";
//klucz pobierzesz ze strony gdzie zakładałeś recaptche
$secretRecaptchaKey = '...';
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$name = $_POST["name"];
$email = $_POST["email"];
$message = $_POST["email"];
$antiSpam = $_POST["honey"];
$errors = Array();
$return = Array();
if (!isset($_POST['token'])) die;
$token = $_POST['token'];
$response = file_get_contents(
"https://www.google.com/recaptcha/api/siteverify?secret=$secretRecaptchaKey&response=$token&remoteip=".$_SERVER['REMOTE_ADDR']
);
$response = json_decode($response);
if ($response->success === false) {
header("Content-Type: application/json");
die('{"status": "error"}'); //lub {"status" : "success"} dla zmylenia
}
//tutaj dalsza część skryptu który powyżej był w fragmencie "po stronie serwera"...
$errors = [];
$return = [];
if (empty($name)) { //jeżeli pusta wartość
array_push($errors, "name");
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { //sprawdzamy czy email ma zły wzór
array_push($errors, "email");
}
if (empty($message)) {
array_push($errors, "message");
}
if (count($errors) > 0) {
$return["errors"] = $errors;
} else {
//każde wysłanie wiadomości musi być poprzedzone ustawieniem nagłówków
$headers = "MIME-Version: 1.0" . "\r\n";
$headers .= "Content-type: text/html; charset=UTF-8". "\r\n";
$headers .= "From: ".$email."\r\n";
$headers .= "Reply-to: ".$email;
$message = "
<html>
<head>
<meta charset=\"utf-8\">
</head>
<body>
<div> Imię: $name</div>
<div> Email: <a href=\"mailto:$email\">$email</a> </div>
<div> Wiadomość: </div>
<div> $message </div>
</body>
</html>";
if (mail($mailToSend, "Wiadomość ze strony - " . date("d-m-Y"), $message, $headers)) {
$return["status"] = "ok";
} else {
$return["status"] = "error";
}
}
header("Content-Type: application/json");
echo json_encode($return);
}
Demo
Gotowy formularz znajdziesz poniżej:
demo