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ż HTML

<form class="form" id="contactForm" method="post" action="./send-script.php">
    <div class="form-row">
        <label for="name" class="form-label">Imię*</label>
        <input class="form-control" type="text" name="name" required id="name">
    </div>
    <div class="form-row">
        <label for="email" class="form-label">Email*</label>
        <input class="form-control" type="email" name="email" required id="email">
    </div>
    <div class="form-row">
        <label for="message" class="form-label">Wiadomość*</label>
        <textarea class="form-control" name="message" required id="message"></textarea>
    </div>
    <div class="form-message"></div>
    <div class="form-row">
        <button type="submit" class="button form-submit">
            Wyślij
        </button>
    </div>
</form>

Pokaż CSS

* {
    box-sizing: border-box;
}

.form {
    margin: 3rem auto;
    font-family: sans-serif;
    max-width: 40rem;
}

.form .form-row {
    margin-bottom: 1rem;
}

.form .form-row:last-child {
    margin-bottom: 0;
}

.form-control {
    font-family: sans-serif;
    padding: 0.8rem;
    border: 1px solid #bbb;
    display: block;
    width: 100%;
    color: #666;
}

.form-control:is(textarea) {
    height: 10rem;
}

.form-label {
    display: block;
    font-size: 0.9rem;
    margin-bottom: 0.5rem;
}

.form-submit {
    font-family: sans-serif;
    padding: 1rem 2rem;
    cursor: pointer;
    background: tomato;
    border: 0;
    border-radius: 0.2rem;
    color: #FFF;
    font-size: 1.1rem;
    font-weight: bold;
    transition: 0.3s background-color;
}

@media screen and (max-width: 500px) {
    .form-submit {
        display: block;
        width: 100%;
    }
}


/* ---------------------------------------------------- */
/* sukces i błąd wysyłania */
/* ---------------------------------------------------- */
.form-send-error {
    font-family: sans-serif;
    color: red;
    margin: 10px 0;
}

.form-send-success {
    font-family: sans-serif;
    color: red;
    margin: 10px 0;
}

.form-send-success {
    color: inherit;
}

.form-send-success strong {
    display: block;
    font-size: 1.5em;
    color: tomato;
    margin-bottom: 0.3em;
}

Początkowe kroki

Pierwsze czynności będą podobne jak w przypadku poprzednich 2 rozdziałów, czyli pobierzemy formularz, pola do walidacji, wyłączymy domyślne dymki walidacji, a następnie podepniemy odpowiednie zdarzenia:


const form = document.querySelector("#contactForm");
const fields = form.querySelectorAll("[required]");
const formMessage = form.querySelector(".form-message");

//wyłączam validationApi by nie przeszkadzało
form.setAttribute("novalidate", true);

//dodaję dynamiczną walidację pól
for (const field of fields) {
    field.addEventListener("input", () => field.classList.toggle("is-invalid", !field.checkValidity()));
}

form.addEventListener("submit", e => {
    ...
});

Samą wysyłkę napiszemy jako oddzielną funkcję, która przed wysłaniem danych sprawdzi wszystkie pola:


//przed wysyłką sprawdzam pola
function checkRequiredFields() {
    let formErrors = false;

    for (const field of fields) {
        if (!el.checkValidity()) {
            field.classList.add("form-error");
            formErrors = true;
        } else {
            field.classList.remove("form-error");
        }
    }

    return formErrors;
}

//funkcja do wysyłania formularza
function submitForm(e) {
    let formErrors = checkRequiredFields();

    if (!formErrors) {
        //form.submit();
        //tym razem bez form.submit(). Dane będziemy wysyłać dynamicznie
    }
}

form.addEventListener("submit", e => {
    e.preventDefault();
    submitForm()
});

Wysyłka formularza

Żeby wysłać dane, musimy pobrać wszystkie dane z formularza. Wystarczy do tego użyć formData:


...

function checkRequiredFields() {
    ...
}

function submitForm() {
    let formErrors = checkRequiredFields();

    if (!formErrors) {
        const formData = new FormData(form);
    }
}

form.addEventListener("submit", e => {
    e.preventDefault();
    submitForm()
});

Przed samym wysłaniem danych wyłączmy przycisk submit poprzez dodanie do niego atrybutu disabled i odpowiedniej klasy. Dzięki temu zniecierpliwiony użytkownik nie będzie mógł klikać w przycisk wysyłania aż do czasu zakończenia poprzedniej wysyłki. Tu możesz zobaczyć przykład.

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;
    });
    

async function submitForm() {
    let formErrors = checkElements();

    if (!formErrors) {
        const formData = new FormData(form);

        const submit = form.querySelector(".form-submit");
        submit.disabled = true;
        submit.classList.add("loading");

        //...tutaj dynamiczna wysyłka

        submit.disabled = false;
        submit.classList.remove("loading");
    }
}

Dane chcemy wysyłać dynamicznie bez przeładowania strony. Zastosujemy do tego fetch. Napiszmy do tego oddzielną funkcję:


const url = "https://skrypt-na-serwerze.php";

...

async function makeRequest(data) {
    const res = await fetch(url, {
        method : "post",
        body: data
    });
    if (res.ok) {
        return res.json();
    }
    return Promise.reject(`${res.status}: ${res.statusText}`);
}

async 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");

        try {
            const response = await makeRequest(formData);
            //tutaj odpowiedź
        } catch(err) {
            //tutaj błędy
        }

        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: "success"} - 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.

Do sprawdzenia maila użyłem paczki email-validator. Dane wysyłamy w przy użyciu FormData, dlatego musimy też użyć dodatkowej paczki express-form-data.

Aby użyć poniższego skryptu powinieneś zainstalować odpowiednie paczki. Oczywiście przydał by się też plik package.json. Mówiliśmy o tym tutaj.


    npm i express body-parser nodemailer cors email-validator express-form-data
    

Poniższy skrypt ustawiony jest dla konta Gmail. Nodemailer pozwala też skorzystać z innych serwerów. Można o tym poczytać na ich stronie.

Dane będziemy wysyłać na adres http://localhost:3002/sendMail.


    //ogólny opis nodemailera:
    //https://nodemailer.com/about/

    //dla konta które będzie wysyłać maile za pomocą google trzeba dodatkowo włączyć:
    //https://myaccount.google.com/lesssecureapps
    const account = {
        user : '...twoj-login-na-gmail',
        pass : '...twoj-password-na-gmail'
    }

    const express = require('express');
    const path = require('path');
    const bodyParser = require('body-parser');
    const formData = require('express-form-data');
    const cors = require('cors');
    const app = express();

    const nodemailer = require('nodemailer');
    const emailValidator = require('email-validator');


    // odpalamy parsowanie requestow dla express
    app.use( bodyParser.json() );
    app.use(cors())
    app.use(formData.parse());
    app.use(bodyParser.urlencoded({ extended: true }));


    //żeby nie mieć problemów z corsami strona index.html i serwer musza byc na tej samej domenie
    //wiec ja serwujemy z tego serwera
    app.get('/', function (req, res) {
        res.sendFile(path.resolve(__dirname + '/index.html'))
    });

    //nasłuchujemy żądania danego typu (get, post, put, path, delete)
    app.post('/sendMail', (req, res) => {
        //obiekt który bede zwracał
        let returnObj = {}
        console.log(req.body)
        //ustawiam nagłówek dla zwrotki
        res.setHeader('Content-Type', 'application/json');

        //sprawdzam prymitywnie dane które przyszły z formularza
        if (!emailValidator.validate(req.body.email)) {
            if (!returnObj.errors) returnObj.errors = [];
            returnObj.errors.push("email");
        }

        if (req.body.name === undefined || req.body.name === "") {
            if (!returnObj.errors) returnObj.errors = [];
            returnObj.errors.push("name");
        }

        if (req.body.message === undefined || req.body.message === "") {
            if (!returnObj.errors) returnObj.errors = [];
            returnObj.errors.push("message");
        }

        //jeżeli są błędy
        if (returnObj.errors) {
            res.setHeader('Content-Type', 'application/json');
            res.send(JSON.stringify(returnObj));
        } else {
            let transporter = nodemailer.createTransport({
                service : 'gmail',
                host: 'smtp.gmail.com',
                port: 587,
                secure: false,
                requireTLS: true,
                auth: {
                    user: account.user,
                    pass: account.pass
                }
            });

            let mailOptions = {
                from: account.user,
                to: account.user,
                subject: 'Wiadomość ze strony',
                text: `
                    Imię: ${req.body.name}
                    Email: ${req.body.email}
                    Wiadomość:
                    ${req.body.message}
                `,
                html: `
                    <html>
                        <head>
                            <meta charset="utf-8">
                        </head>
                        <body>
                            <div> Imię: ${req.body.name} </div>
                            <div> Email: <a href="mailto:${req.body.email}">${req.body.email}</a> </div>
                            <div> Wiadomość: </div>
                            <div> ${req.body.message} </div>
                        </body>
                    </html>`
            };

            transporter.sendMail(mailOptions, (error, info) => {
                if (error) {
                    console.log('Email sent ERROR: ' + error);
                    returnObj.status = "error";
                } else {
                    console.log('Email sent SUCCESS: ' + info.response);
                    returnObj.status = "success";
                }
                res.send(JSON.stringify(returnObj));
            });
        }

    });


    /* statyczne pliki jak css, js, obrazki */
    app.get(/^(.+)$/, (req, res) => {
        res.sendFile(path.resolve(__dirname + '/' + req.params[0]));
    });


    //odpalamy...
    const port = 3002;
    app.listen(port, () => {
        console.log(`Listening on http://localhost:${port}`);
    });

    

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 afterSubmit(res) {
    if (res.errors) {
        const selectors = res.errors.map(el => `[name="${el}"]`);
        const fieldsWithErrors = form.querySelectorAll(selectors.join(","));
        for (const field of fieldsWithErrors) {
            field.classList.add("is-invalid");
        }
    } else {
        if (res.status === "success") {
            showSubmitSuccess();
        }
        if (res.status === "error") {
            showSubmitError(res.status);
        }
    }
}

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");

        try {
            const response = await makeRequest(formData);
            afterSubmit(response);
        } catch(err) {
            //tutaj błędy, ale to za chwilę
        }

        submit.disabled = false;
        submit.classList.remove("loading");
    }
}

Odpowiedź z serwera, błąd serwera

Gdy dostajemy odpowiedź, odpalamy funkcję afterSubmit(), która w zależności od zwróconego statusu odpala funkcje showSubmitError() (linia 10 w powyższym listingu) lub showSubmitSuccess() (linia 13).

Dla statusu "error", możemy dodać do formularza tekst z ogólnym komunikatem o błędzie:


function showSubmitError() {
    formMessage.innerHTML = "Wysłanie wiadomości się nie powiodło";
}

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):


async function submitForm() {
    let formErrors = checkElements();

    if (!formErrors) {
        const submit = form.querySelector("[type=submit]");
        submit.disabled = true;
        submit.classList.add("loading");

        const formData = new FormData(form);

        try {
            const response = await makeRequest(formData);
            afterSubmit(response);
        } catch(err) {
            showSubmitError();
        }

        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 po pozytywnej wysyłce zastępuję cały formularz nową zawartością. Dzięki temu będzie on trochę bardziej odporny na proste ataki:


function showSubmitSuccess() {
    const div = document.createElement("div");
    div.classList.add("form-send-success");
    form.after(div);
    div.innerHTML = `
        <strong>Wiadomość została wysłana</strong>
        <span>Dziękujemy za kontakt. Postaramy się odpowiedzieć jak najszybciej</span>
    `;
    form.remove();
}

Prosty antyspam

Problem z formularzami takimi jak powyższy jest taki, że bardzo lubią je roboty rozsyłające spam. Aby im przeszkodzić, musimy jakoś zabezpieczyć nasze dzieło. Jedną z ł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>

.honey-row {
    display:none !important;
}

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ść. Jeżeli ma, przerywamy wysyłkę, 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"] = "success";
    }

    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 bandyta nie musi.

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ć:


async 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(async (token) => {
                formData.append("token", token);

                try {
                    const response = await makeRequest(formData);
                    afterSubmit(response);
                } catch(err) {
                    showSubmitError(err);
                }

                submit.disabled = false;
                submit.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"] = "success";
        } else {
            $return["status"] = "error";
        }
    }

    header("Content-Type: application/json");
    echo json_encode($return);
}

Demo

Gotowy formularz znajdziesz poniżej:

demo

Wszelkie prawa zastrzeżone. Jeżeli chcesz używać jakiejś części tego kursu, skontaktuj się z autorem.
Aha - i ta strona korzysta z ciasteczek.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.