Commit Graph

92 Commits

Author SHA1 Message Date
Fringg
b615927283 fix(security): SRI для CDN-скрипта сканера QR
Автоматическое ревью справедливо отметило загрузку html5-qrcode с CDN без
проверки целостности: подмена файла на стороне CDN исполнилась бы в кабинете
с полными правами страницы (доступ к сессии, initData Telegram).

Скрипт подключается с integrity (sha384 сверен по фактическому файлу
html5-qrcode@2.3.8), crossorigin=anonymous и referrerPolicy=no-referrer. При
несовпадении хеша браузер откажется исполнять скрипт, и сканер деградирует в
«камера недоступна» вместо запуска чужого кода.

Заодно закрыт тот же изъян в исходном месте, откуда пришёл паттерн:
TvQuickConnect грузил тот же файл с jsdelivr вообще без проверки. Он
переведён на общий хелпер (файлы на unpkg и jsdelivr побайтово идентичны —
сверил), дублирующийся загрузчик и интерфейс удалены.

Виджеты telegram.org намеренно оставлены без SRI: это самообновляемые
эндпоинты Telegram, фиксация хеша сломала бы логин при их обновлении.
2026-07-29 22:14:32 +03:00
Fringg
0e4dc43335 feat(gift): QR подарка и сканирование в кабинете
Получателю раньше оставалось только скопировать ссылку или код руками.

- 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 теста на парсер.
2026-07-29 22:11:04 +03:00
Fringg
2dce539da2 feat(lava): автопродление Lava на странице подписки
Парная фронтовая часть к эндпоинтам /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 тестов утилиты.
2026-07-29 16:34:43 +03:00
Egor
2f04f29b68 Merge PR #526: охват сегментов и прогресс доставки на странице отправки 2026-07-29 12:29:29 +03:00
kewldan
c6d8c0afe0 feat(promo-offers): охват сегментов и прогресс доставки на странице отправки
Сегмент выбирался вслепую — в списке не было числа пользователей, а после
отправки экран показывал только количество созданных офферов: сколько
человек реально получило предложение и сколько заблокировало бота, узнать
было негде.

В селекте сегментов теперь стоит охват каждого (GET /promo-offers/segments),
под ним — сколько уйдёт по выбранному. После отправки экран результата
показывает прогресс доставки с карточками Всего/Отправлено/Заблокировали/
Ошибки и опрашивает запись рассылки раз в 3 секунды, пока она не завершится.

Прогресс-блок и бейдж статуса вынесены из AdminBroadcastDetail в
BroadcastDeliveryStats и переиспользуются обеими страницами, а условие
«доставка ещё идёт» — в утилиту broadcastStatus с тестами.
2026-07-28 22:24:58 +03:00
kewldan
888a02f397 fix(subscription): корректная цена за месяц при выборе периода
Для периодов короче месяца в карточке периода под ценой выводилась
та же сумма с подписью «/мес»: цена за 7 дней показывалась как цена
за месяц. Причина — деление на max(1, days / 30), которое для коротких
периодов даёт делитель 1.

Расчёт вынесен в getMonthlyPriceKopeks: месячная ставка считается
пропорцией (price * 30 / days) и не показывается для периодов в месяц
и короче, где она либо дублирует цену, либо вводит в заблуждение.
Тот же хелпер применён на экране продления, где месячная цена для
периодов вроде 45 дней считалась делением на округлённое число
месяцев.
2026-07-28 21:23:08 +03:00
Fringg
51d0f0431a fix(security): инлайновые regex-барьеры для CodeQL в openAppScheme и redirect.html
Хелпер-функции (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) отбрасываются. Семантика та же.
2026-07-25 00:32:00 +03:00
Fringg
aa6bf44db9 fix(security): внутренний гард openAppScheme от опасных схем (CodeQL)
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).
2026-07-25 00:20:52 +03:00
Fringg
2cd494e949 Merge PR #508: различать устаревшего бота и ненастроенного на логине
404 на /cabinet/branding/telegram-widget и deeplink-роутах = сборка бота
старше v3.24/v3.33 — вместо «бот не настроен» показываем auth.botOutdated
(4 локали). README: фактический минимум v3.33.0. Закрывает #345.
Спасибо @kewldan.
2026-07-24 15:00:36 +03:00
Fringg
05a7c6b02a Merge PR #506: приветствие без пустого имени для email-пользователей
displayName() получил фолбэк на локальную часть email; без имени вовсе —
dashboard.welcomeNoName без запятой (4 локали). Закрывает #423.
Спасибо @kewldan.
2026-07-24 14:50:21 +03:00
Fringg
21ab524aa4 Merge PR #503: deep-link на iOS через top-level переход
iOS запускает кастомную схему только из top-level навигации с жестом —
скрытый iframe молча игнорируется (кнопка «Добавить подписку» не
работала в Safari и миниаппе, больнее всего на iOS-first INCY).
iframe оставлен для Android in-app браузеров (ERR_UNKNOWN_URL_SCHEME).
Симметрично в openAppScheme.ts и redirect.html. Спасибо @AirP0WeR.
2026-07-24 14:30:10 +03:00
Fringg
8b666589eb Merge PR #502: ужесточить resolveSupportContact
Схема support_url валидируется (http/https/tg, чужое → null), legacy
SUPPORT_USERNAME проверяется регуляркой (URL-образное не клеится в
t.me/https://…), null → кнопка контакта не рендерится вместо дефолтного
@support. Ветки ticketsDisabled/both переведены на общий резолв.
Спасибо @AirP0WeR.
2026-07-24 14:21:59 +03:00
Fringg
2e17f8a7e0 Merge PR #505: локализовать дефолт экрана подписки на канал
Захардкоженный английский дефолт бэкенда из 403 channel_subscription_required
перебивал переведённую blocking.channel.defaultMessage. Хелпер
customChannelMessage() отбрасывает известный дефолт и пустые значения,
кастомный текст проходит как есть. Закрывает #311. Спасибо @kewldan.
2026-07-24 14:08:57 +03:00
Fringg
9093aec207 feat(subscription): API и утилиты СБП-автопродления Platega 2026-07-24 02:14:41 +03:00
kewldan
b3b07c322d fix(auth): различать устаревшего бота и ненастроенного на экране логина 2026-07-23 04:09:06 +03:00
kewldan
147b1fbb2e fix(dashboard): приветствие без пустого имени для email-пользователей 2026-07-23 03:36:26 +03:00
kewldan
5e2e3214c9 fix(blocking): локализовать дефолтное сообщение на экране подписки на канал 2026-07-23 03:36:17 +03:00
airp0wer
03aa2e6312 fix(connection): открывать deep-link приложения на iOS через top-level переход, а не iframe
На 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.
2026-07-21 10:36:42 +03:00
airp0wer
ebde1f6174 fix(support): ужесточить resolveSupportContact — схема, legacy-username, null
Инкремент поверх 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 идентичны).
2026-07-21 10:28:31 +03:00
Fringg
f04675b249 fix(support): не склеивать t.me со ссылкой на внешний хелпдеск
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.
2026-07-20 21:41:08 +03:00
kewldan
81fd96c2a7 fix(i18n): форматировать даты и числа локалью выбранного языка, а не браузера 2026-07-14 00:04:45 +03:00
kewldan
42788e842d test: подключить vitest и покрыть утилиты юнит-тестами 2026-07-13 01:09:33 +03:00
Fringg
b011b55f04 refactor(coupons): единый formatShortDate + честная статистика списка + retry
- formatShortDate вынесен в utils/format вместо трёх копий formatDate +
  инлайн localeMap (AdminCoupons/AdminCouponDetail/CouponStatus)
- список партий: агрегатные карточки Активны/Погашено/Отозвано считались
  только по текущей странице и противоречили глобальному total «Партий» —
  показываем их лишь когда весь набор влезает на одну страницу
- CouponStatus: retry:false на публичном статусе (404 — ожидаемый ответ на
  невалидный/погашенный токен, ретрай зря бьёт rate-limited эндпоинт)
2026-07-12 00:34:41 +03:00
Fringg
37a53e40ea fix(auth): make login footer legal links open real pages, not the login tab
The login-page footer linked to /offer, /privacy and /recurrent-payments with
target=_blank, but none of those routes existed, so opening them hit the SPA
catch-all, which redirects unauthenticated users to /login — every footer link
just opened another login tab.

- Add a public PublicLegal page that fetches the offer / privacy document from the
  existing public /cabinet/info endpoints and renders sanitized HTML (DOMPurify via
  the shared formatContent helper).
- Register public /offer and /privacy routes.
- Extract sanitizeHtml/formatContent into src/utils/legalContent.ts and reuse from
  Info.tsx (was duplicated).
- Drop the /recurrent-payments footer link: there is no recurring-payments legal
  document or endpoint in the backend, so it had no real destination. Re-add once
  such a document exists.

type-check, eslint, prettier and build all pass.
2026-07-09 18:14:49 +03:00
Fringg
8f31311112 fix(connection): keep the Happ add-subscription button working on Remnawave 2.8.0
Panel 2.8.0 removed the server-side happ-encrypt endpoint, so for users
without a stored crypto link the backend leaves {{HAPP_CRYPT4_LINK}}
unresolved and the subscriptionLink button silently disappeared
(isValidDeepLink requires '://').

- BlockButtons: resolve button templates client-side at render (with username,
  like the panel's own subpage) instead of dropping the button; collapse
  double-prefixed happ://crypt4/happ://cryptN/... resolvedUrl from old backends
- templateEngine: collapse a hardcoded happ://cryptN/ prefix before
  {{HAPP_CRYPT[34]_LINK}}; cache crypt-link generation (jsencrypt RSA-4096 is
  slow on weak devices and re-randomizes every call)
- Connection: in happ_cryptolink mode force the crypt link only when the button
  fell back to the plain subscription link or kept unresolved templates - an
  explicit Subpage link (e.g. happ://add/...) now wins, so Subpage edits apply
2026-07-02 16:12:18 +03:00
c0mrade
7f68e2cd5e fix(theme): readable text on any operator palette + admin consistency pass
Контраст-аудит (WCAG-обход всех текстовых узлов, 4 палитры × юзер+админ
страницы) показал системные провалы читаемости: вторичный текст 1.5-2.5
на светлой/кастомных темах, статусные цвета 1.3-1.6, белый текст на
светлых акцентах 1.9.

Корневые фиксы:
- useThemeColors: контраст-кламп производных серых токенов — dark-400/
  champagne-600 (вторичный текст) держат >=5.0 к поверхности, dark-500/
  champagne-500 (подсказки) >=3.8; палитры с достаточным контрастом
  рендерятся байт-в-байт как раньше
- --color-on-accent/-success/-warning/-error: чёрный или белый текст
  по фактической яркости цвета оператора; text-white на accent-заливках
  заменён на text-on-accent (135 мест + variant примитива Button +
  .btn-primary)
- .light: статусные текстовые оттенки *-300/400 ремапятся на тёмные
  *-700 (жёлтая «Админка» 1.26 -> 3.8+, зелёные суммы 1.4 -> 3.4+,
  бейджи состояний)

Замер после: операторская тёмная палитра — 0 нарушений (всё >=4.5),
худшие точки остальных подняты с 1.26-1.6 до 3.2+.

Консистентность:
- заголовки админ-страниц приведены к text-xl font-bold text-dark-100
  (были 18/20/24/30px и 600/700 вперемешку)
- «Создать FAQ»: warning-500/80+белый -> warning-500+text-on-warning
- formatTraffic: единицы из i18n (убран микс «GB»/«ГБ» на одном экране)
- categoryCanvas -> categoryCANVAS в локалях (битый ключ на настройках)
- «Управление сквадами» -> «Управление локациями» (юзерский экран)
- мин-макс сумм на выборе метода: dark-600 -> dark-400
- неактивные пункты нижнего нава: dark-500 -> dark-400
2026-06-11 15:49:17 +03:00
Boris Kovalskii
510e4bd738 feat(backgrounds): add constellation background 2026-06-11 11:14:57 +10:00
Boris Kovalskii
71160c6700 feat(backgrounds): add liquid-gradient background 2026-06-11 11:14:57 +10:00
Boris Kovalskii
ab91c86f81 feat(backgrounds): add matrix-rain background 2026-06-11 11:14:57 +10:00
Boris Kovalskii
3742f2f88d feat(backgrounds): add starfield background with depth projection 2026-06-11 11:14:57 +10:00
Boris Kovalskii
e3dbc4e663 feat(backgrounds): add snowfall background 2026-06-11 11:14:57 +10:00
Boris Kovalskii
1d00ca97e5 feat(backgrounds): add fireflies background 2026-06-11 11:14:57 +10:00
c0mrade
81581f5c9b fix: guard decodeURI(Component) against lone-surrogate crash (#667225)
'String contained an illegal UTF-16 sequence' on first Mini App / site
open for users with a truncated emoji in their Telegram name.

The existing surrogate guard only patched the ENCODE direction
(encodeURI/encodeURIComponent). But Telegram percent-encodes such names
as CESU-8 (%ED%A0%BD) in tgWebAppData, and decodeURI/decodeURIComponent
throw on those bytes in every engine (JSC wording is the reported one) —
any decode of init-data-derived strings crashed into the ErrorBoundary.

Not valibot: valibot 1.0.0 has no UTF-16 well-formedness validation
(verified against the installed package), and upstream pins it exactly,
so there is nothing to bump.

decodeURI/decodeURIComponent now try the native decoder first and fall
back to lenient WHATWG-style UTF-8 decoding with U+FFFD replacement —
the same semantics URLSearchParams applies to the same bytes. Decoded
values still flow through existing validation (getSafeRedirectPath etc),
so nothing becomes less strict.
2026-06-10 22:53:25 +03:00
c0mrade
ce5737fcbc fix(payments): open_url_direct payment URL opens externally in Telegram (#654272)
The reported #654272 case ('функция немедленного открытия ссылки', reproduced on RollyPay AND YooKassa, fine on desktop) is the open_url_direct flow — not the connection deep-link opener fixed in 325e221.

When a payment method has open_url_direct, the cabinet did window.location.href = payment_url INSIDE the Telegram in-app WebView. SBP/RollyPay/YooKassa pages then hand off to a bank app via a custom scheme, which the WebView can't open: Android shows net::ERR_UNKNOWN_URL_SCHEME, iOS opens nothing ('приложение не определяется'); link generation logs fine. Desktop works because it's a real browser.

Add openPaymentUrl(): in Telegram open via openLink (external browser — the OS hands off to the bank app, return_url brings the user back); on web keep same-tab navigation (no popup blocker). Applied to TopUpAmount (top-up) and GiftSubscription (gift purchase). QuickPurchase is a web landing page (no platform abstraction) and is unaffected.
2026-06-08 14:21:45 +03:00
c0mrade
325e221e32 fix(connection): stop ERR_UNKNOWN_URL_SCHEME when opening app deep links on mobile
Telegram bug #654272: opening the connection app link (immediate-open / connect button)
showed a full-page net::ERR_UNKNOWN_URL_SCHEME on Android and silently failed on iOS, while
working on desktop.

Cause: a programmatic top-level navigation to a custom scheme (happ://, v2rayng://, …) via
window.location.href is rendered as a full-page error inside in-app WebViews (Telegram/Yandex)
on Android and does nothing on iOS — also wiping the fallback UI.

Add openAppScheme(): http(s) navigate normally; custom schemes launch via a hidden, contained
iframe so a failed launch never replaces the page — the app opens if installed, otherwise the
manual 'Open app' link stays usable (a user tap is the reliable trigger). Applied in
DeepLinkRedirect.tsx, Connection.tsx and the static public/miniapp/redirect.html (which now
also surfaces the manual button immediately instead of after 2s).
2026-06-08 13:11:19 +03:00
c0mrade
d37872fd4f fix(cabinet): harden global encoders against lone UTF-16 surrogates
A lone (unpaired) UTF-16 surrogate — a truncated emoji in a backend name/remark
embedded in a subscription/connection URL — makes encodeURI/encodeURIComponent throw
a URIError on iOS WebKit (V8: 'URI malformed'; Safari/JSC: 'String contained an
illegal UTF-16 sequence'). qrcode.react calls encodeURI internally, so such a value
crashed the QR render and tripped the page-level ErrorBoundary ('Something went
wrong / String contained an illegal UTF-16 sequence / Try again').

Rather than wrap each (current and future) call site — and third-party libs we can't
edit — sanitise at the single chokepoint: patch the global encodeURI/encodeURIComponent
at bootstrap to replace lone surrogates with U+FFFD (same remedy as toWellFormed()).
Fail-safe, not fail-broken: verified byte-identical output for all well-formed strings
(URLs unaffected) and no-throw on lone surrogates for the real call patterns (qrcode.react
and btoa(unescape(encodeURIComponent(...)))). A fast path skips the rewrite when no
surrogate code units are present, so the hot path (every request URL) is untouched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-08 11:11:56 +03:00
c0mrade
ac8a0fc41a feat(cabinet): recoverable "service unavailable" screen when the backend is unreachable
When the backend was down the cabinet got stuck on a blank loader: the bootstrap
token refresh used bare axios with no timeout (hung forever), and every
interceptor guard required an HTTP response — so a transport-level failure had
zero handling and no error UI ever appeared.

Add a full-screen ServiceUnavailableScreen (extends the existing blocking-store
pattern) shown whenever the backend is unreachable, that auto-recovers when it
returns:
- New 'backend_unavailable' blocking type + BlockingOverlay branch; mirrors
  MaintenanceScreen, CloudWarningIcon, i18n in ru/en/zh/fa.
- api/health.ts: pingBackend() probes the root /health/unified (bypasses the
  /api baseURL + interceptor); URL derived from the origin for remote/sub-path
  deploys; 502/503/504 count as down. reportPossibleBackendDown() confirms an
  outage with a liveness probe before flipping, so a one-off blip never blanks a
  loaded app.
- Detected from the response interceptor (no-response) and the bootstrap refresh
  path; doRefresh gets a timeout so it can no longer hang.
- A transport failure during refresh now PRESERVES the session (distinguished
  from a rejected token via lastFailureWasTransport) instead of logging the user
  out, so recovery actually resumes.
- Recovery lifts the overlay + refetches for an already-loaded session, and only
  hard-reloads when the initial bootstrap never reached the backend — no lost
  form state. Manual retry + 5s auto-poll. Dev proxy for /health.
2026-06-04 12:44:27 +03:00
c0mrade
71047780c9 fix(favicon): скругляем углы кастомного лого как у плитки в хедере
Кастомное лого квадратное, и в фавиконе показывалось с острыми углами. Теперь
лого прогоняется через canvas со скруглённым клипом (radiusRatio 0.3 — как
rounded-linear-lg 12px на плитке 40px в хедере) и отдаётся скруглённым PNG.
При сбое canvas (taint/нет 2d-контекста) — фолбэк на сырой лого. Применяется и
в кабинете (useBranding), и на лендинге (QuickPurchase); буквенная монограмма
уже скруглена через rx.
2026-06-03 12:33:10 +03:00
c0mrade
5a6e4589c8 fix(landing): заголовок «Loading...» и пустая иконка вкладки на лендингах
Публичный лендинг (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 (синхронно с бэком).
2026-06-03 11:54:23 +03:00
c0mrade
9a05f23a04 feat(sales-stats): пресет «Этот месяц» (с 1-го числа) и он же по умолчанию
Статистика по умолчанию открывается за текущий месяц с 1-го числа до сегодня
(month-to-date), а не за фиксированные 30 дней. Добавлена кнопка «Этот месяц»
в селектор периода; util getMonthToDateRange/isMonthToDate.
2026-06-02 15:55:10 +03:00
c0mrade
424a19344d fix(security): extract & reuse getSafeRedirectPath, plug TopUpAmount returnTo
TelegramRedirect already had a local getSafeRedirectUrl helper that
collapsed protocol-relative URLs, absolute URLs, exotic schemes, and
URL-encoded forms down to '/'. TopUpAmount.handleSuccess was navigating
straight to a user-supplied returnTo query param without that filter —
not externally exploitable through react-router's navigate() (it doesn't
trigger an external nav), but a crafted link could produce ugly path
artefacts ('?returnTo=https://evil.com' would land the user at
/balance/top-up/<method>/https://evil.com).

Hoist the helper to src/utils/safeRedirect.ts, rename to
getSafeRedirectPath, reuse it in TelegramRedirect, and wrap TopUpAmount's
returnTo through it before navigate().
2026-05-26 16:13:33 +03:00
c0mrade
3df2134cba refactor(theming): orange-* → warning-* for promo/highlight semantics
Sweep orange across SubscriptionPurchase/GiftSubscription/PromoOffersSection/
AdminTrafficUsage/AnalyticsTab/withdrawalUtils to warning-* tokens. Stars brand
yellow→orange gradient kept in TopUpAmount.
2026-05-26 11:22:19 +03:00
c0mrade
d7cbbe077a refactor(theming): yellow-* → warning-* for status/pending semantics
Sweep yellow color classes that signal pending/warning status (admin pending pills,
withdrawal status, risk indicators) → warning-* tokens. Telegram Stars / fortune wheel
yellow kept as brand (TopUpAmount + Wheel + FortuneWheel).
2026-05-26 11:21:16 +03:00
c0mrade
f31160208a feat(auth,a11y): Telegram CloudStorage token recovery + blocking-screen a11y
Telegram CloudStorage token recovery (resilience against WebView localStorage wipes):
- mirror the refresh token to per-user CloudStorage on login, remove it on logout
- restoreRefreshTokenFromCloud() recovers the refresh token in initialize() when
  localStorage is empty, then the normal refresh flow re-establishes the session
- move the initialize() bootstrap call from auth.ts module-load into main.tsx after
  the Telegram SDK init(), since launch params + CloudStorage are unavailable before init
- best-effort: no-ops outside Telegram and on any error -> falls back to prior behavior

Accessibility:
- role=alertdialog + aria-modal + aria-labelledby + focus management (useFocusTrap)
  on Maintenance, Blacklisted, ChannelSubscription and AccountDeleted blocking screens
- aria-label on ColorPicker Hue/Saturation/Lightness range inputs
2026-05-25 23:43:17 +03:00
c0mrade
2bcba3b60f fix(navigation): make Telegram back button work on deep-link entry
Bot deep-links open the Mini App directly on nested routes (/admin,
/balance/top-up, /info, /profile, ...) where React Router history has a
single entry, so navigate(-1) was a no-op and the native back button
looked dead. Fall back to the derived parent route when there is no
in-app history (window.history.state.idx === 0).
2026-05-22 15:02:56 +03:00
Fringg
f301d44f24 fix(admin): show flag for every country code, not just hardcoded 25-36
User reported the EE (Estonia) flag rendered as plain text 'EE' on the
admin servers page. Root cause: getCountryFlag() in AdminServers.tsx
and AdminServerEdit.tsx hardcoded a 25-entry codeMap that didn't
include EE; falling through to 'return code' produced raw text.

Two other admin pages (AdminRemnawaveSquadDetail, AdminUserDetail)
had bigger 35-entry maps that did include EE — so EE worked there
but MX/AR/EG/ZA and friends still wouldn't. AdminRemnawave repeated
the same 35-entry map. AdminTrafficUsage already had the correct
algorithmic ISO→regional-indicator code but as a local duplicate of
utils/subscriptionHelpers.

Unify all six on the single algorithmic helper:

- getFlagEmoji() in utils/subscriptionHelpers.ts now:
  * accepts string | null | undefined (callers don't need to guard)
  * trims whitespace
  * validates [A-Za-z]{2} before composing regional indicators
- Each admin page now either imports getFlagEmoji directly or wraps
  it with the page's preferred fallback character (e.g. '🌍' for
  empty codes where the UI expects a placeholder).
2026-05-16 01:11:00 +03:00
Fringg
4de01ccd1d fix(dashboard): show full name on welcome, fix gender mismatch on trial-expired card
User reported single-letter welcome "Добро пожаловать, О!" (short first_name
"Олег"-style users) and a confused "Истекла" feminine label on the masculine
"Пробный период истёк" trial-expired card.

* Add `src/utils/displayName.ts` helper that composes `first_name + last_name`
  with `username` and `#telegram_id` fallbacks. Single source of truth for
  user-facing name rendering across the app.
* Apply `displayName(user)` in Dashboard welcome, AppHeader mobile drawer,
  and DesktopSidebar profile chip. Now a user with `first_name="О"` and
  `last_name="Иванов"` sees "О Иванов" instead of just "О".
* `SubscriptionCardExpired` — context-aware Russian label: for trial
  subscriptions render masculine "Истёк" (agrees with "пробный период"),
  for paid subscriptions keep feminine "Истекла" (agrees with "подписка").
  Uses i18next `context: subscription.is_trial ? 'trial' : ''` — falls back
  to base key for EN/ZH/FA which are grammatically neutral.
* Add `expiredDate_trial: "Истёк"` only to `ru.json` (no changes needed for
  other locales — i18next context falls back to `expiredDate`).
2026-05-13 09:02:10 +03:00
Fringg
6ffd0ae824 fix(format): convert price by exchange rate on landings and gift page
formatPrice from utils/format.ts only swapped the currency symbol
(220 ₽ -> ¥220) without converting the underlying amount, because it
had no source of exchange rates and the landing/gift pages never called
the useCurrency hook that knew how to fetch them.

- Add a module-level rates cache in utils/format with setExchangeRates
  setter. formatPrice now converts kopeks/100 from RUB to the target
  currency via currencyApi.convertFromRub when rates are cached.
- useCurrency pushes its loaded rates into that cache so any subsequent
  formatPrice call benefits, including subcomponents that cannot easily
  receive a prop.
- Call useCurrency in QuickPurchase, GiftSubscription, and
  AdminLandingEditor — the only entry points whose subcomponents still
  use the synchronous formatPrice (everything else already routes
  through useCurrency.formatAmount).

For RU locale behavior is unchanged. For EN/ZH/FA the amount is now
divided by the per-currency rate and formatted via Intl.NumberFormat
with 2 fraction digits (0 for IRR since amounts are large).
2026-05-13 04:56:15 +03:00
Fringg
020f4c95e2 feat: landing analytics goals, daily bar chart, referrer tracking, contact persistence
- Add per-landing analytics goals (view/click) with admin editor toggle
- Add sticky pay button option for mobile landing pages
- Add daily purchases bar chart (created vs paid) to landing stats
- Replace single purchase count with created/paid split in stats summary
- Add referrer tracking to purchases with hostname display in stats
- Add time display to purchase cards alongside date
- Pass user timezone to stats API for correct daily grouping
- Clamp referrer (500 chars) and subid (255 chars) to backend limits
- Persist contact value per-landing-slug in localStorage
- Fire buy_success analytics goal on successful delivery
- Export USER_TIMEZONE from format utils
- Add analytics/stats translations for fa.json and zh.json locales

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 09:17:52 +03:00
Fringg
80059681da feat: Yandex Metrika CID tracking, offline conversions UI, sticky pay button
- Pass yandex_cid to all auth endpoints (telegram, email, OIDC, OAuth)
- Add OfflineConvGoal interface and offline_conv_* fields to AnalyticsCounters
- Add storeYandexCid API method for server-side CID persistence
- Add analytics fields to LandingConfig (view/click goals, sticky_pay_button)
- Add yandex_cid/referrer/subid to PurchaseRequest
- Add offline conversions UI block in admin AnalyticsTab
- Add cacheYandexCid, syncYandexCid, fireAnalyticsEvent to analytics hook
- Create yandexCid.ts utility (localStorage get/set helpers)
- Add sticky pay button with portal on mobile in QuickPurchase
- Fire view/click analytics goals on landing pages
- Persist contact value and referrer/subid in session/localStorage
- Add i18n keys for offlineConv and apiKey in all 4 locales
2026-04-29 08:59:19 +03:00