React · Запрос стоимости

Запрос стоимости на React

Компонент React для запроса расчёта: компания, email и требования с подтверждением приёма.

Когда подходит

Для заказных работ и B2B-обращений, где цену нельзя назвать без уточнений. Форма собирает запрос, но не рассчитывает стоимость и не принимает оплату. Этот вариант предназначен для существующего React-приложения со сборкой JSX.

Каждый экземпляр компонента хранит свою попытку отправки. При размонтировании запрос отменяется; после возвращения на страницу ключ не восстанавливается.

Понадобятся аккаунт Прилетела, адрес приёма заявок выбранной формы и возможность редактировать HTML своего сайта.

Поля: контактное лицо, компания, email, что нужно рассчитать. Звёздочкой отмечены обязательные поля примера. Проверка обязательности в браузере не заменяет серверную валидацию.

Форма и готовый код

Демонстрация

Данные никуда не отправляются и не сохраняются. Используйте вымышленные контакты.

React · JSX
"use client";
import {useEffect, useRef, useState} from 'react';

const ENDPOINT = "YOUR_FORM_ENDPOINT";
const styles = "\n.priletela-embedded{max-width:560px;width:100%;margin:0 auto;padding:16px;box-sizing:border-box;font:16px/1.5 Arial,sans-serif;color:#111412;letter-spacing:0}\n.priletela-embedded form{display:grid;gap:16px;margin:0;padding:0}\n.priletela-embedded label{display:block;margin:0;font:inherit}\n.priletela-embedded input,.priletela-embedded textarea,.priletela-embedded button{box-sizing:border-box;max-width:100%;min-width:0;font:inherit;border-radius:6px}\n.priletela-embedded input:not([type=checkbox]),.priletela-embedded textarea{display:block;width:100%;margin:6px 0 0;padding:12px;border:1px solid #99aaa1;background:#fff;color:#111412}\n.priletela-embedded textarea{min-height:120px;resize:vertical}\n.priletela-embedded input[type=checkbox]{width:18px;height:18px;margin:0 8px 0 0;vertical-align:middle;accent-color:#0b6b50}\n.priletela-embedded input[hidden]{display:none}\n.priletela-embedded button{justify-self:start;min-height:44px;padding:10px 20px;border:0;background:#0b6b50;color:#fff;cursor:pointer}\n.priletela-embedded button:disabled{opacity:.6;cursor:wait}\n.priletela-embedded a{color:#0b6b50;text-decoration:underline}\n.priletela-embedded :focus-visible{outline:2px solid #0b6b50;outline-offset:3px}\n.priletela-embedded [data-priletela-status]{margin:0;overflow-wrap:anywhere}\n";

function createSubmissionClient(endpoint, onBusy, onStatus) {
  let attempt = null, pending = false, disposed = false, controller = null, timer = null;
  return {
    async submit(form) {
      if (disposed || pending || !form.reportValidity()) return;
      const body = new URLSearchParams(new FormData(form)).toString();
      for (const [name, value] of new URLSearchParams(body)) {
        if (name !== 'website' && value.trim().length > 20000) {
          onStatus('Поле «' + name + '» превышает 20000 символов. Сократите текст.');
          return;
        }
      }
      if (new TextEncoder().encode(body).byteLength > 512 * 1024) {
        onStatus('Объём заявки превышает 512 КБ. Сократите текст. Поля сохранены.');
        return;
      }
      if (attempt && attempt.body !== body && !window.confirm(
        'Данные изменены. Предыдущая заявка могла сохраниться. Отправить новое обращение?'
      )) return;
      try {
        if (!attempt || attempt.body !== body) {
          const bytes = new Uint8Array(16);
          window.crypto.getRandomValues(bytes);
          attempt = {body, key: Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')};
        }
      } catch {
        onStatus('Браузер не поддерживает безопасную отправку. Обновите браузер.');
        return;
      }
      pending = true;
      onBusy(true);
      onStatus('Отправляем заявку…');
      controller = new AbortController();
      timer = setTimeout(() => controller.abort(), 25000);
      try {
        const response = await fetch(endpoint, {
          method: 'POST', credentials: 'omit', signal: controller.signal,
          headers: {'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', 'Idempotency-Key': attempt.key},
          body: attempt.body
        });
        if (response.status === 429) throw new Error('Слишком много попыток. Подождите минуту и повторите отправку.');
        if (response.status === 409) throw new Error('Ключ отправки связан с другими данными. Свяжитесь с владельцем сайта.');
        if (response.status === 404) throw new Error('Форма недоступна. Свяжитесь с владельцем сайта.');
        if (response.status === 413) throw new Error('Объём заявки превышает 512 КБ. Сократите текст. Поля сохранены.');
        const result = await response.json();
        if (response.status === 400 && typeof result?.error === 'string') throw new Error(result.error);
        if (!response.ok || result?.ok !== true) throw new Error('Не удалось отправить заявку. Поля сохранены; повторите попытку.');
        if (disposed) return;
        form.reset();
        attempt = null;
        onStatus(result.test ? 'Пробная заявка принята. Вернитесь в мастер подключения.' : 'Спасибо! Ваша заявка принята.');
      } catch (error) {
        if (!disposed) onStatus(error instanceof Error && error.name === 'Error'
          ? error.message
          : 'Подтверждение не получено. Поля сохранены. Повторите без изменения данных: будет использован тот же ключ.');
      } finally {
        clearTimeout(timer);
        controller = null;
        pending = false;
        if (!disposed) onBusy(false);
      }
    },
    dispose() { disposed = true; clearTimeout(timer); controller?.abort(); }
  };
}

export default function PriletelaForm() {
  const client = useRef(null);
  const [ready, setReady] = useState(false);
  const [busy, setBusy] = useState(false);
  const [status, setStatus] = useState('Отправка пока недоступна. Дождитесь загрузки JavaScript.');
  useEffect(() => {
    const connection = createSubmissionClient(ENDPOINT, setBusy, setStatus);
    client.current = connection;
    setReady(true);
    setStatus('');
    return () => { connection.dispose(); client.current = null; };
  }, []);
  return <div className="priletela-embedded">
    <style>{styles}</style>
    <form method="POST" action={ENDPOINT} aria-busy={busy} onSubmit={event => {
      event.preventDefault();
      client.current?.submit(event.currentTarget);
    }}>
      <fieldset disabled={!ready || busy} style={{border: 0, padding: 0, margin: 0, minWidth: 0, display: 'grid', gap: 16}}>
        <label>{"Контактное лицо"} <input type="text" name={"name"} maxLength={20000} required /></label>
        <label>{"Компания"} <input type="text" name={"company"} maxLength={20000} /></label>
        <label>{"Email"} <input type="email" name={"email"} maxLength={20000} required /></label>
        <label>{"Что нужно рассчитать"} <textarea name={"message"} maxLength={20000} required /></label>
        <input type="text" name="website" hidden tabIndex={-1} autoComplete="off" aria-hidden="true" />
        <label><input type="checkbox" name="consent" value="yes" required /> Согласен на обработку данных на условиях <a href="YOUR_POLICY_URL" target="_blank" rel="noopener noreferrer">политики владельца сайта</a></label>
        <button type="submit">{busy ? 'Отправляем…' : 'Отправить'}</button>
      </fieldset>
      <p data-priletela-status role="status" aria-live="polite" aria-atomic="true">{status}</p>
    </form>
  </div>;
}

Замените YOUR_FORM_ENDPOINT на адрес формы из кабинета, а YOUR_POLICY_URL — на адрес собственных юридических документов. Скопируйте пример целиком. Он содержит собственный обработчик и не использует embed/v1.js.

Как подключить форму на React

Этот пример — компонент JSX для существующего React-приложения. Его нельзя вставить в обычный HTML-блок как готовый виджет. Подключать embed/v1.js к этой форме не нужно: второй обработчик может создать отдельную отправку.

  1. Создайте аккаунт или откройте мастер подключения. Создайте проект, назовите форму и скопируйте её адрес приёма из настроек.
  2. Создайте файл PriletelaForm.jsx в вашем проекте и вставьте весь пример. Это JavaScript с JSX, не TypeScript. В Next.js сохраните директиву «use client» в первой строке.
  3. Замените YOUR_FORM_ENDPOINT на полный адрес выбранной формы, а YOUR_POLICY_URL — на ссылку на документы владельца сайта. Адаптируйте текст согласия. Пароли, токены Google и другие серверные секреты в код не добавляйте.
  4. Импортируйте компонент: import PriletelaForm from './PriletelaForm'; и разместите <PriletelaForm /> на странице приложения. Не вкладывайте его в другую форму. Соберите и опубликуйте приложение обычным способом.
  5. Откройте опубликованную страницу. До запуска JavaScript поля отключены; после загрузки можно вводить данные. При отправке повторный клик блокируется, а подтверждение показывается только после ответа Прилетела.

Как проходит отправка

Обработчик делает POST с текстовыми полями в формате application/x-www-form-urlencoded, без cookie. Заголовок Idempotency-Key защищает повтор неизменённой попытки. CORS текущего API разрешает этот заголовок; не используйте режим no-cors, иначе нельзя прочитать подтверждение. Для своего сайта разрешите адрес Прилетела в connect-src.

При ошибке или тайм-ауте через 25 секунд поля и ключ остаются в памяти страницы. Повторите отправку без изменения данных. Если поля изменены после неудачной попытки, пример попросит подтвердить новую заявку: предыдущая могла уже сохраниться. После успеха поля сбрасываются и следующая отправка получает новый ключ.

При размонтировании компонента текущий запрос отменяется и обновления состояния прекращаются. Каждый компонент имеет собственное состояние. Перезагрузка страницы или повторное монтирование сбрасывает поля и ключ. Отмена запроса не отменяет уже сохранённую на сервере заявку; автоматического повтора после перезагрузки нет.

Как проверить подключение

  1. Начните проверку в мастере Прилетела. Временно разместите на своём сайте пробный фрагмент из мастера: он помечает заявку как тестовую. Отправьте вымышленные данные с опубликованной страницы и дождитесь пробной заявки в мастере.
  2. Верните выбранный пример React или Fetch с обычным адресом формы. Проверьте его в тестовом проекте и убедитесь, что все поля появились в карточке. Обычная отправка без пробного кода считается рабочей заявкой, даже с вымышленными контактами.
  3. При необходимости подтвердите email-получателя и подключите Telegram отдельно. Сохранение заявки не подтверждает доставку уведомлений.

Передаются только текстовые поля. Для файлов, оплаты или проверки номера телефона этот пример не предназначен. Проверка обязательности полей в браузере не заменяет серверную валидацию.

Частые ошибки

Заявка не появилась в кабинете

Проверьте адрес формы, выбранный проект, папки спама и пробных заявок. Форма должна быть включена. Не заполняйте скрытое поле website: оно служит защитой от спама.

Кнопка отправки недоступна

Проверьте выполнение JavaScript и установку всего примера. Для React нужен собранный компонент JSX; embed/v1.js подключать не надо. Отключённый JavaScript или блокировщик может помешать подключению.

Ошибка соединения или блокировка браузером

Проверьте HTTPS и адрес приёма. Для запроса разрешите https://priletela.ru в connect-src. Сам JavaScript и стили подключайте согласно CSP вашего сайта; не используйте no-cors. Не отключайте CSP целиком. Поля при ошибке остаются заполненными.

Поля заполнены, но форма не отправляется

Проверьте обязательные поля, формат email и чекбокс. Код 429 означает временное ограничение частоты: подождите и повторите. Не добавляйте файлы: этот пример принимает текстовые поля.

Заявка есть, а уведомления нет

Email и Telegram подключаются отдельно в настройках проекта. Проверьте подтверждение адреса, получателя и состояние канала. Принятая заявка не гарантирует доставку письма или сообщения.

Данные посетителей

Владелец сайта размещает собственные документы и определяет законные основания обработки данных. Чекбокс в коде — заготовка, которую нужно адаптировать к своему процессу. Подключение Прилетела само по себе не обеспечивает соответствие 152-ФЗ. Не собирайте через пример пароли, платёжные реквизиты или другую чувствительную информацию.

Другие примеры

Вернуться в каталог

Готовы получать заявки с сайта?

Подключите свою форму к Прилетела и получайте обращения в едином кабинете. Email и Telegram можно настроить отдельно.

Нужна помощь с подключением? Поддержка в Telegram: @Stop_routine