← К презентации

ТЕМА 07 / МАТЕРИАЛЫ

Из события — в заявку

Готовые фрагменты для существующего проекта темы 6. Перед публикацией подставьте ID таблицы и URL Web App.

Скачать все файлы одним архивом ↓

Code.gs

Скачать ↓
Посмотреть код
Code.gs
// Укажите 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);
}

script.js

Скачать ↓
Посмотреть код
script.js
// ЗАМЕНИТЕ этим блоком старый 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);
  });
})();

// 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'
      });
    }
  });
}

lead-form.js

Скачать ↓
Посмотреть код
lead-form.js
// ЗАМЕНИТЕ этим блоком старый 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);
  });
})();

form.html

Скачать ↓
Посмотреть код
form.html
<!-- Замените существующую форму. Google Tag в head страницы сохраните. -->
<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 type="submit">Отправить заявку</button>
  <button type="reset">Новая заявка</button>
  <p id="form-status" role="status"></p>
</form>
<iframe name="submission-frame" id="submission-frame" title="Ответ приёмника заявок" hidden></iframe>

Разбор кода

Что делает каждый блок и почему он написан именно так.

Code.gs · приёмник заявок

Константы вверху файла

const SPREADSHEET_ID = '…';
const HEADERS = ['request_id', 'created_at', …];

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 убирает пробелы по краям. Без этого « Иван » и «Иван» считались бы разными значениями, а отсутствующее поле роняло бы скрипт.

Обязательные поля

const missing = ['request_id', 'name', 'email', 'direction'].filter(key => !clean(key));
if (missing.length) return jsonResponse({ok:false, error:'missing_required', fields:missing});

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('|')) …

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

Поиск дубля

const duplicate = sheet.getRange('A:A').createTextFinder(id)
  .matchEntireCell(true).matchCase(false).findNext();

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: что бы ни прислал клиент, поле игнорируется.

Запись строки

sheet.appendRow([id, createdAt, textCell(clean('name')), …]);
SpreadsheetApp.flush();

appendRow дописывает строку сразу после последней заполненной — искать свободное место не нужно. Порядок значений в массиве совпадает со столбцами A–I; переставите местами — данные уедут в чужие колонки. flush заставляет Google записать изменения немедленно, до снятия блокировки.

Защита от формул

function textCell(value) {
  return /^[=+@-]/.test(value) ? "'" + value : value;
}

Google Sheets считает формулой всё, что начинается с =, +, - или @. Если посетитель отправит имя вида =1+1, таблица посчитает его выражением. Апостроф в начале заставляет Sheets воспринимать значение как текст.

Обработка ошибок

catch (error) {
  return jsonResponse({ok:false, error:'storage_error'});
} finally {
  lock.releaseLock();
}

Клиенту возвращается только общий код ошибки: подробности о таблице и содержимом заявки наружу не уходят. Блок finally снимает замок в любом случае — иначе после первой же ошибки приёмник завис бы для всех следующих заявок.

Формат ответа

return ContentService.createTextOutput(JSON.stringify(data))
  .setMimeType(ContentService.MimeType.JSON);

Web App умеет отдавать только текст, поэтому объект превращается в строку через JSON.stringify. setMimeType помечает ответ как JSON — без этого браузер счёл бы его обычным текстом.

js/script.js · форма на сайте

Изолирующая функция

(() => {
  const leadForm = document.querySelector('#lead-form');
  if (!leadForm) return;
  …
})();

Весь код формы завёрнут в функцию, которая выполняется сразу. Её переменные не видны снаружи, поэтому имя 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, и таблица отклонит повтор.

Событие GA4

if (typeof gtag === 'function') {
  gtag('event', 'generate_lead', {lead_source: 'contact_form'});
}

Проверка 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. При отсутствии кадра остаётся честно подписанная рамка.

СлайдФайлЧто снятьСтатус
5 / 264264-shot.pngГотовый лист leads с двумя заявками. Результат всех восьми этапов занятия.Снят
11 / 180180-shot.pngГлавная страница Google Sheets. Кнопка: Blank spreadsheet / Пустая таблицаСнят
12 / 181181-shot.pngGoogle Sheets. Выделить название документа. Снят
13 / 182182-shot.pngНижняя вкладка листа. leadsСнят
14 / 183183-shot.pngПервая строка Google Sheets со всеми заголовками. Снят
15 / 265265-shot.pngGoogle Sheets с раскрытым меню Вид и подменю Закрепить. Вид → Закрепить → 1 строкуСнят
16 / 272272-shot.pngПрокрученный лист leads с закреплённой первой строкой. Серая полоса под строкой 1Снят
17 / 266266-shot.pngМеню Формат → Числа с пунктом даты и времени. Выделен весь столбец BСнят
18 / 267267-shot.pngДиалог изменения размера столбцов с выбранным автоподбором. Автоподбор размераСнят
24 / 189189-shot.pngGoogle Sheets → Data validation rules. Dropdown и четыре значения.Снят
26 / 191191-shot.pngПопытка ввести invalid status. Снят
28 / 193193-shot.pngFormat → Conditional formatting. Показать правило для пустой ячейки. Снят
29 / 270270-shot.pngЛист leads с красной подсветкой неполной строки. Подсвечивается вся строка A–IСнят
30 / 268268-shot.pngВыделенный столбец A2:A1000 и открытая панель условного форматирования. Сначала выделение в сетке, потом «Добавить правило»Снят
31 / 194194-shot.pngConditional formatting rule. Custom formula.Снят
32 / 269269-shot.pngПанель условного форматирования со списком из двух правил. Разные диапазоны у разных правилСнят
33 / 271271-shot.pngЛист leads: красная неполная строка и оранжевый дубль ID. Красная строка и оранжевая ячейкаСнят
38 / 199199-shot.pngGoogle Sheets с раскрытым меню Extensions. Apps Script.Снят
39 / 200200-shot.pngApps Script Editor. Слева Code.gs. Снят
40 / 212212-shot.pngApps Script Editor с итоговым кодом. Снят
41 / 273273-shot.pngАдресная строка браузера с открытой таблицей. Между /d/ и /editСнят
42 / 202202-shot.pngНачало файла Code.gs с константой SPREADSHEET_ID. Снят
44 / 213213-shot.pngНазвание проекта и кнопка Save. Снят
45 / 214214-shot.pngApps Script с раскрытым Deploy. Снят
46 / 215215-shot.pngNew deployment → Select type → Web app. Снят
47 / 216216-shot.pngDeployment configuration. Показать: Description Execute as Who has access Снят
48 / 217217-shot.pngЭкран authorization. Не показывать личные данные аккаунта крупным планом. Снят
49 / 218218-shot.pngDeployment completed. Web app URL.Снят
51 / 220220-shot.pngVS Code Explorer. Снят
52 / 221221-shot.pngVS Code → contacts.html. Снят
55 / 229229-shot.pngБраузер с полной UTM-ссылкой в адресной строке. Снят
56 / 235235-shot.pngVS Code Source Control с двумя изменёнными файлами. Снят
57 / 236236-shot.pngSource Control с commit message. Снят
58 / 237237-shot.pngVercel Deployments. Снят
59 / 238238-shot.pngЗаполненная форма перед отправкой. Снят
60 / 239239-shot.pngПервая реальная тестовая строка в Google Sheets. Снят
61 / 240240-shot.pngGoogle Sheet. request_id + created_at + status.Снят
62 / 241241-shot.pngБраузер с UTM URL. Снят
63 / 242242-shot.pngGoogle Sheet. Три UTM-столбца.Снят
64 / 243243-shot.pngGA4 Realtime / DebugView с generate_lead. Снят
66 / 245245-shot.pngBrowser validation message около пустого поля. Снят
68 / 247247-shot.pngДве строки с одинаковым request_id. Повторяющиеся ID.Снят
70 / 249249-shot.pngОшибка Data Validation. Снят
71 / 250250-shot.pngDropdown status. Снят