Практика: форма заявки на тур
Free PreviewПрименим всё изученное на практике. Добавим на нашу страницу о Санкт-Петербурге форму для отправки заявки на тур: с полями, валидацией и отправкой данных на сервер.
HTML-структура формы
Добавьте секцию с формой в конец страницы, перед <footer>:
<section class="tour-form-section" id="tour-form"> <div class="container"> <h2>Записаться на тур</h2> <p class="section-subtitle">Оставьте заявку — мы свяжемся с вами в течение часа и подберём подходящую программу</p> <form class="tour-form" id="tourForm" novalidate> <div class="form-row"> <div class="form-field"> <label for="name">Ваше имя *</label> <input type="text" id="name" name="name" placeholder="Иван Петров" autocomplete="name"> <span class="error-message" id="name-error"></span> </div> <div class="form-field"> <label for="email">Email *</label> <input type="email" id="email" name="email" placeholder="example@mail.ru" autocomplete="email"> <span class="error-message" id="email-error"></span> </div> </div> <div class="form-row"> <div class="form-field"> <label for="phone">Телефон *</label> <input type="tel" id="phone" name="phone" placeholder="+7 (999) 000-00-00" autocomplete="tel"> <span class="error-message" id="phone-error"></span> </div> <div class="form-field"> <label for="guests">Количество человек *</label> <select id="guests" name="guests"> <option value="">— Выберите —</option> <option value="1">1 человек</option> <option value="2">2 человека</option> <option value="3-5">3–5 человек</option> <option value="6+">6 и более</option> </select> <span class="error-message" id="guests-error"></span> </div> </div> <div class="form-row"> <div class="form-field"> <label for="arrival">Желаемая дата приезда</label> <input type="date" id="arrival" name="arrival"> <span class="error-message" id="arrival-error"></span> </div> <div class="form-field"> <label for="duration">Длительность тура</label> <select id="duration" name="duration"> <option value="">— Выберите —</option> <option value="weekend">Выходные (2–3 дня)</option> <option value="short">Короткий тур (4–5 дней)</option> <option value="week">Неделя</option> <option value="extended">Расширенный (10+ дней)</option> </select> <span class="error-message" id="duration-error"></span> </div> </div> <div class="form-field"> <label for="message">Пожелания или вопросы</label> <textarea id="message" name="message" rows="4" placeholder="Расскажите, что вас интересует: определённые достопримечательности, тип жилья, особые пожелания..."></textarea> <span class="error-message" id="message-error"></span> </div> <div class="form-field form-field--checkbox"> <input type="checkbox" id="agree" name="agree"> <label for="agree">Согласен(а) на обработку персональных данных *</label> <span class="error-message" id="agree-error"></span> </div> <button type="submit" class="submit-btn" id="submitBtn"> <span class="submit-btn__text">Отправить заявку</span> <span class="submit-btn__loader" aria-hidden="true"></span> </button> </form> <div class="form-success" id="formSuccess" hidden> <div class="form-success__icon">✓</div> <h3>Заявка отправлена!</h3> <p>Спасибо, <span id="successName"></span>. Мы свяжемся с вами по адресу <span id="successEmail"></span> в ближайшее время.</p> </div> </div> </section>html
Атрибут novalidate на <form> отключает браузерную валидацию — мы будем полностью управлять ею сами.
CSS
.tour-form-section { padding: 80px 20px; background: #f0f4f8; } .tour-form-section h2 { text-align: center; margin-bottom: 8px; } .section-subtitle { text-align: center; color: #666; margin-bottom: 40px; } .tour-form { max-width: 720px; margin: 0 auto; background: #fff; padding: 40px; border-radius: 16px; box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); } .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } @media (max-width: 600px) { .form-row { grid-template-columns: 1fr; } } .form-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 20px; } .form-field label { font-size: 14px; font-weight: 500; color: #333; } input, textarea, select { padding: 12px 16px; border: 1.5px solid #d0d0d0; border-radius: 8px; font-size: 15px; font-family: inherit; color: inherit; background: #fff; outline: none; transition: border-color 0.2s ease, box-shadow 0.2s ease; box-sizing: border-box; width: 100%; } input:focus, textarea:focus, select:focus { border-color: #4a90e2; box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.15); } input.invalid, textarea.invalid, select.invalid { border-color: #e53e3e; box-shadow: 0 0 0 3px rgba(229, 62, 62, 0.1); } .error-message { display: block; font-size: 12px; color: #e53e3e; min-height: 16px; } .form-field--checkbox { flex-direction: row; align-items: flex-start; gap: 10px; flex-wrap: wrap; } .form-field--checkbox input[type="checkbox"] { width: auto; margin-top: 2px; flex-shrink: 0; } .form-field--checkbox label { font-size: 14px; font-weight: normal; color: #555; } .submit-btn { width: 100%; padding: 14px; background: #4a90e2; color: #fff; border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; transition: background 0.2s ease, opacity 0.2s ease; display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: 8px; } .submit-btn:hover { background: #357abd; } .submit-btn:disabled { opacity: 0.7; cursor: not-allowed; } .submit-btn__loader { display: none; width: 16px; height: 16px; border: 2px solid rgba(255, 255, 255, 0.4); border-top-color: #fff; border-radius: 50%; animation: spin 0.7s linear infinite; } .submit-btn.loading .submit-btn__loader { display: block; } @keyframes spin { to { transform: rotate(360deg); } } .form-success { max-width: 720px; margin: 0 auto; text-align: center; padding: 60px 40px; background: #fff; border-radius: 16px; box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); } .form-success__icon { font-size: 48px; color: #48bb78; margin-bottom: 16px; }css
JavaScript
Создайте файл tour-form.js и подключите его с атрибутом defer:
<script src="tour-form.js" defer></script>html
const form = document.querySelector('#tourForm'); const submitBtn = document.querySelector('#submitBtn'); const formSuccess = document.querySelector('#formSuccess'); // --- Вспомогательные функции --- function showError(id, message) { const input = document.querySelector(`#${id}`); const errorEl = document.querySelector(`#${id}-error`); input.classList.add('invalid'); if (errorEl) errorEl.textContent = message; } function clearError(id) { const input = document.querySelector(`#${id}`); const errorEl = document.querySelector(`#${id}-error`); input.classList.remove('invalid'); if (errorEl) errorEl.textContent = ''; } // --- Правила валидации --- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const phoneRegex = /^[\d\s\+\-\(\)]{7,}$/; function validateForm() { let isValid = true; const name = document.querySelector('#name').value.trim(); const email = document.querySelector('#email').value.trim(); const phone = document.querySelector('#phone').value.trim(); const guests = document.querySelector('#guests').value; const agree = document.querySelector('#agree').checked; // Сбрасываем все ошибки ['name', 'email', 'phone', 'guests', 'arrival', 'agree'].forEach(clearError); if (!name) { showError('name', 'Укажите ваше имя'); isValid = false; } else if (name.length < 2) { showError('name', 'Имя слишком короткое'); isValid = false; } if (!email) { showError('email', 'Укажите email'); isValid = false; } else if (!emailRegex.test(email)) { showError('email', 'Введите корректный email'); isValid = false; } if (!phone) { showError('phone', 'Укажите номер телефона'); isValid = false; } else if (!phoneRegex.test(phone)) { showError('phone', 'Введите корректный номер'); isValid = false; } if (!guests) { showError('guests', 'Укажите количество человек'); isValid = false; } if (!agree) { showError('agree', 'Необходимо согласие на обработку данных'); isValid = false; } return isValid; } // --- Снимаем ошибку при вводе --- ['name', 'email', 'phone'].forEach((id) => { document.querySelector(`#${id}`).addEventListener('input', () => clearError(id)); }); document.querySelector('#guests').addEventListener('change', () => clearError('guests')); document.querySelector('#agree').addEventListener('change', () => clearError('agree')); // --- Отправка формы --- form.addEventListener('submit', async (e) => { e.preventDefault(); if (!validateForm()) { // Прокручиваем к первой ошибке const firstInvalid = form.querySelector('.invalid'); firstInvalid?.scrollIntoView({ behavior: 'smooth', block: 'center' }); return; } const data = Object.fromEntries(new FormData(form).entries()); // Блокируем кнопку и показываем лоадер submitBtn.disabled = true; submitBtn.classList.add('loading'); try { const response = await fetch('https://advanced-frontend.ru/api/frontend-basics/tour-request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); // Сервер вернул ошибки валидации — показываем их под полями if (response.status === 422) { const { errors } = await response.json(); Object.entries(errors).forEach(([field, message]) => { showError(field, message); }); const firstInvalid = form.querySelector('.invalid'); firstInvalid?.scrollIntoView({ behavior: 'smooth', block: 'center' }); return; } if (!response.ok) { throw new Error(`Ошибка сервера: ${response.status}`); } // Показываем сообщение об успехе form.hidden = true; formSuccess.hidden = false; document.querySelector('#successName').textContent = data.name; document.querySelector('#successEmail').textContent = data.email; } catch (error) { console.error(error); alert('Что-то пошло не так. Попробуйте ещё раз или свяжитесь с нами по телефону.'); } finally { submitBtn.disabled = false; submitBtn.classList.remove('loading'); } });js
Разбираем ключевые моменты
novalidate на форме — отключает стандартные всплывающие подсказки браузера. Иначе браузер показал бы свои сообщения параллельно с нашими — это выглядит непоследовательно.
Прокрутка к первой ошибке — если форма длинная и ошибка находится выше видимой области, пользователь может не заметить её. scrollIntoView аккуратно доведёт его до нужного поля.
Состояние загрузки — кнопка блокируется и показывает спиннер, пока идёт запрос. Это предотвращает двойную отправку и даёт пользователю понять, что что-то происходит.
Обработка 422 — статус 422 Unprocessable Entity означает, что сервер получил запрос, разобрал его, но нашёл ошибки в данных. В ответе приходит объект errors с ключами-полями и сообщениями. Мы проходимся по ним и вызываем showError для каждого — пользователь видит серверные ошибки точно так же, как клиентские.
finally — блок выполняется и при успехе, и при ошибке. Здесь мы снимаем блокировку кнопки, чтобы при ошибке пользователь мог попробовать снова.
Ожидаемый результат
После выполнения задания форма должна:
- отображать понятные ошибки под каждым проблемным полем
- выделять проблемные поля красной рамкой
- снимать ошибку сразу при исправлении поля
- прокручивать страницу к первой ошибке, если пользователь не заметил
- блокировать кнопку и показывать спиннер во время отправки
- после успешной отправки скрывать форму и показывать благодарственное сообщение с именем и email пользователя