Pairs with the bot's unified claimable-gift model. A landing gift now lands on
the buyer's success page as PAID/claimable (deferred) instead of being delivered
to a phantom recipient, so the buyer gets a transferable link to forward and the
recipient claims it themselves — guaranteeing delivery regardless of channel or
whether they pre-exist.
- api/landings.ts: PurchaseStatus gains is_claimable/claim_url/bot_claim_link;
add GiftClaimResult + getGiftClaim()/claimGift().
- PurchaseSuccess.tsx: GiftLinkShareState — buyer sees the claim link + Telegram
link + copy-message when a gift is PAID/claimable; stop polling for that state
(it stays PAID until claimed) instead of spinning forever.
- GiftClaim.tsx (new) + /buy/gift/:token route: public recipient claim page with
a Telegram arm (GIFT_ deep link) and a web/email arm (binds + one-click login),
polling while the payment settles, idempotent "already claimed" + 404 states.
- i18n: landing.giftClaim.* / landing.giftLink.* in ru/en/zh/fa.
Кастомное лого квадратное, и в фавиконе показывалось с острыми углами. Теперь
лого прогоняется через canvas со скруглённым клипом (radiusRatio 0.3 — как
rounded-linear-lg 12px на плитке 40px в хедере) и отдаётся скруглённым PNG.
При сбое canvas (taint/нет 2d-контекста) — фолбэк на сырой лого. Применяется и
в кабинете (useBranding), и на лендинге (QuickPurchase); буквенная монограмма
уже скруглена через rx.
У лендинга есть собственное поле background_config (тема на каждый лендинг).
Если оно не заполнено, лендинг показывал голый bg-dark-950, тогда как кабинет
всегда рисует глобальную тему-анимацию (BackgroundRenderer → getAnimationConfig).
Теперь: задан background_config → своя тема лендинга (StaticBackgroundRenderer),
пусто → глобальная тема кабинета (BackgroundRenderer). Обёртка больше не красится
непрозрачным bg-dark-950 — фон рисуется порталом за контентом поверх body #0a0f1a,
ровно как в AppShell. Глобальный конфиг анимации тянется из публичного эндпоинта.
Публичный лендинг (QuickPurchase) рендерится вне AppShell, а useBranding
с заголовком/иконкой завязан на авторизацию — поэтому на лендинге вкладка
оставалась с дефолтным «Loading...» из index.html (тайтл обновлялся только при
заданном meta_title) и без favicon (href="data:,").
- index.html: нейтральная дефолтная иконка-монограмма вместо пустого data-URI
и тайтл «VPN» вместо «Loading...».
- QuickPurchase: тайтл берётся из meta_title || title лендинга; favicon
ставится из брендинга (кастомный лого-блоб или буквенная монограмма), брендинг
тянется из публичного эндпоинта.
- favicon-хелперы вынесены в utils/favicon.ts; useBranding переведён на них
(DRY) + добавлен буквенный фолбэк, чтобы иконка была всегда.
- Тип LandingTariff дополнен is_daily/daily_price_kopeks (синхронно с бэком).
Мульти-тариф блок рендерился даже при пустом списке подписок (пустой
массив truthy) — показывал заголовок «Подписки» и кнопку покупки, которая
дублировала кнопку из блока «нет подписок» (триал-оффер). Получалось две
одинаковые «Посмотреть тарифы и купить подписку».
Теперь мульти-тариф блок рендерится только когда подписки реально есть
(length > 0), а блок «нет подписок» показывает триал (если доступен) и
ВСЕГДА одну явную кнопку покупки — заодно закрывает случай «нет подписок и
триал недоступен» (раньше его прикрывал пустой мульти-тариф блок).
В мульти-тарифе кнопка «+ Купить ещё подписку» висела всегда, когда есть
хоть какая-то подписка — даже если это только триал или всё истёкшее.
Теперь она показывается только при наличии НАСТОЯЩЕЙ платной активной
подписки (hasActivePaid: не триал + status active/limited).
А «Посмотреть тарифы и купить подписку» из тусклой (пунктир + opacity-60,
спрятана в триал-блоке) стала ЯВНОЙ primary-кнопкой (solid accent) и
показывается, когда платной активной подписки ещё нет (триал/истёкшие/
новый юзер) — мы продаём подписки, путь к покупке должен быть очевиден.
Затронуты Dashboard и Subscriptions. Добавлен ключ subscriptions.browsePlans
(раньше рендерился только inline-фолбэк) в 4 локали.
Локальный StatCard держал иконку в чипе p-2, который подстраивался под
размер иконки (а часть иконок передавалась без className) — отсюда разные
по размеру чипы и «кривые» иконки. Привёл к виду карточек статистики продаж:
лейбл сверху, фиксированный чип h-9 w-9 с принудительным размером иконки
([&>svg]:h-5 w-5 нормализует все иконки разом), значение в тон чипу. Рамку
и subValue сохранил.
Новое приложение INCY (incy://import/{url}) не открывалось на роутах
/add и /connect: DeepLinkRedirect держит хардкод-allowlist разрешённых
схем (happ, hiddify, v2rayng, streisand…), и isValidDeepLink резал
incy:// как неизвестную схему. Добавил incy:// в список.
Бэкенд не трогаю: _create_deep_link / _get_url_scheme_for_app собирают
deep-link из urlScheme Remnawave-конфига полностью обобщённо (без списка
имён), incy:// он строит сам. Mini-app путь (redirect.html) тоже generic.
- «Активные подписки» теперь с % (нетто-изменение за период: новые
платные − истёкшие; обычная дельта снимка была бы всегда 0).
- Новая карточка «Новые платные подписки» (с дельтой период-к-периоду).
- «Доход» больше не включает ручные пополнения (бэк отвязал).
- Убрал «Средняя длит.» из триалов — была вечный 0 и непонятна.
- Сводка 9 карточек: сетка 2/3 с orphan-fill на мобайле, без пустот.
Новая вкладка в статистике продаж:
- success-rate платежей (оплачено/создано) — общий + по каждому шлюзу с
цветными барами (зелёный ≥90%, жёлтый ≥70%, красный ниже);
- кол-во попыток оплат;
- «Неудачные покупки» — внутренние откаты при ошибке покупки (сигнал
надёжности, не возвраты).
Данные из нового эндпоинта /payment-health. Шлюзы без активности не
показываются. Карточки 1/3 (без сирот), адаптив. 4 локали.
Возвраты для оператора всегда 0 (реальные возвраты только Apple/Tribute,
которых нет) — постоянно пустые карточки только путают. Сводка вернулась
к 8 карточкам (grid 2/4). Убрал также неиспользуемый invertDelta из
StatCard и ключи refunds из локалей.
Допы (5 карточек) рисовали 4+1: orphan-fill из двух arbitrary-вариантов
конфликтовал по специфичности — правило :last-child:nth-child(odd) бьёт
ресет :last-child, поэтому 5-я карточка тянулась на 2 колонки и на
десктопе. Заменил на один mobile-вариант max-lg:, как в AdminRemnawave:
десктоп — 5 в ряд, планшет/мобайл — 2 колонки + последняя на всю ширину.
Сводку тоже перевёл на lg:grid-cols-5 (было sm) — на планшете 5 колонок
давали ~115px и резали суммы; теперь до десктопа 2 колонки.
Две карточки — количество и сумма возвратов за период (красный тон).
Сетку сводки перевёл на 2/5 (10 карточек делятся ровно, без пустых
ячеек). StatCard получил invertDelta: для «меньше — лучше» метрик рост
красится в красный, падение в зелёный (стрелка по факту). 4 локали.
Поле суммирует все подписочные платежи (новые + продления + докупки),
поэтому пересекалось по смыслу с вкладками Продления/Допы. Честное имя
«Оборот по подпискам» убирает путаницу: видно, что это итог по всем
подписочным платежам, а не только новые продажи. Все 4 локали.
- Числовой формат даты (01.06.2026) — постоянная ширина в любой локали,
раньше короткое имя месяца («мая» vs «сент.») меняло ширину кнопки.
- min-w + justify-start + truncate на триггере: плейсхолдер и выбранная
дата дают одинаковую ширину — поле не прыгает по горизонтали.
- fixedWeeks + showOutsideDays: всегда 6 недель, высота календаря не
скачет при листании месяцев (5↔6 рядов).
- collisionPadding + max-w-[calc(100vw-1rem)]: поповер не вылезает за край
экрана на мобайле.
- Контейнеры диапазона дат (продажи, трафик) переведены на flex-wrap /
full-width на мобайле — два поля больше не вылезают за ширину.
Сводка (8 карточек) была 2/3/4 колонки — на 3-колоночном брейкпоинте
последний ряд оставлял пустую ячейку. Перевёл на 2/4 (8 делится ровно).
Допы (5 карточек) тянули сироту на 2- и 3-колоночном — теперь 2/5 с
orphan-fill: последняя нечётная карточка занимает всю ширину на мобайле
(паттерн как в новостях/подписках).
Переменные --rdp-* библиотека объявляет на самом .rdp-root, поэтому
оверрайды на родителе (.rdp-dark) шадовились — отсюда синий акцент,
синяя рамка вместо заливки и тусклые дни. Теперь целимся в
`.rdp-dark .rdp-root` (специфичность выше базовой). Выбранный день —
залит акцентом, числа — яркие, сегодня — акцентная подсветка, все
цвета через токены темы (dark/accent), так что инвертируются для
светлых тем (champagne) и остаются читаемыми везде.
Тёмный DateField теперь везде, где был браузерный <input type="date">: фильтр
периода в Трафике, диапазон дат в Платежах и Аудит-логе, срок действия промокода.
Единый вид во всём кабинете. (datetime-local не трогал — там нужно ещё и время.)
Нативный <input type="date"> выглядел чужеродно (браузерный светлый календарь).
Новый общий компонент DateField: react-day-picker внутри Radix Popover, тёмная
тема через CSS-переменные .rdp-dark, навигация — наши иконки из барреля,
локализация месяцев/дней по языку кабинета (date-fns). Принимает/отдаёт
'YYYY-MM-DD' — drop-in замена нативного инпута. Подключён в селекторе периода
статистики (свой период). Остальные места — следующими шагами.
Статистика по умолчанию открывается за текущий месяц с 1-го числа до сегодня
(month-to-date), а не за фиксированные 30 дней. Добавлена кнопка «Этот месяц»
в селектор периода; util getMonthToDateRange/isMonthToDate.
Доделал душевный вид: карточки внутри вкладок (Триалы/Продажи/Продления/Допы/
Депозиты) тоже получили иконки в цветных чипах с тонами — единый стиль со сводкой
(регистрации/триалы/конверсия/длительность, продажи/выручка/чек/топ-тариф,
продления/%, допы/GB/устройства, пополнения/сумма/средний). Крупные суммы — без
копеек.
- StatCard: лейбл сверху, чип+значение в одной строке (чип центрируется ровно по
значению, дельта ушла под строку) — иконки больше не «плавают» по вертикали
из-за разной высоты карточек.
- «Активные подписки»: TicketIcon вместо SubscriptionIcon (та визуально как искра
и дублировала «Новые триалы»).
- StatCard: иконка в мягком цветном чипе (tone success/accent/warning/…) в духе
статистики Remnawave; дельта под значением. Обратно совместимо (valueClassName).
- Карточки сводки получили осмысленные иконки (доход/подписки/триалы/конверсия/
продления/допы/пополнения) и цветовые тоны.
- Скачок при переключении периодов убран: на все запросы статистики добавлен
placeholderData: keepPreviousData — старые данные остаются на экране, пока
грузятся новые, без вспышки скелетона и прыжка вёрстки.
formatWithCurrency по умолчанию даёт 2 знака без разделителей разрядов
(«2521000.00 ₽») — длинно и режется. Для итоговых сумм сводки (доход, доход с
допов, ручные пополнения) показываем целые рубли.
Карточки сводки резали суммы («252…», «100.0…»): на десктопе их было 8 в ряд
(слишком узко), а дельта стояла в той же строке и доедала место.
- Дельта переехала ПОД значение — значение получает всю ширину карточки.
- Сетка сводки: убрал xl:grid-cols-8, максимум 4 в ряд (карточки шире).
StatCard получил опциональную дельту (стрелка + %), а страница статистики тянет
сводку ещё и за предыдущий равный период и считает рост/падение для метрик
периода: выручка, новые триалы, продления, доход с допов, ручные пополнения.
Для «всего времени» сравнение не показывается (не с чем сравнивать).
Полировка статистики продаж (читаемее/информативнее):
- BreakdownList — категорийные разбивки списком (иконка/цвет + значение + доля % +
пропорциональный мини-бар) вместо bar-графика. В «Депозиты → по методам» теперь
с иконками платёжек (PaymentMethodIcon), в «Продажи → по тарифам» — с долями.
- StackedBarChart — «по дням разбивкой» теперь stacked-бар вместо наслаивающихся
полупрозрачных area (читалось плохо); тултип по дню показывает все серии.
- Продажи: добавлена карточка «Выручка» (total_revenue бэк отдавал, но не показывался).
В recharts столбцы/секторы/точки становятся фокусируемыми, и при клике браузер
рисует свою обводку — артефакт на read-only графиках. Добавил гашение outline
для .recharts-wrapper/.surface/.sector/.rectangle.
METHOD_LABELS не содержал overpay, хотя метод есть в боте — в статистике/истории
он отображался как сырой ключ «overpay». Добавил человекочитаемое название.
Регрессия после a62d689: Back прятался только при deep-link входе
(depthRef === 0). При внутреннем переходе на /subscriptions/:id (depth > 0)
показывался Back, а в single-tariff «назад» уходил в /subscriptions, который
тут же редиректит обратно на деталь (Subscriptions.tsx) — петля, и юзер видел
Back вместо Close.
Решение depth-независимое: если список-редирект всё равно отбрасывает на эту
же страницу (single-tariff, ≤1 подписки = listRedirectsToDetail, инверсия
listIsSafe из обработчика), деталь — это верхний экран. Прячем Back всегда,
Telegram показывает нативный Close. Петля невозможна по построению: без кнопки
Back обработчик navigate(-1) в редиректящий список просто не вызвать.
Multi-tariff и >1 подписки сохраняют реальный Back к осмысленному списку.
The My Subscriptions list is a 2-col grid on sm+, so a single subscription (e.g. one tariff in multi-tariff mode) left an empty cell. Stretch the last card to span both columns when it's the lone one in its row — same orphan-fill used for the news grid and admin stat cards.
The extra lights that appear during a spin (and bunched into a line through the centre) were the SPARKLE_POSITIONS effect — yellow animate-ping dots rendered only while isSpinning. Moving/scattering them never satisfied; they're unwanted during the spin. Remove them outright: while spinning, only the rim LEDs remain — no extra lights, no centre line.
The center streak doesn't appear in screen recordings, which is the signature of a GPU-compositing artifact — CSS filters on SVG elements mis-render when the wheel group is promoted to a layer during the spin. Replace the two SVG CSS filters with compositing-safe equivalents: LED-glow blur -> radial gradient fill, emoji drop-shadow -> SVG-native feDropShadow. (HTML background blur left as-is; it's not on the SVG and isn't a light.)
Confirmed from the user's spin video: the sparkles bunched into the middle during a spin because they were placed at 16-40% from centre (some right by the hub). Push them all to a 34-43% radius (golden-angle distributed) so they ring the outer wheel and the centre stays clear — no sparkles crossing or near the centre.
The real cause of the center line: SPARKLE_POSITIONS (yellow dots shown only while spinning) used `20 + (i*10)%60` / `15 + (i*13)%70`. For the first ~6 sparkles the modulo never wraps, so top/left increment linearly and the dots land on a straight diagonal through the centre — the 'line of lights' during the spin. Scatter them on a golden-angle spiral inside the wheel instead.
Reverts the earlier LED-glow change (it wasn't the cause): the rim-light blur is restored to the original.
The rim lights are static cx/cy SVG circles; their glow used a CSS filter:blur(2px). During the spin the wheel's transform transition promotes the SVG to a GPU layer, and Chrome mis-renders CSS filters on geometry-positioned SVG elements — the blurs streak toward the view-box origin, drawing a line of lights through the centre. Replace the blur with a soft radial-gradient fill (no filter, so the artefact can't occur).
Also drive both the chase speed and the per-dot delay from one --led-dur var so the chase stays in order at the faster spin speed instead of scrambling (the delay was a fixed 6s value that broke once the duration dropped to 2s).
Windows ships no glyphs for regional-indicator flag emoji, so flags fell back to country letters everywhere react-twemoji wasn't manually wrapped. Bundle the Twemoji country-flags woff2 locally (no runtime CDN — matters for our audience) and add it as an @font-face scoped with unicode-range to flag codepoints only, then put it first in every Tailwind font stack (sans/display/mono). The browser now uses it solely for flag characters, so every flag renders correctly app-wide with zero markup changes and no effect on any other text.
On sm+ the regular news cards sit in a 2-col grid; an odd count left an empty cell. Move the featured card out of the grid (it was col-span-full anyway, which also skewed nth-child parity) and stretch the last regular card to span both columns when it's the lone one in its row — same orphan-fill used for the admin stat cards.
Jumping only happened on mobile (the wrap reshuffled when speeds changed width); desktop fits on one line and never jumped. So keep the 3 fixed rows for mobile (sm:hidden) and restore the original single flex-wrap row for desktop (hidden sm:flex). On mobile, download/upload now sit tight in a flex row (gap-4) instead of a full-width 2-col grid that looked too spread out.
The metrics sat in one flex-wrap, so when realtime speeds change width (Kb/s<->Mb/s, large values) elements reflowed and jumped between lines, and the card height shifted. Split into 3 fixed rows: processor (CPU load + RAM), traffic (down/up), versions (remnanode + xray). Traffic uses a 2-column grid so a wide value never pushes its sibling; tabular-nums keeps digit widths constant.
The panel marks each node version with its product logo. Add an XrayIcon (the Xray core brand pinwheel, custom SVG like RemnawaveIcon) and put RemnawaveIcon before the remnanode version + XrayIcon before the xray version, matching the original node rows. Drop the redundant 'xray' word since the logo conveys it.
Checked the live panel — every node metric carries an icon there, but our card showed RAM% and load average as bare text while the speeds had icons. Add MemoryIcon before RAM% and CpuIcon before the load average so the metrics row matches the original.
Give the inbound/outbound tag min-w-0 flex-1 so a long tag shrinks and ellipsizes instead of pushing the byte counters off-screen on narrow viewports; tighten the counter gap.
Drop the separate Traffic tab and fold its per-node inbound/outbound breakdown into the Nodes tab: click a node card to expand its traffic breakdown, click again to collapse (chevron indicator; action buttons stop propagation). Realtime totals (down/up/Σ) move to a compact line above the node list. Realtime stats now load on the Nodes tab.
The 2-column grid inside the max-w-2xl container made each gift card a narrow ~330px half. Gift cards are content-rich (header, code, share preview, buttons), so stack them single-column full-width — they now stretch to the container width on every breakpoint.
Each Remnawave user status now gets a distinct icon instead of the same people glyph: ACTIVE -> check-circle, DISABLED -> prohibit, LIMITED -> gauge, EXPIRED -> clock (people icon as fallback for unknown statuses).
The panel's provider.faviconLink is the provider's site URL (e.g. https://waicore.com/), not an image, so rendering it directly as <img src> failed and onError hid it. Derive the real favicon image from the URL's hostname via Google's favicon service, like the panel does.
Replace the user-facing 'RemnaWave' spelling with 'Remnawave' across all locale values (en/ru/zh/fa, 67 strings), t() display fallbacks, and code comments. Translation keys (refreshRemnaWave, ...) and code identifiers (RemnaWaveService, setRemnaWaveUuid, ...) are left untouched.
- Render the provider favicon in the node badge (new provider_favicon / provider_name fields), with graceful fallback.
- Mobile-friendly node card: stop the versions block from shifting right on wrapped rows, truncate long addresses, cap the provider badge.
- Give node-summary cards (total/online/offline/disabled) and traffic-period cards (2d/7d/30d/month/year) distinct, meaningful icons instead of all-the-same globe / bar-chart.
- Show traffic as 'used / limit' (e.g. 2.35 TB / ∞).
- Route every icon through the central barrel (drop the direct react-icons/pi import).