// ЗАМЕНИТЕ этим блоком старый submit-обработчик из темы 6.
// Обработчики CTA и остальных событий оставьте. Не подключайте блок дважды.
(() => {
const leadForm = document.querySelector('#lead-form');
if (!leadForm) return;
const params = new URLSearchParams(window.location.search);
const defaults = {utm_source:'direct', utm_medium:'none', utm_campaign:'not_set'};
for (const [key, fallback] of Object.entries(defaults)) {
leadForm.elements.namedItem(key).value = params.get(key)?.trim() || fallback;
}
const requestId = leadForm.elements.namedItem('request_id');
leadForm.addEventListener('submit', event => {
// Это защита от случайной отправки неполностью настроенного примера.
if (!/^https:\/\/script\.google\.com\/macros\/s\/[^/]+\/exec$/.test(leadForm.action)) {
event.preventDefault();
document.querySelector('#form-status').textContent = 'Сначала укажите URL опубликованного Apps Script Web App.';
return;
}
// Повторная доставка той же заявки сохраняет ID. Новый ID — после reset.
if (!requestId.value) requestId.value = 'REQ-' + crypto.randomUUID().toUpperCase();
if (typeof window.gtag === 'function') {
window.gtag('event', 'generate_lead', {lead_source:'contact_form'});
}
document.querySelector('#form-status').textContent =
'POST отправляется. Подтвердите сохранение по request_id в таблице: ' + requestId.value;
// На штатном пути preventDefault() НЕ вызываем: браузер отправляет POST.
// Событие GA4 отражает попытку отправки, а не ответ сервера.
});
leadForm.addEventListener('reset', () => {
setTimeout(() => {
requestId.value = '';
for (const [key, fallback] of Object.entries(defaults)) {
leadForm.elements.namedItem(key).value = params.get(key)?.trim() || fallback;
}
document.querySelector('#form-status').textContent = 'Можно заполнить новую заявку.';
}, 0);
});
})();
ID таблицы лежит в её адресе между /d/ и /edit. Он вынесен в константу, чтобы при переносе на другую таблицу правку делали в одном месте. HEADERS — эталон заголовков: перед записью скрипт сверяет с ним первую строку листа и отказывается писать, если столбцы переставили.
Точка входа
function doPost(e) { … }
Google вызывает doPost сам, когда на адрес /exec приходит POST-запрос. Внутри e.parameter лежат поля формы: ключ — это атрибут name у поля, значение — то, что отправил браузер. Функции doGet в этом приёмнике нет, поэтому при обычном открытии адреса страница выдаст ошибку — так и задумано.
Приведение значений
const clean = key => String(p[key] || '').trim();
Поле может не прийти совсем: тогда p[key] равно undefined. String(… || '') превращает это в пустую строку, а trim убирает пробелы по краям. Без этого « Иван » и «Иван» считались бы разными значениями, а отсутствующее поле роняло бы скрипт.
filter оставляет имена тех полей, которые пришли пустыми. Если список не пуст, запись не делается, а клиент получает причину отказа и перечень недостающих полей. Проверка required в HTML для этого не годится: её легко обойти через DevTools или отправив запрос мимо формы.
Формат request_id
if (!/^REQ-[A-F0-9]{8}-…$/i.test(id)) return jsonResponse({ok:false, error:'invalid_request_id'});
Регулярное выражение требует префикс REQ- и полный UUID четвёртой версии. Это отсекает и случайный мусор, и попытки прислать короткий предсказуемый ID, под которым можно было бы подделать чужую заявку.
Email и направление
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(clean('email'))) …
if (!['frontend','backend','data'].includes(clean('direction'))) …
Email проверяется простым шаблоном: есть имя, собака, домен с точкой. Направление сверяется со списком из трёх значений — это белый список: всё, чего в нём нет, отклоняется. Так в таблицу не попадёт направление, которого не существует.
Длина полей
if (['name','email','utm_source',…].some(key => clean(key).length > 200)) …
Ограничение в 200 символов защищает таблицу от гигантских строк: ячейка Google Sheets вмещает до 50 000 символов, и один запрос мог бы забить лист мусором.
Блокировка
const lock = LockService.getScriptLock();
if (!lock.tryLock(10000)) return jsonResponse({ok:false, error:'busy_retry'});
Две заявки могут прийти в одну и ту же секунду. Без блокировки оба вызова успели бы проверить отсутствие дубля и записать одинаковый ID. Замок один на весь скрипт: второй вызов ждёт до 10 секунд и только потом отвечает busy_retry.
Проверка листа
if (!sheet || sheet.getRange(1,1,1,9).getValues()[0].join('|') !== HEADERS.join('|')) …
Перед записью скрипт читает первую строку и сравнивает её с эталоном. Если лист переименовали или столбцы поменяли местами, приёмник откажется писать, а не разложит данные по чужим колонкам.
createTextFinder ищет по всему столбцу A. matchEntireCell требует совпадения ячейки целиком, иначе нашлось бы частичное вхождение. Если совпадение есть — запись не делается и клиент получает duplicate_request_id.
Серверные поля
const createdAt = new Date();
const source = clean('utm_source') || 'direct';
const status = 'new';
Время берётся с сервера Google, а не из браузера: подделать его нельзя. Пустые метки заменяются заглушками direct / none / not_set — так в столбцах не остаётся дыр, и видно разницу между «пришёл сам» и «метку потеряли». Статус всегда new: что бы ни прислал клиент, поле игнорируется.
appendRow дописывает строку сразу после последней заполненной — искать свободное место не нужно. Порядок значений в массиве совпадает со столбцами A–I; переставите местами — данные уедут в чужие колонки. flush заставляет Google записать изменения немедленно, до снятия блокировки.
Защита от формул
function textCell(value) {
return /^[=+@-]/.test(value) ? "'" + value : value;
}
Google Sheets считает формулой всё, что начинается с =, +, - или @. Если посетитель отправит имя вида =1+1, таблица посчитает его выражением. Апостроф в начале заставляет Sheets воспринимать значение как текст.
Клиенту возвращается только общий код ошибки: подробности о таблице и содержимом заявки наружу не уходят. Блок finally снимает замок в любом случае — иначе после первой же ошибки приёмник завис бы для всех следующих заявок.
Web App умеет отдавать только текст, поэтому объект превращается в строку через JSON.stringify. setMimeType помечает ответ как JSON — без этого браузер счёл бы его обычным текстом.
Весь код формы завёрнут в функцию, которая выполняется сразу. Её переменные не видны снаружи, поэтому имя params не столкнётся с таким же именем в js/metrics.js. Проверка if (!leadForm) return нужна, потому что скрипт подключён ко всем страницам, а форма есть только на Contacts.
Метки кампании
const params = new URLSearchParams(window.location.search);
const defaults = {utm_source:'direct', utm_medium:'none', utm_campaign:'not_set'};
for (const [key, fallback] of Object.entries(defaults)) {
leadForm.elements.namedItem(key).value = params.get(key)?.trim() || fallback;
}
URLSearchParams разбирает часть адреса после «?» в объект. params.get возвращает значение метки или null, если её нет; ?. позволяет вызвать trim, не упав на null. Оператор || подставляет заглушку вместо пустого значения. Один цикл заполняет все три скрытых поля.
Идентификатор заявки
if (!requestId.value) requestId.value = 'REQ-' + crypto.randomUUID().toUpperCase();
crypto.randomUUID даёт случайный идентификатор прямо в браузере — совпадения практически исключены. toUpperCase нужен потому, что приёмник ждёт верхний регистр. Условие if (!requestId.value) означает, что ID создаётся один раз: если посетитель нажмёт «Отправить» дважды, уйдёт тот же ID, и таблица отклонит повтор.
Проверка typeof нужна на случай, когда тег не загрузился или его заблокировали: без неё форма перестала бы отправляться. В параметры уходит только источник заявки. Имя и email в GA4 не передаются — это персональные данные.
Отправка без preventDefault
// preventDefault() не вызываем: браузер отправляет POST сам.
В теме 6 обработчик отменял отправку, потому что форма ничего никуда не отправляла. Теперь отмены нет: браузер сам выполняет POST по адресу из action. Ответ приёмника открывается в скрытом iframe, поэтому страница остаётся на месте и посетитель видит сообщение под формой.
Формулы Google Sheets
Список допустимых статусов
new
in_progress
done
rejected
Это не формула, а правило проверки данных на диапазоне I2:I1000. Заголовок в правило не входит — иначе слово «status» само стало бы недопустимым значением. Режим «Запрещать ввод данных» не пускает в ячейку ничего, кроме четырёх значений; режим «Показывать предупреждение» только подсветил бы ошибку, но значение принял.
Подсветка неполной заявки
=AND($A2<>"";COUNTBLANK($C2:$E2)>0)
Условное форматирование применяет формулу к каждой строке диапазона по очереди. $A2 — столбец закреплён знаком $, а номер строки Sheets подставляет свой: для четвёртой строки формула читается как $A4. Первая часть проверяет, что строка вообще начата: в ней есть request_id. COUNTBLANK считает пустые ячейки среди C, D и E — это name, email и direction. Если пустых больше нуля, строка подсвечивается. AND требует, чтобы оба условия выполнились разом, иначе подсветились бы все пустые строки листа.
Поиск повторяющегося ID
=AND($A2<>"";COUNTIF(A:A;$A2)>1)
COUNTIF считает, сколько раз значение из $A2 встречается во всём столбце A. Сама ячейка тоже попадает в подсчёт, поэтому обычное значение даёт единицу, а дубль — два и больше: отсюда сравнение >1. Диапазон A:A взят целиком, чтобы правило работало и на строках, добавленных позже. Условие $A2<>"" отсекает пустые ячейки: они одинаковы между собой, но дублями заявок не являются.
Скриншоты · 44
Нумерация: экран темы / исходный слайд. PNG сохраняйте в shots/ под указанным именем, затем выполните node build.mjs. При отсутствии кадра остаётся честно подписанная рамка.