П/ВИН

Коммерческое предложение на оказание услуг: генератор КП

·10 мин чтения

Час-два моей жизни уходило на каждое коммерческое предложение на оказание услуг, которое я собирал вручную после диагностического созвона: перечитать транскрипт, выудить из него боли бизнеса, свести «Итоги диагностики», построить график по ответам клиента и упаковать всё это в аккуратный PDF - и так каждый раз заново. В какой-то момент я решил, что хватит, и собрал внутренний back-office инструмент v0-german, который превращает сырой транскрипт встречи и табличку ответов в готовое КП за пару минут. Дальше - как устроен этот пайплайн, какие грабли я собрал по пути и почему рендер PDF в итоге уехал на отдельный сервер.

Что это вообще за проект

v0-german — это Next.js-приложение (App Router, React 19, TypeScript) с пошаговым визардом, который ведёт менеджера от «у меня есть запись созвона» до «вот готовый PDF, отправляй клиенту». Под капотом — связка из нескольких сервисов: сам Next.js отвечает за UI и оркестрацию, Supabase хранит данные, GPT-4o занимается извлечением смысла из текста, а отдельный микросервис kp-renderer рендерит финальные документы в PDF и PNG.

Визард состоит из шести шагов:

  1. Загрузка данных о клиенте
  2. Подгрузка транскрибации созвона (и, как выяснилось позже, не только её)
  3. build-kp — структурирование КП через GPT-4o
  4. build-slide-plan — план слайдов презентации
  5. create-presentation — рендер 14 слайдов в PDF/PNG
  6. Финальная сборка и выдача документов

Ключевая архитектурная развилка здесь — это разделение ответственности между Next.js и kp-renderer. Next.js хорошо умеет в API-роуты, серверную логику и работу с LLM, но плохо — в пиксельно-точный рендер сложной вёрстки в PDF. Поэтому всё, что касается «нарисовать красиво и превратить в файл», мы вынесли в отдельный сервис на Playwright с Handlebars-шаблонами. Next.js-роут kp/create-presentation просто проксирует запрос туда, передавая структурированный контент, а получает обратно готовый бинарник. Такое разделение спасает: фронтенд не тащит за собой headless-браузер, а рендерер можно перезапускать и масштабировать независимо.

Проблема: «Итоги диагностики» без цифр выглядят неубедительно

Основной запрос, с которого началась большая доработка, звучал так: в документ «Итоги диагностики» после блока с проблемами нужно вставлять диаграмму. У клиента есть табличка (CSV или Excel) с ответами на вопросы диагностики, и по ней строится график, который всегда содержит одни и те же сегменты — пять фиксированных блоков. Идея простая и правильная: текстовое описание болей убеждает слабее, чем график, на котором видно, что «Производство» проседает на 20%, а «Продажи» — на 60%.

Тут сразу всплыло несколько неочевидных решений, которые пришлось проговорить до начала кода.

Как рендерить график в PDF? У нас было два пути. Первый — нарисовать диаграмму нативно прямо в шаблоне PDF через примитивы вёрстки: пять горизонтальных полос, цветные зоны, проценты. Быстро, детерминированно, полностью в брендовых стилях, ноль внешних зависимостей. Второй — отрендерить HTML с Chart.js через kp-renderer, получить PNG и вставить картинкой. Здесь плюс в том, что результат — пиксель в пиксель тот, что заказчик уже видел в своём HTML-прототипе.

Мы выбрали второй вариант. Логика была такая: раз клиент уже видел конкретный график и он ему нравится — нужно отдать именно его, а не «похожий». Тем более что «Итоги диагностики» и так рендерятся через kp-renderer пятью HTML-шаблонами, а значит Chart.js из CDN там в принципе заведётся, и прототип встанет почти один в один.

Фиксированные сегменты. Отдельно договорились, что блоков всегда строго пять, и «Производство» присутствует на графике всегда — даже если по нему ноль ответов, тогда просто рисуем ноль. Это важная деталь: график должен быть сопоставимым между разными клиентами, иначе теряется весь смысл визуализации. Если у одного клиента четыре блока, а у другого шесть — их уже не сравнить глазами.

Что если файла нет? Договорились на graceful degradation: если CSV не загружен, секция с диаграммой просто не появляется в документе, и пайплайн идёт дальше без ошибок. Опциональность — это всегда правильный дефолт для дополнительных данных.

Решение: парсер с фиксированной схемой и отдельная страница в рендерере

На стороне Next.js появилось несколько новых файлов. Сердце — парсер CSV с жёстко зашитыми пятью блоками:

// lib/generator/diagnostic-scores.ts
const FIXED_SEGMENTS = [
  "Стратегия",
  "Маркетинг",
  "Продажи",
  "Финансы",
  "Производство",
] as const;
 
export type DiagnosticScore = {
  name: string;
  count: number;
  percent: number;
};
 
export function parseDiagnosticScores(rows: string[][]): DiagnosticScore[] {
  const tally = new Map<string, number>();
  // заранее проставляем все пять блоков нулями
  for (const seg of FIXED_SEGMENTS) tally.set(seg, 0);
 
  for (const row of rows) {
    const segment = normalize(row);
    if (tally.has(segment)) {
      tally.set(segment, (tally.get(segment) ?? 0) + 1);
    }
  }
 
  const total = [...tally.values()].reduce((a, b) => a + b, 0) || 1;
  // порядок гарантирован порядком FIXED_SEGMENTS
  return FIXED_SEGMENTS.map((name) => {
    const count = tally.get(name) ?? 0;
    return { name, count, percent: Math.round((count / total) * 100) };
  });
}

Ключевой приём здесь — преднаполнение Map нулями до того, как мы начнём считать строки. Это автоматически решает проблему «Производство всегда на графике»: даже если в CSV про него ни строчки, в результат он попадёт со значением ноль. И порядок блоков детерминирован, потому что итоговый массив мы собираем не из Map, а проходом по константе FIXED_SEGMENTS.

Дальше — API-роут, который принимает файл через multipart/form-data и возвращает посчитанные scores:

// app/api/generator/parse-diagnostic-csv/route.ts
export async function POST(req: Request) {
  const form = await req.formData();
  const file = form.get("file");
  if (!(file instanceof File)) {
    return Response.json({ scores: null }, { status: 200 });
  }
 
  const text = await file.text(); // UTF-8 по умолчанию
  const rows = parseCsv(text);
  const scores = parseDiagnosticScores(rows);
  return Response.json({ scores });
}

По кодировке мы взяли UTF-8 дефолтом — клиенты обычно выгружают CSV из Google Sheets или сохраняют из Excel как «CSV UTF-8», так что это покрывает подавляющее большинство случаев.

В контекст визарда (kp-context.tsx) добавилось новое поле diagnosticScores, которое сбрасывается при смене клиента и сохраняется в сессии — чтобы менеджер мог уйти на другой шаг и вернуться, не потеряв загруженную табличку. А в компоненте шага транскрипции появился опциональный загрузчик CSV с превью: после загрузки сразу видно пять строк с процентами, так что ошибку в данных можно заметить до генерации.

На стороне kp-renderer добавился новый Handlebars-шаблон diagnostic-page-02b-chart.html. Он получает массив diagnostic_scores и рисует диаграмму через Chart.js из CDN — с теми же зонами, стилями и datalabels, что были в исходном прототипе. Имя 02b выбрано не случайно: сортировка шаблонов по имени гарантирует, что страница с графиком встанет ровно после «Ключевых проблем» (02). Отдельно пришлось добавить skip-логику (нет данных — нет страницы) и ожидание готовности графика: Chart.js рисует асинхронно, и без явного «chart-ready wait» Playwright успевал сделать снимок раньше, чем диаграмма отрисуется, и в PDF попадал пустой прямоугольник.

Не только КП: автоматизация лидов

Параллельно с генератором в проект въехала вторая большая фича — автоматизация входящих заявок. Сценарий бизнесовый: человек на лендинге нажимает «получить 20 файлов», заполняет форму (имя, телефон, email, роль в бизнесе, количество сотрудников) и попадает на страницу благодарности. Дальше всё должно происходить само.

Воронки по мессенджерам (Telegram, MAX, VK) клиент уже собрал через no-code конструктор, так что наша часть свелась к двум вещам: сохранить лида и отправить ему письмо со ссылкой на материалы. Архитектура получилась простой:

Tilda форма → webhook → v0-german API
                          ├─→ Supabase (таблица leads)
                          └─→ Brevo (письмо со ссылкой на 20 файлов)

В Supabase завели две таблицы — leads и lead_events, вторая под трекинг событий (открытия письма, клики). Для отправки писем подключили Brevo через официальный SDK. По пути выяснилось, что у Brevo вышло новое API v5, так что утилиту lib/email.ts пришлось переписать под новые сигнатуры — классический случай, когда копируешь рабочий код из соседнего проекта, а он уже устарел.

Интересная деталь — самостоятельный трекинг писем. Вместо того чтобы целиком полагаться на вебхуки Brevo, мы сделали свой open-pixel (прозрачная картинка-маячок в письме) и click-redirect (ссылки в письме ведут через наш роут, который пишет событие в lead_events и редиректит на целевой URL). Это даёт независимый от провайдера слой аналитики: даже если что-то изменится в Brevo, мы продолжим видеть, кто открыл письмо и по какой кнопке кликнул. Поверх этого позже добавился day-1 follow-up — напоминание забрать файлы на следующий день, если человек так и не пришёл, и еженедельный отчёт по воронке в Telegram каждую пятницу.

Дизайн-система v6.0

Третьим пластом работы стало внедрение новой дизайн-системы на публичный сайт. Заказчик прислал готовую систему — токены палитры, типографику, набор компонентов — и задача была развернуть её на весь сайт. Мы переписали globals.css под новые токены (paper/ink/accent/dust/gray), подключили шрифты Manrope, Newsreader и JetBrains Mono через next/font, собрали shared-компоненты (header, footer, page-intro, секции, кнопки, кастомный курсор, эффекты появления при скролле) и прогнали по новому визуальному языку все внутренние страницы — от главной и каталога программ до глоссария, кейсов и юридических страниц.

Здесь сработал хороший приём для безопасной выкатки: пока сайт допиливается, его повесили на staging-домен (site.germanyun.ru), чтобы заказчик мог трогать вживую, не затрагивая прод. Правда, тут же выяснилось, что DNS поддомена ещё указывает на старый сервер, из-за чего Let's Encrypt не мог выпустить сертификат — типичная история, когда инфраструктурная мелочь блокирует демонстрацию готовой работы.

Результат

Главная метрика — время. Раньше сборка одного КП с «Итогами диагностики» занимала час-полтора ручной работы: прочитать транскрипт, выписать боли, построить график в Excel, сверстать документ. Теперь менеджер загружает транскрипт и CSV, проходит шесть шагов визарда и получает готовый PDF за пару минут. Диаграмма строится автоматически по фиксированной схеме, что делает документы сопоставимыми между клиентами и убирает разнобой в оформлении.

Лид-автоматизация закрыла ручную рассылку: заявка с лендинга теперь сама долетает до Supabase и до почты клиента, а собственный трекинг открытий и кликов даёт прозрачную картину воронки без зависимости от чужих дашбордов. По сборке всё прошло чисто — npm run build без ошибок, деплой через GitHub Actions отработал успешно, kp-renderer после рестарта отдаёт /health → ok.

Выводы

Первый и главный урок — разделяйте генерацию данных и рендер представления. Next.js отлично оркеструет логику и работает с LLM, но пиксельно-точный PDF лучше отдать отдельному сервису на Playwright. Это разделение не только архитектурно чище, оно ещё и спасает в эксплуатации: рендерер можно перезапускать, обновлять шаблоны и масштабировать, не трогая основное приложение и не таща headless-браузер в каждый деплой фронтенда.

Второй урок — фиксированная схема данных бьёт гибкость, когда речь о сравнимости. Соблазн «парсить любой CSV и рисовать сколько найдётся блоков» велик, но именно жёстко зашитые пять сегментов с преднаполнением нулями делают графики осмысленными. Бизнес-ценность диаграммы — в том, что её можно сравнить с диаграммой другого клиента, а это возможно только при одинаковых осях. Иногда правильное решение — это меньше гибкости, а не больше.

Третий урок — про graceful degradation как дефолт. CSV не загружен — секция просто не появляется, а не падает весь пайплайн. Письмо не доставилось — лид всё равно сохранён в базе. Дополнительные данные должны быть опциональными, а основной путь обязан работать всегда. Это снижает количество звонков «у меня всё сломалось» на порядок.

Четвёртый, инфраструктурный урок — асинхронный рендер требует явного ожидания готовности. Chart.js рисует диаграмму не мгновенно, и без «chart-ready wait» Playwright снимает скриншот раньше времени и кладёт в PDF пустоту. Любой рендер, который зависит от асинхронной отрисовки на клиенте, нужно дожидаться явным сигналом, а не таймаутом наугад. И отдельно — собственный слой трекинга (open-pixel + click-redirect) поверх провайдерского даёт независимость, которая однажды окупится, когда у внешнего сервиса в очередной раз сменится API.

И последнее: даже готовую фичу можно заблокировать на ровном месте мелочью вроде DNS-записи, указывающей не туда. Демо-домен, сертификат, прокси — это часть фичи, а не «потом настроим». Закладывайте время на выкатку, а не только на код.

Полезные ссылки

  • Next.js App Router — документация по роутингу и API-роутам
  • Playwright — headless-браузер для рендера PDF
  • Chart.js — библиотека графиков
  • Handlebars — шаблонизатор для HTML
  • Brevo API — транзакционная почта
  • Supabase — база данных и хранилище
Паша Вин
Паша Вин

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.

Связанный проект

CJM Designer