Получателю раньше оставалось только скопировать ссылку или код руками.
- QR на экране готового подарка: кодируется bot-ссылка (активация в боте —
основной путь), при отсутствии username бота — ссылка кабинета. Использован
уже имеющийся qrcode.react, на белой подложке ради контраста в тёмной теме.
- Кнопка «Отсканировать QR» в форме активации. В Telegram используется
НАТИВНЫЙ сканер (в WebView камера через getUserMedia работает ненадёжно —
ровно то, из-за чего скан «в тг» и был проблемой), в вебе — html5-qrcode с
CDN, с фолбэком на фронтальную камеру и понятной ошибкой, если камеры нет.
- src/utils/qrScanner.ts: логика сканера вынесена из TvQuickConnect, чтобы её
мог переиспользовать любой экран. parseGiftCode понимает все три способа
распространения подарка (deep-link бота, ссылка кабинета, голый код с
префиксом GIFT- и без) и ОТКЛОНЯЕТ посторонние QR — иначе сканер подставил
бы в поле мусор с любой ссылки или Wi-Fi-кода.
- Веб-сканер гасится при уходе с вкладки и размонтировании, иначе индикатор
камеры остаётся гореть.
Локали ru/en/zh/fa, +4 теста на парсер.
Парная фронтовая часть к бэкенду (бот: 82609441).
- Поле «Лимит на пользователя» в форме создания партии: 0 — без
ограничения, для раздач и конкурсов ставится 1. Значение показывается в
карточке партии, если задано.
- Кнопка «Удалить партию» с подтверждением: отдельно от отзыва, который
лишь гасит ссылки. Если в партии есть погашенные купоны, подтверждение
предупреждает о потере истории активаций (выданные подписки не
отзываются). После удаления — возврат к списку.
- API: max_per_user в типах партии и запросе создания, deleteBatch.
Локали ru/en — раздел admin.coupons в zh/fa отсутствует целиком (купоны там
никогда не переводились), поэтому новые ключи добавлены в том же объёме.
Парная фронтовая часть к /cabinet/branding/bot-start-video (бот: 524960c3).
В «Брендинге» появилась карточка: загрузить/заменить/удалить видео, которое
бот прикрепляет к сообщению /start вместо картинки-логотипа, и статус
текущего состояния. Файл разово уходит в Telegram ради file_id — на нашей
стороне не хранится.
Видео в рассылках уже работало (кнопка в боте, загрузка в кабинете,
доставка send_video) и не затронуто. Локали ru/en/zh/fa.
Фронтовые хвосты автопродления Lava (бэкенд: de3518b7).
- Поле «Продукт Lava» в форме создания/редактирования тарифа: раньше
lava_product_id можно было задать только через API, из-за чего фичу нельзя
было включить из интерфейса. Пустая строка отвязывает тариф от продукта.
- CTA «Оформить с автооплатой Lava» в форме покупки тарифа — рядом с
СБП-кнопкой, в обеих ветках рендера; флаг lava_recurrent_enabled из
purchase-options наконец получил потребителя.
- API-метод purchaseWithLavaRecurring + типы lava_product_id в списке,
детали и запросах тарифа.
- Локали ru/en/zh/fa: подписи покупки и поля продукта (плейсхолдеры сверены).
Парная фронтовая часть к эндпоинтам /cabinet/subscription/lava-recurrent
(бот: 0dea2182). Блок — сиблинг Platega-блока с той же семантикой состояний
(off/pending/active/past_due), поллинг раз в 8с пока привязка PENDING.
Отличие от Platega в подписи: у Lava период задан продуктом в кабинете
провайдера и приезжает числом дней, поэтому канонические значения (30/90/365
и т.д.) показываются словами, а произвольные — как «раз в N дн.».
utils/lavaRecurring.ts: строгое распознавание 403 'Lava recurrent disabled'
(другие 403-guard'ы прятать нельзя), деградация неизвестного статуса в off.
Локали ru/en/zh/fa (+25 строк каждая, плейсхолдеры сверены). 7 тестов утилиты.
Свотч и превью-чип стиля primary красились в bg-accent-500 — акцент темы
кабинета, который перекрашивается (у оранжевой темы «Синий» выглядел
оранжевым). Реальный цвет кнопки задаёт Telegram Bot API: primary — синий
независимо от темы кабинета, поэтому свотч теперь фиксированный #54a9eb
(тот же телеграм-синий, что у кнопки Telegram-логина).
common.units.mo и common.units.days не существуют ни в одной локали — en/zh/fa
видели русский фолбэк «мес»/«дней», а PR #524 расширил видимость строки
«/мес» (периоды 31–44 дней). subscription.month и subscription.days есть во
всех четырёх локалях и уже используются карточкой периодов тарифа.
Сегмент выбирался вслепую — в списке не было числа пользователей, а после
отправки экран показывал только количество созданных офферов: сколько
человек реально получило предложение и сколько заблокировало бота, узнать
было негде.
В селекте сегментов теперь стоит охват каждого (GET /promo-offers/segments),
под ним — сколько уйдёт по выбранному. После отправки экран результата
показывает прогресс доставки с карточками Всего/Отправлено/Заблокировали/
Ошибки и опрашивает запись рассылки раз в 3 секунды, пока она не завершится.
Прогресс-блок и бейдж статуса вынесены из AdminBroadcastDetail в
BroadcastDeliveryStats и переиспользуются обеими страницами, а условие
«доставка ещё идёт» — в утилиту broadcastStatus с тестами.
Для периодов короче месяца в карточке периода под ценой выводилась
та же сумма с подписью «/мес»: цена за 7 дней показывалась как цена
за месяц. Причина — деление на max(1, days / 30), которое для коротких
периодов даёт делитель 1.
Расчёт вынесен в getMonthlyPriceKopeks: месячная ставка считается
пропорцией (price * 30 / days) и не показывается для периодов в месяц
и короче, где она либо дублирует цену, либо вводит в заблуждение.
Тот же хелпер применён на экране продления, где месячная цена для
периодов вроде 45 дней считалась делением на округлённое число
месяцев.
Аудит локалей кабинета с русским в качестве эталона.
Битые {{плейсхолдеры}} в zh/fa (рендерились как литеральные скобки,
теряли значение или дублировали то, что и так выводится в JSX):
- gift.sentTo / gift.activatedBy: неверное имя переменной
({{name}} → {{recipient}} / {{username}})
- dashboard.devicesConnectedUnlimited: {{count}} → {{used}} (+ «без лимита»)
- dashboard.maxUsage, gift.deviceCount, gift.activateSuccessDesc: потерянные
значения возвращены
- dashboard.expired.activeUntil, subscription.trialInfo.remaining: убран
{{date}}/{{count}} — значение выводится отдельным элементом
- trialInfo.description, trialOffer.freeDesc/paidDesc, gift.shareText:
переписаны без {{days}}/{{price}}/{{code}}, которые код не передаёт
- wheel.payWithStars: одинарные {stars} (i18next их не подставляет) убраны
Мисперводы zh/fa (разошлись по смыслу с ru/en):
- subscription.cta.*Hint, expiredBanner.selectTariff, trialUpgrade.description,
reduceDevicesDescription, menuEditor.customLabelsHint, myGiftsEmptyDesc,
partners.settingsFields.*, remnawave.overview.bandwidth, shareModalActivateVia
fa admin.pinnedMessages: 40 строк были на английском → переведены на фарси.
zh common.all: 'All' → '全部'.
en admin.broadcasts.subtitle: устаревшее «History and management» →
соответствует ru «Массовая отправка сообщений пользователям».
Плейсхолдеры и синхронность en/ru проверены (locales.test.ts).
Хелпер-функции (isSafeToOpen/isSafeAppLink) CodeQL как санитайзеры не
распознаёт — js/xss и js/client-side-unvalidated-url-redirection
продолжали флаговаться на sink'ах. Проверки перенесены инлайн к sink'ам
как префикс-якорные RegExp-гарды (распознаваемый барьер): scheme://
обязателен, javascript/data/vbscript/file/blob/about/intent/content/
filesystem (и http для redirect.html) отбрасываются. Семантика та же.
Callers валидируют вход (allowlist в DeepLinkRedirect, конфиг в
Connection), но сама утилита доверяла аргументу — забытая валидация в
будущем call-site стала бы client-side XSS через
location.href='javascript:…'. Теперь схема парсится и javascript/data/
vbscript/file/blob/about/intent/content/filesystem отбрасываются до
любого sink'а (зеркало isSafeAppLink из redirect.html).
Карточки-соседи слипались: у тоггла нет нижнего маргина, у СБП-блока не
было верхнего, парент вертикальных отступов не даёт. mt-3 зеркально
внутреннему ритму карточки.
Вторая CTA «Оформить с автооплатой СБП» под кнопкой покупки с баланса
(и в daily-ветке) — показывается по platega_recurrent_enabled из
purchase-options. POST /platega-recurrent/purchase → редирект в банк
(первое списание = подтверждение привязки), инвалидация подписочных и
sbp-запросов, переход в /subscriptions. Локали en/ru/zh/fa.
Кнопки растягивались w-full на всю ширину карточки подписки (~1600px на
десктопе) — гигантская плашка вместо контрола. Теперь заголовок и статус
слева, компактное действие справа — зеркально соседнему тогглу
«Автопродление»; на мобиле кнопка по-прежнему на всю ширину
(w-full sm:w-auto).
tsc в vite-проекте ничего не эмитит (noEmit) — только тип-гейт, который
уже стоит в CI и в самом docker-воркфлоу (build-and-push: needs: lint).
build:docker = vite build; npm run build с tsc сохранён для людей и CI.
~10s с каждой сборки образа. Спасибо @kewldan.
Форма слала undefined при пустом выборе, а PATCH применяет поле только
при его наличии — снять все промогруппы или очистить описание было
невозможно. Теперь при редактировании поля уходят всегда ([] и '');
create не меняется (бэкенд маппит пустое в None). Закрывает #455.
Спасибо @kewldan.
404 на /cabinet/branding/telegram-widget и deeplink-роутах = сборка бота
старше v3.24/v3.33 — вместо «бот не настроен» показываем auth.botOutdated
(4 локали). README: фактический минимум v3.33.0. Закрывает #345.
Спасибо @kewldan.
Гейт partner_status === 'approved' был чисто фронтовым: бэкенд-эндпоинты
вывода и referral_withdrawal_service партнёрство не проверяют, а страница
запроса вывода тоже не гейтится. Секция остаётся под админским флагом
partner_section_visible. Закрывает #472. Спасибо @kewldan.
displayName() получил фолбэк на локальную часть email; без имени вовсе —
dashboard.welcomeNoName без запятой (4 локали). Закрывает #423.
Спасибо @kewldan.
- trivy-action@0.24.0 — несуществующий тег (у trivy-action теги с
префиксом v): резолв экшенов в Set up job валил бы весь docker-воркфлоу
даже на PR. Пин на SHA v0.36.0 — как в бот-репо.
- image-ref из github.repository сохраняет регистр (BEDOLAGA-DEV/…), а
OCI требует lowercase — добавлен lowercase-шаг.
- codeql-action v3 → v4 (v3 закатный; бот-репо уже на v4).
iOS запускает кастомную схему только из top-level навигации с жестом —
скрытый iframe молча игнорируется (кнопка «Добавить подписку» не
работала в Safari и миниаппе, больнее всего на iOS-first INCY).
iframe оставлен для Android in-app браузеров (ERR_UNKNOWN_URL_SCHEME).
Симметрично в openAppScheme.ts и redirect.html. Спасибо @AirP0WeR.
Захардкоженный английский дефолт бэкенда из 403 channel_subscription_required
перебивал переведённую blocking.channel.defaultMessage. Хелпер
customChannelMessage() отбрасывает известный дефолт и пустые значения,
кастомный текст проходит как есть. Закрывает #311. Спасибо @kewldan.
Тоггл/статус/отмена на странице подписки (включая суточные тарифы),
СБП-привязки в сохранённых методах, WS-уведомления о событиях списаний,
статус и отмена автооплаты в админ-карточке юзера. Работает только при
включённом PLATEGA_RECURRENT_ENABLED на бэкенде (403 = блок скрыт).
На iOS кастомная схема (incy://, happ://, …) запускается только top-level
навигацией, привязанной к жесту пользователя. Запуск из скрытого iframe iOS
молча игнорирует — приложение не открывается даже если установлено, поэтому
кнопка «Добавить подписку» на iPhone (Safari и TG-миниапп) не срабатывала.
Заметнее всего на INCY: это iOS-first клиент, его аудитория целиком на iPhone.
openAppScheme и public/miniapp/redirect.html теперь на iOS используют
window.location.href (вызов синхронный, внутри обработчика клика — жест
сохраняется), а contained-iframe остаётся для Android in-app браузеров, где
top-level переход к нерешаемой схеме рисует полноэкранный
net::ERR_UNKNOWN_URL_SCHEME и стирает fallback-UI (Telegram bug #654272).
Добавлен юнит-тест на выбор пути: iOS и iPadOS-as-Mac → location.href,
Android и desktop → iframe.
Инкремент поверх f04675b (#501 был технически перекрыт им). resolveSupportContact
теперь не только не клеит t.me с внешним URL, но и отсекает битый конфиг вместо
того, чтобы уводить пользователя по мусорной ссылке:
1. Валидация схемы support_url — открываем только http/https/tg, чужая схема
(javascript:, data: и т.п.) больше не уходит в опенер как есть, а даёт null.
2. Валидация legacy username регуляркой ^[A-Za-z0-9_]{3,}$ — старый бэк с
URL-образным SUPPORT_USERNAME больше не собирает битый t.me/https://…,
URL-образное отдаём резолвить бэку.
3. null → кнопка не рендерится — при ненастроенном/битом контакте (в т.ч. ушёл
дефолт @support) кнопку в Support.tsx не показываем, а не открываем молча
https://t.me/support.
Заодно url-ветка в ticketsDisabled и карточка both-режима ходят через
resolveSupportContact — ушёл прямой openLink(support_url!) с non-null assertion,
три ветки getSupportMessage схлопнуты в две (profile и fallback идентичны).
SUPPORT_USERNAME на бэке принимает и @username, и произвольный URL. Кабинет
же во всех трёх ветках клеил `https://t.me/${support_username}`, из-за чего
внешний хелпдеск превращался в https://t.me/https://help.example.com и не
открывался. Бэк теперь отдаёт контакт разрезолвленным (support_url +
contact_is_telegram) — клеить на клиенте нечего.
Логика вынесена в resolveSupportContact: три копии склейки (profile, fallback
и карточка режима both) заменены одним вызовом. Старый бэк не шлёт support_url,
для него сохранено прежнее поведение — новый фронт не ломается на неподнятом
бэкенде.
Заодно ушли три non-null assertion на support_username.
Ключ был объявлен дважды: как подзаголовок страницы списка методов
и как подпись поля формы. Побеждало последнее определение, поэтому
на странице «Платёжные методы» вместо «Настройка порядка и условий
отображения» показывалось «Описание».
Подзаголовок переименован в subtitle, пара description/descriptionHint
осталась за полем формы. В zh/fa подписи поля не было вовсе — там под
description лежал текст подзаголовка, поэтому форма показывала не то;
добавлены недостающие переводы вместо русского фолбэка.
Заодно форматирование AdminPaymentMethodEdit.tsx: шаг format:check
в CI не доходил до выполнения, пока падал lint.
Чинит красный lint в CI ветки dev.
Регистрация нового провайдера пополнения (api.cispay.app, карта и СБП)
во фронтенде: метка в METHOD_LABELS, иконка, узел настроек
payments_cispay и локали ru/en/zh/fa.
Бэкенд отдаёт метод динамически, поэтому страницы статистики и транзакций
подхватывают его автоматически через METHOD_LABELS и PaymentMethodIcon.