Merge pull request #452 from BEDOLAGA-DEV/dev

Release: desktop header redesign, mobile overflow sweep, security, gifts, stats
This commit is contained in:
c0mrade
2026-06-05 19:05:01 +03:00
committed by GitHub
83 changed files with 2732 additions and 1482 deletions

View File

@@ -2,7 +2,10 @@
<html lang="ru" class="dark"> <html lang="ru" class="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="data:," /> <link
rel="icon"
href="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2064%2064'%3E%3Crect%20width='64'%20height='64'%20rx='14'%20fill='%230a0f1a'/%3E%3Ctext%20x='50%25'%20y='50%25'%20font-family='Manrope,Arial,sans-serif'%20font-size='38'%20font-weight='700'%20fill='%23ffffff'%20text-anchor='middle'%20dominant-baseline='central'%3EV%3C/text%3E%3C/svg%3E"
/>
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
@@ -12,7 +15,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="format-detection" content="telephone=no" /> <meta name="format-detection" content="telephone=no" />
<meta name="referrer" content="strict-origin-when-cross-origin" /> <meta name="referrer" content="strict-origin-when-cross-origin" />
<title>Loading...</title> <title>VPN</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
@@ -30,7 +33,8 @@
<script> <script>
// Load Telegram Web App script only inside Telegram environment // Load Telegram Web App script only inside Telegram environment
(function () { (function () {
var isTelegram = window.TelegramWebviewProxy || var isTelegram =
window.TelegramWebviewProxy ||
location.hash.indexOf('tgWebApp') !== -1 || location.hash.indexOf('tgWebApp') !== -1 ||
location.search.indexOf('tgWebApp') !== -1; location.search.indexOf('tgWebApp') !== -1;
if (isTelegram) { if (isTelegram) {

View File

@@ -124,7 +124,21 @@
document.getElementById('errorText').textContent = t.error; document.getElementById('errorText').textContent = t.error;
document.getElementById('manualBtn').textContent = t.btn; document.getElementById('manualBtn').textContent = t.btn;
if (!url) { // SECURITY: this page navigates to a user-supplied `url`. Only open
// a custom app deep link. A bare `javascript:`/`data:`/`vbscript:`/
// `file:` URI (DOM-XSS) is not scheme://-shaped, and http(s)/intent
// are blocked (open redirect / intent abuse) — so only VPN app
// schemes (happ://, vless://, ...) pass. Mirrors the validation in
// src/pages/DeepLinkRedirect.tsx.
function isSafeAppLink(raw) {
var s = String(raw || '').trim().toLowerCase();
var m = s.match(/^([a-z][a-z0-9+.\-]*):\/\//);
if (!m) return false;
var blocked = ['http', 'https', 'file', 'blob', 'about', 'javascript', 'data', 'vbscript', 'intent', 'content'];
return blocked.indexOf(m[1]) === -1;
}
if (!url || !isSafeAppLink(url)) {
document.getElementById('title').textContent = t.noUrl; document.getElementById('title').textContent = t.noUrl;
document.getElementById('subtitle').textContent = ''; document.getElementById('subtitle').textContent = '';
document.querySelector('.spinner').style.display = 'none'; document.querySelector('.spinner').style.display = 'none';

View File

@@ -28,6 +28,7 @@ import {
ChannelSubscriptionScreen, ChannelSubscriptionScreen,
BlacklistedScreen, BlacklistedScreen,
AccountDeletedScreen, AccountDeletedScreen,
ServiceUnavailableScreen,
} from './components/blocking'; } from './components/blocking';
import { ErrorBoundary } from './components/ErrorBoundary'; import { ErrorBoundary } from './components/ErrorBoundary';
import { PermissionRoute } from '@/components/auth/PermissionRoute'; import { PermissionRoute } from '@/components/auth/PermissionRoute';
@@ -65,6 +66,7 @@ const Connection = lazyWithRetry(() => import('./pages/Connection'));
const ConnectionQR = lazyWithRetry(() => import('./pages/ConnectionQR')); const ConnectionQR = lazyWithRetry(() => import('./pages/ConnectionQR'));
const QuickPurchase = lazyWithRetry(() => import('./pages/QuickPurchase')); const QuickPurchase = lazyWithRetry(() => import('./pages/QuickPurchase'));
const PurchaseSuccess = lazyWithRetry(() => import('./pages/PurchaseSuccess')); const PurchaseSuccess = lazyWithRetry(() => import('./pages/PurchaseSuccess'));
const GiftClaim = lazyWithRetry(() => import('./pages/GiftClaim'));
const RenewSubscription = lazyWithRetry(() => import('./pages/RenewSubscription')); const RenewSubscription = lazyWithRetry(() => import('./pages/RenewSubscription'));
const AutoLogin = lazyWithRetry(() => import('./pages/AutoLogin')); const AutoLogin = lazyWithRetry(() => import('./pages/AutoLogin'));
const TopUpMethodSelect = lazyWithRetry(() => import('./pages/TopUpMethodSelect')); const TopUpMethodSelect = lazyWithRetry(() => import('./pages/TopUpMethodSelect'));
@@ -231,6 +233,10 @@ function BlockingOverlay() {
return <AccountDeletedScreen />; return <AccountDeletedScreen />;
} }
if (blockingType === 'backend_unavailable') {
return <ServiceUnavailableScreen />;
}
return null; return null;
} }
@@ -276,6 +282,14 @@ function App() {
</LazyPage> </LazyPage>
} }
/> />
<Route
path="/buy/gift/:token"
element={
<LazyPage>
<GiftClaim />
</LazyPage>
}
/>
<Route <Route
path="/buy/:slug" path="/buy/:slug"
element={ element={

View File

@@ -15,9 +15,10 @@ import { ThemeColorsProvider } from './providers/ThemeColorsProvider';
import { WebSocketProvider } from './providers/WebSocketProvider'; import { WebSocketProvider } from './providers/WebSocketProvider';
import { ToastProvider } from './components/Toast'; import { ToastProvider } from './components/Toast';
import { TooltipProvider } from './components/primitives/Tooltip'; import { TooltipProvider } from './components/primitives/Tooltip';
import { isInTelegramWebApp } from './hooks/useTelegramSDK'; import { isInTelegramWebApp, closeTelegramApp } from './hooks/useTelegramSDK';
import { getFallbackParentPath } from './utils/navigation'; import { getFallbackParentPath } from './utils/navigation';
import { subscriptionApi } from './api/subscription'; import { subscriptionApi } from './api/subscription';
import { useBlockingStore } from './store/blocking';
const TWEMOJI_OPTIONS = { className: 'twemoji', folder: 'svg', ext: '.svg' } as const; const TWEMOJI_OPTIONS = { className: 'twemoji', folder: 'svg', ext: '.svg' } as const;
@@ -44,6 +45,13 @@ function TelegramBackButton() {
const pathnameRef = useRef(location.pathname); const pathnameRef = useRef(location.pathname);
pathnameRef.current = location.pathname; pathnameRef.current = location.pathname;
// A full-screen blocking overlay (maintenance / channel-sub / blacklist /
// account-deleted / backend-unavailable) takes over the native back button:
// there is nowhere to navigate, so it becomes a single, stable EXIT control.
const blockingType = useBlockingStore((state) => state.blockingType);
const blockingTypeRef = useRef(blockingType);
blockingTypeRef.current = blockingType;
// Reliable in-app navigation depth (the app's entry point is 0). Driven by // Reliable in-app navigation depth (the app's entry point is 0). Driven by
// React Router's navigation TYPE — NOT window.history.state.idx, which the // React Router's navigation TYPE — NOT window.history.state.idx, which the
// app's own redirects mutate unpredictably and which is the root flake behind // app's own redirects mutate unpredictably and which is the root flake behind
@@ -89,6 +97,15 @@ function TelegramBackButton() {
subsCountRef.current = subsCount; subsCountRef.current = subsCount;
useEffect(() => { useEffect(() => {
// On a blocking overlay, keep exactly one visible Back button (its click
// exits the app — see handler). Skip the route logic so it can't flip
// between Back and Close as the hidden route changes underneath.
if (blockingType) {
try {
showBackButton();
} catch {}
return;
}
const isTopLevel = location.pathname === '' || BOTTOM_NAV_PATHS.includes(location.pathname); const isTopLevel = location.pathname === '' || BOTTOM_NAV_PATHS.includes(location.pathname);
// Depth-independent on purpose: whether the user deep-linked in or navigated // Depth-independent on purpose: whether the user deep-linked in or navigated
// here in-app, a single-tariff detail whose list just bounces back has no // here in-app, a single-tariff detail whose list just bounces back has no
@@ -103,10 +120,17 @@ function TelegramBackButton() {
showBackButton(); showBackButton();
} }
} catch {} } catch {}
}, [location, listRedirectsToDetail]); }, [location, listRedirectsToDetail, blockingType]);
// Stable handler — ref prevents re-subscription on every render // Stable handler — ref prevents re-subscription on every render
const handler = useCallback(() => { const handler = useCallback(() => {
// A blocking overlay is a hard block with nowhere to navigate — the back
// button's only job is to EXIT the Mini App (no SPA navigation, so it can't
// flip-flop between Back and Close).
if (blockingTypeRef.current) {
closeTelegramApp();
return;
}
// Real in-app history (depth > 0): a normal back. Otherwise we were opened // Real in-app history (depth > 0): a normal back. Otherwise we were opened
// directly on this route via a deep-link — navigate(-1) is a no-op, so fall // directly on this route via a deep-link — navigate(-1) is a no-op, so fall
// back to a sensible parent route instead. // back to a sensible parent route instead.

View File

@@ -87,7 +87,10 @@ export const authApi = {
message: string; message: string;
email?: string; email?: string;
merge_required?: boolean; merge_required?: boolean;
merge_token?: string; // 'email_code' means the email belongs to another account: a confirmation
// code was mailed to it and must be verified before a merge token is issued.
merge_verification?: 'email_code';
merge_token?: string | null;
}> => { }> => {
const response = await apiClient.post('/cabinet/auth/email/register', { const response = await apiClient.post('/cabinet/auth/email/register', {
email, email,
@@ -97,6 +100,14 @@ export const authApi = {
return response.data; return response.data;
}, },
// Confirm an email account merge with the code sent to the existing account.
verifyEmailMerge: async (
code: string,
): Promise<{ message: string; merge_required?: boolean; merge_token?: string }> => {
const response = await apiClient.post('/cabinet/auth/email/merge/verify', { code });
return response.data;
},
registerEmailStandalone: async (data: { registerEmailStandalone: async (data: {
email: string; email: string;
password: string; password: string;

View File

@@ -7,6 +7,7 @@ import {
safeRedirectToLogin, safeRedirectToLogin,
} from '../utils/token'; } from '../utils/token';
import { useBlockingStore } from '../store/blocking'; import { useBlockingStore } from '../store/blocking';
import { reportPossibleBackendDown, markBackendReached } from './health';
import { API } from '../config/constants'; import { API } from '../config/constants';
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'; const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
@@ -94,6 +95,12 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) =>
const newToken = await tokenRefreshManager.refreshAccessToken(); const newToken = await tokenRefreshManager.refreshAccessToken();
if (newToken) { if (newToken) {
token = newToken; token = newToken;
} else if (tokenRefreshManager.lastFailureWasTransport) {
// Backend unreachable (not a rejected token): keep the session intact so
// the ServiceUnavailableScreen can auto-recover once the backend returns,
// instead of wiping tokens and stranding the user on /login. Let the
// request go out with the stale token; it will fail and be handled.
return config;
} else { } else {
tokenStorage.clearTokens(); tokenStorage.clearTokens();
safeRedirectToLogin(); safeRedirectToLogin();
@@ -195,10 +202,28 @@ export function isAccountDeletedError(
} }
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => {
// First successful response means the app reached the backend at least once
// (it bootstrapped). Recovery from a later outage can then just lift the
// overlay instead of hard-reloading and losing unsaved UI state.
markBackendReached();
return response;
},
async (error: AxiosError) => { async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Transport-level failure: no HTTP response at all (backend unreachable, DNS
// failure, connection refused, timeout). All the coded guards below need
// `error.response`, so this case had zero handling and produced a blank
// screen during bootstrap. Confirm the outage with a liveness probe (so a
// one-off blip doesn't blank an already-loaded app) and, if confirmed, flip
// the full-screen ServiceUnavailableScreen. Fire-and-forget — the original
// request still rejects now. Axios cancellations are not outages.
if (!error.response && error.code !== 'ERR_CANCELED') {
void reportPossibleBackendDown();
return Promise.reject(error);
}
if (isMaintenanceError(error)) { if (isMaintenanceError(error)) {
const detail = (error.response?.data as { detail: MaintenanceError }).detail; const detail = (error.response?.data as { detail: MaintenanceError }).detail;
useBlockingStore.getState().setMaintenance({ useBlockingStore.getState().setMaintenance({
@@ -256,6 +281,9 @@ apiClient.interceptors.response.use(
originalRequest.headers.Authorization = `Bearer ${newToken}`; originalRequest.headers.Authorization = `Bearer ${newToken}`;
} }
return apiClient(originalRequest); return apiClient(originalRequest);
} else if (tokenRefreshManager.lastFailureWasTransport) {
// Backend died between the 401 and the refresh: keep the session so the
// ServiceUnavailableScreen can recover, rather than logging the user out.
} else { } else {
tokenStorage.clearTokens(); tokenStorage.clearTokens();
safeRedirectToLogin(); safeRedirectToLogin();

View File

@@ -74,6 +74,7 @@ export interface GiftPurchaseStatus {
status: GiftPurchaseStatusValue; status: GiftPurchaseStatusValue;
is_gift: boolean; is_gift: boolean;
is_code_only: boolean; is_code_only: boolean;
is_claimable: boolean;
purchase_token: string | null; purchase_token: string | null;
recipient_contact_value: string | null; recipient_contact_value: string | null;
gift_message: string | null; gift_message: string | null;

126
src/api/health.ts Normal file
View File

@@ -0,0 +1,126 @@
import axios from 'axios';
import { useBlockingStore } from '../store/blocking';
/**
* Backend liveness probe + "service unavailable" detection.
*
* The bot backend serves an unauthenticated liveness endpoint at the host ROOT
* (`/health/unified` when the Web API is enabled, `/health` otherwise) — NOT
* under the apiClient baseURL (`/api`). So we probe it with a BARE axios call at
* a root-relative URL, deliberately bypassing both the baseURL and the apiClient
* response interceptor (a probe must never itself touch blocking state).
*/
function resolveHealthUrl(): string {
const override = import.meta.env.VITE_HEALTH_URL;
if (override) return override;
const apiUrl = String(import.meta.env.VITE_API_URL || '/api').trim();
// For an absolute API base (remote / sub-path deployments like
// `https://api.bot.com/cabinet`) the health route still lives at the host
// ROOT, so derive the origin — NOT just strip `/api`. Mirrors the URL parsing
// in WebSocketProvider. For a relative base (`/api`, `/cabinet`) health is
// same-origin at the site root.
if (apiUrl.startsWith('http://') || apiUrl.startsWith('https://')) {
try {
return `${new URL(apiUrl).origin}/health/unified`;
} catch {
// malformed URL — fall through to the root-relative path
}
}
return '/health/unified';
}
export const HEALTH_URL = resolveHealthUrl();
// Statuses a reverse proxy returns when it is up but its API upstream is down.
// These must count as NOT reachable, otherwise the recovery poll would reload
// the app straight back into a still-broken backend.
const GATEWAY_DOWN_STATUSES = new Set([502, 503, 504]);
/**
* Resolves true when the backend is actually serving — i.e. it returns an HTTP
* response that is NOT a gateway-down status. A 404 still counts as up (a
* Web-API-disabled deployment serves `/health`, not `/health/unified`, so
* probing the latter 404s while the server is fine). A 502/503/504 means a front
* proxy is up but the API upstream is dead, so it counts as NOT reachable.
* Resolves false on a transport-level failure with no response.
*/
export async function pingBackend(): Promise<boolean> {
try {
await axios.get(HEALTH_URL, { timeout: 5000 });
return true;
} catch (err) {
if (axios.isAxiosError(err) && err.response) {
return !GATEWAY_DOWN_STATUSES.has(err.response.status);
}
return false;
}
}
let everReachedBackend = false;
/** Called on the first successful API response: the app reached the backend at
* least once, so a later outage struck an already-loaded session. */
export function markBackendReached(): void {
everReachedBackend = true;
}
/** True once the app has had at least one successful backend response. Lets the
* ServiceUnavailableScreen recover by lifting the overlay (state preserved)
* rather than hard-reloading, which is only needed when the INITIAL bootstrap
* never reached the backend. */
export function hasEverReachedBackend(): boolean {
return everReachedBackend;
}
let confirmInFlight = false;
/**
* Called from the places that see a transport-level (no-response) failure — the
* apiClient interceptor and the bootstrap token refresh. Rather than blanking
* the whole app on a single one-off network blip, it CONFIRMS the outage with a
* liveness probe first and only then flips the global `backend_unavailable`
* state that renders the full-screen ServiceUnavailableScreen. Fire-and-forget:
* callers still reject/handle their own request immediately. Guarded so a burst
* of failing requests triggers at most one probe, and skipped once the screen is
* already shown.
*/
export async function reportPossibleBackendDown(): Promise<void> {
if (useBlockingStore.getState().blockingType === 'backend_unavailable') return;
// During the INITIAL bootstrap (we've never reached the backend yet) the
// caller's failed request is itself the confirmation, so flip the screen
// IMMEDIATELY and synchronously. A deferred probe here would let the /login
// page paint for the ~probe duration before the outage screen — the "jump"
// we must avoid. Once the app has loaded, fall through to the confirm-probe so
// a one-off network blip can't blank a working session.
if (!everReachedBackend) {
useBlockingStore.getState().setBackendUnavailable();
return;
}
if (confirmInFlight) return;
confirmInFlight = true;
try {
const reachable = await pingBackend();
if (!reachable) {
useBlockingStore.getState().setBackendUnavailable();
}
} finally {
confirmInFlight = false;
}
}
/**
* Eager liveness check fired once at app launch (from main.tsx), in parallel
* with auth bootstrap. If the backend is already down at launch this paints the
* ServiceUnavailableScreen immediately — before the auth flow can flash the
* /login page — even on the no-stored-token path that makes no early request.
* No-op if the app has already reached the backend or another blocking screen
* is showing.
*/
export async function checkBackendOnStartup(): Promise<void> {
if (everReachedBackend) return;
if (useBlockingStore.getState().blockingType !== null) return;
const reachable = await pingBackend();
if (!reachable && !everReachedBackend && useBlockingStore.getState().blockingType === null) {
useBlockingStore.getState().setBackendUnavailable();
}
}

View File

@@ -25,6 +25,9 @@ export interface LandingTariff {
device_limit: number; device_limit: number;
tier_level: number; tier_level: number;
periods: LandingTariffPeriod[]; periods: LandingTariffPeriod[];
/** Daily tariff: the single purchasable period is 1 day, priced per day. */
is_daily?: boolean;
daily_price_kopeks?: number;
} }
export interface LandingPaymentMethodSubOption { export interface LandingPaymentMethodSubOption {
@@ -134,6 +137,21 @@ export interface PurchaseStatus {
auto_login_token: string | null; auto_login_token: string | null;
recipient_in_bot: boolean | null; recipient_in_bot: boolean | null;
bot_link: string | null; bot_link: string | null;
// Transferable gift claim link — the buyer forwards this; whoever activates it
// gets the gift. Derived from token + status (purchase.user is null until claim).
is_claimable: boolean;
claim_url: string | null;
bot_claim_link: string | null;
}
/** Result returned to the recipient after a successful web (email) gift claim. */
export interface GiftClaimResult {
status: string;
tariff_name: string | null;
period_days: number | null;
subscription_url: string | null;
subscription_crypto_link: string | null;
auto_login_token: string | null;
} }
/** Locale dict for multi-language text fields (admin API) */ /** Locale dict for multi-language text fields (admin API) */
@@ -279,6 +297,18 @@ export const landingApi = {
const response = await apiClient.post(`/cabinet/landing/activate/${token}`); const response = await apiClient.post(`/cabinet/landing/activate/${token}`);
return response.data; return response.data;
}, },
// Public gift claim page data (no auth — the token is the bearer secret).
getGiftClaim: async (token: string): Promise<PurchaseStatus> => {
const response = await apiClient.get(`/cabinet/landing/gift/${token}`);
return response.data;
},
// Web (email) arm of the gift claim — binds the gift to the given email account.
claimGift: async (token: string, email: string): Promise<GiftClaimResult> => {
const response = await apiClient.post(`/cabinet/landing/gift/${token}/claim`, { email });
return response.data;
},
}; };
export interface LandingDailyStat { export interface LandingDailyStat {
@@ -296,10 +326,22 @@ export interface LandingTariffStat {
revenue_kopeks: number; revenue_kopeks: number;
} }
export interface LandingPaymentMethodStat {
method: string;
purchases: number;
revenue_kopeks: number;
}
export interface LandingSourceStat {
source: string;
purchases: number;
}
export interface LandingStatsResponse { export interface LandingStatsResponse {
total_purchases: number; total_purchases: number;
total_revenue_kopeks: number; total_revenue_kopeks: number;
total_gifts: number; total_gifts: number;
total_gifts_claimed: number;
total_regular: number; total_regular: number;
avg_purchase_kopeks: number; avg_purchase_kopeks: number;
total_created: number; total_created: number;
@@ -307,6 +349,8 @@ export interface LandingStatsResponse {
conversion_rate: number; conversion_rate: number;
daily_stats: LandingDailyStat[]; daily_stats: LandingDailyStat[];
tariff_stats: LandingTariffStat[]; tariff_stats: LandingTariffStat[];
payment_method_stats: LandingPaymentMethodStat[];
source_stats: LandingSourceStat[];
} }
export type PurchaseItemStatus = export type PurchaseItemStatus =

View File

@@ -79,9 +79,11 @@ export const ticketsApi = {
return response.data; return response.data;
}, },
// Get media URL for display // Get media URL for display. The signed `token` comes from the ticket
getMediaUrl: (fileId: string): string => { // response and is required by the backend (a raw file_id alone 404s).
getMediaUrl: (fileId: string, token?: string | null): string => {
const baseUrl = import.meta.env.VITE_API_URL || ''; const baseUrl = import.meta.env.VITE_API_URL || '';
return `${baseUrl}/cabinet/media/${fileId}`; const suffix = token ? `?token=${encodeURIComponent(token)}` : '';
return `${baseUrl}/cabinet/media/${fileId}${suffix}`;
}, },
}; };

View File

@@ -60,6 +60,9 @@ export interface SpinResult {
export interface SpinHistoryItem { export interface SpinHistoryItem {
id: number; id: number;
// WheelPrize id of the won prize (null if that prize was later deleted). Used to
// land the wheel animation on the exact winning sector after a Stars payment.
prize_id: number | null;
payment_type: string; payment_type: string;
payment_amount: number; payment_amount: number;
prize_type: string; prize_type: string;

View File

@@ -256,9 +256,13 @@ export default function SuccessNotificationModal() {
{/* Tariff name */} {/* Tariff name */}
{data.tariffName && ( {data.tariffName && (
<div className="flex items-center justify-between rounded-xl bg-dark-800/50 px-4 py-3"> <div className="flex items-center justify-between gap-3 rounded-xl bg-dark-800/50 px-4 py-3">
<span className="text-dark-400">{t('successNotification.tariff', 'Tariff')}</span> <span className="shrink-0 text-dark-400">
<span className="font-semibold text-dark-100">{data.tariffName}</span> {t('successNotification.tariff', 'Tariff')}
</span>
<span className="min-w-0 truncate font-semibold text-dark-100">
{data.tariffName}
</span>
</div> </div>
)} )}

View File

@@ -268,9 +268,9 @@ export function AnalyticsTab() {
</button> </button>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span <span
className={`text-base ${analytics?.google_ads_id ? 'font-mono text-dark-100' : 'text-dark-500'}`} className={`min-w-0 truncate text-base ${analytics?.google_ads_id ? 'font-mono text-dark-100' : 'text-dark-500'}`}
> >
{analytics?.google_ads_id || t('admin.settings.notConfigured')} {analytics?.google_ads_id || t('admin.settings.notConfigured')}
</span> </span>
@@ -280,7 +280,7 @@ export function AnalyticsTab() {
setEditingGoogleId(true); setEditingGoogleId(true);
setError(null); setError(null);
}} }}
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200" className="shrink-0 rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
> >
<PencilIcon className="h-4 w-4" /> <PencilIcon className="h-4 w-4" />
</button> </button>
@@ -322,9 +322,9 @@ export function AnalyticsTab() {
</button> </button>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span <span
className={`text-base ${analytics?.google_ads_label ? 'font-mono text-dark-100' : 'text-dark-500'}`} className={`min-w-0 truncate text-base ${analytics?.google_ads_label ? 'font-mono text-dark-100' : 'text-dark-500'}`}
> >
{analytics?.google_ads_label || t('admin.settings.notConfigured')} {analytics?.google_ads_label || t('admin.settings.notConfigured')}
</span> </span>
@@ -334,7 +334,7 @@ export function AnalyticsTab() {
setEditingGoogleLabel(true); setEditingGoogleLabel(true);
setError(null); setError(null);
}} }}
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200" className="shrink-0 rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
> >
<PencilIcon className="h-4 w-4" /> <PencilIcon className="h-4 w-4" />
</button> </button>

View File

@@ -180,8 +180,8 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
</button> </button>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span className="text-lg text-dark-100"> <span className="min-w-0 truncate text-lg text-dark-100">
{branding?.name || t('admin.settings.notSpecified')} {branding?.name || t('admin.settings.notSpecified')}
</span> </span>
<button <button
@@ -189,7 +189,7 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
setNewName(branding?.name ?? ''); setNewName(branding?.name ?? '');
setEditingName(true); setEditingName(true);
}} }}
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200" className="shrink-0 rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
> >
<PencilIcon /> <PencilIcon />
</button> </button>

View File

@@ -93,7 +93,7 @@ export function SubscriptionSubRow({
/> />
</svg> </svg>
</span> </span>
<span className="text-xs font-semibold text-dark-200"> <span className="min-w-0 flex-1 truncate text-xs font-semibold text-dark-200">
{subscription.tariff_name || '—'} {subscription.tariff_name || '—'}
</span> </span>
</div> </div>

View File

@@ -421,16 +421,16 @@ export function InfoTab(props: InfoTabProps) {
onClick={() => navigate(`/admin/users/${ref.id}`)} onClick={() => navigate(`/admin/users/${ref.id}`)}
className="flex w-full items-center justify-between rounded-lg bg-dark-700/50 p-2 text-left transition-colors hover:bg-dark-700" className="flex w-full items-center justify-between rounded-lg bg-dark-700/50 p-2 text-left transition-colors hover:bg-dark-700"
> >
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-dark-600 text-xs font-bold text-dark-300"> <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-dark-600 text-xs font-bold text-dark-300">
{ref.first_name?.[0] || ref.username?.[0] || '?'} {ref.first_name?.[0] || ref.username?.[0] || '?'}
</div> </div>
<div> <div className="min-w-0">
<div className="text-sm text-dark-100">{ref.full_name}</div> <div className="truncate text-sm text-dark-100">{ref.full_name}</div>
<div className="text-xs text-dark-500">{formatDate(ref.created_at)}</div> <div className="text-xs text-dark-500">{formatDate(ref.created_at)}</div>
</div> </div>
</div> </div>
<div className="text-xs text-dark-400"> <div className="shrink-0 text-xs text-dark-400">
{formatWithCurrency(ref.total_spent_kopeks / 100)} {formatWithCurrency(ref.total_spent_kopeks / 100)}
</div> </div>
</button> </button>

View File

@@ -462,13 +462,13 @@ export function ReferralsTab({ user, userId, onUserRefresh }: ReferralsTabProps)
> >
<button <button
onClick={() => navigate(`/admin/users/${ref.id}`)} onClick={() => navigate(`/admin/users/${ref.id}`)}
className="flex items-center gap-3 text-left" className="flex min-w-0 items-center gap-3 text-left"
> >
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-dark-600/50 text-sm font-bold text-dark-300"> <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-dark-600/50 text-sm font-bold text-dark-300">
{(ref.full_name || ref.username || '?')[0].toUpperCase()} {(ref.full_name || ref.username || '?')[0].toUpperCase()}
</div> </div>
<div> <div className="min-w-0">
<div className="text-sm font-medium text-dark-100"> <div className="truncate text-sm font-medium text-dark-100">
{ref.full_name || ref.username || `ID: ${ref.id}`} {ref.full_name || ref.username || `ID: ${ref.id}`}
</div> </div>
<div className="text-xs text-dark-500"> <div className="text-xs text-dark-500">
@@ -479,7 +479,7 @@ export function ReferralsTab({ user, userId, onUserRefresh }: ReferralsTabProps)
<button <button
onClick={() => handleRemoveReferral(ref.id)} onClick={() => handleRemoveReferral(ref.id)}
disabled={actionLoading} disabled={actionLoading}
className="rounded-lg p-2 text-dark-500 transition-colors hover:bg-error-500/10 hover:text-error-400 disabled:opacity-50" className="shrink-0 rounded-lg p-2 text-dark-500 transition-colors hover:bg-error-500/10 hover:text-error-400 disabled:opacity-50"
title={t('admin.users.detail.referrals.removeReferral')} title={t('admin.users.detail.referrals.removeReferral')}
> >
<XIcon className="h-4 w-4" /> <XIcon className="h-4 w-4" />

View File

@@ -1,8 +1,10 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { usePlatform } from '@/platform'; import { usePlatform } from '@/platform';
import { InfoIcon } from '@/components/icons'; import { InfoIcon } from '@/components/icons';
import { Button } from '@/components/primitives';
import { useBlockingStore } from '../../store/blocking'; import { useBlockingStore } from '../../store/blocking';
import { useFocusTrap } from '../../hooks/useFocusTrap'; import { useFocusTrap } from '../../hooks/useFocusTrap';
import BlockingShell from './BlockingShell';
/** /**
* Full-screen block shown when the backend returns * Full-screen block shown when the backend returns
@@ -45,48 +47,26 @@ export default function AccountDeletedScreen() {
}; };
return ( return (
<div <BlockingShell
ref={screenRef} screenRef={screenRef}
role="alertdialog" titleId="account-deleted-title"
aria-modal="true" accent="warning"
aria-labelledby="account-deleted-title" icon={<InfoIcon className="h-9 w-9" />}
tabIndex={-1} title={t('blocking.accountDeleted.title')}
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6" description={t('blocking.accountDeleted.description')}
> footer={t('blocking.accountDeleted.hint')}
<div className="w-full max-w-md text-center"> actions={
<div className="mb-8"> <>
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
<InfoIcon className="h-12 w-12 text-warning-400" />
</div>
</div>
<h1 id="account-deleted-title" className="mb-4 text-2xl font-bold text-white">
{t('blocking.accountDeleted.title')}
</h1>
<p className="mb-6 text-lg text-dark-400">{t('blocking.accountDeleted.description')}</p>
<div className="space-y-3">
{deepLink && ( {deepLink && (
<button <Button variant="primary" size="lg" fullWidth onClick={handleOpenBot}>
type="button"
onClick={handleOpenBot}
className="block w-full rounded-xl bg-blue-600 px-6 py-3 text-base font-semibold text-white transition-colors hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-dark-950"
>
{t('blocking.accountDeleted.openBot')} {t('blocking.accountDeleted.openBot')}
</button> </Button>
)} )}
<button <Button variant="secondary" size="lg" fullWidth onClick={handleRetry}>
type="button"
onClick={handleRetry}
className="block w-full rounded-xl bg-dark-800 px-6 py-3 text-base font-medium text-dark-200 transition-colors hover:bg-dark-700 focus:outline-none focus:ring-2 focus:ring-dark-400 focus:ring-offset-2 focus:ring-offset-dark-950"
>
{t('blocking.accountDeleted.retry')} {t('blocking.accountDeleted.retry')}
</button> </Button>
</div> </>
}
<p className="mt-8 text-sm text-dark-500">{t('blocking.accountDeleted.hint')}</p> />
</div>
</div>
); );
} }

View File

@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
import { useBlockingStore } from '../../store/blocking'; import { useBlockingStore } from '../../store/blocking';
import { useFocusTrap } from '../../hooks/useFocusTrap'; import { useFocusTrap } from '../../hooks/useFocusTrap';
import { BanIcon } from '@/components/icons'; import { BanIcon } from '@/components/icons';
import BlockingShell from './BlockingShell';
export default function BlacklistedScreen() { export default function BlacklistedScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -9,40 +10,23 @@ export default function BlacklistedScreen() {
const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false }); const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false });
return ( return (
<div <BlockingShell
ref={screenRef} screenRef={screenRef}
role="alertdialog" titleId="blacklisted-title"
aria-modal="true" accent="error"
aria-labelledby="blacklisted-title" icon={<BanIcon className="h-9 w-9" />}
tabIndex={-1} title={t('blocking.blacklisted.title')}
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6" description={t('blocking.blacklisted.defaultMessage')}
footer={t('blocking.blacklisted.contactSupport')}
> >
<div className="w-full max-w-md text-center">
{/* Icon */}
<div className="mb-8">
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
<BanIcon className="h-12 w-12 text-error-500" />
</div>
</div>
{/* Title */}
<h1 id="blacklisted-title" className="mb-4 text-2xl font-bold text-white">
{t('blocking.blacklisted.title')}
</h1>
{/* Message */}
<p className="mb-6 text-lg text-dark-400">{t('blocking.blacklisted.defaultMessage')}</p>
{/* Reason */}
{blacklistedInfo?.message && ( {blacklistedInfo?.message && (
<div className="mb-6 rounded-xl bg-dark-800/50 p-4"> <div className="rounded-xl border border-dark-700/30 bg-dark-800/50 p-4">
<p className="mb-1 text-sm text-dark-500">{t('blocking.blacklisted.reason')}:</p> <p className="mb-1 text-xs font-medium uppercase tracking-wide text-dark-500">
<p className="text-dark-300">{blacklistedInfo.message}</p> {t('blocking.blacklisted.reason')}:
</p>
<p className="text-sm text-dark-300">{blacklistedInfo.message}</p>
</div> </div>
)} )}
</BlockingShell>
<p className="mt-8 text-sm text-dark-500">{t('blocking.blacklisted.contactSupport')}</p>
</div>
</div>
); );
} }

View File

@@ -0,0 +1,196 @@
import { type ReactNode, type Ref } from 'react';
import { motion } from 'framer-motion';
import { scale, scaleTransition, slideUp, slideUpTransition } from '../motion/transitions';
import { cn } from '@/lib/utils';
export type BlockingAccent = 'warning' | 'error' | 'info';
/**
* Per-accent class recipe. `info` maps to the theme accent-* scale (default
* blue). Colors are theme-driven CSS vars (RGB triples), never hardcoded hex.
*/
const accentMap: Record<
BlockingAccent,
{
glow: string;
medallion: string;
sheen: string;
iconColor: string;
dot: string;
hairline: string;
}
> = {
warning: {
glow: 'bg-warning-500/10',
medallion:
'bg-warning-500/10 ring-warning-500/30 shadow-[0_0_44px_-8px_rgba(var(--color-warning-500),0.5)]',
sheen: 'from-warning-500/25',
iconColor: 'text-warning-400',
dot: 'bg-warning-500',
hairline: 'via-warning-500/40',
},
error: {
glow: 'bg-error-500/10',
medallion:
'bg-error-500/10 ring-error-500/30 shadow-[0_0_44px_-8px_rgba(var(--color-error-500),0.5)]',
sheen: 'from-error-500/25',
iconColor: 'text-error-400',
dot: 'bg-error-500',
hairline: 'via-error-500/40',
},
info: {
glow: 'bg-accent-500/10',
medallion: 'bg-accent-500/10 ring-accent-500/30 shadow-glow-lg',
sheen: 'from-accent-500/25',
iconColor: 'text-accent-400',
dot: 'bg-accent-500',
hairline: 'via-accent-500/40',
},
};
interface BlockingShellProps {
/** Unique id wired to aria-labelledby (e.g. 'maintenance-title'). */
titleId: string;
accent: BlockingAccent;
/** Rendered icon element (sized by the caller, e.g. <WrenchIcon className="h-9 w-9" />). */
icon: ReactNode;
/** Already-translated title. */
title: string;
description?: ReactNode;
/** Per-screen body: reason cards, channel list, error block. */
children?: ReactNode;
/** CTA area — pass canonical <Button> elements. */
actions?: ReactNode;
/** Hint / contact-support line under the card. */
footer?: ReactNode;
/** Accent-tinted "working" dots, for screens that actively wait/poll. */
pulse?: boolean;
/** 'polite' for screens whose state changes (retry/error) should announce. */
ariaLive?: 'polite' | 'off';
/** Focus-trap ref — owned by the caller so each screen keeps its own trap. */
screenRef: Ref<HTMLDivElement>;
}
/**
* Shared premium shell for every full-screen blocking/status state: an opaque
* dark canvas with a self-contained accent glow, a centered glass card, and a
* gradient-ringed icon medallion. Replaces the old flat grey-circle + three
* raw dots look. Behavior (focus trap, aria, actions) is supplied by each
* screen; this component owns only the visual chrome.
*/
export default function BlockingShell({
titleId,
accent,
icon,
title,
description,
children,
actions,
footer,
pulse = false,
ariaLive = 'off',
screenRef,
}: BlockingShellProps) {
const a = accentMap[accent];
return (
<div
ref={screenRef}
role="alertdialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
className="fixed inset-0 z-[100] overflow-y-auto bg-dark-950"
>
{/* Self-contained backdrop — accent glow behind the card. No app reveal:
the canvas stays opaque so this remains a hard block. */}
<div aria-hidden className="pointer-events-none fixed inset-0 overflow-hidden">
<div
className={cn(
'absolute left-1/2 top-1/2 h-[30rem] w-[30rem] -translate-x-1/2 -translate-y-[58%] rounded-full blur-[130px]',
a.glow,
)}
/>
<div className="absolute -bottom-24 left-1/2 h-72 w-[22rem] -translate-x-1/2 rounded-full bg-dark-800/30 blur-[120px]" />
</div>
{/* Scroll-safe centering: min-h-full + items-center centers when it fits
and scrolls without clipping the top when content is tall. */}
<div className="relative flex min-h-full items-center justify-center p-6">
<motion.div
variants={scale}
initial="initial"
animate="animate"
transition={scaleTransition}
aria-live={ariaLive === 'polite' ? 'polite' : undefined}
aria-atomic={ariaLive === 'polite' ? true : undefined}
className="relative w-full max-w-md overflow-hidden rounded-[var(--bento-radius)] border border-dark-700/40 bg-dark-900/80 p-8 text-center shadow-[0_4px_24px_-4px_rgba(0,0,0,0.4),inset_0_1px_0_0_rgba(255,255,255,0.05)] backdrop-blur-xl sm:p-10"
>
{/* Top accent hairline */}
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent to-transparent',
a.hairline,
)}
/>
{/* Icon medallion — gradient ring + glow, not a flat grey circle */}
<motion.div
variants={slideUp}
initial="initial"
animate="animate"
transition={slideUpTransition}
className="mb-6 flex justify-center"
>
<span
className={cn(
'relative flex h-20 w-20 items-center justify-center rounded-full ring-1',
a.medallion,
)}
>
<span
aria-hidden
className={cn(
'absolute inset-0 rounded-full bg-gradient-to-br to-transparent',
a.sheen,
)}
/>
<span className={cn('relative', a.iconColor)}>{icon}</span>
</span>
</motion.div>
<h1 id={titleId} className="font-display text-2xl font-bold tracking-tight text-dark-50">
{title}
</h1>
{description && (
<p className="mt-3 text-base leading-relaxed text-dark-400">{description}</p>
)}
{children && <div className="mt-6 space-y-3 text-left">{children}</div>}
{actions && <div className="mt-7 flex flex-col gap-3">{actions}</div>}
{pulse && (
<div aria-hidden className="mt-7 flex items-center justify-center gap-1.5">
<span
className={cn('h-1.5 w-1.5 animate-pulse rounded-full', a.dot)}
style={{ animationDelay: '0ms' }}
/>
<span
className={cn('h-1.5 w-1.5 animate-pulse rounded-full', a.dot)}
style={{ animationDelay: '300ms' }}
/>
<span
className={cn('h-1.5 w-1.5 animate-pulse rounded-full', a.dot)}
style={{ animationDelay: '600ms' }}
/>
</div>
)}
{footer && <p className="mt-6 text-sm text-dark-500">{footer}</p>}
</motion.div>
</div>
</div>
);
}

View File

@@ -1,10 +1,12 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useBlockingStore } from '../../store/blocking'; import { useBlockingStore } from '../../store/blocking';
import { apiClient, isChannelSubscriptionError } from '../../api/client'; import { apiClient, isChannelSubscriptionError } from '../../api/client';
import { usePlatform } from '../../platform'; import { usePlatform } from '../../platform';
import { useFocusTrap } from '../../hooks/useFocusTrap'; import { useFocusTrap } from '../../hooks/useFocusTrap';
import { TelegramIcon, ClockIcon, CheckIcon } from '@/components/icons'; import { TelegramIcon, ClockIcon, CheckIcon, RestartIcon } from '@/components/icons';
import { Button } from '@/components/primitives';
import BlockingShell from './BlockingShell';
const CHECK_COOLDOWN_SECONDS = 5; const CHECK_COOLDOWN_SECONDS = 5;
@@ -84,49 +86,63 @@ export default function ChannelSubscriptionScreen() {
const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false }); const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false });
// Check-subscription button — 3 states (checking / cooldown / idle).
let checkIcon: ReactNode;
let checkLabel: string;
if (isChecking) {
checkIcon = <RestartIcon className="h-5 w-5 animate-spin" />;
checkLabel = t('blocking.channel.checking');
} else if (cooldown > 0) {
checkIcon = <ClockIcon className="h-5 w-5" />;
checkLabel = t('blocking.channel.waitSeconds', { seconds: cooldown });
} else {
checkIcon = <CheckIcon className="h-5 w-5" />;
checkLabel = t('blocking.channel.checkSubscription');
}
return ( return (
<div <BlockingShell
ref={screenRef} screenRef={screenRef}
role="alertdialog" titleId="channel-sub-title"
aria-modal="true" accent="info"
aria-labelledby="channel-sub-title" ariaLive="polite"
tabIndex={-1} icon={<TelegramIcon className="h-9 w-9" />}
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6" title={t('blocking.channel.title')}
description={channelInfo?.message || t('blocking.channel.defaultMessage')}
footer={t('blocking.channel.hint')}
actions={
<Button
variant="secondary"
size="lg"
fullWidth
onClick={checkSubscription}
disabled={isChecking || cooldown > 0}
leftIcon={checkIcon}
>
{checkLabel}
</Button>
}
> >
<div className="w-full max-w-md text-center">
{/* Icon */}
<div className="mb-8">
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-br from-blue-500/20 to-cyan-500/20">
<TelegramIcon className="h-12 w-12 text-blue-400" />
</div>
</div>
{/* Title */}
<h1 id="channel-sub-title" className="mb-4 text-2xl font-bold text-white">
{t('blocking.channel.title')}
</h1>
{/* Message */}
<p className="mb-6 text-lg text-dark-400">
{channelInfo?.message || t('blocking.channel.defaultMessage')}
</p>
{/* Channel list (only unsubscribed channels) */} {/* Channel list (only unsubscribed channels) */}
{channels.length > 0 && ( {channels.length > 0 && (
<div className="mb-6 space-y-3"> <div className="space-y-2">
{channels.map((ch) => ( {channels.map((ch) => (
<div <div
key={ch.channel_id} key={ch.channel_id}
className="flex items-center justify-between rounded-xl border border-error-500/30 bg-error-500/10 p-3" className="flex items-center justify-between gap-3 rounded-xl border border-error-500/30 bg-error-500/10 p-3"
> >
<span className="text-sm font-medium text-white">{ch.title || ch.channel_id}</span> <span className="truncate text-sm font-medium text-dark-50">
{ch.title || ch.channel_id}
</span>
{ch.channel_link && ( {ch.channel_link && (
<button <Button
variant="secondary"
size="sm"
className="shrink-0"
onClick={() => openChannel(ch.channel_link)} onClick={() => openChannel(ch.channel_link)}
className="rounded-lg bg-blue-500/20 px-3 py-1 text-xs font-medium text-blue-400 hover:bg-blue-500/30"
> >
{t('blocking.channel.openChannel')} {t('blocking.channel.openChannel')}
</button> </Button>
)} )}
</div> </div>
))} ))}
@@ -135,67 +151,22 @@ export default function ChannelSubscriptionScreen() {
{/* Fallback: single channel (legacy) */} {/* Fallback: single channel (legacy) */}
{channels.length === 0 && channelInfo?.channel_link && ( {channels.length === 0 && channelInfo?.channel_link && (
<button <Button
variant="primary"
size="lg"
fullWidth
onClick={() => openChannel(channelInfo.channel_link)} onClick={() => openChannel(channelInfo.channel_link)}
className="mb-6 flex w-full items-center justify-center gap-3 rounded-xl bg-accent-500 px-6 py-4 font-semibold text-white transition-colors duration-200 hover:bg-accent-400"
> >
{t('blocking.channel.openChannel')} {t('blocking.channel.openChannel')}
</button> </Button>
)} )}
{/* Error message */} {/* Error message */}
{error && ( {error && (
<div className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-3"> <div className="rounded-xl border border-error-500/30 bg-error-500/10 p-3">
<p className="text-sm text-error-400">{error}</p> <p className="text-sm text-error-400">{error}</p>
</div> </div>
)} )}
</BlockingShell>
{/* Check subscription button */}
<button
onClick={checkSubscription}
disabled={isChecking || cooldown > 0}
className="flex w-full items-center justify-center gap-3 rounded-xl bg-dark-800 px-6 py-4 font-semibold text-white transition-all duration-200 hover:bg-dark-700 disabled:bg-dark-800 disabled:opacity-60"
>
{isChecking ? (
<>
<svg
className="h-5 w-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{t('blocking.channel.checking')}
</>
) : cooldown > 0 ? (
<>
<ClockIcon className="h-5 w-5 text-dark-500" />
{t('blocking.channel.waitSeconds', { seconds: cooldown })}
</>
) : (
<>
<CheckIcon className="h-5 w-5" />
{t('blocking.channel.checkSubscription')}
</>
)}
</button>
{/* Hint */}
<p className="mt-4 text-sm text-dark-500">{t('blocking.channel.hint')}</p>
</div>
</div>
); );
} }

View File

@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
import { useBlockingStore } from '../../store/blocking'; import { useBlockingStore } from '../../store/blocking';
import { useFocusTrap } from '../../hooks/useFocusTrap'; import { useFocusTrap } from '../../hooks/useFocusTrap';
import { WrenchIcon } from '@/components/icons'; import { WrenchIcon } from '@/components/icons';
import BlockingShell from './BlockingShell';
export default function MaintenanceScreen() { export default function MaintenanceScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -9,58 +10,24 @@ export default function MaintenanceScreen() {
const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false }); const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false });
return ( return (
<div <BlockingShell
ref={screenRef} screenRef={screenRef}
role="alertdialog" titleId="maintenance-title"
aria-modal="true" accent="warning"
aria-labelledby="maintenance-title" icon={<WrenchIcon className="h-9 w-9" />}
tabIndex={-1} title={t('blocking.maintenance.title')}
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6" description={maintenanceInfo?.message || t('blocking.maintenance.defaultMessage')}
pulse
footer={t('blocking.maintenance.waitMessage')}
> >
<div className="w-full max-w-md text-center">
{/* Icon */}
<div className="mb-8">
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
<WrenchIcon className="h-12 w-12 text-warning-500" />
</div>
</div>
{/* Title */}
<h1 id="maintenance-title" className="mb-4 text-2xl font-bold text-white">
{t('blocking.maintenance.title')}
</h1>
{/* Message */}
<p className="mb-6 text-lg text-dark-400">
{maintenanceInfo?.message || t('blocking.maintenance.defaultMessage')}
</p>
{/* Reason */}
{maintenanceInfo?.reason && ( {maintenanceInfo?.reason && (
<div className="mb-6 rounded-xl bg-dark-800/50 p-4"> <div className="rounded-xl border border-dark-700/30 bg-dark-800/50 p-4">
<p className="mb-1 text-sm text-dark-500">{t('blocking.maintenance.reason')}:</p> <p className="mb-1 text-xs font-medium uppercase tracking-wide text-dark-500">
<p className="text-dark-300">{maintenanceInfo.reason}</p> {t('blocking.maintenance.reason')}:
</p>
<p className="text-sm text-dark-300">{maintenanceInfo.reason}</p>
</div> </div>
)} )}
</BlockingShell>
{/* Decorative dots */}
<div className="mt-8 flex items-center justify-center gap-2">
<div
className="h-2 w-2 animate-pulse rounded-full bg-warning-500"
style={{ animationDelay: '0ms' }}
/>
<div
className="h-2 w-2 animate-pulse rounded-full bg-warning-500"
style={{ animationDelay: '300ms' }}
/>
<div
className="h-2 w-2 animate-pulse rounded-full bg-warning-500"
style={{ animationDelay: '600ms' }}
/>
</div>
<p className="mt-4 text-sm text-dark-500">{t('blocking.maintenance.waitMessage')}</p>
</div>
</div>
); );
} }

View File

@@ -0,0 +1,103 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { useBlockingStore } from '../../store/blocking';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import { pingBackend, hasEverReachedBackend } from '../../api/health';
import { CloudWarningIcon, RestartIcon } from '@/components/icons';
import { Button } from '@/components/primitives';
import { cn } from '@/lib/utils';
import BlockingShell from './BlockingShell';
const POLL_INTERVAL_MS = 5000;
/**
* Full-screen state shown when the backend is unreachable (transport-level
* failure), replacing the blank loader the app used to get stuck on. Polls the
* liveness endpoint and, once the backend answers again, reloads to re-bootstrap
* cleanly from whatever state the failed boot left behind. A manual retry button
* lets the user force an immediate check.
*/
export default function ServiceUnavailableScreen() {
const { t } = useTranslation();
const clearBlocking = useBlockingStore((state) => state.clearBlocking);
const queryClient = useQueryClient();
const [isChecking, setIsChecking] = useState(false);
const isCheckingRef = useRef(false);
const recover = useCallback(() => {
clearBlocking();
if (hasEverReachedBackend()) {
// The app was already loaded and merely covered by this overlay — its
// routes/forms are still mounted with their state. Lift the overlay and
// refetch instead of a hard reload that would discard unsaved input.
void queryClient.invalidateQueries();
} else {
// The initial bootstrap never reached the backend (blank loader) — reload
// to re-bootstrap cleanly now that it is back.
window.location.reload();
}
}, [clearBlocking, queryClient]);
// Manual retry: immediate probe with a visible checking state.
const handleRetry = useCallback(async () => {
if (isCheckingRef.current) return;
isCheckingRef.current = true;
setIsChecking(true);
try {
if (await pingBackend()) {
recover();
}
} finally {
isCheckingRef.current = false;
setIsChecking(false);
}
}, [recover]);
// Auto-recovery: probe immediately on mount, then every POLL_INTERVAL_MS.
useEffect(() => {
let cancelled = false;
const tick = async () => {
if (cancelled) return;
if (await pingBackend()) {
if (!cancelled) recover();
}
};
void tick();
const id = setInterval(tick, POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(id);
};
}, [recover]);
const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false });
return (
<BlockingShell
screenRef={screenRef}
titleId="service-unavailable-title"
accent="warning"
ariaLive="polite"
icon={<CloudWarningIcon className="h-9 w-9" />}
title={t('blocking.serviceUnavailable.title')}
description={t('blocking.serviceUnavailable.description')}
pulse
footer={t('blocking.serviceUnavailable.hint')}
actions={
<Button
variant="secondary"
size="lg"
fullWidth
onClick={handleRetry}
disabled={isChecking}
leftIcon={<RestartIcon className={cn('h-5 w-5', isChecking && 'animate-spin')} />}
>
{isChecking
? t('blocking.serviceUnavailable.checking')
: t('blocking.serviceUnavailable.retry')}
</Button>
}
/>
);
}

View File

@@ -2,3 +2,4 @@ export { default as MaintenanceScreen } from './MaintenanceScreen';
export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen'; export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen';
export { default as BlacklistedScreen } from './BlacklistedScreen'; export { default as BlacklistedScreen } from './BlacklistedScreen';
export { default as AccountDeletedScreen } from './AccountDeletedScreen'; export { default as AccountDeletedScreen } from './AccountDeletedScreen';
export { default as ServiceUnavailableScreen } from './ServiceUnavailableScreen';

View File

@@ -10,7 +10,7 @@ import type {
} from '@/types'; } from '@/types';
import { useTheme } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme';
import { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock, BlockButtons } from './blocks'; import { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock, BlockButtons } from './blocks';
import type { BlockRendererProps } from './blocks'; import type { BlockRendererProps, RenderBlock } from './blocks';
import TvQuickConnect from './TvQuickConnect'; import TvQuickConnect from './TvQuickConnect';
import { BackIcon, BookOpenIcon, ChevronIcon } from '@/components/icons'; import { BackIcon, BookOpenIcon, ChevronIcon } from '@/components/icons';
@@ -34,6 +34,14 @@ const RENDERERS: Record<string, React.ComponentType<BlockRendererProps>> = {
minimal: MinimalBlock, minimal: MinimalBlock,
}; };
/** TV quick-connect is a Happ-only feature (check.happ.su/sendtv) — show it only
* for the Happ app, detected by its happ:// deep-link scheme (name as fallback). */
function isHappApp(app: RemnawaveAppClient | null): boolean {
if (!app) return false;
if ((app.deepLink ?? '').toLowerCase().startsWith('happ://')) return true;
return app.name.toLowerCase().includes('happ');
}
interface Props { interface Props {
appConfig: AppConfig; appConfig: AppConfig;
onOpenDeepLink: (url: string) => void; onOpenDeepLink: (url: string) => void;
@@ -141,11 +149,12 @@ export default function InstallationGuide({
], ],
); );
const selectedIsTv =
(activePlatformKey || availablePlatforms[0]) === 'androidTV' ||
(activePlatformKey || availablePlatforms[0]) === 'appleTV';
const userIsOnTv = detectedPlatform === 'androidTV' || detectedPlatform === 'appleTV'; const userIsOnTv = detectedPlatform === 'androidTV' || detectedPlatform === 'appleTV';
const isTvPlatform = selectedIsTv && !userIsOnTv; // Happ's TV quick-connect (check.happ.su/sendtv) is ONE API serving BOTH
// Android TV and Apple TV — show the widget on either.
const selectedPlatform = activePlatformKey || availablePlatforms[0];
const isTvLayout =
(selectedPlatform === 'androidTV' || selectedPlatform === 'appleTV') && !userIsOnTv;
const currentPlatformKey = activePlatformKey || availablePlatforms[0]; const currentPlatformKey = activePlatformKey || availablePlatforms[0];
const currentPlatformData = currentPlatformKey const currentPlatformData = currentPlatformKey
@@ -185,6 +194,21 @@ export default function InstallationGuide({
const blockType = appConfig.uiConfig?.installationGuidesBlockType || 'cards'; const blockType = appConfig.uiConfig?.installationGuidesBlockType || 'cards';
const Renderer = RENDERERS[blockType] || CardsBlock; const Renderer = RENDERERS[blockType] || CardsBlock;
// For the Happ TV app (Android TV / Apple TV), inject the TV connect widget as
// customNode so it renders THROUGH the active block style (cards/timeline/
// accordion/minimal) instead of as separate clashing cards that break it.
const showTvConnect = Boolean(
selectedApp && isTvLayout && isHappApp(selectedApp) && appConfig.subscriptionUrl,
);
let renderBlocks: RenderBlock[] = selectedApp?.blocks ?? [];
if (selectedApp && showTvConnect && appConfig.subscriptionUrl) {
// install → add-subscription → connect: attach to the add step (index 1);
// fall back to the last block for shorter configs.
const idx = selectedApp.blocks.length >= 3 ? 1 : Math.max(0, selectedApp.blocks.length - 1);
const widget = <TvQuickConnect subscriptionUrl={appConfig.subscriptionUrl} isLight={isLight} />;
renderBlocks = selectedApp.blocks.map((b, i) => (i === idx ? { ...b, customNode: widget } : b));
}
return ( return (
<div className="space-y-6 pb-6"> <div className="space-y-6 pb-6">
{/* Header + platform dropdown */} {/* Header + platform dropdown */}
@@ -242,7 +266,12 @@ export default function InstallationGuide({
setActivePlatformKey(newPlatform); setActivePlatformKey(newPlatform);
const data = appConfig.platforms[newPlatform] as RemnawavePlatformData | undefined; const data = appConfig.platforms[newPlatform] as RemnawavePlatformData | undefined;
if (data?.apps?.length) { if (data?.apps?.length) {
const app = data.apps.find((a) => a.featured) || data.apps[0]; // Keep the user's current app (by name) if it also exists on the
// new platform; only fall back to featured/first otherwise.
const app =
data.apps.find((a) => a.name === selectedApp?.name) ||
data.apps.find((a) => a.featured) ||
data.apps[0];
if (app) setSelectedApp(app); if (app) setSelectedApp(app);
} }
}} }}
@@ -312,12 +341,12 @@ export default function InstallationGuide({
</a> </a>
)} )}
{/* Blocks — for TV: first block, Quick Connect, last block */} {/* Blocks rendered in the panel's active style. For the Happ Android TV
{selectedApp && isTvPlatform && appConfig.subscriptionUrl ? ( app the TV connect widget is injected into a step (customNode), so it
<> adapts to that style instead of breaking it. */}
{selectedApp.blocks.length > 0 && ( {selectedApp && (
<Renderer <Renderer
blocks={selectedApp.blocks.slice(0, 1)} blocks={renderBlocks}
isMobile={isMobile} isMobile={isMobile}
isLight={isLight} isLight={isLight}
getLocalizedText={getLocalizedText} getLocalizedText={getLocalizedText}
@@ -325,28 +354,6 @@ export default function InstallationGuide({
renderBlockButtons={renderBlockButtons} renderBlockButtons={renderBlockButtons}
/> />
)} )}
<TvQuickConnect subscriptionUrl={appConfig.subscriptionUrl} isLight={isLight} />
{selectedApp.blocks.length > 1 && (
<Renderer
blocks={selectedApp.blocks.slice(-1)}
isMobile={isMobile}
isLight={isLight}
getLocalizedText={getLocalizedText}
getSvgHtml={getSvgHtml}
renderBlockButtons={renderBlockButtons}
/>
)}
</>
) : selectedApp ? (
<Renderer
blocks={selectedApp.blocks}
isMobile={isMobile}
isLight={isLight}
getLocalizedText={getLocalizedText}
getSvgHtml={getSvgHtml}
renderBlockButtons={renderBlockButtons}
/>
) : null}
</div> </div>
); );
} }

View File

@@ -5,6 +5,7 @@ import {
isQrScannerSupported, isQrScannerSupported,
retrieveLaunchParams, retrieveLaunchParams,
} from '@telegram-apps/sdk-react'; } from '@telegram-apps/sdk-react';
import { blockButtonClass } from './blocks/buttonStyles';
const TG_MOBILE_PLATFORMS = new Set(['ios', 'android', 'android_x', 'ios_x']); const TG_MOBILE_PLATFORMS = new Set(['ios', 'android', 'android_x', 'ios_x']);
@@ -190,43 +191,23 @@ export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) {
} }
}, [tgNative, sendToTV, showToast, onScanDecoded, t]); }, [tgNative, sendToTV, showToast, onScanDecoded, t]);
const cardClass = isLight
? 'rounded-2xl border border-dark-700/60 bg-white/80 shadow-sm p-4 sm:p-5'
: 'rounded-2xl border border-dark-700/50 bg-dark-800/50 p-4 sm:p-5';
const inputClass = isLight const inputClass = isLight
? 'w-full rounded-xl border border-dark-700/60 bg-white px-4 py-3 text-center text-2xl font-bold tracking-[0.3em] uppercase text-dark-100 outline-none focus:border-accent-500 focus:ring-1 focus:ring-accent-500' ? 'w-full rounded-xl border border-dark-700/60 bg-white px-4 py-3 text-center text-2xl font-bold tracking-[0.3em] uppercase text-dark-100 outline-none focus:border-accent-500 focus:ring-1 focus:ring-accent-500'
: 'w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-3 text-center text-2xl font-bold tracking-[0.3em] uppercase text-dark-100 outline-none focus:border-accent-500 focus:ring-1 focus:ring-accent-500'; : 'w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-3 text-center text-2xl font-bold tracking-[0.3em] uppercase text-dark-100 outline-none focus:border-accent-500 focus:ring-1 focus:ring-accent-500';
return ( // Full-width buttons in the same outlined-accent language as the config blocks
<div className="space-y-3"> // (so the Happ TV block adapts to the subscription-page styles, not a one-off).
{/* Code input */} const actionBtnClass = `${blockButtonClass('light', isLight)} flex w-full items-center justify-center`;
<div className={cardClass}>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-accent-500/20 to-accent-600/10">
<svg
className="h-5 w-5 text-accent-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 20.25h12m-7.5-3v3m3-3v3m-10.125-3h17.25c.621 0 1.125-.504 1.125-1.125V4.875c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-dark-100">
{t('subscription.tvQuickConnect.title')}
</h3>
<p className="mt-1 text-sm text-dark-400">
{t('subscription.tvQuickConnect.description')}
</p>
<div className="mt-3 space-y-2"> return (
<div className="mt-3 space-y-4">
{/* Code import — the block step (timeline/cards/accordion/minimal) is the
wrapper; here we only render the interactive controls so the widget
inherits whatever style the panel config produced. */}
<div className="space-y-2">
<p className="text-sm font-medium text-dark-200">
{t('subscription.tvQuickConnect.title')}
</p>
<input <input
type="text" type="text"
maxLength={5} maxLength={5}
@@ -240,52 +221,23 @@ export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) {
<button <button
onClick={() => sendToTV(code)} onClick={() => sendToTV(code)}
disabled={sending || code.length !== 5} disabled={sending || code.length !== 5}
className="btn-primary w-full justify-center py-3 disabled:opacity-50" className={`${actionBtnClass} disabled:opacity-50`}
> >
{sending ? ( {sending ? (
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" /> <div className="h-5 w-5 animate-spin rounded-full border-2 border-accent-500/30 border-t-accent-500" />
) : ( ) : (
t('subscription.tvQuickConnect.sendBtn') t('subscription.tvQuickConnect.sendBtn')
)} )}
</button> </button>
</div> </div>
</div>
</div>
</div>
{/* QR Scanner */} {/* QR scan */}
<div className={cardClass}> <div className="space-y-2">
<div className="flex items-start gap-3"> <p className="text-sm font-medium text-dark-200">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-accent-500/20 to-accent-600/10">
<svg
className="h-5 w-5 text-accent-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z"
/>
</svg>
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-dark-100">
{t('subscription.tvQuickConnect.scanTitle')} {t('subscription.tvQuickConnect.scanTitle')}
</h3>
<p className="mt-1 text-sm text-dark-400">
{t('subscription.tvQuickConnect.scanDescription')}
</p> </p>
{!scanning && ( {!scanning && (
<button onClick={startScan} className="btn-secondary mt-3 w-full justify-center py-3"> <button onClick={startScan} className={actionBtnClass}>
<svg <svg
className="mr-2 h-5 w-5" className="mr-2 h-5 w-5"
fill="none" fill="none"
@@ -307,17 +259,15 @@ export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) {
{t('subscription.tvQuickConnect.scanBtn')} {t('subscription.tvQuickConnect.scanBtn')}
</button> </button>
)} )}
<div className={scanning ? 'mt-3 space-y-2' : 'hidden'}> <div className={scanning ? 'space-y-2' : 'hidden'}>
<div id="tv-qr-reader" className="overflow-hidden rounded-xl" /> <div id="tv-qr-reader" className="overflow-hidden rounded-xl" />
{scanning && ( {scanning && (
<button onClick={stopScan} className="btn-secondary w-full justify-center py-2.5"> <button onClick={stopScan} className={actionBtnClass}>
{t('subscription.tvQuickConnect.stopScan')} {t('subscription.tvQuickConnect.stopScan')}
</button> </button>
)} )}
</div> </div>
</div> </div>
</div>
</div>
{/* Toast */} {/* Toast */}
{toast && ( {toast && (

View File

@@ -15,7 +15,11 @@ export function AccordionBlock({
const [openIndex, setOpenIndex] = useState<number | null>(0); const [openIndex, setOpenIndex] = useState<number | null>(0);
const visibleBlocks = blocks.filter( const visibleBlocks = blocks.filter(
(b) => getLocalizedText(b.title) || getLocalizedText(b.description) || b.buttons?.length, (b) =>
getLocalizedText(b.title) ||
getLocalizedText(b.description) ||
b.buttons?.length ||
b.customNode,
); );
if (!visibleBlocks.length) return null; if (!visibleBlocks.length) return null;
@@ -68,6 +72,7 @@ export function AccordionBlock({
{getLocalizedText(block.description)} {getLocalizedText(block.description)}
</p> </p>
{renderBlockButtons(block.buttons, 'light')} {renderBlockButtons(block.buttons, 'light')}
{block.customNode}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { CheckIcon, CopyIcon } from '@/components/icons'; import { CheckIcon, CopyIcon } from '@/components/icons';
import type { RemnawaveButtonClient, LocalizedText } from '@/types'; import type { RemnawaveButtonClient, LocalizedText } from '@/types';
import { copyToClipboard } from '@/utils/clipboard'; import { copyToClipboard } from '@/utils/clipboard';
import { blockButtonClass } from './buttonStyles';
// eslint-disable-next-line no-script-url // eslint-disable-next-line no-script-url
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']; const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'];
@@ -57,14 +58,7 @@ export function BlockButtons({
if (!buttons || buttons.length === 0) return null; if (!buttons || buttons.length === 0) return null;
const baseClass = const baseClass = blockButtonClass(variant, isLight);
variant === 'light'
? isLight
? 'rounded-xl border border-accent-500/50 px-4 py-2 text-sm font-medium text-accent-600 shadow-sm transition-all hover:bg-accent-500/10'
: 'rounded-xl border border-accent-500/40 px-4 py-2 text-sm font-medium text-accent-400 transition-all hover:bg-accent-500/10'
: isLight
? 'rounded-xl px-3 py-1.5 text-sm font-medium text-dark-300 transition-all hover:bg-dark-700/30'
: 'rounded-xl px-3 py-1.5 text-sm font-medium text-dark-300 transition-all hover:bg-dark-700/50';
return ( return (
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">

View File

@@ -11,7 +11,11 @@ export function CardsBlock({
renderBlockButtons, renderBlockButtons,
}: BlockRendererProps) { }: BlockRendererProps) {
const visibleBlocks = blocks.filter( const visibleBlocks = blocks.filter(
(b) => getLocalizedText(b.title) || getLocalizedText(b.description) || b.buttons?.length, (b) =>
getLocalizedText(b.title) ||
getLocalizedText(b.description) ||
b.buttons?.length ||
b.customNode,
); );
if (!visibleBlocks.length) return null; if (!visibleBlocks.length) return null;
@@ -43,6 +47,7 @@ export function CardsBlock({
{getLocalizedText(block.description)} {getLocalizedText(block.description)}
</p> </p>
{renderBlockButtons(block.buttons, 'light')} {renderBlockButtons(block.buttons, 'light')}
{block.customNode}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -11,7 +11,11 @@ export function MinimalBlock({
renderBlockButtons, renderBlockButtons,
}: BlockRendererProps) { }: BlockRendererProps) {
const visibleBlocks = blocks.filter( const visibleBlocks = blocks.filter(
(b) => getLocalizedText(b.title) || getLocalizedText(b.description) || b.buttons?.length, (b) =>
getLocalizedText(b.title) ||
getLocalizedText(b.description) ||
b.buttons?.length ||
b.customNode,
); );
if (!visibleBlocks.length) return null; if (!visibleBlocks.length) return null;
@@ -44,6 +48,7 @@ export function MinimalBlock({
{getLocalizedText(block.description)} {getLocalizedText(block.description)}
</p> </p>
{renderBlockButtons(block.buttons, 'subtle')} {renderBlockButtons(block.buttons, 'subtle')}
{block.customNode}
</div> </div>
); );
})} })}

View File

@@ -11,7 +11,11 @@ export function TimelineBlock({
renderBlockButtons, renderBlockButtons,
}: BlockRendererProps) { }: BlockRendererProps) {
const visibleBlocks = blocks.filter( const visibleBlocks = blocks.filter(
(b) => getLocalizedText(b.title) || getLocalizedText(b.description) || b.buttons?.length, (b) =>
getLocalizedText(b.title) ||
getLocalizedText(b.description) ||
b.buttons?.length ||
b.customNode,
); );
if (!visibleBlocks.length) return null; if (!visibleBlocks.length) return null;
@@ -43,6 +47,7 @@ export function TimelineBlock({
{getLocalizedText(block.description)} {getLocalizedText(block.description)}
</p> </p>
{renderBlockButtons(block.buttons, 'light')} {renderBlockButtons(block.buttons, 'light')}
{block.customNode}
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,16 @@
/**
* Shared button styling for connection blocks. The config-driven blocks
* (BlockButtons) and the Happ TV quick-connect both render through this so the
* latter adapts to exactly the same visual language as the styles coming from
* the subscription-page config — no divergent one-off button styles.
*/
export function blockButtonClass(variant: 'light' | 'subtle', isLight?: boolean): string {
if (variant === 'light') {
return isLight
? 'rounded-xl border border-accent-500/50 px-4 py-2 text-sm font-medium text-accent-600 shadow-sm transition-all hover:bg-accent-500/10'
: 'rounded-xl border border-accent-500/40 px-4 py-2 text-sm font-medium text-accent-400 transition-all hover:bg-accent-500/10';
}
return isLight
? 'rounded-xl px-3 py-1.5 text-sm font-medium text-dark-300 transition-all hover:bg-dark-700/30'
: 'rounded-xl px-3 py-1.5 text-sm font-medium text-dark-300 transition-all hover:bg-dark-700/50';
}

View File

@@ -3,4 +3,4 @@ export { TimelineBlock } from './TimelineBlock';
export { AccordionBlock } from './AccordionBlock'; export { AccordionBlock } from './AccordionBlock';
export { MinimalBlock } from './MinimalBlock'; export { MinimalBlock } from './MinimalBlock';
export { BlockButtons } from './BlockButtons'; export { BlockButtons } from './BlockButtons';
export type { BlockRendererProps } from './types'; export type { BlockRendererProps, RenderBlock } from './types';

View File

@@ -1,7 +1,16 @@
import type { ReactNode } from 'react';
import type { RemnawaveBlockClient, RemnawaveButtonClient, LocalizedText } from '@/types'; import type { RemnawaveBlockClient, RemnawaveButtonClient, LocalizedText } from '@/types';
/**
* A block to render. Beyond the panel's data (title/description/buttons), it may
* carry `customNode` — extra interactive content (e.g. the Happ TV connect
* widget) that every renderer drops into the block body, so it inherits the
* active style (cards/timeline/accordion/minimal) instead of clashing with it.
*/
export type RenderBlock = RemnawaveBlockClient & { customNode?: ReactNode };
export interface BlockRendererProps { export interface BlockRendererProps {
blocks: RemnawaveBlockClient[]; blocks: RenderBlock[];
isMobile: boolean; isMobile: boolean;
isLight: boolean; isLight: boolean;
getLocalizedText: (text: LocalizedText | undefined) => string; getLocalizedText: (text: LocalizedText | undefined) => string;

View File

@@ -283,7 +283,7 @@ export default function SubscriptionCardActive({
> >
{t('dashboard.tariff')} {t('dashboard.tariff')}
</div> </div>
<div className="text-base font-bold leading-tight tracking-tight text-dark-50"> <div className="min-w-0 truncate text-base font-bold leading-tight tracking-tight text-dark-50">
{subscription.tariff_name || t('subscription.currentPlan')} {subscription.tariff_name || t('subscription.currentPlan')}
</div> </div>
<div className="mt-0.5 font-mono text-[10px] text-dark-50/30"> <div className="mt-0.5 font-mono text-[10px] text-dark-50/30">

View File

@@ -49,6 +49,7 @@ import {
PiFlag, PiFlag,
PiArrowCounterClockwise, PiArrowCounterClockwise,
PiArrowClockwise, PiArrowClockwise,
PiCloudWarning,
PiRocket, PiRocket,
PiFloppyDisk, PiFloppyDisk,
PiPaperPlaneTilt, PiPaperPlaneTilt,
@@ -361,6 +362,10 @@ export const RestartIcon = ({ className }: IconProps) => (
<PiArrowClockwise className={cn('h-5 w-5', className)} /> <PiArrowClockwise className={cn('h-5 w-5', className)} />
); );
export const CloudWarningIcon = ({ className }: IconProps) => (
<PiCloudWarning className={cn('h-5 w-5', className)} />
);
export const RocketIcon = ({ className }: IconProps) => ( export const RocketIcon = ({ className }: IconProps) => (
<PiRocket className={cn('h-5 w-5', className)} /> <PiRocket className={cn('h-5 w-5', className)} />
); );

View File

@@ -343,9 +343,11 @@ export function AppHeader({
> >
<UserIcon className="h-5 w-5" /> <UserIcon className="h-5 w-5" />
</div> </div>
<div> <div className="min-w-0">
<div className="text-sm font-medium text-dark-100">{displayName(user)}</div> <div className="truncate text-sm font-medium text-dark-100">
<div className="text-xs text-dark-500"> {displayName(user)}
</div>
<div className="truncate text-xs text-dark-500">
@{user?.username || `ID: ${user?.telegram_id}`} @{user?.username || `ID: ${user?.telegram_id}`}
</div> </div>
</div> </div>

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useLocation, Link } from 'react-router'; import { useLocation, Link } from 'react-router';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { useAuthStore } from '@/store/auth'; import { useAuthStore } from '@/store/auth';
import { useHaptic } from '@/platform'; import { useHaptic } from '@/platform';
@@ -109,11 +110,13 @@ export function AppShell({ children }: AppShellProps) {
}; };
}, []); }, []);
// Desktop navigation items // Desktop navigation — labels always visible (no hover-reveal gimmick)
const desktopNavItems = [ const desktopNav = [
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon }, { path: '/', label: t('nav.dashboard'), icon: HomeIcon },
{ path: '/subscriptions', label: t('nav.subscription'), icon: SubscriptionIcon }, { path: '/subscriptions', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/balance', label: t('nav.balance'), icon: CreditCardIcon }, { path: '/balance', label: t('nav.balance'), icon: CreditCardIcon },
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
...(giftEnabled ? [{ path: '/gift', label: t('nav.gift'), icon: GiftIcon }] : []),
{ path: '/support', label: t('nav.support'), icon: ChatIcon }, { path: '/support', label: t('nav.support'), icon: ChatIcon },
{ path: '/info', label: t('nav.info'), icon: InfoIcon }, { path: '/info', label: t('nav.info'), icon: InfoIcon },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }, { path: '/profile', label: t('nav.profile'), icon: UserIcon },
@@ -128,6 +131,51 @@ export function AppShell({ children }: AppShellProps) {
haptic.impact('light'); haptic.impact('light');
}; };
// A single elegant nav link: icon + label always visible, with a shared
// framer-motion pill that slides to the active item on navigation.
const renderNavLink = (
path: string,
label: string,
Icon: React.ComponentType<{ className?: string }>,
admin = false,
) => {
const active = admin ? location.pathname.startsWith('/admin') : isActive(path);
return (
<Link
key={path}
to={path}
onClick={handleNavClick}
aria-label={label}
className={cn(
'relative flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1.5 text-[13px] font-medium transition-colors duration-200',
active
? admin
? 'text-warning-300'
: 'text-dark-50'
: admin
? 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-300'
: 'text-dark-400 hover:bg-dark-800/60 hover:text-dark-100',
)}
>
{active && (
<motion.span
layoutId="desktop-nav-active"
className={cn(
// Подсветка-пилюля активного пункта — «приподнята» над треком капсулы
'absolute inset-0 rounded-full shadow-sm',
admin
? 'bg-warning-500/15 ring-1 ring-warning-500/20'
: 'bg-dark-700/80 ring-1 ring-dark-600/40',
)}
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
/>
)}
<Icon className="relative h-4 w-4 shrink-0" />
<span className="relative whitespace-nowrap">{label}</span>
</Link>
);
};
// headerHeight comes from useHeaderHeight() — accounts for TG safe area in fullscreen // headerHeight comes from useHeaderHeight() — accounts for TG safe area in fullscreen
return ( return (
@@ -143,9 +191,17 @@ export function AppShell({ children }: AppShellProps) {
{/* Desktop Header */} {/* Desktop Header */}
<header className="fixed left-0 right-0 top-0 z-50 hidden border-b border-dark-800/50 bg-dark-950/95 lg:block"> <header className="fixed left-0 right-0 top-0 z-50 hidden border-b border-dark-800/50 bg-dark-950/95 lg:block">
<div className="mx-auto grid h-14 max-w-6xl grid-cols-[auto_1fr_auto] items-center gap-4 px-6"> {/* 3-зонный grid: лого | капсула | действия. Колонки 1fr_auto_1fr держат
капсулу строго по центру вьюпорта НЕЗАВИСИМО от ширины лого/действий,
а действия — у правого края. Поэтому ничего не «скачет» при переходах
(в т.ч. в админку): смена ширины в одной зоне не двигает другие. */}
<div className="mx-auto grid h-14 max-w-[1600px] grid-cols-[1fr_auto_1fr] items-center gap-4 px-6">
{/* Logo */} {/* Logo */}
<Link to="/" className="flex items-center gap-2.5" onClick={handleNavClick}> <Link
to="/"
className="flex shrink-0 items-center gap-2.5 justify-self-start"
onClick={handleNavClick}
>
<div className="relative flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-lg bg-dark-800"> <div className="relative flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-lg bg-dark-800">
<span <span
className={cn( className={cn(
@@ -169,88 +225,21 @@ export function AppShell({ children }: AppShellProps) {
<span className="text-base font-semibold text-dark-100">{appName}</span> <span className="text-base font-semibold text-dark-100">{appName}</span>
</Link> </Link>
{/* Center Navigation */} {/* Navigation — единая «капсула» (segmented control): все пункты видны
<nav className="flex min-w-0 items-center gap-1"> всегда, без скролла/сжатия/сворачивания. Центрируется средней
{desktopNavItems.map((item) => ( колонкой grid (justify-self-center), а не auto-margin'ами. */}
<Link <nav className="flex items-center gap-0.5 justify-self-center rounded-full border border-dark-800/70 bg-dark-900/50 p-1 shadow-sm backdrop-blur-sm">
key={item.path} {desktopNav.map((item) => renderNavLink(item.path, item.label, item.icon))}
to={item.path}
onClick={handleNavClick}
aria-label={item.label}
className={cn(
'group flex items-center rounded-xl px-2.5 py-2 transition-all duration-200',
isActive(item.path)
? 'bg-dark-800 text-dark-50'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
<item.icon className="h-[18px] w-[18px] shrink-0" />
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-focus-within:ml-2 group-focus-within:max-w-40 group-focus-within:opacity-100 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
{item.label}
</span>
</Link>
))}
{referralEnabled && (
<Link
to="/referral"
onClick={handleNavClick}
aria-label={t('nav.referral')}
className={cn(
'group flex items-center rounded-xl px-2.5 py-2 transition-all duration-200',
isActive('/referral')
? 'bg-dark-800 text-dark-50'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
<UsersIcon className="h-[18px] w-[18px] shrink-0" />
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-focus-within:ml-2 group-focus-within:max-w-40 group-focus-within:opacity-100 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
{t('nav.referral')}
</span>
</Link>
)}
{giftEnabled && (
<Link
to="/gift"
onClick={handleNavClick}
aria-label={t('nav.gift')}
className={cn(
'group flex items-center rounded-xl px-2.5 py-2 transition-all duration-200',
isActive('/gift')
? 'bg-dark-800 text-dark-50'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
<GiftIcon className="h-[18px] w-[18px] shrink-0" />
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-focus-within:ml-2 group-focus-within:max-w-40 group-focus-within:opacity-100 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
{t('nav.gift')}
</span>
</Link>
)}
{isAdmin && ( {isAdmin && (
<> <>
<div className="mx-1 h-5 w-px shrink-0 bg-dark-700" /> <div className="mx-1 h-5 w-px shrink-0 bg-dark-700/60" />
<Link {renderNavLink('/admin', t('admin.nav.title'), ShieldIcon, true)}
to="/admin"
onClick={handleNavClick}
aria-label={t('admin.nav.title')}
className={cn(
'group flex items-center rounded-xl px-2.5 py-2 transition-all duration-200',
location.pathname.startsWith('/admin')
? 'bg-warning-500/10 text-warning-400'
: 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400',
)}
>
<ShieldIcon className="h-[18px] w-[18px] shrink-0" />
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-focus-within:ml-2 group-focus-within:max-w-40 group-focus-within:opacity-100 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
{t('admin.nav.title')}
</span>
</Link>
</> </>
)} )}
</nav> </nav>
{/* Right side actions */} {/* Right side actions — правая колонка grid, прижата к краю, не сжимается */}
<div className="flex items-center justify-end gap-2"> <div className="flex shrink-0 items-center gap-2 justify-self-end">
<button <button
onClick={() => { onClick={() => {
haptic.impact('light'); haptic.impact('light');
@@ -258,7 +247,7 @@ export function AppShell({ children }: AppShellProps) {
}} }}
className={cn( className={cn(
'rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-dark-400 transition-colors duration-200 hover:bg-dark-700 hover:text-accent-400', 'rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-dark-400 transition-colors duration-200 hover:bg-dark-700 hover:text-accent-400',
!canToggleTheme && 'pointer-events-none invisible', !canToggleTheme && 'hidden',
)} )}
aria-label={ aria-label={
isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode' isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'

View File

@@ -1,212 +0,0 @@
import { Link, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/store/auth';
import { displayName } from '@/utils/displayName';
import {
brandingApi,
getCachedBranding,
setCachedBranding,
preloadLogo,
isLogoPreloaded,
} from '@/api/branding';
import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform';
// Icons
import {
HomeIcon,
SubscriptionIcon,
WalletIcon,
UsersIcon,
ChatIcon,
UserIcon,
LogoutIcon,
GamepadIcon,
ClipboardIcon,
InfoIcon,
CogIcon,
WheelIcon,
} from './icons';
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
interface DesktopSidebarProps {
isAdmin?: boolean;
wheelEnabled?: boolean;
referralEnabled?: boolean;
hasContests?: boolean;
hasPolls?: boolean;
}
export function DesktopSidebar({
isAdmin,
wheelEnabled,
referralEnabled,
hasContests,
hasPolls,
}: DesktopSidebarProps) {
const { t } = useTranslation();
const location = useLocation();
const user = useAuthStore((state) => state.user);
const logout = useAuthStore((state) => state.logout);
const { haptic } = usePlatform();
// Branding
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: async () => {
const data = await brandingApi.getBranding();
setCachedBranding(data);
await preloadLogo(data);
return data;
},
initialData: getCachedBranding() ?? undefined,
initialDataUpdatedAt: 0,
staleTime: 60000,
refetchOnWindowFocus: true,
retry: 1,
});
const appName = branding ? branding.name : FALLBACK_NAME;
const logoLetter = branding?.logo_letter || FALLBACK_LOGO;
const hasCustomLogo = branding?.has_custom_logo || false;
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
const isActive = (path: string) =>
path === '/' ? location.pathname === '/' : location.pathname.startsWith(path);
const isAdminActive = () => location.pathname.startsWith('/admin');
const navItems = [
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
{ path: '/subscriptions', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
...(hasContests ? [{ path: '/contests', label: t('nav.contests'), icon: GamepadIcon }] : []),
...(hasPolls ? [{ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon }] : []),
...(wheelEnabled ? [{ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }] : []),
{ path: '/info', label: t('nav.info'), icon: InfoIcon },
];
const handleNavClick = () => {
haptic.impact('light');
};
return (
<aside className="h-viewport fixed left-0 top-0 z-40 flex w-60 flex-col border-r border-dark-700/30 bg-dark-950/80 backdrop-blur-linear">
{/* Logo */}
<div className="flex h-16 items-center gap-3 border-b border-dark-700/30 px-4">
<Link to="/" className="flex items-center gap-3" onClick={handleNavClick}>
<div className="relative flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-linear-lg border border-dark-700/50 bg-dark-800/80">
<span
className={cn(
'absolute text-lg font-bold text-accent-400 transition-opacity duration-200',
hasCustomLogo && isLogoPreloaded() ? 'opacity-0' : 'opacity-100',
)}
>
{logoLetter}
</span>
{hasCustomLogo && logoUrl && (
<img
src={logoUrl}
alt={appName || 'Logo'}
className={cn(
'absolute h-full w-full object-contain transition-opacity duration-200',
isLogoPreloaded() ? 'opacity-100' : 'opacity-0',
)}
/>
)}
</div>
{appName && (
<span className="whitespace-nowrap text-base font-semibold text-dark-100">
{appName}
</span>
)}
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 overflow-y-auto p-3">
{navItems.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={handleNavClick}
className={cn(
'group flex items-center gap-3 rounded-linear px-3 py-2.5 text-sm font-medium transition-all duration-200',
isActive(item.path)
? 'bg-accent-500/10 text-accent-400'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-100',
)}
>
<item.icon className="h-5 w-5 shrink-0" />
<span>{item.label}</span>
{isActive(item.path) && (
<motion.div
layoutId="sidebar-active-indicator"
className="absolute left-0 h-8 w-0.5 rounded-r-full bg-accent-400"
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
/>
)}
</Link>
))}
{/* Admin section */}
{isAdmin && (
<>
<div className="my-3 h-px bg-dark-700/30" />
<Link
to="/admin"
onClick={handleNavClick}
className={cn(
'group flex items-center gap-3 rounded-linear px-3 py-2.5 text-sm font-medium transition-all duration-200',
isAdminActive()
? 'bg-warning-500/10 text-warning-400'
: 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400',
)}
>
<CogIcon className="h-5 w-5 shrink-0" />
<span>{t('admin.nav.title')}</span>
</Link>
</>
)}
</nav>
{/* User section */}
<div className="border-t border-dark-700/30 p-3">
<Link
to="/profile"
onClick={handleNavClick}
className={cn(
'group flex items-center gap-3 rounded-linear px-3 py-2.5 transition-all duration-200',
isActive('/profile') ? 'bg-dark-800/80' : 'hover:bg-dark-800/50',
)}
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-dark-700">
<UserIcon className="h-4 w-4 text-dark-400" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-dark-100">{displayName(user)}</p>
<p className="truncate text-xs text-dark-500">
@{user?.username || `ID: ${user?.telegram_id}`}
</p>
</div>
</Link>
<button
onClick={() => {
haptic.impact('light');
logout();
}}
className="mt-2 flex w-full items-center gap-3 rounded-linear px-3 py-2.5 text-sm text-dark-400 transition-all duration-200 hover:bg-error-500/10 hover:text-error-400"
>
<LogoutIcon className="h-5 w-5 shrink-0" />
<span>{t('nav.logout')}</span>
</button>
</div>
</aside>
);
}

View File

@@ -1,4 +1,3 @@
export { AppShell } from './AppShell'; export { AppShell } from './AppShell';
export { DesktopSidebar } from './DesktopSidebar';
export { MobileBottomNav } from './MobileBottomNav'; export { MobileBottomNav } from './MobileBottomNav';
export { AppHeader } from './AppHeader'; export { AppHeader } from './AppHeader';

View File

@@ -400,8 +400,8 @@ export function ClassicPurchaseWizard({
> >
{selectedServers.includes(server.uuid) && <CheckIcon />} {selectedServers.includes(server.uuid) && <CheckIcon />}
</div> </div>
<div> <div className="min-w-0">
<div className="font-medium text-dark-100"> <div className="truncate font-medium text-dark-100">
<Twemoji options={{ className: 'twemoji', folder: 'svg', ext: '.svg' }}> <Twemoji options={{ className: 'twemoji', folder: 'svg', ext: '.svg' }}>
{server.name} {server.name}
</Twemoji> </Twemoji>

View File

@@ -105,9 +105,9 @@ export function TariffPurchaseForm({
return ( return (
<div ref={ref} className="space-y-6"> <div ref={ref} className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-2">
<h3 className="text-lg font-medium text-dark-100">{tariff.name}</h3> <h3 className="min-w-0 truncate text-lg font-medium text-dark-100">{tariff.name}</h3>
<button onClick={onBack} className="text-dark-400 hover:text-dark-200"> <button onClick={onBack} className="shrink-0 text-dark-400 hover:text-dark-200">
{t('common.back')} {t('common.back')}
</button> </button>
</div> </div>

View File

@@ -168,15 +168,15 @@ export function ServerManagementSheet({
: 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400' : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'
} ${!country.is_available && !isCurrentlyConnected ? 'cursor-not-allowed opacity-50' : ''}`} } ${!country.is_available && !isCurrentlyConnected ? 'cursor-not-allowed opacity-50' : ''}`}
> >
<div className="flex items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<span className="text-lg"> <span className="shrink-0 text-lg">
{willBeAdded ? '' : willBeRemoved ? '' : isSelected ? '✅' : '⚪'} {willBeAdded ? '' : willBeRemoved ? '' : isSelected ? '✅' : '⚪'}
</span> </span>
<div> <div className="min-w-0">
<div className="flex items-center gap-2 font-medium text-dark-100"> <div className="flex min-w-0 items-center gap-2 font-medium text-dark-100">
{country.name} <span className="truncate">{country.name}</span>
{country.has_discount && !isCurrentlyConnected && ( {country.has_discount && !isCurrentlyConnected && (
<span className="rounded bg-success-500/20 px-1.5 py-0.5 text-xs text-success-400"> <span className="shrink-0 rounded bg-success-500/20 px-1.5 py-0.5 text-xs text-success-400">
-{country.discount_percent}% -{country.discount_percent}%
</span> </span>
)} )}
@@ -217,7 +217,7 @@ export function ServerManagementSheet({
</div> </div>
</div> </div>
{country.country_code && ( {country.country_code && (
<span className="text-xl">{getFlagEmoji(country.country_code)}</span> <span className="shrink-0 text-xl">{getFlagEmoji(country.country_code)}</span>
)} )}
</button> </button>
); );

View File

@@ -26,6 +26,25 @@ import type { Tariff } from '../../../types';
// TariffPurchaseForm instead of attempting another switch. // TariffPurchaseForm instead of attempting another switch.
// ────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────
// The backend rejects a switch that must instead go through the purchase flow:
// the subscription lapsed (`subscription_expired`), or it is a trial that has no
// paid value to prorate and would otherwise be handed a full target period
// (`trial_cannot_switch`, bug #629889). Both arrive as detail.code +
// use_purchase_flow=true; some payloads use the legacy `error_code` key, so we
// accept either.
function shouldUsePurchaseFlow(error: unknown): boolean {
if (!(error instanceof AxiosError)) return false;
const detail = error.response?.data?.detail as
| { code?: string; error_code?: string; use_purchase_flow?: boolean }
| undefined;
if (!detail || typeof detail !== 'object') return false;
const code = detail.code ?? detail.error_code;
return (
(code === 'subscription_expired' || code === 'trial_cannot_switch') &&
detail.use_purchase_flow === true
);
}
export interface SwitchTariffSheetProps { export interface SwitchTariffSheetProps {
open: boolean; open: boolean;
tariffId: number | null; tariffId: number | null;
@@ -69,16 +88,10 @@ export function SwitchTariffSheet({
navigate('/subscriptions', { replace: true }); navigate('/subscriptions', { replace: true });
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
// Backend signal: the subscription lapsed mid-flight. Hand the // Backend signal: this subscription can't be switched (it lapsed, or it's
// selected tariff back to the parent so it can open the regular // a trial). Hand the selected tariff back to the parent so it opens the
// purchase form instead. // regular purchase form instead.
if (error instanceof AxiosError) { if (shouldUsePurchaseFlow(error)) {
const detail = error.response?.data?.detail;
if (
typeof detail === 'object' &&
detail?.error_code === 'subscription_expired' &&
detail?.use_purchase_flow === true
) {
const targetTariff = tariffs.find((tariff) => tariff.id === tariffId); const targetTariff = tariffs.find((tariff) => tariff.id === tariffId);
if (targetTariff) { if (targetTariff) {
onClose(); onClose();
@@ -86,7 +99,6 @@ export function SwitchTariffSheet({
queryClient.invalidateQueries({ queryKey: ['purchase-options', subscriptionId] }); queryClient.invalidateQueries({ queryKey: ['purchase-options', subscriptionId] });
} }
} }
}
}, },
}); });
@@ -130,15 +142,15 @@ export function SwitchTariffSheet({
return ( return (
<> <>
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="flex justify-between text-dark-300"> <div className="flex justify-between gap-2 text-dark-300">
<span>{t('subscription.switchTariff.currentTariff')}</span> <span className="shrink-0">{t('subscription.switchTariff.currentTariff')}</span>
<span className="font-medium text-dark-100"> <span className="min-w-0 truncate font-medium text-dark-100">
{switchPreview.current_tariff_name || '-'} {switchPreview.current_tariff_name || '-'}
</span> </span>
</div> </div>
<div className="flex justify-between text-dark-300"> <div className="flex justify-between gap-2 text-dark-300">
<span>{t('subscription.switchTariff.newTariff')}</span> <span className="shrink-0">{t('subscription.switchTariff.newTariff')}</span>
<span className="font-medium text-accent-400"> <span className="min-w-0 truncate font-medium text-accent-400">
{switchPreview.new_tariff_name} {switchPreview.new_tariff_name}
</span> </span>
</div> </div>
@@ -213,15 +225,10 @@ export function SwitchTariffSheet({
{switchMutation.isError && {switchMutation.isError &&
(() => { (() => {
// Suppress the toast when the subscription_expired // Suppress the toast when the purchase-flow fallback already
// fallback already triggered: the parent is now // triggered (expired / trial): the parent is now showing the
// showing the regular purchase form, surfacing the // regular purchase form, so the raw axios message would mislead.
// raw axios message here would be misleading. if (shouldUsePurchaseFlow(switchMutation.error)) {
const detail =
switchMutation.error instanceof AxiosError
? switchMutation.error.response?.data?.detail
: null;
if (typeof detail === 'object' && detail?.error_code === 'subscription_expired') {
return null; return null;
} }
return ( return (

View File

@@ -9,12 +9,15 @@ export interface MediaItem {
type: string; type: string;
file_id: string; file_id: string;
caption?: string | null; caption?: string | null;
/** Signed, expiring download token from the ticket response. */
token?: string | null;
} }
interface MessageLike { interface MessageLike {
has_media?: boolean; has_media?: boolean;
media_type?: string | null; media_type?: string | null;
media_file_id?: string | null; media_file_id?: string | null;
media_token?: string | null;
media_caption?: string | null; media_caption?: string | null;
media_items?: MediaItem[] | null; media_items?: MediaItem[] | null;
} }
@@ -33,6 +36,7 @@ function getItems(message: MessageLike): MediaItem[] {
type: message.media_type, type: message.media_type,
file_id: message.media_file_id, file_id: message.media_file_id,
caption: message.media_caption, caption: message.media_caption,
token: message.media_token,
}, },
]; ];
} }
@@ -113,7 +117,7 @@ export function MessageMediaGrid({
onClick={() => openFullscreen(originalIdx)} onClick={() => openFullscreen(originalIdx)}
> >
<img <img
src={ticketsApi.getMediaUrl(item.file_id)} src={ticketsApi.getMediaUrl(item.file_id, item.token)}
alt={item.caption || 'Attached photo'} alt={item.caption || 'Attached photo'}
className="h-full w-full object-cover transition-opacity group-hover:opacity-90" className="h-full w-full object-cover transition-opacity group-hover:opacity-90"
loading="lazy" loading="lazy"
@@ -131,7 +135,7 @@ export function MessageMediaGrid({
{/* Non-photo media rendered inline */} {/* Non-photo media rendered inline */}
{otherItems.map((item) => { {otherItems.map((item) => {
const mediaUrl = ticketsApi.getMediaUrl(item.file_id); const mediaUrl = ticketsApi.getMediaUrl(item.file_id, item.token);
if (item.type === 'video') { if (item.type === 'video') {
return ( return (
<div key={item.file_id}> <div key={item.file_id}>
@@ -203,7 +207,10 @@ export function MessageMediaGrid({
onClick={closeFullscreen} onClick={closeFullscreen}
> >
<img <img
src={ticketsApi.getMediaUrl(photoItems[fullscreenIndex].file_id)} src={ticketsApi.getMediaUrl(
photoItems[fullscreenIndex].file_id,
photoItems[fullscreenIndex].token,
)}
alt={photoItems[fullscreenIndex].caption || 'Attached photo'} alt={photoItems[fullscreenIndex].caption || 'Attached photo'}
className="max-h-full max-w-full object-contain" className="max-h-full max-w-full object-contain"
style={{ touchAction: 'pinch-zoom' }} style={{ touchAction: 'pinch-zoom' }}

View File

@@ -9,6 +9,7 @@ import {
preloadLogo, preloadLogo,
isLogoPreloaded, isLogoPreloaded,
} from '@/api/branding'; } from '@/api/branding';
import { setFavicon, letterFaviconDataUri, roundedFaviconDataUri } from '@/utils/favicon';
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'; const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V'; const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
@@ -42,18 +43,21 @@ export function useBranding() {
document.title = appName || 'VPN'; document.title = appName || 'VPN';
}, [appName]); }, [appName]);
// Update favicon // Update favicon — custom logo (rounded like the header tile) when available,
// else a brand-letter monogram so the tab always carries an icon.
useEffect(() => { useEffect(() => {
if (!logoUrl) return; if (!logoUrl) {
setFavicon(letterFaviconDataUri(logoLetter));
const link = return;
document.querySelector<HTMLLinkElement>("link[rel*='icon']") || }
document.createElement('link'); let cancelled = false;
link.type = 'image/x-icon'; roundedFaviconDataUri(logoUrl).then((rounded) => {
link.rel = 'shortcut icon'; if (!cancelled) setFavicon(rounded || logoUrl);
link.href = logoUrl; });
document.head.appendChild(link); return () => {
}, [logoUrl]); cancelled = true;
};
}, [logoUrl, logoLetter]);
// Fullscreen setting from server // Fullscreen setting from server
const { data: fullscreenSetting } = useQuery({ const { data: fullscreenSetting } = useQuery({

View File

@@ -15,6 +15,8 @@ import {
retrieveLaunchParams, retrieveLaunchParams,
retrieveRawInitData, retrieveRawInitData,
themeParamsState, themeParamsState,
closeMiniApp as sdkCloseMiniApp,
postEvent,
} from '@telegram-apps/sdk-react'; } from '@telegram-apps/sdk-react';
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled'; const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled';
@@ -50,6 +52,34 @@ export function isInTelegramWebApp(): boolean {
return detectTelegram(); return detectTelegram();
} }
/**
* Closes the Telegram Mini App as reliably as possible. All three paths emit the
* same `web_app_close` event to the Telegram client; we try the most broadly
* compatible first and stop at the first that doesn't throw:
* 1) the legacy `window.Telegram.WebApp.close()` global (telegram-web-app.js,
* loaded in index.html) — widest client coverage, no SDK-mount dependency;
* 2) the modern SDK `closeMiniApp()` (mini app is mounted on init);
* 3) the raw `postEvent('web_app_close')` protocol event — no global/mount
* dependency at all.
* Outside Telegram it is a safe no-op.
*/
export function closeTelegramApp(): void {
try {
const wa = window.Telegram?.WebApp;
if (wa?.close) {
wa.close();
return;
}
} catch {}
try {
sdkCloseMiniApp();
return;
} catch {}
try {
postEvent('web_app_close');
} catch {}
}
export function isTelegramMobile(): boolean { export function isTelegramMobile(): boolean {
try { try {
const { tgWebAppPlatform } = retrieveLaunchParams(); const { tgWebAppPlatform } = retrieveLaunchParams();

View File

@@ -579,7 +579,7 @@
"buyTraffic": "Buy more traffic", "buyTraffic": "Buy more traffic",
"currentTrafficLimit": "Current limit: {{limit}} GB (used {{used}} GB)", "currentTrafficLimit": "Current limit: {{limit}} GB (used {{used}} GB)",
"buyTrafficTitle": "Buy more traffic", "buyTrafficTitle": "Buy more traffic",
"trafficWarning": "Purchased traffic is added to your current limit and does not carry over to the next period", "trafficWarning": "Purchased traffic is added to your current limit and is valid for 30 days from purchase",
"trafficUnavailable": "Traffic purchase is not available for your tariff", "trafficUnavailable": "Traffic purchase is not available for your tariff",
"unlimited": "Unlimited", "unlimited": "Unlimited",
"buyTrafficGb": "Buy {{gb}} GB", "buyTrafficGb": "Buy {{gb}} GB",
@@ -1756,7 +1756,8 @@
"dailyLimit": "Daily Spin Limit (0 = unlimited)", "dailyLimit": "Daily Spin Limit (0 = unlimited)",
"minSubDays": "Min subscription days for day payment", "minSubDays": "Min subscription days for day payment",
"promocodes": "Promocodes", "promocodes": "Promocodes",
"promoPrefix": "Promo code prefix" "promoPrefix": "Promo code prefix",
"promoValidityDays": "Promo code validity (days)"
}, },
"starsNotEnabledGlobally": "Star payments are not enabled in Payment Methods. Enable \"Telegram Stars\" in the Payment Methods section.", "starsNotEnabledGlobally": "Star payments are not enabled in Payment Methods. Enable \"Telegram Stars\" in the Payment Methods section.",
"prizes": { "prizes": {
@@ -1783,7 +1784,9 @@
"emoji": "Emoji", "emoji": "Emoji",
"color": "Color", "color": "Color",
"active": "Active", "active": "Active",
"worth": "Worth" "worth": "Worth",
"probability": "Manual probability (01)",
"probabilityHint": "Empty = auto by RTP"
}, },
"promo": { "promo": {
"title": "Promo Code Settings", "title": "Promo Code Settings",
@@ -1801,7 +1804,9 @@
"times": "times", "times": "times",
"times_one": "{{count}} time", "times_one": "{{count}} time",
"times_other": "{{count}} times", "times_other": "{{count}} times",
"topWins": "Top Wins" "topWins": "Top Wins",
"loadError": "Failed to load statistics",
"empty": "No spins yet"
} }
}, },
"broadcasts": { "broadcasts": {
@@ -3978,6 +3983,12 @@
"dailyChart": "Purchases & Revenue by Day", "dailyChart": "Purchases & Revenue by Day",
"tariffChart": "Tariff Distribution", "tariffChart": "Tariff Distribution",
"giftBreakdown": "Gifts vs Regular", "giftBreakdown": "Gifts vs Regular",
"funnelTitle": "Conversion funnel",
"giftClaimTitle": "Gift activation",
"giftClaimLabel": "claimed / sent",
"dailyRevenue": "Daily revenue",
"byPaymentMethod": "By payment method",
"bySource": "Traffic sources",
"purchases": "Purchases", "purchases": "Purchases",
"revenueLabel": "Revenue", "revenueLabel": "Revenue",
"gifts": "Gifts", "gifts": "Gifts",
@@ -4588,7 +4599,13 @@
"vk": "VK" "vk": "VK"
}, },
"backToAccounts": "Back to accounts" "backToAccounts": "Back to accounts"
} },
"emailMergePasswordRequired": "This email already belongs to another account. To merge the accounts, enter that account's password.",
"emailMergeCodeSent": "A confirmation code was sent to that email.",
"emailMergeCodeDescription": "This email already belongs to another account. We sent a code to it — enter it to confirm ownership and merge the accounts.",
"emailMergeCodeLabel": "Code from the email",
"emailMergeConfirm": "Confirm and merge",
"emailMergeCodeInvalid": "Invalid or expired code"
}, },
"theme": { "theme": {
"colors": "Theme Colors", "colors": "Theme Colors",
@@ -4804,6 +4821,14 @@
"openBot": "Open the bot", "openBot": "Open the bot",
"retry": "I pressed /start, retry", "retry": "I pressed /start, retry",
"hint": "After running /start, return here and tap «Retry»." "hint": "After running /start, return here and tap «Retry»."
},
"serviceUnavailable": {
"title": "Service unavailable",
"description": "We can't reach the service right now. It may be undergoing maintenance or there's a temporary connection issue.",
"retry": "Try again",
"checking": "Checking connection...",
"hint": "This page will refresh automatically as soon as the service is back.",
"close": "Close"
} }
}, },
"merge": { "merge": {
@@ -4834,6 +4859,36 @@
"merging": "Merging..." "merging": "Merging..."
}, },
"landing": { "landing": {
"giftClaim": {
"title": "You have a gift!",
"error": "Could not activate the gift.",
"notFoundTitle": "Gift not found",
"notFoundDesc": "This gift link is invalid or no longer available.",
"alreadyTitle": "Gift already activated",
"alreadyDesc": "This gift has already been claimed.",
"successTitle": "Gift activated!",
"connectDesc": "Use this link to connect:",
"copyLink": "Copy link",
"pendingTitle": "Almost ready…",
"pendingDesc": "The payment is still being confirmed. This page will update automatically.",
"replaceWarning": "You already have a subscription — activating this gift will replace it.",
"activateTelegram": "Activate in Telegram",
"activateWeb": "Activate by email",
"emailLabel": "Your email",
"claimNow": "Get my gift",
"failedTitle": "Gift unavailable",
"failedDesc": "The payment for this gift did not go through, so it cannot be activated."
},
"giftLink": {
"shareText": "I have a gift for you! Activate it here:",
"viaTelegram": "Telegram:",
"title": "Gift is ready!",
"subtitle": "Send this link to whoever you want to receive the gift — they activate it themselves.",
"linkLabel": "Gift link",
"telegramLabel": "Telegram link",
"alsoSent": "We also emailed it to {{contact}}.",
"copyMessage": "Copy message"
},
"notFound": "Page not found", "notFound": "Page not found",
"forMe": "For me", "forMe": "For me",
"asGift": "As a gift", "asGift": "As a gift",
@@ -5103,6 +5158,7 @@
"subscriptions": { "subscriptions": {
"buy": "Buy subscription", "buy": "Buy subscription",
"buyAnother": "Buy another subscription", "buyAnother": "Buy another subscription",
"browsePlans": "View plans and subscribe",
"empty": "You have no subscriptions yet", "empty": "You have no subscriptions yet",
"emptyDesc": "Choose a tariff to get started", "emptyDesc": "Choose a tariff to get started",
"title": "My subscriptions" "title": "My subscriptions"

View File

@@ -429,7 +429,7 @@
"buyTraffic": "خرید ترافیک بیشتر", "buyTraffic": "خرید ترافیک بیشتر",
"currentTrafficLimit": "محدودیت فعلی: {{limit}} GB (مصرف شده {{used}} GB)", "currentTrafficLimit": "محدودیت فعلی: {{limit}} GB (مصرف شده {{used}} GB)",
"buyTrafficTitle": "خرید ترافیک بیشتر", "buyTrafficTitle": "خرید ترافیک بیشتر",
"trafficWarning": "ترافیک خریداری شده به محدودیت فعلی اضافه می‌شود و به دوره بعدی منتقل نمی‌شود", "trafficWarning": "ترافیک خریداریشده به محدودیت فعلی اضافه می‌شود و تا ۳۰ روز از زمان خرید معتبر است",
"trafficUnavailable": "خرید ترافیک برای اشتراک شما در دسترس نیست", "trafficUnavailable": "خرید ترافیک برای اشتراک شما در دسترس نیست",
"unlimited": "نامحدود", "unlimited": "نامحدود",
"buyTrafficGb": "خرید {{gb}} GB", "buyTrafficGb": "خرید {{gb}} GB",
@@ -1432,7 +1432,8 @@
"promoPrefix": "پیشوند کد تخفیف", "promoPrefix": "پیشوند کد تخفیف",
"spinCost": "هزینه چرخش", "spinCost": "هزینه چرخش",
"limitsAndRtp": "محدودیت‌ها و RTP", "limitsAndRtp": "محدودیت‌ها و RTP",
"promocodes": "کدهای تخفیف" "promocodes": "کدهای تخفیف",
"promoValidityDays": "اعتبار کد تخفیف (روز)"
}, },
"starsNotEnabledGlobally": "پرداخت با ستاره در روش‌های پرداخت فعال نیست. «Telegram Stars» را در بخش «روش‌های پرداخت» فعال کنید.", "starsNotEnabledGlobally": "پرداخت با ستاره در روش‌های پرداخت فعال نیست. «Telegram Stars» را در بخش «روش‌های پرداخت» فعال کنید.",
"prizes": { "prizes": {
@@ -1459,7 +1460,9 @@
"emoji": "ایموجی", "emoji": "ایموجی",
"color": "رنگ", "color": "رنگ",
"active": "فعال", "active": "فعال",
"worth": "ارزش" "worth": "ارزش",
"probability": "احتمال دستی (۰۱)",
"probabilityHint": "خالی = خودکار بر اساس RTP"
}, },
"promo": { "promo": {
"title": "تنظیمات کد تخفیف", "title": "تنظیمات کد تخفیف",
@@ -1476,7 +1479,9 @@
"prizeDistribution": "توزیع جوایز", "prizeDistribution": "توزیع جوایز",
"times": "بار", "times": "بار",
"times_other": "{{count}} بار", "times_other": "{{count}} بار",
"topWins": "برترین برنده‌ها" "topWins": "برترین برنده‌ها",
"loadError": "بارگذاری آمار ناموفق بود",
"empty": "هنوز چرخشی انجام نشده"
} }
}, },
"broadcasts": { "broadcasts": {
@@ -3694,6 +3699,12 @@
"dailyChart": "خرید و درآمد روزانه", "dailyChart": "خرید و درآمد روزانه",
"tariffChart": "توزیع طرح‌ها", "tariffChart": "توزیع طرح‌ها",
"giftBreakdown": "هدایا در مقابل عادی", "giftBreakdown": "هدایا در مقابل عادی",
"funnelTitle": "قیف تبدیل",
"giftClaimTitle": "فعال‌سازی هدیه",
"giftClaimLabel": "دریافت‌شده / ارسال‌شده",
"dailyRevenue": "درآمد روزانه",
"byPaymentMethod": "بر اساس روش پرداخت",
"bySource": "منابع ترافیک",
"purchases": "خریدها", "purchases": "خریدها",
"revenueLabel": "درآمد", "revenueLabel": "درآمد",
"gifts": "هدایا", "gifts": "هدایا",
@@ -4133,7 +4144,13 @@
"vk": "VK" "vk": "VK"
}, },
"backToAccounts": "بازگشت به فهرست حساب‌ها" "backToAccounts": "بازگشت به فهرست حساب‌ها"
} },
"emailMergePasswordRequired": "این ایمیل قبلاً به حساب دیگری تعلق دارد. برای ادغام حساب‌ها، رمز عبور آن حساب را وارد کنید.",
"emailMergeCodeSent": "کد تأیید به آن ایمیل ارسال شد.",
"emailMergeCodeDescription": "این ایمیل قبلاً به حساب دیگری تعلق دارد. کدی به آن ارسال کردیم — برای تأیید مالکیت و ادغام حساب‌ها آن را وارد کنید.",
"emailMergeCodeLabel": "کد ارسال‌شده در ایمیل",
"emailMergeConfirm": "تأیید و ادغام",
"emailMergeCodeInvalid": "کد نامعتبر یا منقضی‌شده"
}, },
"theme": { "theme": {
"colors": "رنگ‌های تم", "colors": "رنگ‌های تم",
@@ -4464,6 +4481,14 @@
"openBot": "باز کردن ربات", "openBot": "باز کردن ربات",
"retry": "من /start را زدم، دوباره امتحان کن", "retry": "من /start را زدم، دوباره امتحان کن",
"hint": "پس از اجرای /start، به اینجا بازگردید و «دوباره امتحان کنید» را بزنید." "hint": "پس از اجرای /start، به اینجا بازگردید و «دوباره امتحان کنید» را بزنید."
},
"serviceUnavailable": {
"title": "سرویس در دسترس نیست",
"description": "در حال حاضر امکان اتصال به سرویس وجود ندارد. ممکن است در حال تعمیر باشد یا مشکل موقتی در اتصال وجود داشته باشد.",
"retry": "تلاش مجدد",
"checking": "در حال بررسی اتصال...",
"hint": "به‌محض در دسترس قرار گرفتن سرویس، این صفحه به‌طور خودکار بازخوانی می‌شود.",
"close": "بستن"
} }
}, },
"merge": { "merge": {
@@ -4494,6 +4519,36 @@
"merging": "در حال ادغام..." "merging": "در حال ادغام..."
}, },
"landing": { "landing": {
"giftClaim": {
"title": "شما یک هدیه دارید!",
"error": "فعال‌سازی هدیه ناموفق بود.",
"notFoundTitle": "هدیه یافت نشد",
"notFoundDesc": "این لینک هدیه نامعتبر یا دیگر در دسترس نیست.",
"alreadyTitle": "هدیه قبلاً فعال شده است",
"alreadyDesc": "این هدیه قبلاً دریافت شده است.",
"successTitle": "هدیه فعال شد!",
"connectDesc": "از این لینک برای اتصال استفاده کنید:",
"copyLink": "کپی لینک",
"pendingTitle": "تقریباً آماده است…",
"pendingDesc": "پرداخت در حال تأیید است. این صفحه به‌طور خودکار به‌روزرسانی می‌شود.",
"replaceWarning": "شما در حال حاضر اشتراک دارید — فعال‌سازی این هدیه جایگزین آن خواهد شد.",
"activateTelegram": "فعال‌سازی در تلگرام",
"activateWeb": "فعال‌سازی با ایمیل",
"emailLabel": "ایمیل شما",
"claimNow": "دریافت هدیه",
"failedTitle": "هدیه در دسترس نیست",
"failedDesc": "پرداخت این هدیه انجام نشد، بنابراین قابل فعال‌سازی نیست."
},
"giftLink": {
"shareText": "یک هدیه برای شما دارم! اینجا فعال کنید:",
"viaTelegram": "تلگرام:",
"title": "هدیه آماده است!",
"subtitle": "این لینک را برای کسی که می‌خواهید هدیه را دریافت کند بفرستید — خودش آن را فعال می‌کند.",
"linkLabel": "لینک هدیه",
"telegramLabel": "لینک تلگرام",
"alsoSent": "آن را به {{contact}} نیز ایمیل کردیم.",
"copyMessage": "کپی پیام"
},
"notFound": "صفحه یافت نشد", "notFound": "صفحه یافت نشد",
"forMe": "برای خودم", "forMe": "برای خودم",
"asGift": "به عنوان هدیه", "asGift": "به عنوان هدیه",
@@ -4737,6 +4792,7 @@
"subscriptions": { "subscriptions": {
"buy": "خرید اشتراک", "buy": "خرید اشتراک",
"buyAnother": "خرید اشتراک دیگر", "buyAnother": "خرید اشتراک دیگر",
"browsePlans": "مشاهده تعرفه‌ها و خرید اشتراک",
"empty": "هنوز اشتراکی ندارید", "empty": "هنوز اشتراکی ندارید",
"emptyDesc": "برای شروع تعرفه‌ای انتخاب کنید", "emptyDesc": "برای شروع تعرفه‌ای انتخاب کنید",
"title": "اشتراک‌های من" "title": "اشتراک‌های من"

View File

@@ -608,7 +608,7 @@
"buyTraffic": "Докупить трафик", "buyTraffic": "Докупить трафик",
"currentTrafficLimit": "Текущий лимит: {{limit}} ГБ (использовано {{used}} ГБ)", "currentTrafficLimit": "Текущий лимит: {{limit}} ГБ (использовано {{used}} ГБ)",
"buyTrafficTitle": "Докупить трафик", "buyTrafficTitle": "Докупить трафик",
"trafficWarning": "Докупленный трафик добавляется к текущему лимиту и не переносится на следующий период", "trafficWarning": "Докупленный трафик добавляется к текущему лимиту и действует 30 дней с момента покупки",
"trafficUnavailable": "Докупка трафика недоступна для вашего тарифа", "trafficUnavailable": "Докупка трафика недоступна для вашего тарифа",
"unlimited": "Безлимит", "unlimited": "Безлимит",
"buyTrafficGb": "Купить {{gb}} ГБ", "buyTrafficGb": "Купить {{gb}} ГБ",
@@ -1782,7 +1782,8 @@
"dailyLimit": "Дневной лимит вращений (0 = безлимит)", "dailyLimit": "Дневной лимит вращений (0 = безлимит)",
"minSubDays": "Мин. дней подписки для оплаты днями", "minSubDays": "Мин. дней подписки для оплаты днями",
"promocodes": "Промокоды", "promocodes": "Промокоды",
"promoPrefix": "Префикс промокодов" "promoPrefix": "Префикс промокодов",
"promoValidityDays": "Срок действия промокода (дней)"
}, },
"starsNotEnabledGlobally": "Оплата звёздами не включена в платёжных методах. Включите метод «Telegram Stars» в разделе «Платёжные методы».", "starsNotEnabledGlobally": "Оплата звёздами не включена в платёжных методах. Включите метод «Telegram Stars» в разделе «Платёжные методы».",
"prizes": { "prizes": {
@@ -1809,7 +1810,9 @@
"emoji": "Эмодзи", "emoji": "Эмодзи",
"color": "Цвет", "color": "Цвет",
"active": "Активен", "active": "Активен",
"worth": "Стоимость" "worth": "Стоимость",
"probability": "Ручная вероятность (01)",
"probabilityHint": "Пусто — авто по RTP"
}, },
"promo": { "promo": {
"title": "Настройки промокода", "title": "Настройки промокода",
@@ -1828,7 +1831,9 @@
"times_one": "{{count}} раз", "times_one": "{{count}} раз",
"times_few": "{{count}} раза", "times_few": "{{count}} раза",
"times_many": "{{count}} раз", "times_many": "{{count}} раз",
"topWins": "Топ выигрышей" "topWins": "Топ выигрышей",
"loadError": "Не удалось загрузить статистику",
"empty": "Пока нет вращений"
} }
}, },
"broadcasts": { "broadcasts": {
@@ -4523,6 +4528,12 @@
"dailyChart": "Покупки и доход по дням", "dailyChart": "Покупки и доход по дням",
"tariffChart": "Распределение по тарифам", "tariffChart": "Распределение по тарифам",
"giftBreakdown": "Подарки vs обычные", "giftBreakdown": "Подарки vs обычные",
"funnelTitle": "Воронка конверсии",
"giftClaimTitle": "Активация подарков",
"giftClaimLabel": "забрано / отправлено",
"dailyRevenue": "Выручка по дням",
"byPaymentMethod": "По способам оплаты",
"bySource": "Источники трафика",
"purchases": "Покупки", "purchases": "Покупки",
"revenueLabel": "Доход", "revenueLabel": "Доход",
"gifts": "Подарки", "gifts": "Подарки",
@@ -5140,7 +5151,13 @@
"vk": "VK" "vk": "VK"
}, },
"backToAccounts": "К списку аккаунтов" "backToAccounts": "К списку аккаунтов"
} },
"emailMergePasswordRequired": "Этот email уже привязан к другому аккаунту. Чтобы объединить аккаунты, введите пароль от него.",
"emailMergeCodeSent": "Код подтверждения отправлен на тот email.",
"emailMergeCodeDescription": "Этот email уже привязан к другому аккаунту. Мы отправили на него код — введите его, чтобы подтвердить владение и объединить аккаунты.",
"emailMergeCodeLabel": "Код из письма",
"emailMergeConfirm": "Подтвердить и объединить",
"emailMergeCodeInvalid": "Неверный или просроченный код"
}, },
"theme": { "theme": {
"colors": "Цвета темы", "colors": "Цвета темы",
@@ -5359,6 +5376,14 @@
"openBot": "Открыть бота", "openBot": "Открыть бота",
"retry": "Я нажал /start, попробовать снова", "retry": "Я нажал /start, попробовать снова",
"hint": "После /start вернитесь сюда и нажмите «Попробовать снова»." "hint": "После /start вернитесь сюда и нажмите «Попробовать снова»."
},
"serviceUnavailable": {
"title": "Сервис недоступен",
"description": "Не удаётся подключиться к сервису. Возможно, идут технические работы или временные неполадки со связью.",
"retry": "Повторить попытку",
"checking": "Проверяем соединение...",
"hint": "Страница обновится автоматически, как только сервис снова станет доступен.",
"close": "Закрыть"
} }
}, },
"merge": { "merge": {
@@ -5389,6 +5414,36 @@
"merging": "Объединение..." "merging": "Объединение..."
}, },
"landing": { "landing": {
"giftClaim": {
"title": "Вам подарок!",
"error": "Не удалось активировать подарок.",
"notFoundTitle": "Подарок не найден",
"notFoundDesc": "Ссылка на подарок недействительна или больше недоступна.",
"alreadyTitle": "Подарок уже активирован",
"alreadyDesc": "Этот подарок уже забрали.",
"successTitle": "Подарок активирован!",
"connectDesc": "Используйте эту ссылку для подключения:",
"copyLink": "Скопировать ссылку",
"pendingTitle": "Почти готово…",
"pendingDesc": "Оплата ещё подтверждается. Страница обновится автоматически.",
"replaceWarning": "У вас уже есть подписка — активация подарка заменит её.",
"activateTelegram": "Активировать в Telegram",
"activateWeb": "Активировать по почте",
"emailLabel": "Ваш email",
"claimNow": "Получить подарок",
"failedTitle": "Подарок недоступен",
"failedDesc": "Оплата подарка не прошла, поэтому активировать его нельзя."
},
"giftLink": {
"shareText": "Дарю вам подписку! Активируйте здесь:",
"viaTelegram": "Telegram:",
"title": "Подарок готов!",
"subtitle": "Отправьте эту ссылку тому, кому предназначен подарок — он активирует его сам.",
"linkLabel": "Ссылка на подарок",
"telegramLabel": "Ссылка для Telegram",
"alsoSent": "Мы также отправили её на {{contact}}.",
"copyMessage": "Скопировать сообщение"
},
"notFound": "Страница не найдена", "notFound": "Страница не найдена",
"forMe": "Для себя", "forMe": "Для себя",
"asGift": "В подарок", "asGift": "В подарок",
@@ -5665,6 +5720,7 @@
"subscriptions": { "subscriptions": {
"buy": "Купить подписку", "buy": "Купить подписку",
"buyAnother": "Купить ещё подписку", "buyAnother": "Купить ещё подписку",
"browsePlans": "Посмотреть тарифы и купить подписку",
"empty": "У вас пока нет подписок", "empty": "У вас пока нет подписок",
"emptyDesc": "Выберите тариф, чтобы начать пользоваться сервисом", "emptyDesc": "Выберите тариф, чтобы начать пользоваться сервисом",
"title": "Мои подписки" "title": "Мои подписки"

View File

@@ -429,7 +429,7 @@
"buyTraffic": "购买更多流量", "buyTraffic": "购买更多流量",
"currentTrafficLimit": "当前限制:{{limit}} GB已使用 {{used}} GB", "currentTrafficLimit": "当前限制:{{limit}} GB已使用 {{used}} GB",
"buyTrafficTitle": "购买更多流量", "buyTrafficTitle": "购买更多流量",
"trafficWarning": "购买的流量将添加到当前限制,不会延续到下一个周期", "trafficWarning": "购买的流量将添加到当前限制,自购买之日起30天内有效",
"trafficUnavailable": "您的套餐不支持购买流量", "trafficUnavailable": "您的套餐不支持购买流量",
"unlimited": "无限制", "unlimited": "无限制",
"buyTrafficGb": "购买 {{gb}} GB", "buyTrafficGb": "购买 {{gb}} GB",
@@ -1509,7 +1509,8 @@
"promoPrefix": "优惠码前缀", "promoPrefix": "优惠码前缀",
"spinCost": "旋转费用", "spinCost": "旋转费用",
"limitsAndRtp": "限制和RTP", "limitsAndRtp": "限制和RTP",
"promocodes": "促销码" "promocodes": "促销码",
"promoValidityDays": "促销码有效期(天)"
}, },
"starsNotEnabledGlobally": "星星支付未在支付方式中启用。请在「支付方式」中启用「Telegram Stars」。", "starsNotEnabledGlobally": "星星支付未在支付方式中启用。请在「支付方式」中启用「Telegram Stars」。",
"prizes": { "prizes": {
@@ -1536,7 +1537,9 @@
"emoji": "表情", "emoji": "表情",
"color": "颜色", "color": "颜色",
"active": "启用", "active": "启用",
"worth": "价值" "worth": "价值",
"probability": "手动概率 (01)",
"probabilityHint": "留空 = 按 RTP 自动"
}, },
"promo": { "promo": {
"title": "优惠码设置", "title": "优惠码设置",
@@ -1553,7 +1556,9 @@
"prizeDistribution": "奖品分布", "prizeDistribution": "奖品分布",
"times": "次", "times": "次",
"times_other": "{{count}} 次", "times_other": "{{count}} 次",
"topWins": "最高奖品" "topWins": "最高奖品",
"loadError": "无法加载统计数据",
"empty": "暂无抽奖记录"
} }
}, },
"broadcasts": { "broadcasts": {
@@ -3693,6 +3698,12 @@
"dailyChart": "每日购买与收入", "dailyChart": "每日购买与收入",
"tariffChart": "套餐分布", "tariffChart": "套餐分布",
"giftBreakdown": "礼物 vs 普通", "giftBreakdown": "礼物 vs 普通",
"funnelTitle": "转化漏斗",
"giftClaimTitle": "礼物激活",
"giftClaimLabel": "已领取 / 已发送",
"dailyRevenue": "每日收入",
"byPaymentMethod": "按支付方式",
"bySource": "流量来源",
"purchases": "购买", "purchases": "购买",
"revenueLabel": "收入", "revenueLabel": "收入",
"gifts": "礼物", "gifts": "礼物",
@@ -4132,7 +4143,13 @@
"vk": "VK" "vk": "VK"
}, },
"backToAccounts": "返回账户列表" "backToAccounts": "返回账户列表"
} },
"emailMergePasswordRequired": "该邮箱已属于另一个账户。要合并账户,请输入该账户的密码。",
"emailMergeCodeSent": "确认码已发送至该邮箱。",
"emailMergeCodeDescription": "该邮箱已属于另一个账户。我们已向其发送验证码——请输入以确认所有权并合并账户。",
"emailMergeCodeLabel": "邮件中的验证码",
"emailMergeConfirm": "确认并合并",
"emailMergeCodeInvalid": "验证码无效或已过期"
}, },
"theme": { "theme": {
"colors": "主题颜色", "colors": "主题颜色",
@@ -4345,6 +4362,14 @@
"openBot": "打开机器人", "openBot": "打开机器人",
"retry": "我已按 /start重试", "retry": "我已按 /start重试",
"hint": "运行 /start 后,返回此处并点击「重试」。" "hint": "运行 /start 后,返回此处并点击「重试」。"
},
"serviceUnavailable": {
"title": "服务不可用",
"description": "暂时无法连接到服务。可能正在进行维护,或存在临时的网络问题。",
"retry": "重试",
"checking": "正在检查连接...",
"hint": "服务恢复后,本页面将自动刷新。",
"close": "关闭"
} }
}, },
"banSystem": { "banSystem": {
@@ -4493,6 +4518,36 @@
"merging": "合并中..." "merging": "合并中..."
}, },
"landing": { "landing": {
"giftClaim": {
"title": "您收到一份礼物!",
"error": "无法激活礼物。",
"notFoundTitle": "未找到礼物",
"notFoundDesc": "此礼物链接无效或已不可用。",
"alreadyTitle": "礼物已激活",
"alreadyDesc": "此礼物已被领取。",
"successTitle": "礼物已激活!",
"connectDesc": "使用此链接连接:",
"copyLink": "复制链接",
"pendingTitle": "即将完成…",
"pendingDesc": "付款仍在确认中。本页面将自动更新。",
"replaceWarning": "您已有订阅——激活此礼物将替换它。",
"activateTelegram": "在 Telegram 中激活",
"activateWeb": "通过邮箱激活",
"emailLabel": "您的邮箱",
"claimNow": "领取礼物",
"failedTitle": "礼物不可用",
"failedDesc": "此礼物的付款未成功,无法激活。"
},
"giftLink": {
"shareText": "我送您一份订阅!在此激活:",
"viaTelegram": "Telegram",
"title": "礼物已就绪!",
"subtitle": "将此链接发送给您想赠送的人——他们自行激活。",
"linkLabel": "礼物链接",
"telegramLabel": "Telegram 链接",
"alsoSent": "我们也已发送到 {{contact}}。",
"copyMessage": "复制消息"
},
"notFound": "页面未找到", "notFound": "页面未找到",
"forMe": "为自己", "forMe": "为自己",
"asGift": "作为礼物", "asGift": "作为礼物",
@@ -4736,6 +4791,7 @@
"subscriptions": { "subscriptions": {
"buy": "购买订阅", "buy": "购买订阅",
"buyAnother": "再购买一个订阅", "buyAnother": "再购买一个订阅",
"browsePlans": "查看套餐并订阅",
"empty": "您还没有订阅", "empty": "您还没有订阅",
"emptyDesc": "选择套餐以开始使用", "emptyDesc": "选择套餐以开始使用",
"title": "我的订阅" "title": "我的订阅"

View File

@@ -24,6 +24,7 @@ import { useAuthStore } from './store/auth';
import { AppWithNavigator } from './AppWithNavigator'; import { AppWithNavigator } from './AppWithNavigator';
import { ErrorBoundary } from './components/ErrorBoundary'; import { ErrorBoundary } from './components/ErrorBoundary';
import { initLogoPreload } from './api/branding'; import { initLogoPreload } from './api/branding';
import { checkBackendOnStartup } from './api/health';
import { getCachedFullscreenEnabled, isTelegramMobile } from './hooks/useTelegramSDK'; import { getCachedFullscreenEnabled, isTelegramMobile } from './hooks/useTelegramSDK';
import { applyTelegramLanguage } from './i18n'; import { applyTelegramLanguage } from './i18n';
import './styles/globals.css'; import './styles/globals.css';
@@ -106,6 +107,11 @@ if (isTelegramEnv && !alreadyInitialized) {
// are only available post-init()). // are only available post-init()).
void useAuthStore.getState().initialize(); void useAuthStore.getState().initialize();
// In parallel with auth bootstrap, eagerly check backend liveness so a dead
// backend paints the ServiceUnavailableScreen immediately instead of flashing
// the /login page first.
void checkBackendOnStartup();
if ('requestIdleCallback' in window) { if ('requestIdleCallback' in window) {
requestIdleCallback(() => initLogoPreload()); requestIdleCallback(() => initLogoPreload());
} else { } else {

View File

@@ -88,9 +88,11 @@ export default function AdminApplicationReview() {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2"> <div className="mb-1 flex min-w-0 items-center gap-2">
<span className="font-medium text-dark-100">{displayName}</span> <span className="truncate font-medium text-dark-100">{displayName}</span>
{app.username && <span className="text-sm text-dark-500">@{app.username}</span>} {app.username && (
<span className="shrink-0 text-sm text-dark-500">@{app.username}</span>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,20 +2,7 @@ import { useState, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router'; import { useParams, useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import { Cell, Funnel, FunnelChart, LabelList, ResponsiveContainer } from 'recharts';
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { import {
adminLandingsApi, adminLandingsApi,
resolveLocaleDisplay, resolveLocaleDisplay,
@@ -23,21 +10,32 @@ import {
type LandingPurchaseItem, type LandingPurchaseItem,
} from '../api/landings'; } from '../api/landings';
import { useCurrency } from '../hooks/useCurrency'; import { useCurrency } from '../hooks/useCurrency';
import { useChartColors } from '../hooks/useChartColors';
import { CHART_COMMON } from '../constants/charts'; import { CHART_COMMON } from '../constants/charts';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import { StatCard } from '../components/stats';
import { BreakdownList } from '../components/sales-stats/BreakdownList';
import { DonutChart } from '../components/sales-stats/DonutChart';
import { SimpleAreaChart } from '../components/sales-stats/SimpleAreaChart';
import { MultiSeriesAreaChart } from '../components/sales-stats/MultiSeriesAreaChart';
import { import {
ChartIcon, ChartIcon,
EmailIcon, EmailIcon,
TelegramSmallIcon, TelegramSmallIcon,
ArrowRightIcon, ArrowRightIcon,
GiftIcon, GiftIcon,
EyeIcon,
CheckCircleIcon,
BanknotesIcon,
PercentIcon,
TicketIcon,
CardIcon,
WalletIcon,
ChevronLeftIcon as ChevronLeftSmall, ChevronLeftIcon as ChevronLeftSmall,
ChevronRightIcon as ChevronRightSmall, ChevronRightIcon as ChevronRightSmall,
} from '@/components/icons'; } from '@/components/icons';
const TARIFF_PALETTE = ['#818cf8', '#34d399', '#f59e0b', '#ec4899', '#06b6d4', '#8b5cf6']; const FUNNEL_COLORS = ['#f59e0b', '#34d399'];
const GIFT_COLOR = '#a855f7'; const GIFT_DONUT = { regular: '#818cf8', gift: '#a855f7' };
const PURCHASE_STATUS_STYLES: Record<string, string> = { const PURCHASE_STATUS_STYLES: Record<string, string> = {
pending: 'bg-warning-500/20 text-warning-400', pending: 'bg-warning-500/20 text-warning-400',
@@ -181,7 +179,8 @@ export default function AdminLandingStats() {
const isValidId = !isNaN(numericId); const isValidId = !isNaN(numericId);
const navigate = useNavigate(); const navigate = useNavigate();
const { formatWithCurrency } = useCurrency(); const { formatWithCurrency } = useCurrency();
const colors = useChartColors(); const divisor = CHART_COMMON.KOPEKS_DIVISOR;
const money = (kopeks: number) => formatWithCurrency(kopeks / divisor);
// Purchases list state // Purchases list state
const [purchaseOffset, setPurchaseOffset] = useState(0); const [purchaseOffset, setPurchaseOffset] = useState(0);
@@ -234,49 +233,59 @@ export default function AdminLandingStats() {
const purchaseTotalPages = Math.ceil(purchaseTotal / PURCHASES_PAGE_SIZE); const purchaseTotalPages = Math.ceil(purchaseTotal / PURCHASES_PAGE_SIZE);
const purchaseCurrentPage = Math.floor(purchaseOffset / PURCHASES_PAGE_SIZE) + 1; const purchaseCurrentPage = Math.floor(purchaseOffset / PURCHASES_PAGE_SIZE) + 1;
// Prepare daily chart data // Daily counts (created / paid) — flat data for MultiSeriesAreaChart
const dailyData = useMemo(() => { const dailyCounts = useMemo(() => {
if (!stats) return []; if (!stats) return [];
return stats.daily_stats.map((item) => ({ const createdLabel = t('admin.landings.stats.created', 'Created');
label: (() => { const paidLabel = t('admin.landings.stats.paid', 'Paid');
const d = new Date(item.date + 'T00:00:00'); return stats.daily_stats.flatMap((d) => [
return `${d.getDate()}.${String(d.getMonth() + 1).padStart(2, '0')}`; { date: d.date, key: createdLabel, value: d.created },
})(), { date: d.date, key: paidLabel, value: d.purchases },
created: item.created, ]);
purchases: item.purchases, }, [stats, t]);
revenue: item.revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR,
gifts: item.gifts,
}));
}, [stats]);
// Prepare tariff chart data // Daily revenue — for SimpleAreaChart
const tariffData = useMemo(() => { const dailyRevenue = useMemo(() => {
if (!stats) return []; if (!stats) return [];
return stats.tariff_stats.map((item) => ({ return stats.daily_stats.map((d) => ({ date: d.date, value: d.revenue_kopeks / divisor }));
name: item.tariff_name, }, [stats, divisor]);
purchases: item.purchases,
revenue: item.revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR,
}));
}, [stats]);
// Donut data for gift vs regular // Tariff breakdown (by revenue)
const donutData = useMemo(() => { const tariffItems = useMemo(
() =>
(stats?.tariff_stats ?? []).map((t2) => ({
key: String(t2.tariff_id ?? t2.tariff_name),
label: t2.tariff_name,
value: t2.revenue_kopeks / divisor,
})),
[stats, divisor],
);
// Payment method breakdown (by purchases)
const paymentDonut = useMemo(
() => (stats?.payment_method_stats ?? []).map((p) => ({ name: p.method, value: p.purchases })),
[stats],
);
// Traffic source breakdown (by purchases)
const sourceItems = useMemo(
() =>
(stats?.source_stats ?? []).map((s) => ({
key: s.source,
label: s.source,
value: s.purchases,
})),
[stats],
);
// Funnel: created -> paid
const funnelData = useMemo(() => {
if (!stats) return []; if (!stats) return [];
return [ return [
{ { name: t('admin.landings.stats.created', 'Created'), value: stats.total_created },
name: t('admin.landings.stats.regular'), { name: t('admin.landings.stats.paid', 'Paid'), value: stats.total_successful },
value: stats.total_regular,
color: colors.referrals,
},
{ name: t('admin.landings.stats.gifts'), value: stats.total_gifts, color: GIFT_COLOR },
]; ];
}, [stats, t, colors.referrals]); }, [stats, t]);
// Bar chart height based on tariff count
const barChartHeight = useMemo(() => {
const count = tariffData.length;
return Math.max(220, count * 45 + 40);
}, [tariffData.length]);
// Loading state // Loading state
if (isLoading) { if (isLoading) {
@@ -309,6 +318,9 @@ export default function AdminLandingStats() {
} }
const landingTitle = landing ? resolveLocaleDisplay(landing.title) : `#${numericId}`; const landingTitle = landing ? resolveLocaleDisplay(landing.title) : `#${numericId}`;
const giftsClaimed = stats.total_gifts_claimed ?? 0;
const giftClaimRate =
stats.total_gifts > 0 ? Math.round((giftsClaimed / stats.total_gifts) * 100) : 0;
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
@@ -336,377 +348,185 @@ export default function AdminLandingStats() {
</div> </div>
</div> </div>
<div className="space-y-6"> <div className="space-y-4">
{/* Summary Cards */} {/* Summary Cards */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center"> <StatCard
<div className="text-xl font-bold sm:text-2xl"> label={t('admin.landings.stats.created', 'Created')}
<span className="text-warning-400">{stats.total_created}</span> value={stats.total_created}
<span className="mx-1 text-dark-600">/</span> icon={<EyeIcon className="h-5 w-5" />}
<span className="text-success-400">{stats.total_successful}</span> tone="warning"
</div> />
<div className="text-xs text-dark-500"> <StatCard
{t('admin.landings.stats.created', 'Created')} /{' '} label={t('admin.landings.stats.paid', 'Paid')}
{t('admin.landings.stats.paid', 'paid')} value={stats.total_successful}
</div> icon={<CheckCircleIcon className="h-5 w-5" />}
</div> tone="success"
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center"> />
<div className="truncate text-xl font-bold text-accent-400 sm:text-2xl"> <StatCard
{formatWithCurrency(stats.total_revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR)} label={t('admin.landings.stats.revenue')}
</div> value={money(stats.total_revenue_kopeks)}
<div className="text-xs text-dark-500">{t('admin.landings.stats.revenue')}</div> icon={<BanknotesIcon className="h-5 w-5" />}
</div> tone="success"
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center"> />
<div className="text-xl font-bold text-accent-400 sm:text-2xl">{stats.total_gifts}</div> <StatCard
<div className="text-xs text-dark-500">{t('admin.landings.stats.giftPurchases')}</div> label={t('admin.landings.stats.conversionRate')}
</div> value={`${stats.conversion_rate}%`}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center"> icon={<PercentIcon className="h-5 w-5" />}
<div className="text-xl font-bold text-dark-200 sm:text-2xl"> tone="accent"
{stats.conversion_rate}% />
</div>
<div className="text-xs text-dark-500">{t('admin.landings.stats.conversionRate')}</div>
</div>
</div> </div>
{/* Charts */} {/* Breakdown Cards */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard
label={t('admin.landings.stats.purchases')}
value={stats.total_purchases}
icon={<TicketIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.landings.stats.regularPurchases')}
value={stats.total_regular}
icon={<CardIcon className="h-5 w-5" />}
/>
<StatCard
label={t('admin.landings.stats.giftPurchases')}
value={stats.total_gifts}
icon={<GiftIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.landings.stats.avgPurchase')}
value={money(stats.avg_purchase_kopeks)}
icon={<WalletIcon className="h-5 w-5" />}
/>
</div>
{/* Funnel + gift activation */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* Daily Purchases & Revenue */} <div className="bento-card">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4"> <h4 className="mb-3 text-sm font-semibold text-dark-200">
<h3 className="mb-4 font-medium text-dark-200"> {t('admin.landings.stats.funnelTitle', 'Conversion funnel')}
{t('admin.landings.stats.dailyChart')} </h4>
</h3> {stats.total_created === 0 ? (
{dailyData.length === 0 ? ( <div className="flex h-[180px] items-center justify-center text-sm text-dark-500">
<div className="flex h-[220px] items-center justify-center text-sm text-dark-500">
{t('admin.landings.stats.noPurchases')} {t('admin.landings.stats.noPurchases')}
</div> </div>
) : ( ) : (
<ResponsiveContainer width="100%" height={220}> <>
<AreaChart data={dailyData} margin={CHART_COMMON.CHART.MARGIN}> <ResponsiveContainer width="100%" height={170}>
<defs> <FunnelChart>
<linearGradient <Funnel dataKey="value" data={funnelData} isAnimationActive>
id={`landingPurchaseGrad-${numericId}`} {/* Center value labels (dark text reads on the light segments)
x1="0" — left/right labels can clip in the half-width column. */}
y1="0" <LabelList
x2="0" position="center"
y2="1"
>
<stop
offset={CHART_COMMON.GRADIENT.START_OFFSET}
stopColor={colors.referrals}
stopOpacity={CHART_COMMON.GRADIENT.START_OPACITY}
/>
<stop
offset={CHART_COMMON.GRADIENT.END_OFFSET}
stopColor={colors.referrals}
stopOpacity={CHART_COMMON.GRADIENT.END_OPACITY}
/>
</linearGradient>
<linearGradient
id={`landingRevenueGrad-${numericId}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset={CHART_COMMON.GRADIENT.START_OFFSET}
stopColor={colors.earnings}
stopOpacity={CHART_COMMON.GRADIENT.START_OPACITY}
/>
<stop
offset={CHART_COMMON.GRADIENT.END_OFFSET}
stopColor={colors.earnings}
stopOpacity={CHART_COMMON.GRADIENT.END_OPACITY}
/>
</linearGradient>
<linearGradient
id={`landingCreatedGrad-${numericId}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset={CHART_COMMON.GRADIENT.START_OFFSET}
stopColor="#f59e0b"
stopOpacity={CHART_COMMON.GRADIENT.START_OPACITY}
/>
<stop
offset={CHART_COMMON.GRADIENT.END_OFFSET}
stopColor="#f59e0b"
stopOpacity={CHART_COMMON.GRADIENT.END_OPACITY}
/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray={CHART_COMMON.GRID_DASH} stroke={colors.grid} />
<XAxis
dataKey="label"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
interval="preserveStartEnd"
/>
<YAxis
yAxisId="left"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
allowDecimals={false}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
/>
<Tooltip
contentStyle={{
backgroundColor: colors.tooltipBg,
border: `1px solid ${colors.tooltipBorder}`,
borderRadius: CHART_COMMON.TOOLTIP.BORDER_RADIUS,
fontSize: CHART_COMMON.TOOLTIP.FONT_SIZE,
color: colors.label,
}}
labelStyle={{ color: colors.label }}
itemStyle={{ color: colors.label }}
/>
<Area
yAxisId="left"
type="monotone"
dataKey="created"
name={t('admin.landings.stats.created', 'Created')}
stroke="#f59e0b"
fill={`url(#landingCreatedGrad-${numericId})`}
strokeWidth={CHART_COMMON.STROKE_WIDTH}
/>
<Area
yAxisId="left"
type="monotone"
dataKey="purchases"
name={t('admin.landings.stats.purchases')}
stroke={colors.referrals}
fill={`url(#landingPurchaseGrad-${numericId})`}
strokeWidth={CHART_COMMON.STROKE_WIDTH}
/>
<Area
yAxisId="right"
type="monotone"
dataKey="revenue"
name={t('admin.landings.stats.revenueLabel')}
stroke={colors.earnings}
fill={`url(#landingRevenueGrad-${numericId})`}
strokeWidth={CHART_COMMON.STROKE_WIDTH}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
{/* Daily Purchases Bar Chart */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<h3 className="mb-4 font-medium text-dark-200">
{t('admin.landings.stats.dailyPurchases', 'Daily purchases')}
</h3>
{dailyData.length === 0 ? (
<div className="flex h-[220px] items-center justify-center text-sm text-dark-500">
{t('admin.landings.stats.noPurchases')}
</div>
) : (
<div className="space-y-2">
{[...dailyData]
.slice(-7)
.reverse()
.map((day, i) => {
const purchasedPct =
(day.created || 0) > 0
? ((day.purchases || 0) / (day.created || 1)) * 100
: 0;
return (
<div key={i} className="flex items-center gap-2">
<span className="w-10 shrink-0 text-right text-xs text-dark-500">
{day.label}
</span>
<div
className="group relative h-5 flex-1 overflow-hidden rounded-full bg-warning-500/80"
title={`${t('admin.landings.stats.created', 'Created')}: ${day.created || 0}\n${t('admin.landings.stats.paid', 'paid')}: ${day.purchases || 0}\n${t('admin.landings.stats.revenueLabel', 'Revenue')}: ${day.revenue?.toFixed(0) || 0} ${t('common.currency', '\u20BD')}\nCR: ${Math.round(purchasedPct)}%`}
>
<div
className="absolute inset-y-0 left-0 rounded-full bg-accent-500"
style={{ width: `${purchasedPct}%` }}
/>
</div>
<span className="w-12 shrink-0 text-xs text-dark-400">
<span className="text-warning-400">{day.created || 0}</span>
<span className="text-dark-600">/</span>
<span className="text-accent-400">{day.purchases || 0}</span>
</span>
</div>
);
})}
<div className="mt-2 flex items-center gap-4 text-xs text-dark-500">
<div className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-warning-500/80" />
<span>{t('admin.landings.stats.created', 'Created')}</span>
</div>
<div className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-accent-500" />
<span>{t('admin.landings.stats.paid', 'paid')}</span>
</div>
</div>
</div>
)}
</div>
</div>
{/* Tariff Distribution -- full width */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<h3 className="mb-4 font-medium text-dark-200">
{t('admin.landings.stats.tariffChart')}
</h3>
{tariffData.length === 0 ? (
<div className="flex h-[220px] items-center justify-center text-sm text-dark-500">
{t('admin.landings.stats.noPurchases')}
</div>
) : (
<ResponsiveContainer width="100%" height={barChartHeight}>
<BarChart
data={tariffData}
layout="vertical"
margin={{ ...CHART_COMMON.CHART.MARGIN, left: 10 }}
>
<CartesianGrid strokeDasharray={CHART_COMMON.GRID_DASH} stroke={colors.grid} />
<XAxis
type="number"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
allowDecimals={false}
/>
<YAxis
type="category"
dataKey="name"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
width={100}
/>
<Tooltip
contentStyle={{
backgroundColor: colors.tooltipBg,
border: `1px solid ${colors.tooltipBorder}`,
borderRadius: CHART_COMMON.TOOLTIP.BORDER_RADIUS,
fontSize: CHART_COMMON.TOOLTIP.FONT_SIZE,
color: colors.label,
}}
labelStyle={{ color: colors.label }}
itemStyle={{ color: colors.label }}
formatter={(value: number | undefined) => {
return [value ?? 0, t('admin.landings.stats.purchases')];
}}
/>
<Bar
dataKey="purchases"
name={t('admin.landings.stats.purchases')}
radius={[0, 4, 4, 0]}
>
{tariffData.map((_, index) => (
<Cell key={index} fill={TARIFF_PALETTE[index % TARIFF_PALETTE.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Additional Stats Row */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="mb-1 text-sm text-dark-400">
{t('admin.landings.stats.avgPurchase')}
</div>
<div className="text-lg font-medium text-dark-200">
{formatWithCurrency(stats.avg_purchase_kopeks / CHART_COMMON.KOPEKS_DIVISOR)}
</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="mb-1 text-sm text-dark-400">
{t('admin.landings.stats.regularPurchases')}
</div>
<div className="text-lg font-medium text-dark-200">{stats.total_regular}</div>
</div>
<div className="col-span-2 rounded-xl border border-dark-700 bg-dark-800 p-4 sm:col-span-1">
<div className="mb-1 text-sm text-dark-400">{t('admin.landings.stats.funnel')}</div>
<div className="text-lg font-medium text-dark-200">
{stats.total_created}{' '}
<span className="text-sm text-dark-500">{t('admin.landings.stats.created')}</span>
{' / '}
{stats.total_successful}{' '}
<span className="text-sm text-dark-500">
{t('admin.landings.stats.paid', 'paid')}
</span>
</div>
</div>
</div>
{/* Gift vs Regular Donut */}
{stats.total_purchases > 0 && (
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<h3 className="mb-4 font-medium text-dark-200">
{t('admin.landings.stats.giftBreakdown')}
</h3>
<div className="flex items-center justify-center gap-8">
<div className="relative">
<ResponsiveContainer width={160} height={160}>
<PieChart>
<Pie
data={donutData}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={70}
dataKey="value" dataKey="value"
strokeWidth={0} fill="#0a0f1a"
> stroke="none"
{donutData.map((entry, index) => ( fontSize={14}
<Cell key={index} fill={entry.color} /> />
{funnelData.map((_, i) => (
<Cell key={i} fill={FUNNEL_COLORS[i % FUNNEL_COLORS.length]} />
))} ))}
</Pie> </Funnel>
<Tooltip </FunnelChart>
contentStyle={{
backgroundColor: colors.tooltipBg,
border: `1px solid ${colors.tooltipBorder}`,
borderRadius: CHART_COMMON.TOOLTIP.BORDER_RADIUS,
fontSize: CHART_COMMON.TOOLTIP.FONT_SIZE,
color: colors.label,
}}
itemStyle={{ color: colors.label }}
/>
</PieChart>
</ResponsiveContainer> </ResponsiveContainer>
{/* Center text */} <div className="mt-2 flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs text-dark-400">
<div className="absolute inset-0 flex items-center justify-center"> {funnelData.map((s, i) => (
<span className="text-lg font-bold text-dark-100">{stats.total_purchases}</span> <span key={i} className="flex items-center gap-1.5">
</div> <span
</div> className="h-2 w-2 rounded-full"
<div className="space-y-3"> style={{ backgroundColor: FUNNEL_COLORS[i % FUNNEL_COLORS.length] }}
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: colors.referrals }}
/> />
<span className="text-sm text-dark-300"> {s.name}: <span className="text-dark-200">{s.value}</span>
{t('admin.landings.stats.regular')}: {stats.total_regular}
</span> </span>
))}
<span className="text-accent-400">{stats.conversion_rate}%</span>
</div> </div>
<div className="flex items-center gap-2"> </>
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: GIFT_COLOR }} />
<span className="text-sm text-dark-300">
{t('admin.landings.stats.gifts')}: {stats.total_gifts}
</span>
</div>
</div>
</div>
</div>
)} )}
</div>
<div className="bento-card">
<h4 className="mb-3 text-sm font-semibold text-dark-200">
{t('admin.landings.stats.giftClaimTitle', 'Gift activation')}
</h4>
<div className="flex items-end justify-between">
<div>
<div className="text-2xl font-semibold text-dark-100">
{giftsClaimed}
<span className="text-base text-dark-500"> / {stats.total_gifts}</span>
</div>
<div className="mt-0.5 text-xs text-dark-500">
{t('admin.landings.stats.giftClaimLabel', 'claimed / sent')}
</div>
</div>
<div className="text-2xl font-semibold text-accent-400">{giftClaimRate}%</div>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-dark-800/60">
<div
className="h-full rounded-full bg-accent-500 transition-all"
style={{ width: `${giftClaimRate}%` }}
/>
</div>
</div>
</div>
{/* Daily charts */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<MultiSeriesAreaChart
data={dailyCounts}
title={t('admin.landings.stats.dailyPurchases', 'Daily purchases')}
chartId={`landing-counts-${numericId}`}
/>
<SimpleAreaChart
data={dailyRevenue}
title={t('admin.landings.stats.dailyRevenue', 'Daily revenue')}
chartId={`landing-revenue-${numericId}`}
valueLabel={t('admin.landings.stats.revenueLabel', 'Revenue')}
/>
</div>
{/* Tariff + payment method */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<BreakdownList
title={t('admin.landings.stats.tariffChart')}
items={tariffItems}
valueFormatter={(v) => formatWithCurrency(v)}
/>
<DonutChart
data={paymentDonut}
title={t('admin.landings.stats.byPaymentMethod', 'By payment method')}
/>
</div>
{/* Source + gift composition */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<BreakdownList
title={t('admin.landings.stats.bySource', 'Traffic sources')}
items={sourceItems}
/>
<DonutChart
data={[
{
name: t('admin.landings.stats.regular'),
value: stats.total_regular,
color: GIFT_DONUT.regular,
},
{
name: t('admin.landings.stats.gifts'),
value: stats.total_gifts,
color: GIFT_DONUT.gift,
},
]}
title={t('admin.landings.stats.giftBreakdown')}
/>
</div>
{/* Purchases List */} {/* Purchases List */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4"> <div className="bento-card">
{/* Header row: title + status filter */} {/* Header row: title + status filter */}
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h3 className="font-medium text-dark-200">{t('admin.landings.purchases.title')}</h3> <h3 className="font-medium text-dark-200">{t('admin.landings.purchases.title')}</h3>
@@ -745,9 +565,7 @@ export default function AdminLandingStats() {
<PurchaseCard <PurchaseCard
key={item.id} key={item.id}
item={item} item={item}
formatPrice={(kopeks) => formatPrice={(kopeks) => money(kopeks)}
formatWithCurrency(kopeks / CHART_COMMON.KOPEKS_DIVISOR)
}
lang={i18n.language} lang={i18n.language}
t={t} t={t}
/> />

View File

@@ -87,9 +87,11 @@ export default function AdminPartnerCampaignAssign() {
</span> </span>
)} )}
</div> </div>
<div className="mt-1 flex items-center gap-3 text-xs text-dark-500"> <div className="mt-1 flex min-w-0 items-center gap-3 text-xs text-dark-500">
<span className="font-mono">?start={campaign.start_parameter}</span> <span className="min-w-0 truncate font-mono">
<span> ?start={campaign.start_parameter}
</span>
<span className="shrink-0">
{campaign.registrations_count}{' '} {campaign.registrations_count}{' '}
{t('admin.campaigns.overview.registrations').toLowerCase()} {t('admin.campaigns.overview.registrations').toLowerCase()}
</span> </span>

View File

@@ -129,12 +129,14 @@ export default function AdminPartners() {
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2"> <div className="mb-1 flex min-w-0 items-center gap-2">
<h3 className="truncate font-medium text-dark-100"> <h3 className="truncate font-medium text-dark-100">
{partner.first_name || partner.username || `#${partner.user_id}`} {partner.first_name || partner.username || `#${partner.user_id}`}
</h3> </h3>
{partner.username && ( {partner.username && (
<span className="text-sm text-dark-500">@{partner.username}</span> <span className="shrink-0 text-sm text-dark-500">
@{partner.username}
</span>
)} )}
</div> </div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400"> <div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
@@ -177,12 +179,12 @@ export default function AdminPartners() {
<div key={app.id} className="rounded-xl border border-dark-700 bg-dark-800 p-4"> <div key={app.id} className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="mb-3 flex items-start justify-between gap-4"> <div className="mb-3 flex items-start justify-between gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2"> <div className="mb-1 flex min-w-0 items-center gap-2">
<h3 className="truncate font-medium text-dark-100"> <h3 className="truncate font-medium text-dark-100">
{app.first_name || app.username || `#${app.user_id}`} {app.first_name || app.username || `#${app.user_id}`}
</h3> </h3>
{app.username && ( {app.username && (
<span className="text-sm text-dark-500">@{app.username}</span> <span className="shrink-0 text-sm text-dark-500">@{app.username}</span>
)} )}
</div> </div>
{app.company_name && ( {app.company_name && (

View File

@@ -339,18 +339,18 @@ export default function AdminPromoOfferSend() {
<div ref={searchRef} className="relative"> <div ref={searchRef} className="relative">
{selectedUser ? ( {selectedUser ? (
// Selected user display // Selected user display
<div className="flex items-center justify-between rounded-lg border border-accent-500 bg-accent-500/10 px-3 py-2.5"> <div className="flex items-center justify-between gap-2 rounded-lg border border-accent-500 bg-accent-500/10 px-3 py-2.5">
<div className="flex items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-dark-600"> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-dark-600">
<UserIcon /> <UserIcon />
</div> </div>
<div> <div className="min-w-0">
<div className="text-sm font-medium text-dark-100"> <div className="truncate text-sm font-medium text-dark-100">
{selectedUser.full_name || {selectedUser.full_name ||
selectedUser.username || selectedUser.username ||
`ID: ${selectedUser.telegram_id}`} `ID: ${selectedUser.telegram_id}`}
</div> </div>
<div className="text-xs text-dark-400"> <div className="truncate text-xs text-dark-400">
{selectedUser.username && `@${selectedUser.username} · `} {selectedUser.username && `@${selectedUser.username} · `}
Telegram: {selectedUser.telegram_id} Telegram: {selectedUser.telegram_id}
</div> </div>
@@ -358,7 +358,7 @@ export default function AdminPromoOfferSend() {
</div> </div>
<button <button
onClick={handleClearUser} onClick={handleClearUser}
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-600 hover:text-dark-100" className="shrink-0 rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-600 hover:text-dark-100"
> >
<CloseIcon className="h-4 w-4" /> <CloseIcon className="h-4 w-4" />
</button> </button>

View File

@@ -100,7 +100,7 @@ export default function AdminPromocodeStats() {
<AdminBackButton to="/admin/promocodes" /> <AdminBackButton to="/admin/promocodes" />
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div <div
className={`rounded-lg px-3 py-1.5 font-mono text-lg font-bold ${getTypeColor(promocode.type)}`} className={`min-w-0 break-all rounded-lg px-3 py-1.5 font-mono text-lg font-bold ${getTypeColor(promocode.type)}`}
> >
{promocode.code} {promocode.code}
</div> </div>

View File

@@ -229,11 +229,11 @@ export default function AdminRemnawaveSquadDetail() {
key={idx} key={idx}
className="flex items-center justify-between rounded-lg bg-dark-700/50 px-4 py-3" className="flex items-center justify-between rounded-lg bg-dark-700/50 px-4 py-3"
> >
<span className="text-sm text-dark-200"> <span className="min-w-0 flex-1 truncate text-sm text-dark-200">
{String(inbound.tag || inbound.uuid || `Inbound ${idx + 1}`)} {String(inbound.tag || inbound.uuid || `Inbound ${idx + 1}`)}
</span> </span>
{typeof inbound.type === 'string' && ( {typeof inbound.type === 'string' && (
<span className="rounded bg-dark-600 px-2 py-1 text-xs text-dark-400"> <span className="ml-2 shrink-0 rounded bg-dark-600 px-2 py-1 text-xs text-dark-400">
{inbound.type} {inbound.type}
</span> </span>
)} )}

View File

@@ -697,8 +697,10 @@ export default function AdminTariffCreate() {
> >
{isSelected && <CheckIcon />} {isSelected && <CheckIcon />}
</div> </div>
<span className="flex-1 text-sm font-medium">{squad.name}</span> <span className="min-w-0 flex-1 truncate text-sm font-medium">
<span className="text-xs text-dark-500"> {squad.name}
</span>
<span className="shrink-0 text-xs text-dark-500">
{squad.members_count} {t('admin.tariffs.externalSquadUsers')} {squad.members_count} {t('admin.tariffs.externalSquadUsers')}
</span> </span>
</button> </button>
@@ -1059,7 +1061,9 @@ export default function AdminTariffCreate() {
> >
{isSelected && <CheckIcon />} {isSelected && <CheckIcon />}
</div> </div>
<span className="flex-1 text-sm font-medium">{group.name}</span> <span className="min-w-0 flex-1 truncate text-sm font-medium">
{group.name}
</span>
</button> </button>
); );
})} })}

View File

@@ -443,7 +443,7 @@ export default function AdminTickets() {
</span> </span>
</div> </div>
</div> </div>
<div className="mb-4 flex items-center gap-2 text-sm text-dark-500"> <div className="mb-4 flex flex-wrap items-center gap-2 text-sm text-dark-500">
<span> <span>
{t('admin.tickets.from')}: {formatUser(selectedTicket)} {t('admin.tickets.from')}: {formatUser(selectedTicket)}
{selectedTicket.user?.telegram_id && ( {selectedTicket.user?.telegram_id && (

View File

@@ -40,8 +40,13 @@ import {
StarIcon, StarIcon,
TicketIcon, TicketIcon,
TrashIcon, TrashIcon,
WalletIcon,
WheelIcon,
XMarkIcon, XMarkIcon,
} from '@/components/icons'; } from '@/components/icons';
import { StatCard } from '@/components/stats';
import { BreakdownList } from '@/components/sales-stats/BreakdownList';
import { useCurrency } from '@/hooks/useCurrency';
import { usePlatform } from '../platform/hooks/usePlatform'; import { usePlatform } from '../platform/hooks/usePlatform';
import { toNumber } from '../utils/inputHelpers'; import { toNumber } from '../utils/inputHelpers';
@@ -166,6 +171,7 @@ export default function AdminWheel() {
const confirmDelete = useDestructiveConfirm(); const confirmDelete = useDestructiveConfirm();
const { capabilities } = usePlatform(); const { capabilities } = usePlatform();
const notify = useNotify(); const notify = useNotify();
const { formatWithCurrency } = useCurrency();
const [activeTab, setActiveTab] = useState<Tab>('settings'); const [activeTab, setActiveTab] = useState<Tab>('settings');
const [expandedPrizeId, setExpandedPrizeId] = useState<number | null>(null); const [expandedPrizeId, setExpandedPrizeId] = useState<number | null>(null);
@@ -184,6 +190,7 @@ export default function AdminWheel() {
daily_spin_limit: number | ''; daily_spin_limit: number | '';
min_subscription_days_for_day_payment: number | ''; min_subscription_days_for_day_payment: number | '';
promo_prefix: string; promo_prefix: string;
promo_validity_days: number | '';
} | null>(null); } | null>(null);
// Fetch config // Fetch config
@@ -193,7 +200,11 @@ export default function AdminWheel() {
}); });
// Fetch statistics // Fetch statistics
const { data: stats } = useQuery({ const {
data: stats,
isLoading: statsLoading,
error: statsError,
} = useQuery({
queryKey: ['admin-wheel-stats'], queryKey: ['admin-wheel-stats'],
queryFn: () => adminWheelApi.getStatistics(), queryFn: () => adminWheelApi.getStatistics(),
enabled: activeTab === 'statistics', enabled: activeTab === 'statistics',
@@ -214,6 +225,7 @@ export default function AdminWheel() {
daily_spin_limit: config.daily_spin_limit, daily_spin_limit: config.daily_spin_limit,
min_subscription_days_for_day_payment: config.min_subscription_days_for_day_payment, min_subscription_days_for_day_payment: config.min_subscription_days_for_day_payment,
promo_prefix: config.promo_prefix, promo_prefix: config.promo_prefix,
promo_validity_days: config.promo_validity_days,
}; };
}); });
} }
@@ -232,7 +244,8 @@ export default function AdminWheel() {
settingsForm.daily_spin_limit !== config.daily_spin_limit || settingsForm.daily_spin_limit !== config.daily_spin_limit ||
settingsForm.min_subscription_days_for_day_payment !== settingsForm.min_subscription_days_for_day_payment !==
config.min_subscription_days_for_day_payment || config.min_subscription_days_for_day_payment ||
settingsForm.promo_prefix !== config.promo_prefix); settingsForm.promo_prefix !== config.promo_prefix ||
settingsForm.promo_validity_days !== config.promo_validity_days);
// Update config mutation // Update config mutation
const updateConfigMutation = useMutation({ const updateConfigMutation = useMutation({
@@ -669,6 +682,26 @@ export default function AdminWheel() {
className="input w-full" className="input w-full"
/> />
</div> </div>
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.wheel.settings.promoValidityDays')}
</label>
<input
type="number"
value={settingsForm?.promo_validity_days ?? config.promo_validity_days}
onChange={(e) => {
const val = e.target.value;
setSettingsForm((prev) =>
prev
? { ...prev, promo_validity_days: val === '' ? '' : parseInt(val) || 0 }
: null,
);
}}
min={1}
max={365}
className="input w-full"
/>
</div>
</div> </div>
</div> </div>
@@ -702,6 +735,10 @@ export default function AdminWheel() {
settingsForm.min_subscription_days_for_day_payment, settingsForm.min_subscription_days_for_day_payment,
config.min_subscription_days_for_day_payment, config.min_subscription_days_for_day_payment,
), ),
promo_validity_days: toNumber(
settingsForm.promo_validity_days,
config.promo_validity_days,
),
}); });
}} }}
disabled={updateConfigMutation.isPending} disabled={updateConfigMutation.isPending}
@@ -740,28 +777,22 @@ export default function AdminWheel() {
<div className="flex items-center gap-3 rounded-xl border border-warning-500/30 bg-warning-500/10 p-4"> <div className="flex items-center gap-3 rounded-xl border border-warning-500/30 bg-warning-500/10 p-4">
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium text-warning-400"> <p className="text-sm font-medium text-warning-400">
{t('admin.wheel.prizes.unsavedOrder') || 'Есть несохраненные изменения порядка'} {t('admin.wheel.prizes.unsavedOrder')}
</p> </p>
<p className="text-xs text-warning-400/70"> <p className="text-xs text-warning-400/70">
{t('admin.wheel.prizes.unsavedOrderHint') || {t('admin.wheel.prizes.unsavedOrderHint')}
'Сохраните изменения или отмените их'}
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button onClick={handleDiscardOrderChanges} className="btn-secondary">
onClick={handleDiscardOrderChanges} {t('common.cancel')}
className="rounded-lg border border-dark-600 bg-dark-700 px-4 py-2 text-sm text-dark-200 transition-colors hover:bg-dark-600"
>
{t('common.cancel') || 'Отменить'}
</button> </button>
<button <button
onClick={handleSavePrizeOrder} onClick={handleSavePrizeOrder}
disabled={reorderPrizesMutation.isPending} disabled={reorderPrizesMutation.isPending}
className="rounded-lg bg-warning-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-warning-600 disabled:opacity-50" className="btn-primary"
> >
{reorderPrizesMutation.isPending {reorderPrizesMutation.isPending ? t('common.saving') : t('common.save')}
? t('common.saving') || 'Сохранение...'
: t('common.save') || 'Сохранить'}
</button> </button>
</div> </div>
</div> </div>
@@ -833,79 +864,82 @@ export default function AdminWheel() {
)} )}
{/* Statistics Tab */} {/* Statistics Tab */}
{activeTab === 'statistics' && stats && ( {activeTab === 'statistics' && statsLoading && (
<div className="space-y-4"> <div className="flex justify-center py-12">
{/* Stats cards */} <div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="card p-4 text-center">
<div className="text-3xl font-bold text-accent-400">{stats.total_spins}</div>
<div className="text-sm text-dark-400">{t('admin.wheel.statistics.totalSpins')}</div>
</div>
<div className="card p-4 text-center">
<div className="text-3xl font-bold text-success-400">
{(stats.total_revenue_kopeks / 100).toFixed(0)}
</div>
<div className="text-sm text-dark-400">{t('admin.wheel.statistics.revenue')}</div>
</div>
<div className="card p-4 text-center">
<div className="text-3xl font-bold text-warning-400">
{(stats.total_payout_kopeks / 100).toFixed(0)}
</div>
<div className="text-sm text-dark-400">{t('admin.wheel.statistics.payouts')}</div>
</div>
<div className="card p-4 text-center">
<div
className={`text-3xl font-bold ${
stats.actual_rtp_percent <= stats.configured_rtp_percent
? 'text-success-400'
: 'text-error-400'
}`}
>
{stats.actual_rtp_percent.toFixed(1)}%
</div>
<div className="text-sm text-dark-400">
{t('admin.wheel.statistics.actualRtp')} ({t('admin.wheel.statistics.targetRtp')}:{' '}
{stats.configured_rtp_percent}%)
</div>
</div>
</div>
{/* Prize distribution */}
{stats.prizes_distribution.length > 0 && (
<div className="card p-4">
<h3 className="mb-3 font-semibold text-dark-100">
{t('admin.wheel.statistics.prizeDistribution')}
</h3>
<div className="space-y-2">
{stats.prizes_distribution.map((prize, i) => (
<div key={i} className="flex items-center justify-between">
<span className="text-dark-300">{prize.display_name}</span>
<span className="text-dark-100">
{t('admin.wheel.statistics.times', { count: prize.count })}
</span>
</div>
))}
</div>
</div> </div>
)} )}
{/* Top wins */} {activeTab === 'statistics' && !statsLoading && statsError && (
<div className="bento-card py-8 text-center text-error-400">
{t('admin.wheel.statistics.loadError')}
</div>
)}
{activeTab === 'statistics' &&
!statsLoading &&
!statsError &&
stats &&
stats.total_spins === 0 && (
<div className="bento-card py-12 text-center text-dark-400">
{t('admin.wheel.statistics.empty')}
</div>
)}
{activeTab === 'statistics' && stats && stats.total_spins > 0 && (
<div className="space-y-4">
{/* Headline metrics — shared StatCard, like the rest of the admin stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard
label={t('admin.wheel.statistics.totalSpins')}
value={stats.total_spins}
icon={<WheelIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.wheel.statistics.revenue')}
value={formatWithCurrency(stats.total_revenue_kopeks / 100, 0)}
icon={<WalletIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('admin.wheel.statistics.payouts')}
value={formatWithCurrency(stats.total_payout_kopeks / 100, 0)}
icon={<GiftIcon className="h-5 w-5" />}
tone="warning"
/>
<StatCard
label={`${t('admin.wheel.statistics.actualRtp')} · ${t('admin.wheel.statistics.targetRtp')} ${stats.configured_rtp_percent}%`}
value={`${stats.actual_rtp_percent.toFixed(1)}%`}
icon={<ChartIcon className="h-5 w-5" />}
tone={stats.actual_rtp_percent <= stats.configured_rtp_percent ? 'success' : 'error'}
/>
</div>
{/* Prize distribution — shared BreakdownList (ranked bars + share %) */}
{stats.prizes_distribution.length > 0 && (
<BreakdownList
title={t('admin.wheel.statistics.prizeDistribution')}
items={stats.prizes_distribution.map((prize) => ({
key: prize.display_name,
label: prize.display_name,
value: prize.count,
}))}
valueFormatter={(v) => t('admin.wheel.statistics.times', { count: v })}
/>
)}
{/* Top wins — same BreakdownList, ranked by win value */}
{stats.top_wins.length > 0 && ( {stats.top_wins.length > 0 && (
<div className="card p-4"> <BreakdownList
<h3 className="mb-3 font-semibold text-dark-100"> title={t('admin.wheel.statistics.topWins')}
{t('admin.wheel.statistics.topWins')} items={stats.top_wins.slice(0, 8).map((win, i) => ({
</h3> key: `${win.user_id}-${i}`,
<div className="space-y-2"> label: `${win.username || `#${win.user_id}`} · ${win.prize_display_name}`,
{stats.top_wins.slice(0, 5).map((win, i) => ( value: win.prize_value_kopeks / 100,
<div key={i} className="flex items-center justify-between"> }))}
<span className="text-dark-300">{win.username || `User #${win.user_id}`}</span> valueFormatter={(v) => formatWithCurrency(v, 0)}
<span className="text-dark-100"> />
{win.prize_display_name} ({(win.prize_value_kopeks / 100).toFixed(0)})
</span>
</div>
))}
</div>
</div>
)} )}
</div> </div>
)} )}
@@ -1097,6 +1131,33 @@ function InlinePrizeForm({
</label> </label>
</div> </div>
{/* Manual probability override (optional; empty = auto by RTP) */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.wheel.prizes.fields.probability')}
</label>
<input
type="number"
value={formData.manual_probability ?? ''}
onChange={(e) => {
const val = e.target.value;
setFormData({
...formData,
manual_probability:
val === '' ? null : Math.min(1, Math.max(0, parseFloat(val) || 0)),
});
}}
min={0}
max={1}
step={0.01}
placeholder="0.00 1.00"
className="input w-full sm:w-1/2"
/>
<p className="mt-1 text-xs text-dark-500">
{t('admin.wheel.prizes.fields.probabilityHint')}
</p>
</div>
{/* Promocode settings */} {/* Promocode settings */}
{formData.prize_type === 'promocode' && ( {formData.prize_type === 'promocode' && (
<div className="space-y-3 rounded-lg bg-dark-700/50 p-3"> <div className="space-y-3 rounded-lg bg-dark-700/50 p-3">

View File

@@ -116,7 +116,7 @@ export default function AdminWithdrawals() {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{/* User and amount */} {/* User and amount */}
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<span className="truncate font-medium text-dark-100"> <span className="min-w-0 truncate font-medium text-dark-100">
{item.username {item.username
? `@${item.username}` ? `@${item.username}`
: item.first_name || `#${item.user_id}`} : item.first_name || `#${item.user_id}`}

View File

@@ -275,10 +275,10 @@ export default function Balance() {
key={sub.id} key={sub.id}
onClick={() => handlePromocodeActivate(sub.id)} onClick={() => handlePromocodeActivate(sub.id)}
disabled={promocodeLoading} disabled={promocodeLoading}
className="flex w-full items-center justify-between rounded-linear border border-dark-600 bg-dark-700 px-3 py-2 text-sm text-dark-200 transition-colors hover:border-accent-500/50 hover:bg-dark-600" className="flex w-full min-w-0 items-center justify-between gap-3 rounded-linear border border-dark-600 bg-dark-700 px-3 py-2 text-sm text-dark-200 transition-colors hover:border-accent-500/50 hover:bg-dark-600"
> >
<span>{sub.tariff_name}</span> <span className="truncate">{sub.tariff_name}</span>
<span className="text-dark-400"> <span className="shrink-0 text-dark-400">
{t('balance.promocode.daysLeft', '{{count}} дн.', { count: sub.days_left })} {t('balance.promocode.daysLeft', '{{count}} дн.', { count: sub.days_left })}
</span> </span>
</button> </button>

View File

@@ -318,6 +318,10 @@ export default function ConnectedAccounts() {
const [emailConfirmPassword, setEmailConfirmPassword] = useState(''); const [emailConfirmPassword, setEmailConfirmPassword] = useState('');
const [emailError, setEmailError] = useState<string | null>(null); const [emailError, setEmailError] = useState<string | null>(null);
const [emailSuccess, setEmailSuccess] = useState<string | null>(null); const [emailSuccess, setEmailSuccess] = useState<string | null>(null);
// Email-merge confirmation: the target email belongs to another account, so a
// one-time code was mailed to it; verifying the code yields the merge token.
const [emailMergeCodePending, setEmailMergeCodePending] = useState(false);
const [emailMergeCode, setEmailMergeCode] = useState('');
const setUser = useAuthStore((state) => state.setUser); const setUser = useAuthStore((state) => state.setUser);
const { data: emailAuthConfig } = useQuery<EmailAuthEnabled>({ const { data: emailAuthConfig } = useQuery<EmailAuthEnabled>({
@@ -396,6 +400,15 @@ export default function ConnectedAccounts() {
navigate(`/merge/${response.merge_token}`, { replace: true }); navigate(`/merge/${response.merge_token}`, { replace: true });
return; return;
} }
// The email belongs to another account: a one-time code was mailed to it.
// Switch to the code step; verifying it yields the merge token.
if (response.merge_required && response.merge_verification === 'email_code') {
setEmailMergeCodePending(true);
setEmailMergeCode('');
setEmailSuccess(t('profile.emailMergeCodeSent'));
setEmailError(null);
return;
}
setEmailSuccess(t('profile.emailSent')); setEmailSuccess(t('profile.emailSent'));
setEmailError(null); setEmailError(null);
setEmailValue(''); setEmailValue('');
@@ -409,7 +422,12 @@ export default function ConnectedAccounts() {
}, },
onError: (err: { response?: { data?: { detail?: string } } }) => { onError: (err: { response?: { data?: { detail?: string } } }) => {
const detail = err.response?.data?.detail; const detail = err.response?.data?.detail;
if (detail?.includes('already registered')) { // The email belongs to another account and merging it requires proving
// ownership: the backend asks for THAT account's password (account-takeover
// fix). Guide the user to enter it rather than showing a dead-end error.
if (detail?.includes('merge')) {
setEmailError(t('profile.emailMergePasswordRequired'));
} else if (detail?.includes('already registered')) {
setEmailError(t('profile.emailAlreadyRegistered')); setEmailError(t('profile.emailAlreadyRegistered'));
} else if (detail?.includes('already have a verified email')) { } else if (detail?.includes('already have a verified email')) {
setEmailError(t('profile.alreadyHaveEmail')); setEmailError(t('profile.alreadyHaveEmail'));
@@ -420,6 +438,36 @@ export default function ConnectedAccounts() {
}, },
}); });
const verifyEmailMergeMutation = useMutation({
mutationFn: (code: string) => authApi.verifyEmailMerge(code),
onSuccess: (response) => {
if (response.merge_token) {
navigate(`/merge/${response.merge_token}`, { replace: true });
}
},
onError: (err: { response?: { data?: { detail?: string } } }) => {
setEmailError(err.response?.data?.detail || t('profile.emailMergeCodeInvalid'));
},
});
const handleVerifyMergeCode = (e: React.SyntheticEvent) => {
e.preventDefault();
setEmailError(null);
const code = emailMergeCode.trim();
if (!/^\d{6}$/.test(code)) {
setEmailError(t('profile.emailMergeCodeInvalid'));
return;
}
verifyEmailMergeMutation.mutate(code);
};
const cancelEmailMerge = () => {
setEmailMergeCodePending(false);
setEmailMergeCode('');
setEmailError(null);
setEmailSuccess(null);
};
const handleEmailSubmit = (e: React.SyntheticEvent) => { const handleEmailSubmit = (e: React.SyntheticEvent) => {
e.preventDefault(); e.preventDefault();
setEmailError(null); setEmailError(null);
@@ -623,19 +671,19 @@ export default function ConnectedAccounts() {
{data?.providers.map((provider) => ( {data?.providers.map((provider) => (
<motion.div key={provider.provider} variants={staggerItem}> <motion.div key={provider.provider} variants={staggerItem}>
<Card> <Card>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<ProviderIcon provider={provider.provider} /> <ProviderIcon provider={provider.provider} />
<div> <div className="min-w-0">
<p className="font-medium text-dark-100"> <p className="truncate font-medium text-dark-100">
{t(`profile.accounts.providers.${provider.provider}`)} {t(`profile.accounts.providers.${provider.provider}`)}
</p> </p>
{provider.identifier && ( {provider.identifier && (
<p className="text-sm text-dark-400">{provider.identifier}</p> <p className="truncate text-sm text-dark-400">{provider.identifier}</p>
)} )}
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex shrink-0 flex-col items-end gap-1.5">
{provider.linked ? ( {provider.linked ? (
<> <>
<span className="text-sm text-success-500">{t('profile.accounts.linked')}</span> <span className="text-sm text-success-500">{t('profile.accounts.linked')}</span>
@@ -680,8 +728,54 @@ export default function ConnectedAccounts() {
> >
<div className="mt-4 border-t border-dark-700/30 pt-4"> <div className="mt-4 border-t border-dark-700/30 pt-4">
<p className="mb-4 text-sm text-dark-400"> <p className="mb-4 text-sm text-dark-400">
{t('profile.linkEmailDescription')} {emailMergeCodePending
? t('profile.emailMergeCodeDescription')
: t('profile.linkEmailDescription')}
</p> </p>
{emailMergeCodePending ? (
<form onSubmit={handleVerifyMergeCode} className="space-y-3">
<div>
<label htmlFor="email-merge-code" className="label">
{t('profile.emailMergeCodeLabel')}
</label>
<input
id="email-merge-code"
type="text"
inputMode="numeric"
maxLength={6}
value={emailMergeCode}
onChange={(e) => setEmailMergeCode(e.target.value.replace(/\D/g, ''))}
placeholder="000000"
className="input tracking-[0.5em]"
autoComplete="one-time-code"
/>
</div>
{emailError && (
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
{emailError}
</div>
)}
{emailSuccess && (
<div className="rounded-xl border border-success-500/30 bg-success-500/10 p-3 text-sm text-success-400">
{emailSuccess}
</div>
)}
<Button
type="submit"
fullWidth
loading={verifyEmailMergeMutation.isPending}
>
{t('profile.emailMergeConfirm')}
</Button>
<button
type="button"
onClick={cancelEmailMerge}
className="w-full text-sm text-dark-400 transition-colors hover:text-dark-200"
>
{t('common.cancel')}
</button>
</form>
) : (
<form onSubmit={handleEmailSubmit} className="space-y-3"> <form onSubmit={handleEmailSubmit} className="space-y-3">
<div> <div>
<label htmlFor="email-link-input" className="label"> <label htmlFor="email-link-input" className="label">
@@ -710,7 +804,9 @@ export default function ConnectedAccounts() {
className="input" className="input"
autoComplete="new-password" autoComplete="new-password"
/> />
<p className="mt-1 text-xs text-dark-500">{t('profile.passwordHint')}</p> <p className="mt-1 text-xs text-dark-500">
{t('profile.passwordHint')}
</p>
</div> </div>
<div> <div>
<label htmlFor="email-link-confirm" className="label"> <label htmlFor="email-link-confirm" className="label">
@@ -742,6 +838,7 @@ export default function ConnectedAccounts() {
{t('profile.linkEmail')} {t('profile.linkEmail')}
</Button> </Button>
</form> </form>
)}
</div> </div>
</motion.div> </motion.div>
)} )}

View File

@@ -198,14 +198,14 @@ export default function Contests() {
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{contests.map((contest) => ( {contests.map((contest) => (
<div key={contest.id} className="card"> <div key={contest.id} className="card">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between gap-2">
<div> <div className="min-w-0">
<h3 className="text-lg font-semibold">{contest.name}</h3> <h3 className="break-words text-lg font-semibold">{contest.name}</h3>
{contest.description && ( {contest.description && (
<p className="mt-1 text-sm text-dark-400">{contest.description}</p> <p className="mt-1 text-sm text-dark-400">{contest.description}</p>
)} )}
</div> </div>
<div className="flex items-center gap-1 text-accent-400"> <div className="flex shrink-0 items-center gap-1 text-accent-400">
<TrophyIcon /> <TrophyIcon />
<span className="text-sm font-medium"> <span className="text-sm font-medium">
+{t('contests.days', { count: contest.prize_days })} +{t('contests.days', { count: contest.prize_days })}

View File

@@ -198,6 +198,12 @@ export default function Dashboard() {
? multiSubData !== undefined && (multiSubData.subscriptions?.length ?? 0) === 0 ? multiSubData !== undefined && (multiSubData.subscriptions?.length ?? 0) === 0
: subscriptionResponse?.has_subscription === false && !subLoading; : subscriptionResponse?.has_subscription === false && !subLoading;
// Есть ли НАСТОЯЩАЯ (платная, не триал) живая подписка — от этого зависит CTA:
// «+ Купить ещё» только при наличии платной; иначе явная «Посмотреть тарифы».
const hasActivePaid = (multiSubData?.subscriptions ?? []).some(
(s) => !s.is_trial && (s.status === 'active' || s.status === 'limited'),
);
// Show onboarding for new users after data loads // Show onboarding for new users after data loads
useEffect(() => { useEffect(() => {
if (!isOnboardingCompleted && !subLoading && !refLoading && !blockingType) { if (!isOnboardingCompleted && !subLoading && !refLoading && !blockingType) {
@@ -273,8 +279,10 @@ export default function Dashboard() {
{/* Pending Gift Activations */} {/* Pending Gift Activations */}
{pendingGifts && pendingGifts.length > 0 && <PendingGiftCard gifts={pendingGifts} />} {pendingGifts && pendingGifts.length > 0 && <PendingGiftCard gifts={pendingGifts} />}
{/* Multi-tariff: show subscription cards (max 3) */} {/* Multi-tariff: show subscription cards (max 3) — только когда подписки
{isMultiTariff && multiSubData?.subscriptions && ( реально есть. Пустой случай (нет подписок) ведёт блок ниже (триал/покупка),
иначе кнопка покупки дублировалась. */}
{isMultiTariff && multiSubData?.subscriptions && multiSubData.subscriptions.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between px-1"> <div className="flex items-center justify-between px-1">
<span className="text-sm font-medium opacity-60"> <span className="text-sm font-medium opacity-60">
@@ -299,12 +307,23 @@ export default function Dashboard() {
{t('dashboard.showAll', 'Показать все')} ({multiSubData.subscriptions.length}) {t('dashboard.showAll', 'Показать все')} ({multiSubData.subscriptions.length})
</Link> </Link>
)} )}
{hasActivePaid ? (
<Link <Link
to="/subscription/purchase" to="/subscription/purchase"
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-500/15 p-3.5 text-sm font-medium text-accent-400 transition-all hover:bg-accent-500/25" className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-500/15 p-3.5 text-sm font-medium text-accent-400 transition-all hover:bg-accent-500/25"
> >
<span className="text-base">+</span> {t('subscriptions.buyAnother', 'Купить ещё тариф')} <span className="text-base">+</span>{' '}
{t('subscriptions.buyAnother', 'Купить ещё тариф')}
</Link> </Link>
) : (
<Link
to="/subscription/purchase"
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-500 p-3.5 text-sm font-semibold text-white transition-colors hover:bg-accent-600"
>
<span className="text-base">+</span>{' '}
{t('subscriptions.browsePlans', 'Посмотреть тарифы и купить подписку')}
</Link>
)}
</div> </div>
)} )}
@@ -344,9 +363,14 @@ export default function Dashboard() {
</> </>
)} )}
{/* Trial Activation */} {/* Нет подписок: показываем триал (если доступен) и ВСЕГДА одну явную
{hasNoSubscription && !trialLoading && trialInfo?.is_available && ( кнопку покупки. Триал не обязателен, чтобы попасть в витрину — раньше
при доступном триале это был единственный экран без кнопки покупки
(Telegram-баг #605056/#605063). Единственная кнопка тут (вместо дубля
с мульти-тариф блоком). */}
{hasNoSubscription && !trialLoading && (
<div className="space-y-3"> <div className="space-y-3">
{trialInfo?.is_available && (
<TrialOfferCard <TrialOfferCard
trialInfo={trialInfo} trialInfo={trialInfo}
balanceKopeks={balanceData?.balance_kopeks || 0} balanceKopeks={balanceData?.balance_kopeks || 0}
@@ -354,14 +378,12 @@ export default function Dashboard() {
activateTrialMutation={activateTrialMutation} activateTrialMutation={activateTrialMutation}
trialError={trialError} trialError={trialError}
/> />
{/* Новый пользователь не обязан активировать триал, чтобы попасть в )}
витрину — даём явный путь к покупке подписки. Раньше при доступном
триале это был единственный экран без кнопки покупки, и на дашборде
(вход по умолчанию) юзер оставался заперт (Telegram-баг #605056/#605063). */}
<Link <Link
to="/subscription/purchase" to="/subscription/purchase"
className="flex w-full items-center justify-center rounded-2xl border border-dashed border-white/15 p-3.5 text-sm font-medium opacity-60 transition-opacity hover:opacity-90" className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-500 p-3.5 text-sm font-semibold text-white transition-colors hover:bg-accent-600"
> >
<span className="text-base">+</span>{' '}
{t('subscriptions.browsePlans', 'Посмотреть тарифы и купить подписку')} {t('subscriptions.browsePlans', 'Посмотреть тарифы и купить подписку')}
</Link> </Link>
</div> </div>

321
src/pages/GiftClaim.tsx Normal file
View File

@@ -0,0 +1,321 @@
import { useMemo, useRef, useState } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { landingApi, type GiftClaimResult } from '../api/landings';
import { getApiErrorMessage } from '../utils/api-error';
import { copyToClipboard } from '@/utils/clipboard';
import { Spinner } from '@/components/ui/Spinner';
import { AnimatedCheckmark } from '@/components/ui/AnimatedCheckmark';
import { cn } from '@/lib/utils';
import { CheckCircleIcon, CheckIcon, CopyIcon } from '@/components/icons';
const MAX_POLL_MS = 10 * 60 * 1000; // poll an unsettled payment for up to 10 min
function isValidEmail(value: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value.trim());
}
function Shell({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-dvh items-center justify-center bg-dark-950 px-4 py-10">
<div className="w-full max-w-md rounded-2xl border border-dark-800/50 bg-dark-900/50 p-6 sm:p-8">
{children}
</div>
</div>
);
}
export default function GiftClaim() {
const { token } = useParams<{ token: string }>();
const { t } = useTranslation();
const navigate = useNavigate();
const startedAt = useRef(Date.now());
const [email, setEmail] = useState('');
const [showEmail, setShowEmail] = useState(false);
const [claimError, setClaimError] = useState<string | null>(null);
const [result, setResult] = useState<GiftClaimResult | null>(null);
const [copied, setCopied] = useState(false);
const {
data: gift,
isLoading,
error,
} = useQuery({
queryKey: ['gift-claim', token],
queryFn: () => landingApi.getGiftClaim(token!),
enabled: !!token,
retry: 1,
// Poll only while the payment is still settling (not yet claimable / terminal).
refetchInterval: (query) => {
const data = query.state.data;
if (!data) return 3000;
if (Date.now() - startedAt.current > MAX_POLL_MS) return false;
const settled = data.is_claimable || data.status === 'delivered' || data.status === 'failed';
return settled ? false : 3000;
},
});
const claimMutation = useMutation({
mutationFn: () => landingApi.claimGift(token!, email.trim()),
onSuccess: (res) => {
// One-click cabinet login for a fresh email account; otherwise show the link.
if (res.auto_login_token) {
navigate(`/auto-login?token=${encodeURIComponent(res.auto_login_token)}`);
return;
}
setResult(res);
},
onError: (err) => {
setClaimError(
getApiErrorMessage(err, t('landing.giftClaim.error', 'Could not activate the gift.')),
);
},
});
const willReplace = gift?.status === 'pending_activation';
const handleCopyLink = async () => {
const url = result?.subscription_url;
if (!url) return;
try {
await copyToClipboard(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
/* ignore */
}
};
const periodLabel = useMemo(() => {
const days = gift?.period_days;
if (!days) return '';
return `${days} ${t('gift.days', 'days')}`;
}, [gift?.period_days, t]);
if (isLoading) {
return (
<Shell>
<div className="flex flex-col items-center gap-4 py-6 text-center">
<Spinner className="h-12 w-12 border-[3px]" />
<p className="text-sm text-dark-400">{t('common.loading', 'Loading...')}</p>
</div>
</Shell>
);
}
// 404 / unknown gift
if (error || !gift || !gift.is_gift) {
return (
<Shell>
<div className="flex flex-col items-center gap-3 py-6 text-center">
<h1 className="text-lg font-semibold text-dark-50">
{t('landing.giftClaim.notFoundTitle', 'Gift not found')}
</h1>
<p className="text-sm text-dark-400">
{t(
'landing.giftClaim.notFoundDesc',
'This gift link is invalid or no longer available.',
)}
</p>
</div>
</Shell>
);
}
// Already activated
if (gift.status === 'delivered' && !result) {
return (
<Shell>
<div className="flex flex-col items-center gap-4 py-4 text-center">
<CheckCircleIcon className="h-14 w-14 text-success-400" />
<h1 className="text-xl font-bold text-dark-50">
{t('landing.giftClaim.alreadyTitle', 'Gift already activated')}
</h1>
<p className="text-sm text-dark-400">
{t('landing.giftClaim.alreadyDesc', 'This gift has already been claimed.')}
</p>
</div>
</Shell>
);
}
// Payment failed/expired → tell the recipient instead of spinning forever
if (gift.status === 'failed' || gift.status === 'expired') {
return (
<Shell>
<div className="flex flex-col items-center gap-3 py-6 text-center">
<h1 className="text-lg font-semibold text-dark-50">
{t('landing.giftClaim.failedTitle', 'Gift unavailable')}
</h1>
<p className="text-sm text-dark-400">
{t(
'landing.giftClaim.failedDesc',
'The payment for this gift did not go through, so it cannot be activated.',
)}
</p>
</div>
</Shell>
);
}
// Successful web claim → show connection link
if (result) {
return (
<Shell>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-5 text-center"
>
<AnimatedCheckmark />
<h1 className="text-xl font-bold text-dark-50">
{t('landing.giftClaim.successTitle', 'Gift activated!')}
</h1>
{result.subscription_url && (
<>
<p className="text-sm text-dark-300">
{t('landing.giftClaim.connectDesc', 'Use this link to connect:')}
</p>
<p className="w-full select-all truncate rounded-lg bg-dark-900/60 px-3 py-2 text-sm text-accent-400">
{result.subscription_url}
</p>
<button
type="button"
onClick={handleCopyLink}
className={cn(
'flex w-full items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-bold transition-all active:scale-[0.98]',
copied
? 'bg-success-500/20 text-success-400'
: 'bg-accent-500 text-white hover:bg-accent-400',
)}
>
{copied ? <CheckIcon className="h-4 w-4" /> : <CopyIcon className="h-4 w-4" />}
{copied
? t('common.copied', 'Copied!')
: t('landing.giftClaim.copyLink', 'Copy link')}
</button>
</>
)}
</motion.div>
</Shell>
);
}
// Payment still settling
if (!gift.is_claimable) {
return (
<Shell>
<div className="flex flex-col items-center gap-4 py-6 text-center">
<Spinner className="h-12 w-12 border-[3px]" />
<h1 className="text-lg font-semibold text-dark-50">
{t('landing.giftClaim.pendingTitle', 'Almost ready...')}
</h1>
<p className="text-sm text-dark-400">
{t(
'landing.giftClaim.pendingDesc',
'The payment is still being confirmed. This page will update automatically.',
)}
</p>
</div>
</Shell>
);
}
// Claimable — offer Telegram + web arms
return (
<Shell>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-5 text-center"
>
<div className="text-4xl">🎁</div>
<div>
<h1 className="text-xl font-bold text-dark-50">
{t('landing.giftClaim.title', 'You have a gift!')}
</h1>
{gift.tariff_name && (
<p className="mt-1 text-sm text-dark-300">
{gift.tariff_name}
{periodLabel ? `${periodLabel}` : ''}
</p>
)}
</div>
{gift.gift_message && (
<div className="w-full rounded-xl border border-dark-700/30 bg-dark-800/40 p-4 text-left">
<p className="text-sm italic text-dark-200">&ldquo;{gift.gift_message}&rdquo;</p>
</div>
)}
{willReplace && (
<p className="w-full rounded-lg border border-warning-500/20 bg-warning-500/5 p-3 text-xs text-warning-400">
{t(
'landing.giftClaim.replaceWarning',
'You already have a subscription — activating this gift will replace it.',
)}
</p>
)}
{/* Telegram arm */}
{gift.bot_claim_link && (
<a
href={gift.bot_claim_link}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-500 px-6 py-3.5 text-sm font-bold text-white shadow-lg shadow-accent-500/25 transition-all hover:bg-accent-400 active:scale-[0.98]"
>
{t('landing.giftClaim.activateTelegram', 'Activate in Telegram')}
</a>
)}
{/* Web/email arm */}
{!showEmail ? (
<button
type="button"
onClick={() => setShowEmail(true)}
className="w-full rounded-xl border border-dark-700/50 px-6 py-3 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-800/50"
>
{t('landing.giftClaim.activateWeb', 'Activate by email')}
</button>
) : (
<div className="w-full space-y-3 text-left">
<label htmlFor="claim-email" className="block text-sm font-medium text-dark-200">
{t('landing.giftClaim.emailLabel', 'Your email')}
</label>
<input
id="claim-email"
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setClaimError(null);
}}
placeholder="email@example.com"
className="w-full rounded-xl border border-dark-700/50 bg-dark-800/50 px-4 py-3 text-sm text-dark-50 placeholder-dark-500 outline-none focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/25"
/>
{claimError && <p className="text-sm text-error-400">{claimError}</p>}
<button
type="button"
disabled={!isValidEmail(email) || claimMutation.isPending}
onClick={() => claimMutation.mutate()}
className={cn(
'flex w-full items-center justify-center gap-2 rounded-xl px-6 py-3.5 text-sm font-bold transition-all',
isValidEmail(email) && !claimMutation.isPending
? 'bg-accent-500 text-white hover:bg-accent-400 active:scale-[0.98]'
: 'cursor-not-allowed bg-dark-800 text-dark-500',
)}
>
{claimMutation.isPending ? (
<Spinner className="h-5 w-5 border-2" />
) : (
t('landing.giftClaim.claimNow', 'Get my gift')
)}
</button>
</div>
)}
</motion.div>
</Shell>
);
}

View File

@@ -476,8 +476,9 @@ export default function GiftResult() {
const s = d?.status; const s = d?.status;
if (s === 'delivered' || s === 'failed' || s === 'pending_activation' || s === 'expired') if (s === 'delivered' || s === 'failed' || s === 'pending_activation' || s === 'expired')
return false; return false;
// Code-only gifts stay in 'paid' status — stop polling // Claimable gifts (code-only AND directed) stay in 'paid' until claimed —
if (s === 'paid' && d?.is_code_only) return false; // stop polling; the buyer shares the link.
if (s === 'paid' && d?.is_claimable) return false;
// Check poll timeout // Check poll timeout
if (Date.now() - pollStart.current > MAX_POLL_MS) { if (Date.now() - pollStart.current > MAX_POLL_MS) {
@@ -511,8 +512,8 @@ export default function GiftResult() {
); );
} }
const isCodeOnlyPaid = const isClaimablePaid =
status?.status === 'paid' && status?.is_code_only && status?.purchase_token != null; status?.status === 'paid' && status?.is_claimable && status?.purchase_token != null;
const isDelivered = status?.status === 'delivered'; const isDelivered = status?.status === 'delivered';
const isPendingActivation = status?.status === 'pending_activation'; const isPendingActivation = status?.status === 'pending_activation';
const isFailed = status?.status === 'failed' || status?.status === 'expired'; const isFailed = status?.status === 'failed' || status?.status === 'expired';
@@ -531,7 +532,7 @@ export default function GiftResult() {
> >
{isError ? ( {isError ? (
<PollErrorState /> <PollErrorState />
) : isCodeOnlyPaid ? ( ) : isClaimablePaid ? (
<CodeOnlySuccessState <CodeOnlySuccessState
purchaseToken={status.purchase_token!} purchaseToken={status.purchase_token!}
tariffName={status.tariff_name} tariffName={status.tariff_name}

View File

@@ -205,9 +205,9 @@ export default function Polls() {
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{polls.map((poll) => ( {polls.map((poll) => (
<div key={poll.id} className="card"> <div key={poll.id} className="card">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between gap-2">
<div className="flex-1"> <div className="min-w-0 flex-1">
<h3 className="text-lg font-semibold">{poll.title}</h3> <h3 className="break-words text-lg font-semibold">{poll.title}</h3>
{poll.description && ( {poll.description && (
<p className="mt-1 text-sm text-dark-400">{poll.description}</p> <p className="mt-1 text-sm text-dark-400">{poll.description}</p>
)} )}
@@ -219,7 +219,7 @@ export default function Polls() {
</div> </div>
</div> </div>
{poll.reward_amount && ( {poll.reward_amount && (
<div className="flex items-center gap-1 text-accent-400"> <div className="flex shrink-0 items-center gap-1 text-accent-400">
<GiftIcon className="h-5 w-5" /> <GiftIcon className="h-5 w-5" />
<span className="text-sm font-medium">+{poll.reward_amount}</span> <span className="text-sm font-medium">+{poll.reward_amount}</span>
</div> </div>

View File

@@ -60,9 +60,9 @@ function CopyableField({ label, value }: { label: string; value: string }) {
return ( return (
<div className="flex items-center gap-2 rounded-xl bg-dark-800/50 px-4 py-3"> <div className="flex items-center gap-2 rounded-xl bg-dark-800/50 px-4 py-3">
<div className="flex-1 text-left"> <div className="min-w-0 flex-1 text-left">
<p className="text-xs text-dark-400">{label}</p> <p className="text-xs text-dark-400">{label}</p>
<p className="mt-0.5 font-mono text-sm text-dark-100">{value}</p> <p className="mt-0.5 break-all font-mono text-sm text-dark-100">{value}</p>
</div> </div>
<button <button
type="button" type="button"
@@ -553,6 +553,113 @@ function PollTimedOutState({ onRetry }: { onRetry: () => void }) {
); );
} }
function GiftLinkShareState({
claimUrl,
botClaimLink,
tariffName,
periodDays,
recipientContactValue,
contactType,
}: {
claimUrl: string | null;
botClaimLink: string | null;
tariffName: string | null;
periodDays: number | null;
recipientContactValue: string | null;
contactType: 'email' | 'telegram' | null;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const message = [
t('landing.giftLink.shareText', 'I have a gift for you! Activate it here:'),
'',
claimUrl,
botClaimLink ? `${t('landing.giftLink.viaTelegram', 'Telegram:')} ${botClaimLink}` : null,
]
.filter(Boolean)
.join('\n');
const handleCopy = async () => {
try {
await copyToClipboard(message);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
/* ignore */
}
};
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-5 text-center"
>
<AnimatedCheckmark />
<div>
<h1 className="text-xl font-bold text-dark-50">
{t('landing.giftLink.title', 'Gift is ready!')}
</h1>
{tariffName && periodDays !== null && (
<p className="mt-1 text-sm text-dark-300">
{tariffName} {periodDays} {t('landing.days', 'days')}
</p>
)}
</div>
<p className="text-sm text-dark-300">
{t(
'landing.giftLink.subtitle',
'Send this link to whoever you want to receive the gift — they activate it themselves.',
)}
</p>
{claimUrl && (
<CopyableField label={t('landing.giftLink.linkLabel', 'Gift link')} value={claimUrl} />
)}
{botClaimLink && (
<CopyableField
label={t('landing.giftLink.telegramLabel', 'Telegram link')}
value={botClaimLink}
/>
)}
{recipientContactValue && contactType === 'email' && (
<p className="text-xs text-dark-500">
{t('landing.giftLink.alsoSent', {
contact: recipientContactValue,
defaultValue: 'We also emailed it to {{contact}}.',
})}
</p>
)}
<button
type="button"
onClick={handleCopy}
className={cn(
'flex w-full items-center justify-center gap-2 rounded-xl px-4 py-3.5 text-sm font-bold transition-all duration-200 active:scale-[0.98]',
copied
? 'bg-success-500/20 text-success-400'
: 'bg-accent-500 text-white shadow-lg shadow-accent-500/25 hover:bg-accent-400',
)}
>
{copied ? (
<>
<CheckIcon className="h-4 w-4" />
{t('landing.copied', 'Copied!')}
</>
) : (
<>
<ClipboardIcon className="h-4 w-4" />
{t('landing.giftLink.copyMessage', 'Copy message')}
</>
)}
</button>
</motion.div>
);
}
export default function PurchaseSuccess() { export default function PurchaseSuccess() {
const { t } = useTranslation(); const { t } = useTranslation();
const { token } = useParams<{ token: string }>(); const { token } = useParams<{ token: string }>();
@@ -586,7 +693,12 @@ export default function PurchaseSuccess() {
queryFn: () => landingApi.getPurchaseStatus(token!), queryFn: () => landingApi.getPurchaseStatus(token!),
enabled: !!token && !pollTimedOut, enabled: !!token && !pollTimedOut,
refetchInterval: (query) => { refetchInterval: (query) => {
const currentStatus = query.state.data?.status; const data = query.state.data;
const currentStatus = data?.status;
// A gift that reached PAID is terminal for the BUYER (it stays PAID until
// the recipient claims) — stop polling and show the share link instead of
// spinning. A paid gift is always claimable, so don't gate on is_claimable.
if (currentStatus === 'paid' && data?.is_gift) return false;
if (currentStatus === 'pending' || currentStatus === 'paid') { if (currentStatus === 'pending' || currentStatus === 'paid') {
if (Date.now() - pollStart.current > MAX_POLL_MS) { if (Date.now() - pollStart.current > MAX_POLL_MS) {
setPollTimedOut(true); setPollTimedOut(true);
@@ -652,6 +764,10 @@ export default function PurchaseSuccess() {
const isPendingActivation = purchaseStatus?.status === 'pending_activation'; const isPendingActivation = purchaseStatus?.status === 'pending_activation';
const isFailed = purchaseStatus?.status === 'failed' || purchaseStatus?.status === 'expired'; const isFailed = purchaseStatus?.status === 'failed' || purchaseStatus?.status === 'expired';
// Deferred gift the buyer just paid for → show the transferable claim link to
// forward (it stays PAID until the recipient claims it).
const isBuyerGiftLink = purchaseStatus?.status === 'paid' && !!purchaseStatus?.is_gift;
// Gift pending activation → buyer sees "gift sent" message, not the activate button. // Gift pending activation → buyer sees "gift sent" message, not the activate button.
// Recipient arrives via email link with ?activate=1 and sees the activate button instead. // Recipient arrives via email link with ?activate=1 and sees the activate button instead.
const isGiftPendingActivation = isPendingActivation && purchaseStatus?.is_gift && !isActivateHint; const isGiftPendingActivation = isPendingActivation && purchaseStatus?.is_gift && !isActivateHint;
@@ -672,6 +788,15 @@ export default function PurchaseSuccess() {
> >
{isError ? ( {isError ? (
<FailedState /> <FailedState />
) : isBuyerGiftLink ? (
<GiftLinkShareState
claimUrl={purchaseStatus.claim_url}
botClaimLink={purchaseStatus.bot_claim_link}
tariffName={purchaseStatus.tariff_name}
periodDays={purchaseStatus.period_days}
recipientContactValue={purchaseStatus.recipient_contact_value}
contactType={purchaseStatus.contact_type}
/>
) : isEmailSelfPurchase ? ( ) : isEmailSelfPurchase ? (
<CabinetCredentialsState <CabinetCredentialsState
cabinetEmail={purchaseStatus.cabinet_email!} cabinetEmail={purchaseStatus.cabinet_email!}

View File

@@ -7,6 +7,13 @@ import { fireAnalyticsEvent, getYandexCid } from '../hooks/useAnalyticsCounters'
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { landingApi } from '../api/landings'; import { landingApi } from '../api/landings';
import {
brandingApi,
getCachedBranding,
setCachedBranding,
preloadLogo,
getLogoBlobUrl,
} from '../api/branding';
import type { import type {
LandingConfig, LandingConfig,
LandingTariff, LandingTariff,
@@ -14,12 +21,16 @@ import type {
LandingPaymentMethod, LandingPaymentMethod,
PurchaseRequest, PurchaseRequest,
} from '../api/landings'; } from '../api/landings';
import { StaticBackgroundRenderer } from '../components/backgrounds/BackgroundRenderer'; import {
BackgroundRenderer,
StaticBackgroundRenderer,
} from '../components/backgrounds/BackgroundRenderer';
import { CheckCircleIcon, CheckIcon, DevicesIcon, DownloadIcon } from '@/components/icons'; import { CheckCircleIcon, CheckIcon, DevicesIcon, DownloadIcon } from '@/components/icons';
import LanguageSwitcher from '../components/LanguageSwitcher'; import LanguageSwitcher from '../components/LanguageSwitcher';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { getApiErrorMessage } from '../utils/api-error'; import { getApiErrorMessage } from '../utils/api-error';
import { formatPrice } from '../utils/format'; import { formatPrice } from '../utils/format';
import { setFavicon, letterFaviconDataUri, roundedFaviconDataUri } from '../utils/favicon';
import { useCurrency } from '../hooks/useCurrency'; import { useCurrency } from '../hooks/useCurrency';
function detectContactType(value: string): 'email' | 'telegram' { function detectContactType(value: string): 'email' | 'telegram' {
@@ -772,6 +783,42 @@ export default function QuickPurchase() {
retry: 1, retry: 1,
}); });
// Public branding — drives the favicon on this standalone landing page.
// The cabinet's useBranding hook is auth-gated and AppShell-only, so a public
// landing would otherwise keep the empty index.html favicon. The branding
// endpoint is public; logo is preloaded as a blob to keep the backend URL out
// of the DOM (same pattern as the authenticated app).
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: async () => {
const data = await brandingApi.getBranding();
setCachedBranding(data);
await preloadLogo(data);
return data;
},
initialData: getCachedBranding() ?? undefined,
initialDataUpdatedAt: 0,
staleTime: 60_000,
retry: 1,
});
useEffect(() => {
if (!branding) return;
const logoUrl = branding.has_custom_logo ? getLogoBlobUrl() : null;
if (!logoUrl) {
setFavicon(letterFaviconDataUri(branding.logo_letter));
return;
}
let cancelled = false;
// Round the custom logo like the header tile instead of a hard square.
roundedFaviconDataUri(logoUrl).then((rounded) => {
if (!cancelled) setFavicon(rounded || logoUrl);
});
return () => {
cancelled = true;
};
}, [branding]);
const [discountExpired, setDiscountExpired] = useState(false); const [discountExpired, setDiscountExpired] = useState(false);
const handleDiscountExpired = useCallback(() => { const handleDiscountExpired = useCallback(() => {
@@ -900,15 +947,18 @@ export default function QuickPurchase() {
} }
}, [visibleTariffs, selectedTariffId]); }, [visibleTariffs, selectedTariffId]);
// SEO: set document title // SEO: set document title. Fall back to the landing's own title when no
// dedicated meta_title is set — otherwise the tab keeps the static
// index.html "VPN" placeholder and never reflects the landing.
useEffect(() => { useEffect(() => {
if (!config?.meta_title) return; const pageTitle = config?.meta_title || config?.title;
if (!pageTitle) return;
const prev = document.title; const prev = document.title;
document.title = config.meta_title; document.title = pageTitle;
return () => { return () => {
document.title = prev; document.title = prev;
}; };
}, [config?.meta_title]); }, [config?.meta_title, config?.title]);
// SEO: set meta description // SEO: set meta description
useEffect(() => { useEffect(() => {
@@ -1069,8 +1119,16 @@ export default function QuickPurchase() {
const showTariffCards = visibleTariffs.length > 1; const showTariffCards = visibleTariffs.length > 1;
return ( return (
<div className={cn('min-h-dvh overflow-x-hidden', !config.background_config && 'bg-dark-950')}> <div className="min-h-dvh overflow-x-hidden">
{config.background_config && <StaticBackgroundRenderer config={config.background_config} />} {/* Background: the landing's own per-landing theme when configured, else
fall back to the cabinet's global animated theme (instead of a bare
dark canvas). Both render via a portal behind the content, so the
wrapper stays transparent over the body's #0a0f1a. */}
{config.background_config ? (
<StaticBackgroundRenderer config={config.background_config} />
) : (
<BackgroundRenderer />
)}
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8"> <div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
{/* Language switcher */} {/* Language switcher */}
<div className="mb-4 flex justify-end"> <div className="mb-4 flex justify-end">

View File

@@ -60,11 +60,13 @@ export function CampaignDetailPanel({ campaignId, className }: CampaignDetailPan
<div className="space-y-5"> <div className="space-y-5">
{/* Info */} {/* Info */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between gap-2 text-sm">
<span className="text-dark-500"> <span className="shrink-0 text-dark-500">
{t('admin.referralNetwork.campaign.startParam')} {t('admin.referralNetwork.campaign.startParam')}
</span> </span>
<span className="font-mono text-dark-200">{campaign.start_parameter}</span> <span className="min-w-0 truncate font-mono text-dark-200">
{campaign.start_parameter}
</span>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<span <span
@@ -133,17 +135,19 @@ export function CampaignDetailPanel({ campaignId, className }: CampaignDetailPan
{campaign.top_referrers.map((referrer, index) => ( {campaign.top_referrers.map((referrer, index) => (
<div <div
key={referrer.user_id} key={referrer.user_id}
className="flex items-center justify-between text-sm" className="flex items-center justify-between gap-2 text-sm"
> >
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-dark-700 text-[10px] font-medium text-dark-300"> <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-dark-700 text-[10px] font-medium text-dark-300">
{index + 1} {index + 1}
</span> </span>
<span className="text-dark-200"> <span className="truncate text-dark-200">
{referrer.username ? `@${referrer.username}` : `#${referrer.user_id}`} {referrer.username ? `@${referrer.username}` : `#${referrer.user_id}`}
</span> </span>
</div> </div>
<span className="font-mono text-dark-300">{referrer.referral_count}</span> <span className="shrink-0 font-mono text-dark-300">
{referrer.referral_count}
</span>
</div> </div>
))} ))}
</div> </div>

View File

@@ -61,9 +61,9 @@ export function UserDetailPanel({ userId, className }: UserDetailPanelProps) {
{/* Identity */} {/* Identity */}
<div className="space-y-2"> <div className="space-y-2">
{user.username && ( {user.username && (
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between gap-2 text-sm">
<span className="text-dark-500">@</span> <span className="shrink-0 text-dark-500">@</span>
<span className="font-mono text-dark-200">{user.username}</span> <span className="min-w-0 truncate font-mono text-dark-200">{user.username}</span>
</div> </div>
)} )}
{user.tg_id && ( {user.tg_id && (
@@ -196,11 +196,11 @@ export function UserDetailPanel({ userId, className }: UserDetailPanelProps) {
</span> </span>
</div> </div>
{user.campaign_name && ( {user.campaign_name && (
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between gap-2 text-sm">
<span className="text-dark-400"> <span className="shrink-0 text-dark-400">
{t('admin.referralNetwork.user.fromCampaign')} {t('admin.referralNetwork.user.fromCampaign')}
</span> </span>
<span className="text-dark-200">{user.campaign_name}</span> <span className="min-w-0 truncate text-dark-200">{user.campaign_name}</span>
</div> </div>
)} )}
</div> </div>

View File

@@ -62,6 +62,12 @@ export default function Subscriptions() {
const subscriptions = data?.subscriptions ?? []; const subscriptions = data?.subscriptions ?? [];
const isMultiTariff = data?.multi_tariff_enabled ?? false; const isMultiTariff = data?.multi_tariff_enabled ?? false;
const hasNoSubscriptions = !isLoading && subscriptions.length === 0; const hasNoSubscriptions = !isLoading && subscriptions.length === 0;
// Есть ли хотя бы одна НАСТОЯЩАЯ (платная, не триал) живая подписка. От этого
// зависит CTA: «+ Купить ещё» — только если уже есть платная; иначе показываем
// явную «Посмотреть тарифы и купить подписку» (триал/истёкшие — это ещё не покупка).
const hasActivePaid = subscriptions.some(
(s) => !s.is_trial && (s.status === 'active' || s.status === 'limited'),
);
// Если у юзера нет подписок — проверяем доступность триала, иначе // Если у юзера нет подписок — проверяем доступность триала, иначе
// (в multi-tariff) ему вообще негде увидеть оффер. // (в multi-tariff) ему вообще негде увидеть оффер.
@@ -107,7 +113,8 @@ export default function Subscriptions() {
<h1 className="text-2xl font-bold" style={{ color: g.text }}> <h1 className="text-2xl font-bold" style={{ color: g.text }}>
{t('subscriptions.title', 'Мои подписки')} {t('subscriptions.title', 'Мои подписки')}
</h1> </h1>
{!isLoading && subscriptions.length > 0 && ( {/* «+ Купить ещё» — только если уже есть платная активная подписка */}
{!isLoading && hasActivePaid && (
<button <button
onClick={() => navigate('/subscription/purchase')} onClick={() => navigate('/subscription/purchase')}
className="flex items-center gap-1.5 rounded-xl px-4 py-2 text-sm font-medium transition-colors" className="flex items-center gap-1.5 rounded-xl px-4 py-2 text-sm font-medium transition-colors"
@@ -123,6 +130,18 @@ export default function Subscriptions() {
)} )}
</div> </div>
{/* Есть подписки, но платной активной нет (только триал/истёкшие) —
даём ЯВНУЮ primary-кнопку покупки: мы продаём подписки. */}
{!isLoading && subscriptions.length > 0 && !hasActivePaid && (
<button
onClick={() => navigate('/subscription/purchase')}
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-500 p-3.5 text-sm font-semibold text-white transition-colors hover:bg-accent-600"
>
<PlusIcon className="h-5 w-5" />
{t('subscriptions.browsePlans', 'Посмотреть тарифы и купить подписку')}
</button>
)}
{/* Loading */} {/* Loading */}
{isLoading && ( {isLoading && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -152,9 +171,9 @@ export default function Subscriptions() {
(Telegram-баг #605056/#605063). */} (Telegram-баг #605056/#605063). */}
<button <button
onClick={() => navigate('/subscription/purchase')} onClick={() => navigate('/subscription/purchase')}
className="w-full rounded-xl border px-6 py-3 text-sm font-medium transition-colors" className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-500 px-6 py-3 text-sm font-semibold text-white transition-colors hover:bg-accent-600"
style={{ background: g.innerBg, borderColor: g.cardBorder, color: g.text }}
> >
<PlusIcon className="h-5 w-5" />
{t('subscriptions.browsePlans', 'Посмотреть тарифы и купить подписку')} {t('subscriptions.browsePlans', 'Посмотреть тарифы и купить подписку')}
</button> </button>
</div> </div>

View File

@@ -22,38 +22,57 @@ const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
); );
/** /**
* Calculate rotation degrees for a prize based on its position on the wheel. * Rotation (mod 360) that brings sector `prizeIndex` under the top pointer.
* Mirrors the backend _calculate_rotation logic in wheel_service.py. * Mirrors the backend _calculate_rotation logic in wheel_service.py.
*/ */
function rotationForIndex(prizes: WheelPrize[], prizeIndex: number): number {
if (prizes.length === 0) return 0;
const sectorAngle = 360 / prizes.length;
const baseAngle = prizeIndex * sectorAngle + sectorAngle / 2;
const offset = (Math.random() - 0.5) * sectorAngle * 0.6; // ±30% within the sector
return 360 - baseAngle + offset;
}
/** Index of the "Nothing" sector, or 0 if there isn't one. */
function neutralIndex(prizes: WheelPrize[]): number {
const idx = prizes.findIndex((p) => p.prize_type === 'nothing');
return idx === -1 ? 0 : idx;
}
/**
* Rotation that lands the wheel on the ACTUAL won prize's sector.
*
* Matches by prize_id (exact). It must NEVER land on a random sector: a random
* angle was the root cause of "the wheel shows месяц/50₽ but the result is
* Ничего" on the browser Stars path — when the prize couldn't be located the old
* code span to Math.random()*360, which often pointed at a winning slot. When the
* prize can't be resolved we land on the neutral ("Nothing") sector instead, so
* the animation never falsely celebrates a win.
*/
function calculateRotationForPrize(prizes: WheelPrize[], result: SpinResult): number { function calculateRotationForPrize(prizes: WheelPrize[], result: SpinResult): number {
// Find prize index by matching display_name + emoji (both must match for safety) let prizeIndex = result.prize_id != null ? prizes.findIndex((p) => p.id === result.prize_id) : -1;
let prizeIndex = prizes.findIndex(
// Defensive fallbacks for older payloads without prize_id: name+emoji, then name.
if (prizeIndex === -1 && result.prize_display_name) {
prizeIndex = prizes.findIndex(
(p) => p.display_name === result.prize_display_name && p.emoji === result.emoji, (p) => p.display_name === result.prize_display_name && p.emoji === result.emoji,
); );
// Fallback: match by display_name only
if (prizeIndex === -1) { if (prizeIndex === -1) {
prizeIndex = prizes.findIndex((p) => p.display_name === result.prize_display_name); prizeIndex = prizes.findIndex((p) => p.display_name === result.prize_display_name);
} }
// Fallback: match by emoji + prize_type
if (prizeIndex === -1) {
prizeIndex = prizes.findIndex(
(p) => p.emoji === result.emoji && p.prize_type === result.prize_type,
);
} }
if (prizeIndex === -1) { if (prizeIndex === -1) prizeIndex = neutralIndex(prizes); // unknown → neutral, never random
// Can't determine prize position — random angle
return Math.random() * 360; return rotationForIndex(prizes, prizeIndex);
} }
const sectorAngle = 360 / prizes.length; /**
const baseAngle = prizeIndex * sectorAngle + sectorAngle / 2; * Rotation used when the real result is unknown (e.g. the Stars-payment poll timed
const offset = (Math.random() - 0.5) * sectorAngle * 0.6; // ±30% of sector * out). Lands on the neutral ("Nothing") sector — never a random/winning angle.
const stopAngle = 360 - baseAngle + offset; */
function neutralRotation(prizes: WheelPrize[]): number {
return stopAngle; return rotationForIndex(prizes, neutralIndex(prizes));
} }
export default function Wheel() { export default function Wheel() {
@@ -144,7 +163,10 @@ export default function Wheel() {
// Found a new spin! Return it as SpinResult // Found a new spin! Return it as SpinResult
return { return {
success: true, success: true,
prize_id: latestSpin.id, // WheelPrize id — lets the wheel land on the exact won sector.
// (Was latestSpin.id, the SPIN id, which never matched a sector and
// forced the random-angle fallback → the fake-win bug.)
prize_id: latestSpin.prize_id,
prize_type: latestSpin.prize_type, prize_type: latestSpin.prize_type,
prize_value: latestSpin.prize_value, prize_value: latestSpin.prize_value,
prize_display_name: latestSpin.prize_display_name, prize_display_name: latestSpin.prize_display_name,
@@ -219,13 +241,11 @@ export default function Wheel() {
if (result) { if (result) {
pendingStarsResultRef.current = result; pendingStarsResultRef.current = result;
// Calculate rotation so the wheel lands on the correct prize sector // Land on the ACTUAL won sector (matched by prize_id).
const rotation = config?.prizes setTargetRotation(calculateRotationForPrize(config?.prizes ?? [], result));
? calculateRotationForPrize(config.prizes, result)
: Math.random() * 360;
setTargetRotation(rotation);
} else { } else {
// Fallback: couldn't get result — use random rotation // Couldn't get the result — land on the neutral sector (never a
// random/winning angle) and tell the user to check their history.
pendingStarsResultRef.current = { pendingStarsResultRef.current = {
success: true, success: true,
prize_id: null, prize_id: null,
@@ -239,7 +259,7 @@ export default function Wheel() {
promocode: null, promocode: null,
error: null, error: null,
}; };
setTargetRotation(Math.random() * 360); setTargetRotation(neutralRotation(config?.prizes ?? []));
} }
setIsSpinning(true); setIsSpinning(true);
@@ -249,7 +269,8 @@ export default function Wheel() {
setIsPayingStars(false); setIsPayingStars(false);
// Error polling — spin with random rotation and show generic message // Error polling — land on the neutral sector (never a random/winning
// angle) and show the generic "check your history" message.
pendingStarsResultRef.current = { pendingStarsResultRef.current = {
success: true, success: true,
prize_id: null, prize_id: null,
@@ -263,7 +284,7 @@ export default function Wheel() {
promocode: null, promocode: null,
error: null, error: null,
}; };
setTargetRotation(Math.random() * 360); setTargetRotation(neutralRotation(config?.prizes ?? []));
setIsSpinning(true); setIsSpinning(true);
}); });
} else if (status !== 'cancelled') { } else if (status !== 'cancelled') {
@@ -578,16 +599,16 @@ export default function Wheel() {
key={sub.id} key={sub.id}
onClick={() => setSelectedSubscriptionId(sub.id)} onClick={() => setSelectedSubscriptionId(sub.id)}
disabled={isSpinning} disabled={isSpinning}
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-all ${ className={`flex w-full items-center justify-between gap-2 rounded-lg px-3 py-2 text-sm transition-all ${
selectedSubscriptionId === sub.id selectedSubscriptionId === sub.id
? 'bg-accent-500/15 text-accent-400' ? 'bg-accent-500/15 text-accent-400'
: 'text-dark-400 hover:text-dark-200' : 'text-dark-400 hover:text-dark-200'
}`} }`}
> >
<span className="font-medium"> <span className="min-w-0 truncate font-medium">
{sub.tariff_name || t('subscription.defaultName', 'Подписка')} {sub.tariff_name || t('subscription.defaultName', 'Подписка')}
</span> </span>
<span className="text-xs opacity-60"> <span className="shrink-0 text-xs opacity-60">
{sub.days_left} {t('common.units.days', 'дней')} {sub.days_left} {t('common.units.days', 'дней')}
</span> </span>
</button> </button>

View File

@@ -217,6 +217,11 @@ export const useAuthStore = create<AuthState>()(
const user = await authApi.getMe(); const user = await authApi.getMe();
await get().checkAdminStatus(); await get().checkAdminStatus();
applySession(newToken, refreshToken, user); applySession(newToken, refreshToken, user);
} else if (tokenRefreshManager.lastFailureWasTransport) {
// Backend unreachable, not a rejected token — keep the session
// (don't wipe tokens) so the ServiceUnavailableScreen can resume
// once the backend returns. Just stop the bootstrap loader.
set({ isLoading: false });
} else { } else {
clearSession(); clearSession();
} }
@@ -237,6 +242,11 @@ export const useAuthStore = create<AuthState>()(
} catch { } catch {
clearSession(); clearSession();
} }
} else if (tokenRefreshManager.lastFailureWasTransport) {
// Backend unreachable, not a rejected token — keep the session
// (don't wipe tokens) so the ServiceUnavailableScreen can resume
// once the backend returns. Just stop the bootstrap loader.
set({ isLoading: false });
} else { } else {
clearSession(); clearSession();
} }

View File

@@ -5,6 +5,7 @@ export type BlockingType =
| 'channel_subscription' | 'channel_subscription'
| 'blacklisted' | 'blacklisted'
| 'account_deleted' | 'account_deleted'
| 'backend_unavailable'
| null; | null;
interface MaintenanceInfo { interface MaintenanceInfo {
@@ -54,6 +55,9 @@ interface BlockingState {
setChannelSubscription: (info: ChannelSubscriptionInfo) => void; setChannelSubscription: (info: ChannelSubscriptionInfo) => void;
setBlacklisted: (info: BlacklistedInfo) => void; setBlacklisted: (info: BlacklistedInfo) => void;
setAccountDeleted: (info: AccountDeletedInfo) => void; setAccountDeleted: (info: AccountDeletedInfo) => void;
/** Backend is unreachable (transport-level failure). Renders the full-screen
* ServiceUnavailableScreen. No info payload — the screen shows static copy. */
setBackendUnavailable: () => void;
clearBlocking: () => void; clearBlocking: () => void;
} }
@@ -100,6 +104,15 @@ export const useBlockingStore = create<BlockingState>((set) => ({
blacklistedInfo: null, blacklistedInfo: null,
}), }),
setBackendUnavailable: () =>
set({
blockingType: 'backend_unavailable',
maintenanceInfo: null,
channelInfo: null,
blacklistedInfo: null,
accountDeletedInfo: null,
}),
clearBlocking: () => clearBlocking: () =>
set({ set({
blockingType: null, blockingType: null,

View File

@@ -486,6 +486,8 @@ export interface TicketMediaItem {
type: 'photo' | 'video' | 'document'; type: 'photo' | 'video' | 'document';
file_id: string; file_id: string;
caption?: string | null; caption?: string | null;
/** Signed, expiring download token (response only). */
token?: string | null;
} }
export interface TicketMessage { export interface TicketMessage {
@@ -495,6 +497,8 @@ export interface TicketMessage {
has_media: boolean; has_media: boolean;
media_type: string | null; media_type: string | null;
media_file_id: string | null; media_file_id: string | null;
/** Signed, expiring download token for the legacy single media_file_id. */
media_token?: string | null;
media_caption: string | null; media_caption: string | null;
media_items?: TicketMediaItem[] | null; media_items?: TicketMediaItem[] | null;
created_at: string; created_at: string;

94
src/utils/favicon.ts Normal file
View File

@@ -0,0 +1,94 @@
/**
* Favicon helpers.
*
* The static fallback in index.html is a neutral monogram so the tab is never
* left without an icon. Once branding is known we override it with the custom
* logo (or a brand-letter monogram) via {@link setFavicon}.
*/
/** Point the page favicon at `href`, creating the <link> if needed. */
export function setFavicon(href: string): void {
if (!href) return;
let link = document.querySelector<HTMLLinkElement>("link[rel*='icon']");
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
link.href = href;
}
/**
* Generate a square monogram favicon (SVG data URI) from a brand letter.
* Used when the deployment has no custom logo, so every page still gets an
* icon that matches the brand letter instead of the browser's blank default.
*/
export function letterFaviconDataUri(letter: string): string {
const ch = (letter || 'V').trim().charAt(0).toUpperCase() || 'V';
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">` +
`<rect width="64" height="64" rx="14" fill="#0a0f1a"/>` +
`<text x="50%" y="50%" font-family="Manrope,Arial,sans-serif" font-size="38" ` +
`font-weight="700" fill="#ffffff" text-anchor="middle" dominant-baseline="central">${ch}</text>` +
`</svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
/**
* Render `src` (e.g. a square custom logo) into a rounded-corner PNG data URI
* so the favicon gets the same rounded tile as the header logo, instead of
* hard square corners.
*
* `radiusRatio` 0.3 mirrors the header tile (rounded-linear-lg = 12px on a 40px
* tile). Returns null if the image can't be loaded or the canvas is tainted —
* the caller should fall back to the raw `src`.
*/
export async function roundedFaviconDataUri(
src: string,
size = 64,
radiusRatio = 0.3,
): Promise<string | null> {
if (!src) return null;
try {
const img = await loadImage(src);
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
traceRoundedRect(ctx, size, size * radiusRatio);
ctx.clip();
// object-fit: cover — fill the rounded tile, center-cropping any overflow.
const scale = Math.max(size / img.width, size / img.height);
const dw = img.width * scale;
const dh = img.height * scale;
ctx.drawImage(img, (size - dw) / 2, (size - dh) / 2, dw, dh);
return canvas.toDataURL('image/png');
} catch {
return null;
}
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
/** Trace a centered square rounded-rect path of side `size` and corner `r`. */
function traceRoundedRect(ctx: CanvasRenderingContext2D, size: number, r: number): void {
const radius = Math.min(r, size / 2);
ctx.beginPath();
ctx.moveTo(radius, 0);
ctx.arcTo(size, 0, size, size, radius);
ctx.arcTo(size, size, 0, size, radius);
ctx.arcTo(0, size, 0, 0, radius);
ctx.arcTo(0, 0, size, 0, radius);
ctx.closePath();
}

View File

@@ -5,6 +5,8 @@ import {
deleteCloudStorageItem, deleteCloudStorageItem,
} from '@telegram-apps/sdk-react'; } from '@telegram-apps/sdk-react';
import { isInTelegramWebApp } from '../hooks/useTelegramSDK'; import { isInTelegramWebApp } from '../hooks/useTelegramSDK';
import { API } from '../config/constants';
import { reportPossibleBackendDown } from '../api/health';
const TOKEN_KEYS = { const TOKEN_KEYS = {
ACCESS: 'access_token', ACCESS: 'access_token',
@@ -205,6 +207,17 @@ class TokenRefreshManager {
private subscribers: ((token: string | null) => void)[] = []; private subscribers: ((token: string | null) => void)[] = [];
private refreshEndpoint = '/api/cabinet/auth/refresh'; private refreshEndpoint = '/api/cabinet/auth/refresh';
/**
* True when the most recent refresh failed at the TRANSPORT level (backend
* unreachable / timeout) rather than because the refresh token was rejected.
* Callers read this right after a null `refreshAccessToken()` to decide whether
* to destroy the session (rejected token) or KEEP it so the recoverable
* ServiceUnavailableScreen can resume once the backend returns. Synchronously
* accurate: it is set inside doRefresh before the shared promise resolves, so
* every awaiter (including deduped concurrent callers) reads a consistent value.
*/
lastFailureWasTransport = false;
setRefreshEndpoint(endpoint: string): void { setRefreshEndpoint(endpoint: string): void {
this.refreshEndpoint = endpoint; this.refreshEndpoint = endpoint;
} }
@@ -214,6 +227,8 @@ class TokenRefreshManager {
return this.refreshPromise; return this.refreshPromise;
} }
this.lastFailureWasTransport = false;
const refreshToken = tokenStorage.getRefreshToken(); const refreshToken = tokenStorage.getRefreshToken();
if (!refreshToken) { if (!refreshToken) {
return null; return null;
@@ -238,7 +253,10 @@ class TokenRefreshManager {
const response = await axios.post<{ access_token?: string }>( const response = await axios.post<{ access_token?: string }>(
this.refreshEndpoint, this.refreshEndpoint,
{ refresh_token: refreshToken }, { refresh_token: refreshToken },
{ headers: { 'Content-Type': 'application/json' } }, // Without an explicit timeout this inherits axios's default of 0 (no
// timeout): on the persisted-token bootstrap path an unreachable backend
// would hang the refresh indefinitely, pinning the app on a blank loader.
{ headers: { 'Content-Type': 'application/json' }, timeout: API.TIMEOUT_MS },
); );
const newAccessToken = response.data.access_token; const newAccessToken = response.data.access_token;
@@ -249,7 +267,16 @@ class TokenRefreshManager {
} }
return null; return null;
} catch { } catch (err) {
// This bare-axios call bypasses the apiClient interceptor, so a dead
// backend on the refresh-only bootstrap path would otherwise be swallowed
// here and silently fall through to a login redirect. Distinguish a
// transport-level failure (no response) from an invalid/expired token (a
// 4xx response) and surface the service-unavailable screen for the former.
if (axios.isAxiosError(err) && !err.response) {
this.lastFailureWasTransport = true;
void reportPossibleBackendDown();
}
return null; return null;
} }
} }

4
src/vite-env.d.ts vendored
View File

@@ -7,6 +7,8 @@ interface ImportMetaEnv {
readonly VITE_TELEGRAM_BOT_USERNAME?: string; readonly VITE_TELEGRAM_BOT_USERNAME?: string;
readonly VITE_APP_NAME?: string; readonly VITE_APP_NAME?: string;
readonly VITE_APP_LOGO?: string; readonly VITE_APP_LOGO?: string;
/** Optional override for the backend liveness URL (defaults to `<origin>/health/unified`). */
readonly VITE_HEALTH_URL?: string;
} }
interface ImportMeta { interface ImportMeta {
@@ -17,6 +19,8 @@ interface ImportMeta {
interface TelegramWebAppGlobal { interface TelegramWebAppGlobal {
onEvent?: (event: string, callback: () => void) => void; onEvent?: (event: string, callback: () => void) => void;
offEvent?: (event: string, callback: () => void) => void; offEvent?: (event: string, callback: () => void) => void;
/** Closes the Mini App (injected by telegram-web-app.js). */
close?: () => void;
} }
/** Telegram Login JS SDK — loaded from https://oauth.telegram.org/js/telegram-login.js */ /** Telegram Login JS SDK — loaded from https://oauth.telegram.org/js/telegram-login.js */

View File

@@ -27,6 +27,13 @@ export default defineConfig({
// Strip /api prefix: /api/cabinet/auth -> /cabinet/auth // Strip /api prefix: /api/cabinet/auth -> /cabinet/auth
rewrite: (path) => path.replace(/^\/api/, ''), rewrite: (path) => path.replace(/^\/api/, ''),
}, },
// The backend serves its liveness endpoint at the host root (not under
// /api). Proxy it too so the "service unavailable" detection probe hits the
// real backend in dev instead of the Vite server (which would mask outages).
'/health': {
target: 'http://localhost:8080',
changeOrigin: true,
},
}, },
}, },
build: { build: {