Pairs with the bot commit that adds the user_device_aliases table.
Surfaces the new local_name field on the existing devices list in
both the user-facing subscription page and the admin user-detail
page, with the same inline-edit pattern in both places.
User-side (src/pages/Subscription.tsx):
- pencil button next to each device row toggles edit mode
- input is focused automatically, capped at 64 chars (matches the
backend ALIAS_MAX_LENGTH and DB column width)
- Enter saves, Escape cancels, empty input clears the alias
- display priority: local_name → device_model → platform
- works identically in classic / single-tariff / multi-tariff —
subscriptionId is forwarded as a query param like every other
device-management endpoint already does
Admin-side (src/pages/AdminUserDetail.tsx):
- same pencil + inline input pattern, admin acts on behalf of the
user. notify.success on save, loadDevices() refresh.
API (src/api/subscription.ts + src/api/adminUsers.ts):
- new renameDevice(hwid, name, subscriptionId?) on subscriptionApi
- new renameUserDevice(userId, hwid, name) on adminUsersApi
- existing getDevices/getUserDevices contracts widened with
local_name?: string | null on the returned device shape
Locales (src/locales/ru.json):
- subscription.renameDevice / .renameDeviceSave / .renameDeviceCancel
/ .renameDevicePlaceholder / .deviceRenamed
- admin.users.detail.devices.rename / .renameSave / .renamed
Security reviewer flagged that the safety of injecting payment-provider
verification tokens into <meta content=...> rests entirely on the use
of Element.setAttribute() (plain-string attribute, browser does not
parse as HTML). A future contributor switching to innerHTML or template-
string concatenation into <head> would turn this hook into an XSS sink.
Add a comment that calls this out explicitly so the invariant survives
maintenance.
Pairs with the bedolaga-bot commit that exposes
GET /cabinet/public/site-verification (JSON: { apay_tag: string | null }).
New useSiteVerification hook fires once on App mount, fetches the
configured tag value, and upserts <meta name='apay-tag' content='...'>
into document.head. When the bot returns null we proactively remove
any previously-rendered tag so toggling the env var off cleans up the
page.
Failure modes are silent — verification is best-effort and must
never block the cabinet from rendering. No admin UI field is needed:
the value lives in the bot's .env (ANTILOPAY_APAY_VERIFICATION_TAG),
matching how all other Antilopay credentials are configured.
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).
- Tie spotlight/tooltip pointer-events to isVisible so opacity:0 stops
trapping taps during step transitions
- Render target click-catcher only when overlay is fully visible and
clear stale targetRect on step change
- Retry target lookup up to 6 times and auto-skip step (or complete
tour) when target is missing, instead of leaving overlay stuck
invisible-but-interactive
- Persist completion to localStorage before unmounting the tour, so
the flag survives even if unmount throws
Vite warned that `api/branding.ts` was dynamically imported from
`useAnalyticsCounters.ts` while also being statically imported by 18 other
files. The mixed static+dynamic pattern blocked Vite from splitting the
module into a separate chunk — the dynamic import was a no-op for bundle
size and only added an extra Promise hop.
The same file already had `import { brandingApi } from '../api/branding'`
at the top. Inlined the storeYandexCid call to use it directly.
Post-review nits:
* AdminPaymentMethodEdit.tsx: `config.open_url_direct ?? false` when seeding
state — defends against stale backend rendering (e.g. cache invalidation
race before migration applies)
* Same file: aria-label now uses the localized `admin.paymentMethods.openUrlDirect`
translation key instead of hardcoded English
* TopUpAmount.tsx: case-insensitive guard for Telegram deep-link URLs —
`https://t.me/`, `http://t.me/`, and `tg://` all match regardless of provider
casing quirks. Lowercase normalization done once before the comparisons.
User asked for the gift-style seamless flow on balance top-up: provider
checkout opens inside Telegram MiniApp WebView without a click-to-open link
panel. Made it an admin per-method toggle so it can be enabled selectively.
src/pages/TopUpAmount.tsx — on topup mutation success:
* Move saveTopUpPendingInfo to BEFORE any redirect so /balance/top-up/result
can still pick up the pending payment after the provider's return_url fires
* If method?.open_url_direct === true AND the URL is not a t.me/ deep link
(Stars/CryptoBot), window.location.href = redirectUrl and return early
* Otherwise fall through to the existing setPaymentUrl panel — preserves
current behavior for methods without the flag enabled
The t.me/ guard is important: window.location.href to a Telegram deep link
inside a MiniApp WebView is unreliable (native shell cant always intercept).
Those URLs continue to go through openTelegramLink / openInvoice in the panel
path. Stars never reaches topUpMutation.onSuccess anyway (handled by the
separate starsPaymentMutation); the guard is defense-in-depth for CryptoBot
and any future t.me-deep-link providers.
src/pages/AdminPaymentMethodEdit.tsx — added Open URL directly toggle with
the same slider styling as the is_enabled toggle. Defaults to off.
src/types/index.ts
* PaymentMethod.open_url_direct?: boolean (user-facing)
* PaymentMethodConfig.open_url_direct: boolean (admin shape)
Translations added for ru/en/zh/fa with a hint clarifying behavior and the
t.me/ exemption.
User report: a user in EN locale paid 1.65 USD for a 150₽ subscription, but the
bot received 14960 kopeks (149.60₽) — short by 40 kopeks, blocking the
subscription purchase.
Root cause: TopUpAmount.tsx displays the prefilled RUB amount converted via
`.toFixed(2)`. With an exchange rate like 90.66 RUB/USD, 150₽ → 1.6545 USD →
displayed "1.65" (rounded down by 0.0045 USD ≈ 0.4 RUB). When the user submits
without editing, `convertToRub("1.65")` runs again and returns 1.65 × 90.66 =
149.589₽ — less than the 150₽ the user is trying to pay. Math.round/Math.ceil
on this only handles sub-kopek IEEE-754 fractions, not the deeper FX display-
rounding direction.
Fix: when the user does not edit the prefilled amount (`amount === initialDisplayAmount`)
and `initialAmountRubles > 0` is known, bypass the FX round-trip and send
`Math.round(initialAmountRubles * 100)` directly. Math.ceil for non-RUB targets
when the user does type a custom amount still helps with sub-kopek fractions.
`?amount=150` from a renew CTA → "1.65 USD" displayed → user clicks pay → bot
receives 15000 kopeks instead of 14960. Subscription renews as expected.
Follow-up to displayName migration. Profile page still rendered
`{user?.first_name} {user?.last_name}` directly, leaving a trailing space when
last_name is null. displayName() handles the joining and fallback uniformly.
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`).
Two cabinet routes never showed TrialOfferCard for users without any
subscription when MULTI_TARIFF was enabled:
1. Dashboard (/)
hasNoSubscription was derived from subscriptionResponse, but the
/cabinet/subscription query is disabled in multi-tariff mode
(`enabled: !isMultiTariff`). subscriptionResponse stayed undefined
forever, so `has_subscription === false` was never true and the
trial card never rendered.
Fix: branch on isMultiTariff — use multiSubData.subscriptions.length
when in multi-tariff, keep the single-tariff path unchanged.
2. /subscriptions list
When the array came back empty the page rendered EmptyState directly,
bypassing trial eligibility entirely. Users who navigated to
'Подписки' from the menu before opening the dashboard saw the empty
placeholder and nothing about the trial.
Fix: fetch trial-info on empty state, render TrialOfferCard if
trialInfo.is_available, fall back to EmptyState otherwise. Also
wired the activate mutation locally (mirrors Dashboard) so the
button works without bouncing through the home screen first.
Single-tariff behavior is unchanged. After the fix both entry points
agree: if you have no subscriptions and trial is available, you see
the offer regardless of which page you opened first.
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).
The per-device price line in the buy-devices modal showed a
strikethrough 'Бесплатно' for users with a promo-group discount,
because original_price_per_device_kopeks was missing in the API
response and formatPrice(0) renders 'Бесплатно'. Now the
strikethrough is rendered only when original_price_per_device_kopeks
is present and non-zero, mirroring the existing guard on the total
price block.
- METHOD_LABELS entry for lava
- Custom SVG icon: dark base with orange-red lava flow and ember dots
- Admin settings tree node: payments_lava
- Locale strings (ru/en/zh/fa) for descriptions and admin labels
The reissue button was inside the Additional Options card which is gated
by device_limit !== 0, so it was hidden when device_limit was 0. Move it
to its own card block with independent visibility condition.
- Add antilopay to METHOD_LABELS for stats charts
- Add PaymentMethodIcon case with amber gradient SVG
- Add payments_antilopay to admin settings tree
- Add i18n keys in all 4 locales (en, ru, zh, fa)
- Add etoplatezhi to METHOD_LABELS for stats charts
- Add PaymentMethodIcon case with green gradient SVG
- Add payments_etoplatezhi to admin settings tree
- Add i18n keys in all 4 locales (en, ru, zh, fa)
When user selects Android TV or Apple TV platform (from non-TV device),
shows 5-char code input + QR scanner to send subscription to TV via
Happ TV API. Hybrid QR strategy: native Telegram scanner on mobile,
html5-qrcode CDN fallback on desktop. i18n keys for ru/en/zh/fa.
Based on PR #415 by @smediainfo.
Add BroadcastPreview component with local rendering of Telegram messages
(HTML tokenizer with tag whitelist, no dangerouslySetInnerHTML) and Email
preview (sandboxed iframe). Preview buttons appear in broadcast create page
section headers, disabled when content is empty.
Based on PR #416 by @smediainfo.
Add apple_iap across all payment surfaces: method label, icon (Apple
logo SVG), admin settings tree (APPLE_IAP category), and all four
locale files (en, ru, fa, zh) with payment description + tree label.
Add force_delete_active_paid guard to prevent accidental deletion of
active paid subscriptions. Shows warning with count and requires
explicit checkbox confirmation. Also fixes allVisibleSubscriptionIds
to use filteredUsers and getFilteredSubs to respect trialOnly filter
on subscription sub-rows.
- 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>
- 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
- Add media_items array support to ticket messages (types, API clients)
- Create shared MessageMediaGrid component with photo grid, fullscreen
viewer, keyboard nav, video/document rendering
- Replace per-page AdminMessageMedia/MessageMedia with MessageMediaGrid
- Add linkifyText util (DOMPurify-sanitized) for auto-linking URLs
- Support multi-file upload (up to 10) in AdminTickets and Support pages
- Remove unused ticketsApi import from AdminUserDetail
- Show VPN connection block even when panelInfo not yet loaded
- Add subscription dropdown when user has multiple subscriptions
- Show spinner while loading, "no data" when panel returns not found
- Changing subscription in VPN block reloads panel info for that sub
- Request history subscription selector no longer mutates shared
activeSubscriptionId — uses independent requestHistorySubId state
- Request history reloads when subscription selector changes
- Split mount effect: loadPanelInfo in separate effect to avoid
redundant loadUser calls when activeSubscriptionId changes
- Load panelInfo eagerly on page load (not just on subscription tab)
- Add VPN connection card to info tab: last/first connection, online status indicator, last node
- Rename "Last activity" to "Bot activity" to distinguish from VPN connection
- Show cabinet_last_login field that was never rendered
- Add collapsible subscription request history section in subscription tab with paginated table
- Add subscription request history API types and method
- Add i18n keys for all 4 locales (ru, en, zh, fa)
Promise.all was used for tariffs, promo groups, campaigns, and partners.
If any one endpoint failed (e.g., partners not configured), all four
setCampaigns/setPartners/etc calls were skipped. Now each loads independently.