React · Заказ обратного звонка
Обратный звонок на React
React-компонент для имени и телефона с состоянием отправки, ошибкой и защитой повторов.
Когда подходит
Для блока рядом с услугой или предложения связаться по телефону. Заявка сохраняется в кабинете; сам звонок выполняет ваша команда, не Прилетела. Этот вариант предназначен для существующего React-приложения со сборкой JSX.
Каждый экземпляр компонента хранит свою попытку отправки. При размонтировании запрос отменяется; после возвращения на страницу ключ не восстанавливается.
Понадобятся аккаунт Прилетела, адрес приёма заявок выбранной формы и возможность редактировать HTML своего сайта.
Поля: ваше имя, телефон. Звёздочкой отмечены обязательные поля примера. Проверка обязательности в браузере не заменяет серверную валидацию.
Форма и готовый код
Демонстрация
Данные никуда не отправляются и не сохраняются. Используйте вымышленные контакты.
"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} /></label>
<label>{"Телефон"} <input type="tel" name={"phone"} 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 к этой форме не нужно: второй обработчик может создать отдельную отправку.
- Создайте аккаунт или откройте мастер подключения. Создайте проект, назовите форму и скопируйте её адрес приёма из настроек.
- Создайте файл PriletelaForm.jsx в вашем проекте и вставьте весь пример. Это JavaScript с JSX, не TypeScript. В Next.js сохраните директиву «use client» в первой строке.
- Замените
YOUR_FORM_ENDPOINTна полный адрес выбранной формы, аYOUR_POLICY_URL— на ссылку на документы владельца сайта. Адаптируйте текст согласия. Пароли, токены Google и другие серверные секреты в код не добавляйте. - Импортируйте компонент:
import PriletelaForm from './PriletelaForm';и разместите<PriletelaForm />на странице приложения. Не вкладывайте его в другую форму. Соберите и опубликуйте приложение обычным способом. - Откройте опубликованную страницу. До запуска JavaScript поля отключены; после загрузки можно вводить данные. При отправке повторный клик блокируется, а подтверждение показывается только после ответа Прилетела.
Как проходит отправка
Обработчик делает POST с текстовыми полями в формате application/x-www-form-urlencoded, без cookie. Заголовок Idempotency-Key защищает повтор неизменённой попытки. CORS текущего API разрешает этот заголовок; не используйте режим no-cors, иначе нельзя прочитать подтверждение. Для своего сайта разрешите адрес Прилетела в connect-src.
При ошибке или тайм-ауте через 25 секунд поля и ключ остаются в памяти страницы. Повторите отправку без изменения данных. Если поля изменены после неудачной попытки, пример попросит подтвердить новую заявку: предыдущая могла уже сохраниться. После успеха поля сбрасываются и следующая отправка получает новый ключ.
При размонтировании компонента текущий запрос отменяется и обновления состояния прекращаются. Каждый компонент имеет собственное состояние. Перезагрузка страницы или повторное монтирование сбрасывает поля и ключ. Отмена запроса не отменяет уже сохранённую на сервере заявку; автоматического повтора после перезагрузки нет.
Как проверить подключение
- Начните проверку в мастере Прилетела. Временно разместите на своём сайте пробный фрагмент из мастера: он помечает заявку как тестовую. Отправьте вымышленные данные с опубликованной страницы и дождитесь пробной заявки в мастере.
- Верните выбранный пример React или Fetch с обычным адресом формы. Проверьте его в тестовом проекте и убедитесь, что все поля появились в карточке. Обычная отправка без пробного кода считается рабочей заявкой, даже с вымышленными контактами.
- При необходимости подтвердите 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
