Практика: форма заявки на тур

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 пользователя