UI для купонов из BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot#3042
(API: PR #3044 бота):
- /admin/coupons — список партий с погашениями и offset-пагинацией;
- /admin/coupons/create — мастер партии (тариф, дни, количество,
учётная оптовая цена, срок), после создания — экран со ссылками
(копирование всех, скачивание .txt);
- /admin/coupons/:id — карточка со статистикой, экспорт актуальных
ссылок, отзыв непогашенных за PermissionGate coupons:edit
с подтверждением и тостом;
- /coupon/:token — публичная страница купона: тариф/срок, кнопка
активации в боте, для залогиненных — активация прямо в кабинете
с маппингом структурированных кодов ошибок {code, message};
- пункт меню в разделе тарифов (гейт coupons:read), лениво
загружаемые роуты, локали ru/en (zh/fa падают на ru-фолбэк).
Backend now serves a dedicated recurring-payments legal document, so:
- Add adminLegalPagesApi.get/updateRecurrentPayments and infoApi.getRecurrentPayments
(+ InfoVisibility.recurrent).
- AdminLegalPages: new 'Рекуррентные платежи' editor tab (DocumentEditor now driven
by a DOCUMENT_API dispatch map instead of privacy/offer ternaries).
- PublicLegal: add 'recurrent' doc; add public /recurrent-payments route.
- Re-add the recurrent link to the login LegalFooter (was removed pending backend).
- i18n: admin.legalPages.tabs.recurrent for ru/en/fa/zh (footer.recurrent already existed).
type-check, eslint, prettier and build all pass.
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.
Add an /admin/tickets/:ticketId route that pre-selects the ticket, and a
StartParamNavigator that maps a Telegram Mini App start param (admin_ticket_<id>,
delivered by a group-chat t.me/<bot>/<app>?startapp deep link) to that route.
This lets the bot's admin ticket notification buttons open the cabinet straight on
the ticket — web_app buttons for private chats, the startapp deep link for groups
where web_app is unavailable. Selection mirrors the URL param (cleared on the bare
/admin/tickets list). The static /admin/tickets/settings route still out-ranks the
new dynamic segment.
Pairs with bot feature BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot#2988.
Companion to the bot-side fix: defaults now arrive with {placeholders}
instead of baked-in sample values (example.com links).
- inline preview tab (iframe srcDoc) — works on mobile and in Telegram;
the separate desktop-only preview page and its route are removed
- preview/test render the CURRENT editor content with sample values
- test email: recipient input + detailed backend error messages
- variable chips insert at cursor instead of copying to clipboard
- 'insert default template' button to migrate broken saved overrides
- fa added to language tabs
- Lava returns to a path-based result URL (/balance/top-up/result/:method) because Lava
Business rejects query strings in success/fail URLs. Add the route and read the method
from the path param as a fallback (alongside ?method=) so external-browser redirects
still poll the right payment. Pairs with the backend fix.
- ConnectedAccounts: the "Привязка Telegram временно недоступна" message overflowed the
card (it sat in the non-shrinking action column and never wrapped). Constrain its width
and wrap it (max-w + break-words) so it stays inside the card. Verified by rendering.
Both reported in the Bedolaga bug topic.
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.
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.
Six routes were wrapped in <ErrorBoundary level='app'> around LazyPage.
After the previous commit moved a level='page' boundary inside LazyPage,
the outer 'app' boundary became unreachable — the inner one catches
first.
Strip the dead wrappers (PurchaseSuccess, QuickPurchase, AutoLogin,
TopUpResult, GiftSubscription, GiftResult). Behavior is identical, the
route blocks are 25 fewer lines, and there's only one boundary to
reason about per route.
A render error in any route used to bubble all the way up to the single
page-level boundary at AppWithNavigator, replacing the entire shell
(sidebar, header, blocking overlays) with the contained error UI — even
if only one sub-page was broken.
Embed an ErrorBoundary inside LazyPage so the smallest meaningful unit
(a route's lazy chunk + its rendered tree) is the boundary scope. Shell
chrome stays alive, the user can navigate elsewhere, and chunk-load
failures still get the lazyWithRetry reload path because the boundary
sits outside Suspense.
Pairs with backend feat(deleted-users) — when the cabinet returns
403 {detail: {code: 'account_deleted', message, bot_username,
telegram_deep_link}} the SPA shows a dedicated screen instead of the
generic auth-error toast.
* Adds AccountDeletedScreen with localized title/description, an
Open-bot button using the backend-provided telegram_deep_link, and a
Retry button that clears the block + reloads (so the next request
observes the now-revived row).
* useBlockingStore gains accountDeleted slice mirroring the existing
maintenance/channel-subscription/blacklisted slots — consistent with
the precedent for cross-cutting 403 codes.
* apiClient: AccountDeletedError type + isAccountDeletedError guard +
response interceptor routes the 403 into the blocking store.
* i18n: ru/en/zh/fa parity for title, description, openBot, retry, hint.
No behavior change for ACTIVE users. BLOCKED users still get the
generic 403 (the backend keeps the message opaque for admin actions).
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.
Full-featured admin page for mass operations on users:
- TanStack Table with checkbox selection (select all / individual)
- Server-side pagination, search, and filters (status, tariff, promo group)
- Columns: user, subscription status, tariff, balance, days left, promo group
- Floating glass-effect action bar with 7 bulk operations:
extend subscription, deactivate, activate, change tariff,
add traffic, add balance, assign promo group
- Action modal dialogs with input fields, loading state, result summary
- Responsive design for desktop, mobile, and Telegram Mini App
- Full i18n support (ru, en)
- Dark theme with accent highlights and status color badges
When a new build changes chunk hashes, users with cached index.html
get "Failed to fetch dynamically imported module" errors. Now:
- lazyWithRetry() wraps all lazy imports with auto-reload on failure
- ErrorBoundary catches chunk errors and auto-reloads once
- 30s guard in sessionStorage prevents reload loops
Renewal flow (per-subscription):
- New /subscriptions/:id/renew page showing only period options for
the specific tariff with balance display and renew button
- PurchaseCTAButton links to /renew in multi-tariff mode
Purchase flow (new tariffs only):
- Hide already purchased tariffs (is_purchased flag from backend)
- Fallback UI when all tariffs are owned
- Types: added is_purchased, all_tariffs_purchased fields
- Add SubscriptionListItem and SubscriptionsListResponse types
- Add getSubscriptions/getSubscriptionById API methods
- Add optional subscriptionId parameter to all 30+ API methods
- Create /subscriptions page with subscription list cards
- Add /subscription/:subscriptionId route for per-subscription management
- Dashboard: show multi-tariff navigation block when user has 2+ subscriptions
- Subscription page: read subscriptionId from URL params, pass to all queries
- SubscriptionPurchase: accept subscriptionId from query params
- SubscriptionCardExpired: pass subscription.id to API calls
- Full backward compatibility: without subscriptionId, works as before
- Handle expired status in GiftResult (stop polling + show FailedState)
- Add PollErrorState for balance mode poll errors (softer UX)
- Remove non-null assertions in handleSubmit (explicit narrowing)
- Wrap gift routes in ErrorBoundary
- Add pollErrorTitle/pollErrorDesc i18n keys to all 4 locales
Replace toast-based payment return with animated result page showing
pending/success/failed/timeout states. Extract shared Spinner,
AnimatedCheckmark and AnimatedCrossmark components. Add sessionStorage
persistence with validation and TTL for cross-redirect payment data.
Unify ProtectedRoute with withLayout prop, add aria-live accessibility.
- Wrap /connection/qr route in ProtectedRoute with Layout
- Remove fullscreen overlay and custom close button
- Add AdminBackButton with replace prop to avoid history cycles
- Use replace navigation from Connection to QR page
- Add replace prop support to AdminBackButton component
- CabinetCredentialsState with copyable email/password fields
- Auto-login via JWT token with checkAdminStatus
- /auto-login route with ErrorBoundary and no-referrer meta
- Narrowed contact_type to 'email' | 'telegram' | null
- Disabled button styling for PendingActivation go-to-cabinet
- i18n: added landing.copy key to all 4 locales
- QuickPurchase: публичная страница покупки с выбором тарифа/периода/оплаты
- PurchaseSuccess: статус покупки с поллингом и копированием ссылки
- AdminLandings: управление лендингами (создание, сортировка, удаление)
- AdminLandingEditor: полнофункциональный редактор лендинга
- API-клиент landings.ts для всех эндпоинтов
- Роутинг /buy/:slug и /buy/success/:token
- Локализация ru/en/zh/fa для всех текстов лендинга
- Санитизация HTML/CSS, rate limit защита, DOMPurify
- Update route guard and menu item to use sales_stats:read
- Add sales_stats:read to marketer preset
- Add i18n translations for sales_stats section (ru, en, fa, zh)
- Remove LinkOAuthCallback.tsx (dead code) and its route from App.tsx
- Move LINK_OAUTH_* constants to OAuthCallback.tsx
- Replace local ServerLinkResult with LinkCallbackResponse from types
- Move merge navigate() from render body to useEffect
- Track errorMode to show correct CTA (Return to Telegram vs Back to Login)
- Remove non-null assertions with proper narrowing
- Extract getErrorDetail helper for error casting
- Add smart polling success detection (stop + toast when linked count increases)
- TelegramLinkWidget component with Login Widget for browser users
- One-click initData linking for Mini App users
- LinkTelegramCallback page with CSRF state validation
- URL credential clearing via history.replaceState
- Merge flow support with replace navigation
- i18n keys for all 4 locales (ru, en, zh, fa)
Add Connected Accounts page (link/unlink OAuth providers), Link OAuth
Callback handler, and Merge Accounts page with subscription comparison
and user choice. Includes API methods, TypeScript types, routes in
App.tsx, navigation from Profile, and i18n for all 4 locales (ru, en,
zh, fa). Merge page works without JWT auth (validated by merge token).
- Extract shared chart constants to constants/charts.ts
- Add TREND_STYLES to stats/constants.ts for reuse
- Fix useChartColors with MutationObserver for theme changes
- Fix DailyChart date parsing to prevent timezone shift
- Add truncate to StatCard value for overflow safety
- Fix CampaignCard copy feedback inside try block
- Add NaN validation and staleTime to AdminCampaignStats
- Replace hardcoded strings with i18n keys in Referral page
- Add missing i18n keys across all 4 locales (en, ru, zh, fa)
- Add sales stats route to AdminPanel and App router
Move ~1900 lines of purchase/renewal logic from Subscription.tsx into
a new SubscriptionPurchase.tsx page at /subscription/purchase.
- Create SubscriptionPurchase.tsx with tariffs grid, classic mode wizard,
switch preview modal, and promo handling
- Create PurchaseCTAButton.tsx with animated gradient border, state-aware
colors and context-aware text
- Create subscriptionHelpers.ts with shared utilities
- Add CopyIcon to shared icons module
- Register /subscription/purchase route with lazy loading
- Update SubscriptionCardExpired and PromoOffersSection navigators
- Add CTA translation keys to ru.json and en.json
- Remove all scrollToExtend references
- New ConnectionQR page with fullscreen QR display (no modal)
- QR button in InstallationGuide header next to platform selector
- Respects hideLink setting: shows URL below QR only when visible
- i18n translations for ru, en, zh, fa
Dashboard is the default landing page for all authenticated users.
Loading it eagerly eliminates one extra network round-trip for the
JS chunk (+30KB / +7KB gzip to main bundle, but saves ~200-500ms
of chunk fetch + parse time on first load).
- Create AdminRoleEdit.tsx for /admin/roles/create and /admin/roles/:id/edit
- Create AdminPolicyEdit.tsx for /admin/policies/create and /admin/policies/:id/edit
- Remove create/edit modals from AdminRoles.tsx and AdminPolicies.tsx
- Add new lazy routes in App.tsx with proper permission gates
- Keep delete confirmation as small inline modal (appropriate for destructive action)
- Channel subscription blocking screen with per-channel status display
- Safe URL handling with protocol validation (no open redirect)
- Race-condition-safe subscription check with useRef guard
- Admin channel management page with create, toggle, delete
- Error handlers on all admin mutations with haptic feedback
- Proper type guard for channel subscription error detection
- Accessibility: aria-labels on icon-only buttons
- Localization: en, ru, zh, fa translations
Replace all bare useAuthStore(), useBlockingStore(), and
useSuccessNotification() calls with individual field selectors.
This prevents components from re-rendering when unrelated store
fields change.
- 21 useAuthStore usages across 20 files: individual selectors
for 1-2 fields, useShallow for 3+ fields
- 5 useBlockingStore usages: individual selectors
- 2 useSuccessNotification usages: individual selectors
- Add partner badge to campaigns list
- Add partner selector dropdown in campaign edit
- Support ?partnerId= query param in campaign create (replaces separate page)
- Filter already-assigned campaigns in partner assign flow
- Show taken campaigns greyed out with partner name
- Delete AdminPartnerCampaignCreate (replaced by query param approach)
- Add partner translations (ru, en, zh, fa)
- New AdminPartnerSettings page with validation (pattern from AdminTicketSettings)
- Settings gear button on AdminPartners header
- Hide partner/withdrawal sections when partner_section_visible is false
- Use custom requisites_text as label in withdrawal form
- i18n keys for ru/en
- Admin partners page: list partners, view applications, approve/reject
- Admin partner detail: stats, commission management, campaign assignment
- Admin withdrawals page: list with risk scoring, status filters including cancelled
- Admin withdrawal detail: fraud analysis, approve/reject/complete actions
- Partner API client with full TypeScript interfaces
- Withdrawal API client with admin and user endpoints
- Referral page: partner application form, withdrawal balance and history
- Navigation: partners and withdrawals in admin panel marketing group
- i18n: full translations for ru, en, zh, fa locales
- Neutral fallbacks for unknown status badges
- API client with full CRUD, broadcast, activate/deactivate, unpin endpoints
- List page with active/inactive badges, action buttons, pagination
- Create/edit form with content editor, media upload, display settings toggles
- Routes and navigation in admin marketing group
- i18n translations for ru, en, fa, zh