П/ВИН

Не работает промокод на первый заказ: фикс UX в WebGPT

·8 мин чтения

Юзер кликает в письме по баннеру с промокодом и попадает на webgpt.ru, а поле для ввода найти не может - классический сценарий "не работает промокод на первый заказ", только сломали его мы сами. WebGPT - это наша обёртка над LobeChat, русскоязычный AI-агрегатор с десятками моделей в одном чате и подпиской через ЮKassa. За последние пару недель я прошёлся по всему пути пользователя, от клика в письме до выбора локальной модели в чате, и закрыл три накопившихся UX-косяка, которые ели конверсию: спрятанный промокод, чужой логотип в шапке и битый canonical на /policy лендинга.

Контекст: что такое WebGPT и почему мы его форкнули

WebGPT — production-инстанс, поднятый на базе LobeChat. Lobe мы выбрали за модульную архитектуру провайдеров (@lobehub/icons, пакет model-runtime, router-runtime для мульти-апстримов) и готовый чат-UI с поддержкой картинок, артефактов и плагинов. Поверх Lobe мы прикрутили свой биллинг (src/business/client/BusinessSettingPages/Plans.tsx), интеграцию с ЮKassa и роутер моделей через OpenRouter — чтобы пользователю не нужно было держать API-ключи к OpenAI, Anthropic и Google по отдельности.

Когда продукт растёт, начинают вылезать мелкие, но болезненные расхождения между тем, что мы знаем как разработчики, и тем, что видит пользователь. Именно три таких расхождения и пришлось закрывать.

Проблема №1: промокод спрятан в «модале восстановления»

Мы запустили email-рассылку с промокодом для возврата ушедших пользователей. Письмо вело на страницу тарифов /business/plans. Пользователь пишет в поддержку:

«Я перешёл по ссылке, и я что-то не понял — нигде вводить промокод, по факту в тарифах. По сути, перешёл, и мне непонятно, что я что-то получу, какой-то промокод, какие-то кредиты».

Открываю Plans.tsx — поле для ввода промокода действительно есть. Но оно живёт внутри recoveryModal (строки 344–348), а сам модал поднимается только если у пользователя есть отменённый или зафейленный платёж в ЮKassa. У свежего юзера, который пришёл из письма, никаких отменённых платежей нет → модал не открывается → промокод ввести некуда. Классический случай, когда фича есть в коде, но её нельзя дотронуться руками.

Решение: промо-блок наверх и автозаполнение из URL

Вытащил поле ввода из модала в отдельный блок над тарифами:

<Card style={{ marginBottom: 24 }}>
  <Title level={4}>У вас есть промокод?</Title>
  <Input.Search
    enterButton="Применить"
    placeholder="Введите промокод"
    value={promoInput}
    onChange={(e) => setPromoInput(e.target.value)}
    onSearch={handleRedeem}
    loading={redeeming}
    size="large"
  />
</Card>

Это раз. Второе — сделал так, чтобы пользователю вообще не нужно было ничего вводить руками. Каждое письмо рассылки уже знает свой promo_code, поэтому в ссылку из письма зашили параметр ?ref=<code>. На клиенте при монтировании страницы читаем его и пред-заполняем поле:

useEffect(() => {
  const ref = new URLSearchParams(window.location.search).get('ref');
  if (ref && !promoInput) {
    setPromoInput(ref);
  }
}, []);

Сценарий теперь: пользователь кликнул в письме → попал на тарифы → поле уже заполнено → нажал «Применить» → получил кредиты. Два клика вместо одного перехода в поддержку.

Заодно подправил HTML самой рассылки в таблице broadcast_campaigns: добавил недостающий P.S. со ссылкой на Telegram-канал @webgpt_ru — это была вторая претензия пользователя из обратной связи. Сделал прямо в Postgres:

UPDATE broadcast_campaigns
SET email_body_html = REPLACE(
  email_body_html,
  '<p>P.S. ...',
  '<p>P.S. Telegram-канал @webgpt_ru ...'
);

Знаю, что хотфикс через REPLACE в проде — не идеальная практика, но рассылка уже была в очереди, и быстрее было поправить шаблон точечно, чем гонять полный re-render через CMS.

Проблема №2: local-модели «висят сбоку», и в шапке чужой логотип

Параллельная история. Мы добавили поддержку локальных моделей (Ollama-стиль), но изначально они оказались отдельным списком где-то в настройках провайдеров — пользователь должен был сам выбирать «ага, сейчас я хочу не GPT, а локальную модель», переключаться куда-то ещё, копировать имя модели. Это полностью ломало главную идею WebGPT — «одно окно, все модели».

Пользователь сформулировал чётко:

«Не совсем так, у нас они должны быть внутри WebGPT, где и другие модели, просто должна быть пометка local. И также надо заменить логотип webgpt на наш — там сейчас старый логотип Lobe».

Полез разбираться в архитектуру провайдеров LobeChat. У них всё крутится вокруг createRouterRuntime из packages/model-runtime. Корневой LobeHub-провайдер (packages/model-runtime/src/providers/lobehub/index.ts) — это диспетчер, который смотрит на ID модели и выбирает апстрим: чат-модели идут через OpenRouter, image-модели — на другой бэкенд. Чтобы добавить локальные модели как ещё одну «ветку» этого роутера, нужно зарегистрировать их в lobehubRouterRuntimeOptions и протащить метаданные (бейдж local) до компонента ModelSelect.

С логотипом ситуация веселее. Логотип «LobeHub» в шапке рендерится через <ProviderIcon provider="lobehub" /> из пакета @lobehub/icons. Сам компонент ModelSelect (src/components/ModelSelect/index.tsx, строка 340) импортирует LobeHub, ModelIcon и ProviderIcon из этого пакета. То есть логотип не лежит в нашем public/, он зашит в npm-пакет и подменяется на SVG в рантайме.

Найти все точки, где Lobe-айкон утекал в UI, помог банальный grep:

grep -rln "ProviderIcon\|LobeHub" src/ | head

Десять файлов: ModelSelect, InvalidAPIKey, community-страницы, settings. По каждому надо было либо переопределить иконку через свой provider="webgpt" (если иконка есть в нашем форке @lobehub/icons), либо подсунуть свой SVG через override-маппинг.

Решение: переопределение провайдера и бейдж «local»

Подход разделили на два слоя.

Слой 1 — runtime-регистрация локальных моделей. В lobehubRouterRuntimeOptions добавили ветку для моделей с префиксом local/. Они ходят не в OpenRouter, а на наш внутренний эндпоинт. Метаданные модели теперь содержат флаг isLocal: true, который пробрасывается в ModelSelect.

Слой 2 — UI-бейдж. В рендерере списка моделей добавили условный лейбл рядом с именем:

{model.isLocal && (
  <Tag color="blue" style={{ marginLeft: 8 }}>local</Tag>
)}

Никаких отдельных вкладок, никаких «переключений провайдера» — модели просто появляются в общем списке, с понятной пометкой, что они крутятся локально. Это и есть тот UX, ради которого мы вообще брали Lobe.

С логотипом сделали override-маппинг для ProviderIcon: при provider === "lobehub" отдаём свой SVG WebGPT. Альтернатива — форкать @lobehub/icons и публиковать в свой npm-репозиторий — показалась перебором: пакет апдейтится часто, мы быстро отстанем от апстрима. Override через wrapper-компонент проще и легче в поддержке.

Проблема №3: SEO на лендинге — битый canonical

Третья история — про маркетинг-сурфейс. На лендинге webgpt.ru шёл SEO-аудит, и автоматический отчёт нашёл одну критическую ошибку: страница /policy (правовые документы) выдаёт canonical-URL на главную:

<link rel="canonical" href="https://webgpt.ru/" />

То есть Google думает, что /policy — это дубликат главной. Эффект: страница вообще не индексируется по своим запросам, и при этом главная получает «размытый» сигнал.

Причина прозаична: в app/layout.tsx (Next.js App Router) объявлен корневой alternates.canonical: "/", а app/policy/page.tsx его не переопределял. В Next.js metadata-объекты мерджатся каскадом, и если ребёнок не перекрыл поле — берётся родительское значение. Подробнее это описано в Next.js Metadata API docs.

Фикс — одна строчка в app/policy/page.tsx:

export const metadata: Metadata = {
  title: 'Политика конфиденциальности — WebGPT',
  description: '...',
  alternates: {
    canonical: '/policy',
  },
};

Запустил next build — страница пререндерится как статическая, canonical теперь корректный. Закоммитил, оставил CI прогнать остальные проверки. Это маленький, но кристально однозначный фикс: ровно одна правка, ровно одна проблема, никакой регрессии.

Что получилось в сумме

Три на первый взгляд несвязанные истории — а на самом деле один и тот же паттерн: продукт виден пользователю целиком, и любая дырка в любом из слоёв ломает воронку. Промокод, который существует в коде, но не виден в UI, — это потерянный платящий клиент. Чужой логотип в шапке — это размытие бренда на каждом скриншоте, который пользователь скинет в чат. Битый canonical на правовой странице — это потерянный SEO-трафик и небольшое, но реальное наказание главной страницы.

Метрики, которые мы будем смотреть в следующие пару недель:

  • Активация промокодов из писем: ожидаем рост в 3–5 раз после того, как поле стало видимым и autofill-ится из ?ref=.
  • Брендовые запросы «webgpt»: должны вырасти, потому что в новых скриншотах от пользователей теперь наш логотип, а не lobehub.
  • Индексация /policy в Google Search Console: ожидаем переход страницы из «Excluded — Duplicate» в «Indexed» в течение 2–3 недель.

Выводы

Первое — фича без видимого UI не существует. Мы потеряли несколько недель потенциальных активаций, потому что промокод формально «был», но добраться до него мог только пользователь с зафейленным платежом. Этот класс ошибок ловится только feedback-loop'ом: реальным письмом от реального пользователя. Никакой unit-тест и никакой Storybook не подскажет, что блок виден только в редком state. Урок — при запуске любой кампании пройти весь путь как обычный новый пользователь, не залогиненный, без истории.

Второе — white-label всегда дороже, чем кажется. Когда форкаешь продукт уровня LobeChat, ты получаешь не только функционал, но и брендинг этого продукта, зашитый в десяти местах: иконки, дефолтные тексты, имена провайдеров, ссылки на их GitHub в футере. Регулярно делайте grep по имени родительского продукта в кодовой базе — это самый дешёвый аудит на «не утекает ли чужой бренд». Один раз в месяц прогнать grep -ri "lobehub" src/ сильно дешевле, чем потом по одному отлавливать жалобы.

Третье — Next.js metadata-каскад — это feature, но и грабли. Удобно объявить canonical раз в layout.tsx и забыть. Но любая дочерняя страница, которая забыла переопределить, унаследует канонический URL родителя — и для статических страниц вроде /policy, /about, /pricing это автоматически создаёт SEO-проблему. Правило: либо вообще не задавать canonical в корневом layout, либо явно переопределять его в каждой странице. Промежуточные варианты опаснее обоих краёв.

Четвёртое — мелкий UX-долг копится незаметно. Каждая из этих трёх правок занимала меньше дня. Но накопленный эффект от трёх таких «недоделок» — это та самая воронка, которая течёт, и при этом непонятно, где именно. Регулярно выделяйте время на «цикл шлифовки»: пройти продукт глазами пользователя, прочитать письма так, будто получили их впервые, прогнать SEO-аудит лендинга. Это не звучит как геройский релиз, но именно из таких шлифовок и собирается ощущение «качественного продукта», за который не стыдно брать деньги.

Паша Вин
Паша Вин

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

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

GPTweb.ru