Ключ был объявлен дважды: как подзаголовок страницы списка методов
и как подпись поля формы. Побеждало последнее определение, поэтому
на странице «Платёжные методы» вместо «Настройка порядка и условий
отображения» показывалось «Описание».
Подзаголовок переименован в 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.
Поверх #491: #489 уже в dev — записи resetPassword.*/expiredDate_trial и
placeholder-исключение serverAccess из allowlist удалены (по инструкции
самого теста); последняя дыра admin.remnawave.traffic.realtimeTitle закрыта
переводом — проверка синхронности теперь строгая, без известных исключений
Со времён редизайна со скоуп-селектором граф строится только для
выбранных кампаний, партнёров или пользователей, и посмотреть сеть
целиком стало нечем. Бэкенд для этого уже есть — GET /cabinet/admin/
referral-network/ отдаёт полный граф и остался неиспользуемым после
того, как фронт перешёл на /scoped.
Возвращаем к нему доступ: в дропдауне селектора появляется закреплённый
пункт «Вся сеть», который грузит граф целиком. Режим взаимоисключающий
с выбором чипов — полный граф и так содержит все сущности.
White-label (per-method display_name + description in admin) was defeated on
balance/top-up pages: render used `translatedName || method.name` and
`translatedDesc || method.description`, so the bundled locale always won and
the admin override was ignored.
- Flip priority: admin-configured value first, locale as fallback
(TopUpMethodSelect, Balance)
- Add missing Description field to payment-method edit page
(mirrors Display name: description / reset_description)
- Add description to admin PaymentMethodConfig type
- Add description/descriptionHint i18n keys (ru, en)
Форма создания промокода переведена с жёсткого выбора одного типа на режим
«Набор бонусов»: галочками выбирается, что входит в промокод — бонус на
баланс, дни подписки, промогруппа — в любой комбинации. Тип для бэкенда
выводится из выбора (balance / subscription_days / balance_and_days /
promo_group), промогруппа едет через promo_group_id при любом типе (бэкенд
назначает её при активации независимо от типа).
Триал и процентная скидка остаются отдельными режимами: скидка
переиспользует те же колонки с другой семантикой (процент/часы) и не
комбинируется без миграции.
- список и статистика: лейбл/цвет нового типа balance_and_days, у составных
кодов видны оба значения (+руб и +дни)
- edit-режим восстанавливает чекбоксы из типа и promo_group_id
- унифицирована валидация формы (isValid = список ошибок пуст; для скидки
0 часов = «до первой покупки» — разрешено, как и было в isValid)
- локали ru/en/fa/zh
Парный PR бэкенда: remnawave-bedolaga-telegram-bot (тип BALANCE_AND_DAYS,
порядок эффектов дни→баланс, валидации, бот-визард).
Паритет с бот-кнопкой «✉️ Отправить сообщение»: в блоке действий карточки
юзера появляется кнопка (RBAC users:send_message), открывающая модалку с
текстом (HTML, лимит 4096) — бот отправляет юзеру прямое сообщение через
POST /admin/users/{id}/send-message.
Для email-only юзеров без telegram_id кнопка задизейблена с подсказкой;
коды ошибок бэкенда (no_telegram_id / forbidden / bad_request) маппятся
в честные локализованные сообщения. Локали ru/en/fa/zh.
The bot backend now rejects prorated tariff switches from a free (0RUB)
tariff with 400 free_tariff_cannot_switch + use_purchase_flow=true
(TARIFF_SWITCH_RESET_FREE_DAYS: the free remainder must reset, not be
prorated over hundreds of accumulated days and carried to a paid
tariff). Handle it like the existing trial_cannot_switch contract:
- SwitchTariffSheet: accept free_tariff_cannot_switch in
shouldUsePurchaseFlow so the sheet hands off to the purchase form
- TariffPickerGrid: new subscription_on_free_tariff purchase-options
flag disables the switch CTA up front and shows purchase instead
Companion to BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot#3056.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Два новых типа записей таймлайна: button_click (нажатия кнопок в боте,
с текстом кнопки) и cabinet_action (мутационные действия юзера в
кабинете, метод + нормализованный путь). Чип-фильтр «Клики», иконки
BotIcon/BoltIcon, переводы во всех 4 локалях.
biome check, type-check и build проходят (pre-commit пропущен: biome
не установлен локально).
Единый таймлайн действий юзера в боте и кабинете
(GET /cabinet/admin/users/{id}/activity): вертикальная лента с
иконками-кружками по типу записи, бейджи подтипов, суммы со знаком
(+зелёный / −красный), относительное время (абсолютное — в title),
чипы-фильтры по категориям (платежи, события, промо, тикеты, подарки,
рефералы, входы), счётчик StatCard и постраничная догрузка по house
load-more паттерну. Компонент самодостаточный (ActivityTab), как
TicketsTab. Переводы во всех 4 локалях; время переиспользует
admin.auditLog.time.*.
biome check, type-check и build проходят (pre-commit пропущен: biome
не установлен локально — бинарь есть только в CI).
Кнопка «Активировать триал» в rich-меню бота при платном триале ведёт в
миниапп: startapp=trial открывает дашборд, где TrialOfferCard показывает
цену и flow оплаты с баланса.
biome check, type-check и build проходят (pre-commit пропущен: biome не
установлен локально — бинарь есть только в CI).
StartParamNavigator обобщён до таблицы маршрутов: помимо admin_ticket_<id>
теперь разбираются renew_<id> → /subscriptions/<id>/renew и subscriptions →
/subscriptions — их шлют ссылки «Продлить» у истёкших подписок в rich-меню
бота (t.me/<bot>/<app>?startapp=…). Доступ по-прежнему ограничен гвардами
самих маршрутов.
biome check, type-check и build проходят (pre-commit пропущен: biome не
установлен локально как зависимость — бинарь есть только в CI).
Парный к боту #3049. В /admin/legal-pages под полем политики/оферты/правил:
постоянный счётчик символов + предупреждение с оценкой числа сообщений бота
(estimateTelegramParts зеркалит split_telegram_text, порог 3500). Скрыто там,
где документ в боте не показывается (recurrent-payments, web-only); счётчик
остаётся. Переводы ru/en с плюральными формами, прочие локали — en fallback.
Валидация: JSON-ключи, tsc, biome lint/format, build — чисто.
framer-motion stagger раздаёт детям команду показаться один раз при монтировании
родителя; дети, смонтированные позже (когда resolve-ится react-query), оставались
в initial (opacity:0) — пустые страницы после F5/прямого захода.
- ConnectedAccounts, SavedCards: key={isLoading?...} перемонтирует контейнер
после загрузки → stagger проигрывается со всеми детьми;
- Balance (2), Profile, Support: явные initial/animate на условных staggerItem —
элемент самоанимируется при mount, вне оркестрации родителя;
- Wheel: исправлены несуществующие варианты hidden/show → initial/animate
(stagger истории спинов молча не работал).
Валидация на смердженном дереве: tsc, biome lint/format, build — чисто.
Правки #477 и #478 в общих файлах (ConnectedAccounts/Profile) сосуществуют.
FastAPI/Pydantic на 422 кладёт в detail массив объектов {type,loc,msg,...};
компоненты рендерили его в JSX как есть → React падал в ErrorBoundary
(Objects are not valid as a React child), пользователь не видел причину.
Все такие обработчики (Login, Profile, ConnectedAccounts, admin AnalyticsTab
и MenuEditorTab) переведены на существующий getApiErrorMessage — строковый
detail возвращается как есть (ветки .includes работают), Pydantic-массив
разворачивается в строку field: msg; ..., любой формат безопасен для рендера.
Валидация на смердженном дереве: tsc, biome lint/format, build — чисто.
Кабинет, в отличие от админки бота (лимит 4000 символов), позволяет
сохранять юридические тексты любой длины — и до парного фикса в боте
(BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot#3049) текст длиннее
~4096 символов ронял показ правил и политики при /start
(MESSAGE_TOO_LONG).
Теперь под текстовым полем политики, оферты и правил постоянно
показывается счётчик символов, а для текстов длиннее одного сообщения —
предупреждение с оценкой, на сколько сообщений бот разобьёт текст.
Оценка зеркалит жадную упаковку абзацев из split_telegram_text бота
(порог 3500 символов на кусок).
Предупреждение не показывается там, где оно неуместно:
- «Рекуррентные платежи» — документ не отображается в боте вовсе;
- режим отображения «Только веб» — бот документ не показывает.
Переводы добавлены в ru/en (с плюральными формами); остальные локали
используют английский fallback через defaultValue.
Оркестрация staggerContainer/staggerItem у framer-motion раздаёт детям
команду «показаться» один раз — при анимации родителя на mount. Дети,
смонтированные позже (когда resolve'ится запрос react-query), в
оркестрацию не попадают и навсегда остаются в initial (opacity: 0, y: 8).
Симптом: после жёсткой перезагрузки (F5) на странице виден только
заголовок — карточки есть в DOM, но прозрачны. При SPA-навигации кэш
react-query тёплый, дети монтируются вместе с родителем — поэтому баг
проявлялся только при холодном заходе и маскировался на проде (нет
StrictMode, same-origin API, тёплый кэш почти всегда выигрывают гонку
с ~300-мс окном анимации).
Два способа фикса в зависимости от формы страницы:
1. key={isLoading ? 'loading' : 'ready'} на контейнере — перемонтирование
после загрузки, stagger проигрывается заново уже со всеми детьми.
Для страниц, где весь контент асинхронный (каскад сохраняется):
- ConnectedAccounts — карточки провайдеров
- SavedCards — список сохранённых карт
2. Явные initial="initial" animate="animate" на условном staggerItem —
элемент анимирует себя сам, не дожидаясь оркестрации родителя. Для
страниц, где асинхронны только 1-2 опциональные карточки (не
перезапускает анимацию всей страницы на каждый resolve):
- Balance — «Способы пополнения» и ссылка «Сохранённые карты»
- Support — карточка «Написать нам» (support_type='both')
- Profile — реферальный виджет
Попутно: в Wheel у контейнера истории спинов стояли несуществующие
варианты initial="hidden"/animate="show" (у staggerContainer/staggerItem
ключи — initial/animate/exit), из-за чего stagger там молча не работал
вовсе; имена исправлены.
Проверено и не требует правок: TopUpAmount (early-return до загрузки
методов), TopUpMethodSelect и MergeAccounts (динамический контент —
обычный HTML внутри всегда смонтированных staggerItem), вложенный
контейнер транзакций в Balance (самоанимирующийся).
Что: все обработчики ошибок, которые клали сырой response.data.detail в
состояние и затем в JSX, переведены на существующий хелпер
getApiErrorMessage (src/utils/api-error.ts):
- Login.tsx — Telegram-auth, вход/регистрация по email, восстановление пароля
- Profile.tsx — смена email (запрос и подтверждение кода), повторная
отправка письма верификации
- ConnectedAccounts.tsx — привязка email к аккаунту
- admin/AnalyticsTab.tsx, admin/MenuEditorTab.tsx — сохранение настроек
Почему: на невалидный по схеме запрос FastAPI/Pydantic отвечает 422, где
detail — не строка, а массив объектов {type, loc, msg, input, ctx}.
Компоненты рендерили его как есть, React падал с «Objects are not valid
as a React child», и вся страница (включая логин) уходила в ErrorBoundary
«Something went wrong» — пользователь не видел причину отказа вообще.
Зачем: getApiErrorMessage сводит Pydantic-массив к строке
«field: message; ...», поэтому вместо краша форма показывает настоящий
текст ошибки валидации. Ветвления по detail.includes(...) сохранены и
работают по нормализованной строке — поведение прежнее для строковых
detail, но безопасное для любой формы ответа.