GA4
generate_leadСобытие попытки отправки.
Без имени и email.
UTM, сайт на Vercel и Google Tag уже дают события. Теперь найдём место для самой заявки.
generate_leadСобытие попытки отправки.
Без имени и email.
request_id · status · UTMЗапись после проверки.
Имя и email — только здесь.
generate_lead · страница · источник · кампания
request_id · created_at · name · email · status
generate_leadСобытие попытки отправки.
Без имени и email.
request_id · status · UTMЗапись после проверки.
Имя и email — только здесь.
Девять столбцов, закреплённая строка заголовков, формат даты у created_at и список статусов в последнем столбце.
Две строки внизу — настоящие заявки, отправленные формой сайта: одна пришла без меток, вторая по ссылке из Telegram.
request_id · created_at
name · email · direction
utm_source · utm_medium · utm_campaign
status
Пример:
request_id REQ-031A5470-9AE5-4305-B6A1-095B91D9C278
created_at 2026-09-23 12:40
name Иван
email student@example.com
direction backend
utm_source telegram
utm_medium social
utm_campaign september
status newname · email · direction
utm_source · utm_medium · utm_campaign
request_id — браузер; created_at и status — сервер
Имя, email и направление.
request_id и UTM можно изменить через DevTools: сервер проверяет их.
created_at и status создаёт Apps Script.
В практической работе используйте тестовые значения.
Например:
Иван Тестов
student@example.comНе используйте:
sheets.google.com→Пустая таблица
Откройте Google Таблицы и создайте новую пустую таблицу.
ОТКРЫТЬ В НОВОЙ ВКЛАДКЕsheets.google.comКнопка «Пустая таблица» в блоке «Создать таблицу»Клик по названию «Новая таблица»→Ввести GA4 Analytics Lab — Leads→Enter
Название:
GA4 Analytics Lab — Leadsили:
GA4 Leads — ИвановПо названию должно быть сразу понятно, для чего таблица.
Должно получитьсяВ шапке документа вместо «Новая таблица» стоит ваше название, а рядом ненадолго появляется надпись «Сохранено на Диске».
Двойной клик по ярлыку «Лист1»→Ввести leads→Enter
Переименуйте первый лист:
leadsПо этому названию Apps Script найдёт лист, поэтому пишите его точно так же, маленькими латинскими буквами.
leadsВставьте заголовки в A1:I1. Закрепление строки и формат даты — три следующих шага.
request_id created_at name email direction utm_source utm_medium utm_campaign statusДолжно получитьсяПервая строка занята девятью заголовками, по одному в каждой ячейке от A1 до I1.
Клик по любой ячейке→Вид→Закрепить→1 строку
Пока строка не закреплена, при прокрутке заголовки уезжают вверх и непонятно, в каком столбце вы находитесь.
Клик по любой ячейке → Вид → Закрепить → 1 строку.
Под первой строкой появится серая разделительная полоса: по ней видно, что строка закреплена.
Прокрутите лист вниз. Строка с заголовками осталась вверху, под ней — серая разделительная полоса.
Номера строк при этом идут с большими значениями: видно, что таблица прокручена, а шапка осталась на месте.
Если полосы нет и заголовки уехали — закрепление не применилось, вернитесь на шаг назад.
Заголовок столбца B→Формат→Числа→Дата и время
Apps Script пришлёт в столбец created_at настоящую дату, но без формата таблица покажет её как число.
Клик по заголовку столбца B → Формат → Числа → Дата и время.
Формат задаём заранее, до первой заявки: тогда первая же запись сразу читается как дата.
Заголовки A…I→Правый клик→Изменить размер столбцов A–I→Автоподбор размера→ОК
request_id длинный: в столбце по умолчанию он обрезается многоточием и номер заявки не прочитать целиком.
Выделите заголовки A…I → правый клик по любому из них → Изменить размер столбцов A–I → Автоподбор размера.
Ширина подстроится под самое длинное значение в каждом столбце.
Должно получитьсяrequest_id виден целиком, без многоточия. Остальные столбцы сузились до ширины своего содержимого.
Правило нашей таблицы:
одна строка = одна заявка
Не объединяем несколько заявок в одну ячейку.
Не вставляем комментарии между строками данных.
Не создаём отдельную таблицу для каждого источника.
request_id — уникальный идентификатор конкретной заявки.
Например:
REQ-031A5470-9AE5-4305-B6A1-095B91D9C278Он нужен, чтобы:
created_at хранит дату и время появления записи.
Например:
24.09.2026 9:37:48Время записывает сервер в момент, когда заявка пришла. Посетитель дату не вводит.
Если пользователь пришёл по ссылке:
?utm_source=telegram
&utm_medium=social
&utm_campaign=septemberэти значения необходимо сохранить вместе с заявкой.
Тогда можно связать:
источник
↓
реальную заявку
↓
её дальнейший статус.
Используем ограниченный набор статусов:
new
in_progress
done
rejectedНе допускаем свободные варианты:
Новая
NEW
готово
готов
Finished
обработалВыделить I2:I→Данные→Настроить проверку данных→Добавить правило→Раскрывающийся список
Выделите I2:I → Данные → Настроить проверку данных. Заголовок не включаем.
new
in_progress
done
rejectedI2:IРаскрывающийся списокЗапрещать ввод данныхДолжно получитьсяВ ячейках столбца status появился серый чип со стрелкой. По стрелке открываются ровно четыре значения и ничего больше.
Без проверки легко получить:
new
NEW
New
новаяДля аналитики это четыре разных значения.
Проверка данных (Data Validation) оставляет в ячейке только значения из заранее заданного списка.
Вручную попробуйте написать:
finishedв ячейке status.
Google Sheets должен показать, что значение не соответствует правилу.
После теста удалите экспериментальную строку.
Минимально обязательны:
request_id
created_at
name
email
direction
statusUTM может отсутствовать.
Например, пользователь может открыть сайт напрямую.
В этом случае используем:
utm_source = direct
utm_medium = none
utm_campaign = not_setВыделить A2:I→Формат → Условное форматирование→Добавить правило→Ваша формула
Для диапазона A2:I выберите «Ваша формула». Подсвечиваем только начатые строки с пропущенным обязательным полем.
=AND($A2<>"";COUNTBLANK($C2:$E2)>0)Словами: если в строке уже есть ID, но имя, email или направление не заполнены — покрасить строку.
$A2<>""COUNTBLANK($C2:$E2)>0AND(…;…)Впишите в пустую строку только request_id и имя, а email и направление оставьте пустыми.
Строка целиком станет красной: ID в строке есть, а из трёх обязательных полей заполнено только одно.
Заполните недостающие поля, и подсветка пропадёт.
Клик по A2→⌘ + Shift + ↓→Добавить правило
Второе правило работает только по столбцу request_id, поэтому диапазон у него другой.
Кликните в сетке по ячейке A2, затем нажмите ⌘ + Shift + ↓ — выделение дойдёт до конца столбца.
Если в столбце уже есть заявки, сочетание сначала остановится на последней заполненной строке. Нажмите его ещё раз.
Поле «Применить к диапазону» в панели заполнится само. Набирать диапазон руками в этом поле не нужно: панель принимает только выделение из сетки.
Формат→Условное форматирование→Добавить правило→Ваша формула
Диапазон A2:A → Условное форматирование → Ваша формула.
=AND($A2<>"";COUNTIF(A:A;$A2)>1)Словами: если ID заполнен и точно такой же ID уже есть в столбце — покрасить ячейку.
COUNTIF(A:A;$A2)A:A>1$A2<>""Должно получитьсяПравил в панели стало два. Одинаковый request_id в двух строках теперь красится оранжевым.
Нажмите Готово. В панели останется список из двух правил.
У первого диапазон по всем девяти столбцам — подсветка неполной заявки. У второго только столбец A — повторяющийся request_id.
Порядок в списке важен: если два правила совпадут на одной ячейке, Sheets применит то, что выше.
Теперь на листе работают обе проверки, различить их можно по цвету и по ширине подсветки.
Красная строка — неполная заявка, подсвечена целиком от A до I.
Оранжевая ячейка — повторяющийся request_id, подсвечен только столбец A.
Пользователь может оставить несколько разных заявок.
Поэтому:
одинаковый emailещё не означает технический дубль.
В нашем стенде дубль определяется как повторная запись с тем же:
request_idGoogle Apps Script — среда, в которой код на JavaScript выполняется на серверах Google и работает с их сервисами, в том числе с Таблицами.
В этой теме скрипт выполняет шесть действий:
newРасширения→Apps Script
В таблице откройте:
Расширения
↓
Apps Script
Откроется редактор скрипта, связанный с этой таблицей.
В редакторе уже существует файл:
Code.gsУдалите демонстрационную функцию myFunction, если она есть.
Мы создадим собственную функцию:
doPost(e)Code.gsmyFunctiondoPost(e)Должно получитьсяСлева остался один файл Code.gs, а в редакторе — пустое место под вашу функцию.
Code.gs.Скачать Code.gs→Выделить всё в редакторе: ⌘ + A→Вставить→Подставить свой ID в строку 2→⌘ + S
Сначала скачайте Code.gs и вставьте его целиком. Затем подставьте ID таблицы — это следующие два слайда. После них идёт разбор файла по шагам.
Должно получитьсяВ редакторе 60 строк, над списком функций появилось имя doPost — код разобран без ошибок.
Откройте свою таблицу и посмотрите на адресную строку. Адрес устроен так:
docs.google.com/spreadsheets/d/ВАШ_ID/editID — длинная строка между /d/ и /edit. Скопируйте её целиком, без самих слэшей.
Это не название таблицы и не ссылка «Поделиться»: у копии таблицы будет свой ID.
Code.gsстрока 2 и строка 28
Вставьте скопированный ID в константу в самом начале файла. Ниже, внутри doPost, по этому ID открывается лист.
// строка 2 — сюда вставляется ID
const SPREADSHEET_ID = 'ВАШ_ID_ТАБЛИЦЫ';
// ниже, внутри doPost
const sheet = SpreadsheetApp
.openById(SPREADSHEET_ID)
.getSheetByName('leads');openById(SPREADSHEET_ID)getSheetByName('leads')Должно получитьсяНад списком функций появится имя doPost: редактор разобрал код и не нашёл ошибок.
Файл целиком — слева, шаги — справа. Сравнение для всего разбора: приёмник работает как администратор в приёмной. Он получает бланк, проверяет его и записывает в журнал — лист leads.
// Укажите ID своей таблицы между /d/ и /edit в её URL.const SPREADSHEET_ID = 'PASTE_YOUR_SPREADSHEET_ID';const HEADERS = ['request_id', 'created_at', 'name', 'email', 'direction', 'utm_source', 'utm_medium', 'utm_campaign', 'status'];function doPost(e) { const p = (e && e.parameter) || {}; const clean = key => String(p[key] || '').trim(); const missing = ['request_id', 'name', 'email', 'direction'].filter(key => !clean(key)); if (missing.length) return jsonResponse({ok:false, error:'missing_required', fields:missing}); const id = clean('request_id'); if (!/^REQ-[A-F0-9]{8}-[A-F0-9]{4}-4[A-F0-9]{3}-[89AB][A-F0-9]{3}-[A-F0-9]{12}$/i.test(id)) { return jsonResponse({ok:false, error:'invalid_request_id'}); } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(clean('email'))) { return jsonResponse({ok:false, error:'invalid_email'}); } if (!['frontend','backend','data'].includes(clean('direction'))) { return jsonResponse({ok:false, error:'invalid_direction'}); } if (['name','email','utm_source','utm_medium','utm_campaign'].some(key => clean(key).length > 200)) { return jsonResponse({ok:false, error:'value_too_long'}); } // Проверка ID и запись выполняются под одной блокировкой. const lock = LockService.getScriptLock(); if (!lock.tryLock(10000)) return jsonResponse({ok:false, error:'busy_retry'}); try { const sheet = SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName('leads'); if (!sheet || sheet.getRange(1,1,1,9).getValues()[0].join('|') !== HEADERS.join('|')) { return jsonResponse({ok:false, error:'invalid_sheet_schema'}); } const duplicate = sheet.getRange('A:A').createTextFinder(id) .matchEntireCell(true).matchCase(false).useRegularExpression(false).findNext(); if (duplicate) return jsonResponse({ok:false, error:'duplicate_request_id'}); const createdAt = new Date(); const source = clean('utm_source') || 'direct'; const medium = clean('utm_medium') || 'none'; const campaign = clean('utm_campaign') || 'not_set'; const status = 'new'; // Присланный клиентом status игнорируется. sheet.appendRow([id, createdAt, textCell(clean('name')), textCell(clean('email')), clean('direction'), textCell(source), textCell(medium), textCell(campaign), status]); SpreadsheetApp.flush(); return jsonResponse({ok:true, request_id:id}); } catch (error) { // Не возвращаем клиенту детали таблицы и содержимое заявки. return jsonResponse({ok:false, error:'storage_error'}); } finally { lock.releaseLock(); }}function textCell(value) { // Не даём введённому тексту превратиться в формулу Google Sheets. return /^[=+@-]/.test(value) ? "'" + value : value;}function jsonResponse(data) { return ContentService.createTextOutput(JSON.stringify(data)) .setMimeType(ContentService.MimeType.JSON);}1 / 19Строки 1–4
Первые строки задают значения, которые нужны остальному коду. Слово const создаёт константу: имя, за которым закреплено значение. Дальше в коде пишется короткое имя вместо длинного значения.
// Укажите ID…//, Apps Script не выполняет — это пояснение для человека.const SPREADSHEET_ID = '…'/d/ и /edit. По нему скрипт найдёт таблицу. Кавычки означают, что значение — текст, в программировании его называют строкой.const HEADERS = ['request_id', …]leads. В строке 29 код сверит по этому списку, что лист устроен правильно.Как в жизниТабличка на двери приёмной: в какой журнал записывать заявки и какие графы в нём есть.
↓ ещё ниже
2 / 19Строки 6, 50
Функция — именованный блок кода, который выполняется, когда его вызывают. Всё между { в строке 6 и } в строке 50 — тело функции doPost.
Эту функцию вызывает сам Google. Когда форма отправляет POST-запрос на адрес /exec, Apps Script ищет в проекте функцию с именем doPost и запускает её. Поэтому имя менять нельзя: функцию с другим именем Google не найдёт, и заявка не обработается.
В скобках стоит e — параметр функции. В него Google кладёт всё, что пришло с запросом: поля формы и служебные сведения. Имя e — сокращение от event, «событие»; так его принято называть в Apps Script.
Когда адрес /exec просто открывают в браузере, Google вызывает другую функцию — doGet. В нашем файле её нет: форме она не нужна.
Как в жизниЗвонок в дверь приёмной. Пока не позвонили, администратор ничего не делает. e — конверт, который принёс посетитель.
↓ ещё ниже
3 / 19Строка 7
e.parameter — объект с полями формы. Объект хранит пары «ключ — значение». Ключ — атрибут name поля в HTML, значение — то, что ввёл посетитель или подставил скрипт. Значение читается через точку: p.name, p.email.
Запись (e && e.parameter) || {} защищает от запуска без данных. && означает «и»: если e нет, e.parameter даже не читается. || означает «или»: если слева ничего не получилось, берётся правая часть — пустой объект {}. Так бывает, если нажать «Выполнить» в редакторе: тогда e не передаётся, и без этой защиты код остановился бы с ошибкой.
Форма отправила: name=Иван, email=ivan@example.com p.name → 'Иван' p.email → 'ivan@example.com' p.phone → undefined (такого поля в форме нет)
Как в жизниОткрыть конверт и достать бланк. Если конверт пришёл пустым, администратор берёт чистый бланк и продолжает работу.
↓ ещё ниже
4 / 19Строка 8
clean — короткая функция, записанная через стрелку =>. Слева от стрелки параметр key — имя поля, справа — что функция возвращает. Дальше в коде значения полей читаются только через неё: clean('name'), clean('email').
p[key]key. Квадратные скобки нужны, потому что имя поля хранится в переменной: clean('email') прочитает p['email'], то же самое, что p.email.|| ''undefined.String(…)trim..trim()name=' Иван ' → clean('name') → 'Иван'
поля phone нет → clean('phone') → ''Как в жизниПрежде чем читать бланк, администратор стирает случайные пробелы и помарки на краях граф.
↓ ещё ниже
5 / 19Строки 9–10
Строка 9 составляет список пустых обязательных полей. Сначала записан массив из четырёх имён. Метод filter проходит по нему и оставляет только элементы, для которых условие верно. Условие !clean(key) читается как «значение пустое»: пустая строка в JavaScript считается ложью, а ! превращает ложь в истину.
Строка 10 проверяет результат. missing.length — длина списка. Ноль означает, что всё заполнено, и if пропускает строку. Если пустых полей больше нуля, return сразу завершает функцию и отправляет браузеру ответ с ошибкой. Код ниже не выполняется, строка в таблицу не пишется.
Пришло: name='Иван', email='', direction='data', request_id='REQ-…'
missing → ['email']
Ответ: {ok:false, error:'missing_required', fields:['email']}Атрибут required в HTML проверяет то же самое в браузере. Эту проверку можно обойти: удалить атрибут в DevTools или отправить запрос без формы. Код на сервере посетитель изменить не может, поэтому проверка повторяется здесь.
Как в жизниАдминистратор пробегает глазами обязательные графы. Нашёл пустую — возвращает бланк и говорит, что дописать.
↓ ещё ниже
6 / 19Строки 11–14
Строка 11 сохраняет очищенный номер заявки в константу id, чтобы ниже не вызывать clean('request_id') каждый раз.
Строка 12 проверяет, как номер выглядит. Между косыми чертами /…/ записано регулярное выражение — шаблон для текста. Шаблон описывает формат номеров, которые создаёт js/script.js: буквы REQ, дефис и UUID — пять групп шестнадцатеричных цифр (0–9 и A–F) через дефис. Буква i после шаблона отключает различие больших и маленьких букв. Метод test отвечает true, если текст подходит под шаблон.
Знак ! в начале условия переворачивает ответ: «если номер не подходит». Тогда функция отвечает invalid_request_id и завершается.
REQ-3F7A81D2-9AE5-4305-B6A1-095B91D9C278 → подходит REQ-3F7A81D2 → invalid_request_id 12345 → invalid_request_id
Как в жизниНомер талона сверяют с образцом. Номер, напечатанный не тем аппаратом, не принимают.
↓ ещё ниже
7 / 19Строки 15–17
Ещё одно регулярное выражение. [^\s@]+ означает «один или больше символов, кроме пробела и @». Весь шаблон читается так: символы без пробелов, затем @, затем символы, затем точка, затем ещё символы.
Шаблон не проверяет, существует ли такой ящик. Он отсекает очевидные ошибки: пропущенную @, пробел внутри адреса, адрес без точки в домене. Если адрес не подошёл, ответ — invalid_email.
ivan@example.com → подходит ivan.example.com → invalid_email (нет @) ivan@example → invalid_email (нет точки после @)
Как в жизниПроверка, что в графе «Адрес» указаны город и улица. Дойдёт ли письмо, так не узнать, но явную ошибку видно сразу.
↓ ещё ниже
8 / 19Строки 18–20
В квадратных скобках — список разрешённых направлений. Метод includes отвечает, есть ли значение в списке. Если значения нет, ответ — invalid_direction.
Выпадающий список на странице предлагает только эти три варианта. Но вариант можно подменить в DevTools, например на design. Сервер такое значение не примет, поэтому в столбце direction остаются только три значения, и заявки по ним удобно считать.
Как в жизниВ графе с вариантами для галочки засчитываются только напечатанные. Вариант, вписанный от руки, не принимают.
↓ ещё ниже
9 / 19Строки 21–23
Метод some проходит по списку полей и отвечает true, если условие верно хотя бы для одного из них. Условие — длина значения больше 200 символов. Тогда ответ — value_too_long.
У полей в HTML стоит maxlength="200", но его, как и required, можно убрать. Проверка на сервере не даёт записать в таблицу огромный текст, отправленный в обход формы.
Как в жизниБланк, где в графе «Имя» написано три страницы текста, не принимают.
↓ ещё ниже
10 / 19Строки 24–27, 47–49
Две заявки могут прийти одновременно, и Google запустит doPost два раза параллельно. Без защиты возможен такой порядок: первый запуск проверил, что такого номера в таблице нет; второй запуск проверил то же самое; оба записали строку. В таблице окажется дубль, хотя проверка на повтор в коде есть.
Блокировка выстраивает запуски в очередь: пока один проверяет и записывает, остальные ждут.
LockService.getScriptLock()lock.tryLock(10000)true.return … 'busy_retry'try { … } finally { … }finally (строки 47–49) выполняется в любом случае: после успешной записи, после ответа с ошибкой и после сбоя. Там замок снимается — lock.releaseLock(). Если бы замок остался закрытым, следующие заявки ждали бы его и получали busy_retry.Как в жизниЖурнал один, и писать в него может один человек. Второй администратор ждёт, пока первый допишет строку и положит ручку.
↓ ещё ниже
11 / 19Строки 28–31
Строка 28: SpreadsheetApp — встроенная в Apps Script служба для работы с таблицами. openById открывает таблицу по ID из строки 2, getSheetByName('leads') выбирает в ней лист с этим именем. Если такого листа нет, в sheet окажется null — «ничего».
Строка 29 проверяет две вещи. !sheet — лист не найден. Вторая часть читает первую строку листа: getRange(1,1,1,9) — диапазон «с первой строки и первого столбца, одна строка, девять столбцов», то есть A1:I1. getValues() возвращает значения ячеек, join('|') склеивает их в одну строку через черту. Так же склеивается список HEADERS, и две строки сравниваются. Если они разные, заголовки кто-то переименовал или переставил.
В обоих случаях ответ — invalid_sheet_schema, запись не делается. Иначе данные попали бы в чужие столбцы.
A1:I1 на листе: request_id|created_at|name|email|direction|… HEADERS: request_id|created_at|name|email|direction|… совпало → идём дальше
Как в жизниПеред записью администратор проверяет, что открыл нужный журнал и графы в нём стоят на своих местах.
↓ ещё ниже
12 / 19Строки 32–34
createTextFinder(id) ищет номер заявки в столбце A, где хранятся все номера. Следующие вызовы настраивают поиск:
.matchEntireCell(true).matchCase(false).useRegularExpression(false).findNext()null, если ничего не нашлось.Если ячейка нашлась, заявка с этим номером уже записана. Функция отвечает duplicate_request_id, новая строка не появляется. Так бывает, когда посетитель нажал «Отправить» дважды или браузер повторил запрос.
Как в жизниНомер талона уже есть в журнале — ту же заявку второй раз не вносят.
↓ ещё ниже
13 / 19Строка 35
new Date() создаёт дату и время в момент выполнения строки. Код выполняется на серверах Google, поэтому и время берётся оттуда.
Из формы время не приходит вовсе. Часы на компьютере посетителя могут спешить или стоять в другом часовом поясе, а значение, присланное из браузера, можно подменить.
В ячейке время показывается в часовом поясе, указанном в настройках таблицы: Файл → Настройки.
Как в жизниДату на бланке ставит администратор своим штампом.
↓ ещё ниже
14 / 19Строки 36–38
Для каждой из трёх меток одна и та же схема: clean читает значение, а || подставляет заглушку, если значение пустое. Пустая строка в JavaScript считается ложью, поэтому || берёт правую часть.
utm_source=telegram → source = 'telegram' utm_source не пришла → source = 'direct' utm_medium не пришла → medium = 'none' utm_campaign не пришла → campaign = 'not_set'
direct / none / not_set — наши собственные заглушки. По ним в таблице видно, что посетитель пришёл без меток. С тем, как GA4 определяет источник сессии, они не связаны.
Как в жизниЕсли на бланке нет промокода, администратор пишет в графе «Откуда»: «пришёл сам».
↓ ещё ниже
15 / 19Строка 39
Статус задаётся одной строкой и всегда равен new. Код нигде не читает p.status, поэтому поле status в запросе, если его кто-то пришлёт, ни на что не влияет.
Если бы статус брался из формы, любой посетитель мог бы прислать заявку сразу со статусом done, и менеджер посчитал бы её обработанной.
Как в жизниОтметку «принято» ставит администратор. Посетитель не может сам написать на бланке «выполнено».
↓ ещё ниже
16 / 19Строки 40–42
appendRow добавляет строку сразу под последней заполненной. В квадратных скобках — массив из девяти значений. Первое попадёт в столбец A, второе — в B и так далее до I. Порядок совпадает с HEADERS. Если поменять два значения местами, имя окажется в столбце email.
Всё, что прислал посетитель — имя, email и метки, — проходит через textCell. Её устройство разобрано на предпоследнем шаге. Номер, время, направление и статус в неё не заворачиваются: их формат уже проверен или задан сервером.
SpreadsheetApp.flush() сразу применяет изменения в таблице. Без этого вызова Google может отложить запись. Это важно до снятия замка: следующий запуск должен увидеть новую строку, когда будет искать дубль.
Как в жизниНовая строка в конце журнала, графы заполняются строго по порядку.
↓ ещё ниже
17 / 19Строки 43–46
Строка 43: всё прошло, функция возвращает {ok:true, request_id:id}. ok — признак успеха, request_id — номер записанной заявки.
Строки 44–46: catch перехватывает любую ошибку внутри try, например если таблицу удалили или у скрипта нет к ней доступа. Без catch Google вернул бы страницу с текстом ошибки. Здесь браузер получает короткий ответ storage_error. Подробности ошибки и данные заявки в ответ не попадают: посетителю они не нужны, а постороннему подскажут, как устроена таблица.
Страница пока не читает этот ответ: он загружается в скрытую рамку. Сохранилась ли заявка, проверяем по строке в таблице.
Как в жизниПосетителю отдают корешок квитанции с номером. Если что-то сломалось, ему коротко говорят «не получилось, повторите», а журнал не показывают.
↓ ещё ниже
18 / 19Строки 52–55
В Google Таблицах значение, которое начинается с =, считается формулой и вычисляется. Так же таблица относится к значениям, начинающимся с +, - и @. Если посетитель впишет в поле «Имя» формулу, она выполнится в вашей таблице.
Регулярное выражение /^[=+@-]/ проверяет первый символ: ^ — начало строки, в квадратных скобках — список опасных символов. Запись условие ? A : B — короткий if: если условие верно, результат A, иначе B. Для опасного значения результат — апостроф и само значение. Апостроф говорит таблице «это текст» и в ячейке не отображается.
textCell('=1+1') → "'=1+1" в ячейке видно =1+1, но это текст
textCell('Иван') → 'Иван' без измененийКак в жизниЧужие слова берут в кавычки: записали как есть, выполнять не стали.
↓ ещё ниже
19 / 19Строки 57–60
Этой функцией заканчивается каждый return в doPost. Она получает объект, например {ok:false, error:'invalid_email'}, и превращает его в ответ веб-приложения.
JSON.stringify(data){"ok":false,"error":"invalid_email"}. Веб-приложение отдаёт только текст, объект напрямую отправить нельзя.ContentService.createTextOutput(…).setMimeType(…JSON)Как в жизниОтвет кладут в конверт стандартного вида, чтобы получатель знал, как его открыть.
↓ ещё ниже
Клик по названию проекта→Ввести имя→Переименовать→⌘ + S
Сохраните проект Apps Script.
Название проекта:
GA4 Analytics Lab — Leads ReceiverСейчас код сохранён только в редакторе. Чтобы сайт мог отправлять ему запросы, проект нужно развернуть как веб-приложение.
Должно получитьсяРядом с названием проекта появится облачко с галочкой — код сохранён. В списке функций станет доступна doPost.
Начать развертывание→Новое развертывание
В правом верхнем углу нажмите:
Начать развертывание
↓
Новое развертывание
Должно получитьсяОткроется окно «Новое развертывание»: тип ещё не выбран, кнопка запуска серая.
Шестерёнка «Выберите тип»→Веб-приложение
В открывшемся окне нажмите шестерёнку «Выберите тип».
Выберите:
Веб-приложение
У веб-приложения появится адрес, на который сайт сможет отправлять запросы.
Должно получитьсяСлева в окне появится строка «Веб-приложение», справа — поля описания и доступа.
Описание→Запуск от имени: от моего имени→У кого есть доступ: Все→Начать развертывание
Описание: Приём заявок с сайта GA4 Analytics Lab.
«От моего имени» — скрипт пишет в вашу таблицу.
«Все» (Anyone) — форму отправит посетитель, не входивший в Google.
Должно получитьсяКнопка «Начать развертывание» станет активной. После нажатия Google запросит разрешения — это следующий шаг.
Предоставить доступ→Выбрать аккаунт→Advanced→Go to … (unsafe)→Continue
При первом Deployment Google запросит разрешение.
Скрипту нужны два разрешения:
Проверьте, что выбран нужный аккаунт. Google предупредит, что приложение не проверено: скрипт ваш, и Google его не проверял. Нажмите Advanced → Go to … (unsafe) и подтвердите разрешения.
Копировать URL→Готово
После Deployment появится адрес вида:
https://script.google.com/macros/s/...../execСкопируйте адрес веб-приложения.
Он понадобится в HTML-форме.
Заявка проходит такой путь:
POST
https://script.google.com/.../exec↓
doPost(e)↓
sheet.appendRow(...)↓
Google Sheets
Продолжаем существующий сайт и его репозиторий — новый проект не создаём.
ga4-analytics-lab-ivanov.vercel.app ↗ · репозиторий на GitHub ↗
Расширяем уже существующую lead-form.
Заменяем только прежний submit-обработчик.
Должно получитьсяВ Explorer виден тот же набор файлов, что и в репозитории: css, js, about.html, contacts.html, index.html.
Откройте:
contacts.htmlНайдите существующую форму:
<form id="lead-form">Новую форму не создаём: на следующем шаге эту форму заменим обновлённой.
id="lead-form"Где искатьИтоговые файлы после темы 7:
<!doctype html>
<html lang="ru">
<head>
<!-- Google tag (gtag.js) — подставьте свой ID вместо G-XXXXXXXXXX -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Учебная лаборатория веб-аналитики: страницы, переходы и события.">
<title>Contacts — GA4 Lab</title>
<link rel="stylesheet" href="css/style.css">
<script src="js/script.js" defer></script>
<script src="js/metrics.js" defer></script>
</head>
<body>
<header><a class="brand" href="index.html">GA4<span>LAB</span></a><nav aria-label="Основная навигация"><a href="index.html">Home</a><a href="about.html">About</a><a href="contacts.html" aria-current="page">Contacts</a></nav></header>
<main><p class="eyebrow">ОП.03 / ЛАБОРАТОРИЯ ВЕБ-АНАЛИТИКИ</p><h1>Давайте<br><em>оставаться на связи.</em></h1><p class="lead">Последняя остановка маршрута. Здесь можно оставить учебную заявку.</p><div class="contact-grid">
<!-- ВАШ_APPS_SCRIPT_URL замените адресом Web App, который оканчивается на /exec -->
<form id="lead-form" action="ВАШ_APPS_SCRIPT_URL" method="POST" target="submission-frame">
<label>Имя
<input name="name" required maxlength="200" autocomplete="off" placeholder="Иван Тестов">
</label>
<label>Email
<input name="email" type="email" required maxlength="200" autocomplete="off" placeholder="student@example.com">
</label>
<label>Направление
<select name="direction" required>
<option value="">Выберите направление</option>
<option value="frontend">Frontend</option>
<option value="backend">Backend</option>
<option value="data">Data</option>
</select>
</label>
<input type="hidden" name="request_id" id="request_id">
<input type="hidden" name="utm_source" id="utm_source">
<input type="hidden" name="utm_medium" id="utm_medium">
<input type="hidden" name="utm_campaign" id="utm_campaign">
<button class="button" type="submit">Отправить заявку <span>↗</span></button>
<button type="reset">Новая заявка</button>
<p id="form-status" role="status"></p>
<small>Учебная форма: используйте только вымышленные имя и email.</small>
</form>
<iframe name="submission-frame" id="submission-frame" title="Ответ приёмника заявок" hidden></iframe>
<aside><span class="tag">ВАШ МАРШРУТ</span><h2>Home → About<br>→ Contacts</h2><p>Все три страницы имеют собственный адрес. После установки тега их просмотры можно искать в отчётах.</p><a class="secondary" href="index.html">Начать маршрут заново →</a></aside></div></main>
<footer><span>GA4 Lab · Учебный сайт</span><span>HTML / CSS / JavaScript</span></footer>
</body>
</html>
'use strict';
// Итоговый js/script.js после темы 7: форма заявок + CTA из темы 6.
// Google Tag устанавливается отдельно в head каждой HTML-страницы.
(() => {
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);
});
})();
// CTA из существующего сайта темы 6 — без изменений.
const programCta = document.querySelector('#program-cta');
if (programCta) {
programCta.addEventListener('click', () => {
document.querySelector('#program-preview').hidden = false;
if (typeof gtag === 'function') {
gtag('event', 'cta_click', {
button_name: 'program',
page_section: 'hero'
});
}
});
}
Замените в contacts.html старую форму фрагментом слева — кнопка «Копировать» в его шапке. Затем по шагам: что в форме осталось, что добавилось и зачем.
<!-- ВАШ_APPS_SCRIPT_URL замените адресом Web App, который оканчивается на /exec --><form id="lead-form" action="ВАШ_APPS_SCRIPT_URL" method="POST" target="submission-frame"> <label>Имя <input name="name" required maxlength="200" autocomplete="off" placeholder="Иван Тестов"> </label> <label>Email <input name="email" type="email" required maxlength="200" autocomplete="off" placeholder="student@example.com"> </label> <label>Направление <select name="direction" required> <option value="">Выберите направление</option> <option value="frontend">Frontend</option> <option value="backend">Backend</option> <option value="data">Data</option> </select> </label> <input type="hidden" name="request_id" id="request_id"> <input type="hidden" name="utm_source" id="utm_source"> <input type="hidden" name="utm_medium" id="utm_medium"> <input type="hidden" name="utm_campaign" id="utm_campaign"> <button class="button" type="submit">Отправить заявку <span>↗</span></button> <button type="reset">Новая заявка</button> <p id="form-status" role="status"></p> <small>Учебная форма: используйте только вымышленные имя и email.</small></form><iframe name="submission-frame" id="submission-frame" title="Ответ приёмника заявок" hidden></iframe>+ новое в теме 7~ измененобез отметки — осталось из темы 6
1 / 6Строки 27–32
Форма в HTML — тег <form> с полями внутри. Поле ввода — тег <input>. Записи вида имя="значение" внутри тега называются атрибутами: они настраивают поле.
<label>Имя … </label>label, поэтому клик по слову «Имя» ставит курсор в поле.name="name"name=Иван, и в Apps Script значение окажется в p.name. Этот атрибут связывает форму с кодом на сервере: если переименовать поле, Code.gs его не найдёт.requiredmaxlength="200"type="email"autocomplete="off"placeholder="…"В теме 6 эти поля уже были. Теперь подпись стоит вокруг поля, как у нового поля ниже, а ограничение длины у обоих полей — 200 символов.
Как в жизниГрафы бумажного бланка. У каждой графы есть подпись, и по ней понятно, в какой столбец журнала её переписать.
↓ ещё ниже
2 / 6Строки 33–40
Значения frontend, backend и data совпадают со списком в строке 18 Code.gs. Если добавить сюда четвёртый вариант, его нужно добавить и в Code.gs. Иначе сервер ответит invalid_direction.
<select name="direction" required>direction.<option value="frontend">Frontend</option>value — frontend.<option value="">Выберите направление</option>required не даст отправить форму.Как в жизниГрафа с вариантами для галочки: выбрать можно только напечатанное.
↓ ещё ниже
3 / 6Строки 25–26
Строка 25 — комментарий HTML. Всё между <!-- и --> на странице не видно. В строке 26 открывающий тег формы получил три новых атрибута.
action="…"https://script.google.com/macros/s/ и заканчивается на /exec.method="POST"method не указать, браузер отправит форму способом GET: данные окажутся в адресе, а Apps Script вызовет doGet вместо doPost.target="submission-frame"target браузер ушёл бы со страницы Contacts на страницу ответа.СделайтеЗамените ВАШ_APPS_SCRIPT_URL в строке 26 своим адресом /exec.
Как в жизниАдрес на конверте. Бланк заполнен, но без адреса его не отправить.
↓ ещё ниже
4 / 6Строки 41–44
<input type="hidden"> — поле, которого не видно на странице. Посетитель его не заполняет, но при отправке браузер отправляет его вместе с остальными полями.
Здесь четыре таких поля: request_id — номер заявки, utm_source, utm_medium, utm_campaign — метки источника. В HTML они пустые. Значения в них кладёт js/script.js: метки — сразу при открытии страницы, номер — в момент нажатия «Отправить».
У каждого поля есть name и id. name нужен для отправки на сервер, id — уникальное имя элемента на странице, по нему поле можно найти в DevTools.
Поля status здесь нет: статус заявки назначает сервер, строка 39 Code.gs.
Скрытое поле видно в DevTools, и его значение там можно поменять. Поэтому Code.gs проверяет формат номера и длину меток.
Как в жизниГрафа «Для служебных отметок» внизу бланка. Её заполняет сотрудник, посетитель её не трогает.
↓ ещё ниже
5 / 6Строки 45–48
<button type="submit">submit: сначала выполняется обработчик из js/script.js, потом браузер отправляет данные.<button type="reset"><p id="form-status" role="status">role="status" сообщает программам чтения с экрана, что здесь появляются сообщения.<small>…</small>Как в жизниКнопка «Новая заявка» — взять чистый бланк.
↓ ещё ниже
6 / 6Строки 49–50
<iframe> — рамка, в которую загружается другая страница. Атрибут hidden её прячет: рамки не видно, и места она не занимает.
У формы target="submission-frame", у рамки name="submission-frame". Имена совпадают, поэтому браузер загружает ответ Apps Script в рамку. Посетитель остаётся на странице Contacts и видит сообщение под формой. Без рамки браузер перешёл бы на адрес script.google.com и показал текст вида {"ok":true,…}.
Рамка стоит после </form>, снаружи формы. Атрибут title описывает рамку для программ чтения с экрана.
Прочитать ответ из рамки страница не может: браузер не даёт странице одного сайта читать содержимое страницы другого сайта. Поэтому результат проверяем в таблице.
Как в жизниПочтовый ящик за шторкой: ответ приходит туда, а посетитель остаётся у стойки.
↓ ещё ниже
Удалите из js/script.js старый обработчик submit из темы 6 и вставьте блок из строк 4–39. Можно заменить файл целиком: «Копировать» берёт весь файл.
'use strict';// Итоговый js/script.js после темы 7: форма заявок + CTA из темы 6.// Google Tag устанавливается отдельно в head каждой HTML-страницы.(() => { 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); });})();// CTA из существующего сайта темы 6 — без изменений.const programCta = document.querySelector('#program-cta');if (programCta) { programCta.addEventListener('click', () => { document.querySelector('#program-preview').hidden = false; if (typeof gtag === 'function') { gtag('event', 'cta_click', { button_name: 'program', page_section: 'hero' }); } });}+ новое в теме 7~ измененобез отметки — осталось из темы 6
1 / 10Строки 1–3, 41–53
'use strict' включает строгий режим JavaScript: часть ошибок, которые иначе прошли бы незаметно, становится явными. Строки 2–3 — комментарии.
В теме 6 обработчик формы вызывал event.preventDefault(). Этот вызов отменяет отправку формы: данные никуда не уходили, в GA4 отправлялось только событие. Теперь форма должна отправлять данные в Apps Script, поэтому старый обработчик удаляется целиком, а на его место встаёт блок строк 4–39.
Строки 41–53 — обработчик кнопки программы (CTA) из темы 6. Он не меняется.
Если оставить старый обработчик рядом с новым, его preventDefault() отменит отправку, и заявки перестанут доходить до таблицы.
Как в жизниРаньше бланк заполняли для тренировки и не отправляли. Теперь его отправляют по почте.
↓ ещё ниже
2 / 10Строки 4–6, 39
(() => { … })();() в строке 39 — это вызов. Переменные, объявленные внутри, видны только внутри. В js/metrics.js тоже есть переменная params. Без обёртки два объявления с одним именем дали бы ошибку, и скрипт остановился бы.document.querySelector('#lead-form')id="lead-form" — нашу форму. Если такого элемента нет, возвращает null.if (!leadForm) return;return завершает блок, и код ниже не выполняется.Как в жизниОтдельная комната со своими вещами: одинаковые названия в соседней комнате не мешают. Если формы на странице нет, в комнату не заходим.
↓ ещё ниже
3 / 10Строка 7
window.location.search — часть адреса страницы после знака ?. Для ссылки contacts.html?utm_source=telegram&utm_medium=social это ?utm_source=telegram&utm_medium=social.
new URLSearchParams(…) разбирает эту строку на пары «имя — значение». После этого значение метки берётся вызовом params.get('utm_source'). Если метки в адресе нет, get возвращает null.
Адрес: contacts.html?utm_source=telegram&utm_medium=social
params.get('utm_source') → 'telegram'
params.get('utm_medium') → 'social'
params.get('utm_campaign') → nullКак в жизниПромокод с рекламной листовки: по нему видно, из какой рекламы пришёл человек.
↓ ещё ниже
4 / 10Строки 8–11
const defaults = {…}name скрытых полей.for (const [key, fallback] of Object.entries(defaults))key — имя метки, fallback — её заглушка. Одинаковое действие для трёх полей записано один раз.leadForm.elements.namedItem(key)name.params.get(key)?.trim() || fallback?. защищает от null: если метки нет, trim не вызывается, и ошибки не будет. || подставляет заглушку, если значение пустое.Адрес: contacts.html?utm_source=telegram utm_source = 'telegram' utm_medium = 'none' (заглушка) utm_campaign = 'not_set' (заглушка)
Код выполняется при открытии страницы, до нажатия кнопки. Результат видно в DevTools: вкладка Elements, скрытые поля формы.
Как в жизниЕсли промокода нет, в графе «Откуда» сразу пишут «пришёл сам».
↓ ещё ниже
5 / 10Строки 12–13
Строка 12 находит скрытое поле request_id и запоминает его в requestId: оно понадобится в обработчиках ниже.
Строка 13 назначает обработчик. addEventListener('submit', …) говорит браузеру: когда форму отправляют, сначала выполни эту функцию. event — объект события, через него отправку можно отменить.
Порядок такой: посетитель нажимает «Отправить» → браузер проверяет required → выполняется обработчик, строки 14–28 → браузер отправляет POST.
Как в жизниПеред отправкой письма сотрудник делает на бланке последние отметки.
↓ ещё ниже
6 / 10Строки 14–19
Условие проверяет адрес из action регулярным выражением. Адрес должен начинаться с https://script.google.com/macros/s/ и заканчиваться на /exec. Если в action осталось ВАШ_APPS_SCRIPT_URL или вставлен адрес, который заканчивается на /dev, условие сработает.
Тогда event.preventDefault() отменяет отправку, под формой появляется подсказка «Сначала укажите URL…», а return завершает обработчик.
Проверка ловит пропущенный шаг 20: без неё форма ушла бы по неправильному адресу, и заявка пропала бы без всякого сообщения.
Как в жизниПеред тем как опустить письмо в ящик, проверяют, что на конверте настоящий адрес.
↓ ещё ниже
7 / 10Строки 20–21
crypto.randomUUID() — встроенная функция браузера. Она возвращает UUID: случайную строку из 32 шестнадцатеричных цифр в пяти группах через дефис. Вероятность, что два UUID совпадут, настолько мала, что на практике её не учитывают. toUpperCase() делает буквы заглавными, спереди добавляется REQ-.
Условие if (!requestId.value) создаёт номер, только если поле пустое. Если посетитель нажал «Отправить» второй раз, номер останется прежним. Code.gs найдёт его в столбце A и ответит duplicate_request_id, и заявка не запишется дважды.
Номер строки таблицы на роль номера заявки не подходит. Строки сортируют и удаляют, и после этого в строке 15 окажется другая заявка. UUID закреплён за заявкой навсегда.
crypto.randomUUID() → '3f7a81d2-9ae5-4305-b6a1-095b91d9c278' requestId.value → 'REQ-3F7A81D2-9AE5-4305-B6A1-095B91D9C278'
Как в жизниТалон электронной очереди: один номер на одного посетителя. Если нажать кнопку второй раз, новый талон не выдают.
↓ ещё ниже
8 / 10Строки 22–24
typeof window.gtag === 'function' проверяет, что Google Tag загрузился. Если его остановил блокировщик рекламы, функции gtag нет. Без проверки строка 23 вызвала бы ошибку, и обработчик остановился бы.
Событие то же, что в теме 6: generate_lead с параметром lead_source. Имя и email в GA4 не передаются: правила Google Analytics запрещают отправлять туда персональные данные.
Событие уходит в момент нажатия и означает попытку отправки. Сохранилась ли заявка, видно только в таблице.
Как в жизниСчётчик у двери щёлкает, когда человек подходит к стойке. Приняли ли его бланк, счётчик не знает.
↓ ещё ниже
9 / 10Строки 25–29
textContent записывает текст в абзац form-status. Посетитель видит номер своей заявки, по этому номеру строку потом ищут в таблице.
На этом обработчик заканчивается. preventDefault() на этом пути не вызывается, поэтому браузер продолжает обычную отправку: POST на адрес из action, ответ — в скрытую рамку.
Как в жизниПосетителю называют номер талона, и письмо уходит.
↓ ещё ниже
10 / 10Строки 30–38
leadForm.addEventListener('reset', …)setTimeout(() => { … }, 0)reset наступает до того, как браузер вернёт поля к исходным значениям, поэтому код внутри выполняется после очистки.requestId.value = ''for (…) { … }textContent = 'Можно заполнить новую заявку.'Как в жизниЧистый бланк и новый талон.
↓ ещё ниже
Откройте:
https://ВАШ-САЙТ.vercel.app/contacts.html
?utm_source=telegram
&utm_medium=social
&utm_campaign=lesson07JavaScript должен получить:
telegram
social
lesson07Иконка Source Control→Проверить список изменённых файлов
Сохраните:
contacts.html
js/script.jsПроверьте Source Control.
Должно получитьсяВ Source Control перечислены ровно два изменённых файла с пометкой M. Третьего файла быть не должно.
Ввести сообщение коммита→Commit→Sync Changes
Commit:
Add Google Sheets lead collectionПосле этого:
Commit
↓
Sync Changes / Push
Должно получитьсяКоммит уходит в GitHub, Vercel начинает сборку, и в Deployments появляется новая строка с вашим сообщением.
Откройте свой проект на Vercel и убедитесь, что последний deployment — это ваш коммит.
ОТКРЫТЬ В НОВОЙ ВКЛАДКЕvercel.com/dashboardProject → Deployments → статус ReadyAdd Google Sheets lead collectionОткройте сайт обычной ссылкой:
https://ВАШ-САЙТ.vercel.app/contacts.htmlЗаполните тестовыми данными:
Иван Тестов
student@example.com
FrontendНажмите:
Отправить заявку
Найдите новую строку на листе leads. Сверьте ID из сообщения под формой.
REQ-… | дата и время | Иван Тестов
student@example.com | frontend
direct | none | not_set | newНайдите:
request_id
created_at
statusЭти поля посетитель не заполнял: номер создал браузер, время и статус — Apps Script. Такие данные называют системными.
Откройте:
https://ВАШ-САЙТ.vercel.app/contacts.html
?utm_source=telegram
&utm_medium=social
&utm_campaign=lesson07Отправьте ещё одну тестовую заявку.
В новой строке должны появиться:
utm_source = telegram
utm_medium = social
utm_campaign = lesson07Так заявка связана с рекламной ссылкой, по которой пришёл посетитель.
Откройте Realtime или DebugView и найдите generate_lead после теста формы.
Есть событие попытки отправки.
Есть строка с теми же данными и нужными UTM.
В GA4:
generate_lead
utm_source
campaign
страницаВ Google Sheets:
request_id
created_at
name
email
direction
utm_source
utm_medium
utm_campaign
statusУдалите значение name.
Попробуйте отправить форму.
HTML required должен остановить отправку до обращения к Apps Script.
Локальная модель — в Google ничего не отправляется.
Скопируйте одну корректную строку Google Sheets и вставьте её ниже без изменения request_id.
Conditional Formatting должен выделить одинаковые ID.
Так выглядит проблема: одна заявка записана дважды.
Code.gsстроки 32–34
Повторный POST с тем же ID не должен добавлять строку. На прошлом шаге строку копировали вручную, а здесь повтор приходит от формы.
{
"ok": false,
"error": "duplicate_request_id"
}createTextFinder(id)matchEntireCell(true)Если нашёлсяВ тестовой строке попробуйте установить:
finishedвместо допустимых значений.
Data Validation должна показать ошибку или отклонить ввод.
Допустимый словарь:
new
in_progress
done
rejectedКлик по ячейке status→Клик по стрелке→Выбрать значение
Откройте dropdown одной тестовой заявки. Переключите статус по порядку: new, in_progress, done.
У контролируемого источника данных есть:
Проблемный набор:
id status source
1 new Telegram
2 готово tg
2 NEW telegram
done
5 закончил ТелегаПроблемы:
request_id | source | status
REQ-001 | telegram | new
REQ-002 | telegram | done
REQ-003 | vk | in_progressДанные становятся пригодны для:
generate_leadСобытие попытки отправки.
Без имени и email.
request_id · status · UTMЗапись после проверки.
Имя и email — только здесь.
Наш Apps Script — простой приёмник заявок.
Мы пока не разбираем:
POST отправляется в скрытый iframe. Страница пока не обрабатывает JSON-ответ.
В ответе Apps Script — ok и error.
Подтверждения успешного сохранения в интерфейсе формы.
До сегодняшнего занятия мы могли искусственно создать CSV.
Теперь CSV можно получить из реальной таблицы заявок.
Поэтому в следующей теме работаем с собственным набором данных.
Со временем появятся:
Следующая задача:
повторить те же преобразования на новой версии данных нажатием:
Refresh
generate_leadСобытие попытки отправки.
Без имени и email.
request_id · status · UTMЗапись после проверки.
Имя и email — только здесь.
Следующая тема: повторяемая очистка вместо ручного исправления.