Не работает промокод на первый заказ: фикс UX в WebGPT
Юзер кликает в письме по баннеру с промокодом и попадает на 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 →