mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 10:03:46 +00:00
Merge pull request #452 from BEDOLAGA-DEV/dev
Release: desktop header redesign, mobile overflow sweep, security, gifts, stats
This commit is contained in:
10
index.html
10
index.html
@@ -2,7 +2,10 @@
|
||||
<html lang="ru" class="dark">
|
||||
<head>
|
||||
<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
|
||||
name="viewport"
|
||||
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="format-detection" content="telephone=no" />
|
||||
<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.gstatic.com" crossorigin />
|
||||
<link
|
||||
@@ -30,7 +33,8 @@
|
||||
<script>
|
||||
// Load Telegram Web App script only inside Telegram environment
|
||||
(function () {
|
||||
var isTelegram = window.TelegramWebviewProxy ||
|
||||
var isTelegram =
|
||||
window.TelegramWebviewProxy ||
|
||||
location.hash.indexOf('tgWebApp') !== -1 ||
|
||||
location.search.indexOf('tgWebApp') !== -1;
|
||||
if (isTelegram) {
|
||||
|
||||
@@ -124,7 +124,21 @@
|
||||
document.getElementById('errorText').textContent = t.error;
|
||||
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('subtitle').textContent = '';
|
||||
document.querySelector('.spinner').style.display = 'none';
|
||||
|
||||
14
src/App.tsx
14
src/App.tsx
@@ -28,6 +28,7 @@ import {
|
||||
ChannelSubscriptionScreen,
|
||||
BlacklistedScreen,
|
||||
AccountDeletedScreen,
|
||||
ServiceUnavailableScreen,
|
||||
} from './components/blocking';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { PermissionRoute } from '@/components/auth/PermissionRoute';
|
||||
@@ -65,6 +66,7 @@ const Connection = lazyWithRetry(() => import('./pages/Connection'));
|
||||
const ConnectionQR = lazyWithRetry(() => import('./pages/ConnectionQR'));
|
||||
const QuickPurchase = lazyWithRetry(() => import('./pages/QuickPurchase'));
|
||||
const PurchaseSuccess = lazyWithRetry(() => import('./pages/PurchaseSuccess'));
|
||||
const GiftClaim = lazyWithRetry(() => import('./pages/GiftClaim'));
|
||||
const RenewSubscription = lazyWithRetry(() => import('./pages/RenewSubscription'));
|
||||
const AutoLogin = lazyWithRetry(() => import('./pages/AutoLogin'));
|
||||
const TopUpMethodSelect = lazyWithRetry(() => import('./pages/TopUpMethodSelect'));
|
||||
@@ -231,6 +233,10 @@ function BlockingOverlay() {
|
||||
return <AccountDeletedScreen />;
|
||||
}
|
||||
|
||||
if (blockingType === 'backend_unavailable') {
|
||||
return <ServiceUnavailableScreen />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -276,6 +282,14 @@ function App() {
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/buy/gift/:token"
|
||||
element={
|
||||
<LazyPage>
|
||||
<GiftClaim />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/buy/:slug"
|
||||
element={
|
||||
|
||||
@@ -15,9 +15,10 @@ import { ThemeColorsProvider } from './providers/ThemeColorsProvider';
|
||||
import { WebSocketProvider } from './providers/WebSocketProvider';
|
||||
import { ToastProvider } from './components/Toast';
|
||||
import { TooltipProvider } from './components/primitives/Tooltip';
|
||||
import { isInTelegramWebApp } from './hooks/useTelegramSDK';
|
||||
import { isInTelegramWebApp, closeTelegramApp } from './hooks/useTelegramSDK';
|
||||
import { getFallbackParentPath } from './utils/navigation';
|
||||
import { subscriptionApi } from './api/subscription';
|
||||
import { useBlockingStore } from './store/blocking';
|
||||
|
||||
const TWEMOJI_OPTIONS = { className: 'twemoji', folder: 'svg', ext: '.svg' } as const;
|
||||
|
||||
@@ -44,6 +45,13 @@ function TelegramBackButton() {
|
||||
const pathnameRef = useRef(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
|
||||
// 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
|
||||
@@ -89,6 +97,15 @@ function TelegramBackButton() {
|
||||
subsCountRef.current = subsCount;
|
||||
|
||||
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);
|
||||
// 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
|
||||
@@ -103,10 +120,17 @@ function TelegramBackButton() {
|
||||
showBackButton();
|
||||
}
|
||||
} catch {}
|
||||
}, [location, listRedirectsToDetail]);
|
||||
}, [location, listRedirectsToDetail, blockingType]);
|
||||
|
||||
// Stable handler — ref prevents re-subscription on every render
|
||||
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
|
||||
// directly on this route via a deep-link — navigate(-1) is a no-op, so fall
|
||||
// back to a sensible parent route instead.
|
||||
|
||||
@@ -87,7 +87,10 @@ export const authApi = {
|
||||
message: string;
|
||||
email?: string;
|
||||
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', {
|
||||
email,
|
||||
@@ -97,6 +100,14 @@ export const authApi = {
|
||||
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: {
|
||||
email: string;
|
||||
password: string;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
safeRedirectToLogin,
|
||||
} from '../utils/token';
|
||||
import { useBlockingStore } from '../store/blocking';
|
||||
import { reportPossibleBackendDown, markBackendReached } from './health';
|
||||
import { API } from '../config/constants';
|
||||
|
||||
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();
|
||||
if (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 {
|
||||
tokenStorage.clearTokens();
|
||||
safeRedirectToLogin();
|
||||
@@ -195,10 +202,28 @@ export function isAccountDeletedError(
|
||||
}
|
||||
|
||||
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) => {
|
||||
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)) {
|
||||
const detail = (error.response?.data as { detail: MaintenanceError }).detail;
|
||||
useBlockingStore.getState().setMaintenance({
|
||||
@@ -256,6 +281,9 @@ apiClient.interceptors.response.use(
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
}
|
||||
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 {
|
||||
tokenStorage.clearTokens();
|
||||
safeRedirectToLogin();
|
||||
|
||||
@@ -74,6 +74,7 @@ export interface GiftPurchaseStatus {
|
||||
status: GiftPurchaseStatusValue;
|
||||
is_gift: boolean;
|
||||
is_code_only: boolean;
|
||||
is_claimable: boolean;
|
||||
purchase_token: string | null;
|
||||
recipient_contact_value: string | null;
|
||||
gift_message: string | null;
|
||||
|
||||
126
src/api/health.ts
Normal file
126
src/api/health.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ export interface LandingTariff {
|
||||
device_limit: number;
|
||||
tier_level: number;
|
||||
periods: LandingTariffPeriod[];
|
||||
/** Daily tariff: the single purchasable period is 1 day, priced per day. */
|
||||
is_daily?: boolean;
|
||||
daily_price_kopeks?: number;
|
||||
}
|
||||
|
||||
export interface LandingPaymentMethodSubOption {
|
||||
@@ -134,6 +137,21 @@ export interface PurchaseStatus {
|
||||
auto_login_token: string | null;
|
||||
recipient_in_bot: boolean | 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) */
|
||||
@@ -279,6 +297,18 @@ export const landingApi = {
|
||||
const response = await apiClient.post(`/cabinet/landing/activate/${token}`);
|
||||
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 {
|
||||
@@ -296,10 +326,22 @@ export interface LandingTariffStat {
|
||||
revenue_kopeks: number;
|
||||
}
|
||||
|
||||
export interface LandingPaymentMethodStat {
|
||||
method: string;
|
||||
purchases: number;
|
||||
revenue_kopeks: number;
|
||||
}
|
||||
|
||||
export interface LandingSourceStat {
|
||||
source: string;
|
||||
purchases: number;
|
||||
}
|
||||
|
||||
export interface LandingStatsResponse {
|
||||
total_purchases: number;
|
||||
total_revenue_kopeks: number;
|
||||
total_gifts: number;
|
||||
total_gifts_claimed: number;
|
||||
total_regular: number;
|
||||
avg_purchase_kopeks: number;
|
||||
total_created: number;
|
||||
@@ -307,6 +349,8 @@ export interface LandingStatsResponse {
|
||||
conversion_rate: number;
|
||||
daily_stats: LandingDailyStat[];
|
||||
tariff_stats: LandingTariffStat[];
|
||||
payment_method_stats: LandingPaymentMethodStat[];
|
||||
source_stats: LandingSourceStat[];
|
||||
}
|
||||
|
||||
export type PurchaseItemStatus =
|
||||
|
||||
@@ -79,9 +79,11 @@ export const ticketsApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get media URL for display
|
||||
getMediaUrl: (fileId: string): string => {
|
||||
// Get media URL for display. The signed `token` comes from the ticket
|
||||
// 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 || '';
|
||||
return `${baseUrl}/cabinet/media/${fileId}`;
|
||||
const suffix = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
return `${baseUrl}/cabinet/media/${fileId}${suffix}`;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -60,6 +60,9 @@ export interface SpinResult {
|
||||
|
||||
export interface SpinHistoryItem {
|
||||
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_amount: number;
|
||||
prize_type: string;
|
||||
|
||||
@@ -256,9 +256,13 @@ export default function SuccessNotificationModal() {
|
||||
|
||||
{/* Tariff name */}
|
||||
{data.tariffName && (
|
||||
<div className="flex items-center justify-between rounded-xl bg-dark-800/50 px-4 py-3">
|
||||
<span className="text-dark-400">{t('successNotification.tariff', 'Tariff')}</span>
|
||||
<span className="font-semibold text-dark-100">{data.tariffName}</span>
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl bg-dark-800/50 px-4 py-3">
|
||||
<span className="shrink-0 text-dark-400">
|
||||
{t('successNotification.tariff', 'Tariff')}
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-semibold text-dark-100">
|
||||
{data.tariffName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -268,9 +268,9 @@ export function AnalyticsTab() {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<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')}
|
||||
</span>
|
||||
@@ -280,7 +280,7 @@ export function AnalyticsTab() {
|
||||
setEditingGoogleId(true);
|
||||
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" />
|
||||
</button>
|
||||
@@ -322,9 +322,9 @@ export function AnalyticsTab() {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<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')}
|
||||
</span>
|
||||
@@ -334,7 +334,7 @@ export function AnalyticsTab() {
|
||||
setEditingGoogleLabel(true);
|
||||
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" />
|
||||
</button>
|
||||
|
||||
@@ -180,8 +180,8 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg text-dark-100">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 truncate text-lg text-dark-100">
|
||||
{branding?.name || t('admin.settings.notSpecified')}
|
||||
</span>
|
||||
<button
|
||||
@@ -189,7 +189,7 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
setNewName(branding?.name ?? '');
|
||||
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 />
|
||||
</button>
|
||||
|
||||
@@ -93,7 +93,7 @@ export function SubscriptionSubRow({
|
||||
/>
|
||||
</svg>
|
||||
</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 || '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -421,16 +421,16 @@ export function InfoTab(props: InfoTabProps) {
|
||||
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"
|
||||
>
|
||||
<div className="flex 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 min-w-0 items-center gap-2">
|
||||
<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] || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-dark-100">{ref.full_name}</div>
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
</div>
|
||||
<div className="text-xs text-dark-400">
|
||||
<div className="shrink-0 text-xs text-dark-400">
|
||||
{formatWithCurrency(ref.total_spent_kopeks / 100)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -462,13 +462,13 @@ export function ReferralsTab({ user, userId, onUserRefresh }: ReferralsTabProps)
|
||||
>
|
||||
<button
|
||||
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()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-dark-100">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-dark-100">
|
||||
{ref.full_name || ref.username || `ID: ${ref.id}`}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
@@ -479,7 +479,7 @@ export function ReferralsTab({ user, userId, onUserRefresh }: ReferralsTabProps)
|
||||
<button
|
||||
onClick={() => handleRemoveReferral(ref.id)}
|
||||
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')}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlatform } from '@/platform';
|
||||
import { InfoIcon } from '@/components/icons';
|
||||
import { Button } from '@/components/primitives';
|
||||
import { useBlockingStore } from '../../store/blocking';
|
||||
import { useFocusTrap } from '../../hooks/useFocusTrap';
|
||||
import BlockingShell from './BlockingShell';
|
||||
|
||||
/**
|
||||
* Full-screen block shown when the backend returns
|
||||
@@ -45,48 +47,26 @@ export default function AccountDeletedScreen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={screenRef}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="account-deleted-title"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"
|
||||
>
|
||||
<div className="w-full max-w-md text-center">
|
||||
<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">
|
||||
<BlockingShell
|
||||
screenRef={screenRef}
|
||||
titleId="account-deleted-title"
|
||||
accent="warning"
|
||||
icon={<InfoIcon className="h-9 w-9" />}
|
||||
title={t('blocking.accountDeleted.title')}
|
||||
description={t('blocking.accountDeleted.description')}
|
||||
footer={t('blocking.accountDeleted.hint')}
|
||||
actions={
|
||||
<>
|
||||
{deepLink && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Button variant="primary" size="lg" fullWidth onClick={handleOpenBot}>
|
||||
{t('blocking.accountDeleted.openBot')}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Button variant="secondary" size="lg" fullWidth onClick={handleRetry}>
|
||||
{t('blocking.accountDeleted.retry')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-sm text-dark-500">{t('blocking.accountDeleted.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useBlockingStore } from '../../store/blocking';
|
||||
import { useFocusTrap } from '../../hooks/useFocusTrap';
|
||||
import { BanIcon } from '@/components/icons';
|
||||
import BlockingShell from './BlockingShell';
|
||||
|
||||
export default function BlacklistedScreen() {
|
||||
const { t } = useTranslation();
|
||||
@@ -9,40 +10,23 @@ export default function BlacklistedScreen() {
|
||||
const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={screenRef}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="blacklisted-title"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"
|
||||
<BlockingShell
|
||||
screenRef={screenRef}
|
||||
titleId="blacklisted-title"
|
||||
accent="error"
|
||||
icon={<BanIcon className="h-9 w-9" />}
|
||||
title={t('blocking.blacklisted.title')}
|
||||
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 && (
|
||||
<div className="mb-6 rounded-xl bg-dark-800/50 p-4">
|
||||
<p className="mb-1 text-sm text-dark-500">{t('blocking.blacklisted.reason')}:</p>
|
||||
<p className="text-dark-300">{blacklistedInfo.message}</p>
|
||||
<div className="rounded-xl border border-dark-700/30 bg-dark-800/50 p-4">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-dark-500">
|
||||
{t('blocking.blacklisted.reason')}:
|
||||
</p>
|
||||
<p className="text-sm text-dark-300">{blacklistedInfo.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-8 text-sm text-dark-500">{t('blocking.blacklisted.contactSupport')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</BlockingShell>
|
||||
);
|
||||
}
|
||||
|
||||
196
src/components/blocking/BlockingShell.tsx
Normal file
196
src/components/blocking/BlockingShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 { useBlockingStore } from '../../store/blocking';
|
||||
import { apiClient, isChannelSubscriptionError } from '../../api/client';
|
||||
import { usePlatform } from '../../platform';
|
||||
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;
|
||||
|
||||
@@ -84,49 +86,63 @@ export default function ChannelSubscriptionScreen() {
|
||||
|
||||
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 (
|
||||
<div
|
||||
ref={screenRef}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="channel-sub-title"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"
|
||||
<BlockingShell
|
||||
screenRef={screenRef}
|
||||
titleId="channel-sub-title"
|
||||
accent="info"
|
||||
ariaLive="polite"
|
||||
icon={<TelegramIcon className="h-9 w-9" />}
|
||||
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) */}
|
||||
{channels.length > 0 && (
|
||||
<div className="mb-6 space-y-3">
|
||||
<div className="space-y-2">
|
||||
{channels.map((ch) => (
|
||||
<div
|
||||
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 && (
|
||||
<button
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
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')}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -135,67 +151,22 @@ export default function ChannelSubscriptionScreen() {
|
||||
|
||||
{/* Fallback: single channel (legacy) */}
|
||||
{channels.length === 0 && channelInfo?.channel_link && (
|
||||
<button
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
fullWidth
|
||||
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')}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</BlockingShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useBlockingStore } from '../../store/blocking';
|
||||
import { useFocusTrap } from '../../hooks/useFocusTrap';
|
||||
import { WrenchIcon } from '@/components/icons';
|
||||
import BlockingShell from './BlockingShell';
|
||||
|
||||
export default function MaintenanceScreen() {
|
||||
const { t } = useTranslation();
|
||||
@@ -9,58 +10,24 @@ export default function MaintenanceScreen() {
|
||||
const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={screenRef}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="maintenance-title"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"
|
||||
<BlockingShell
|
||||
screenRef={screenRef}
|
||||
titleId="maintenance-title"
|
||||
accent="warning"
|
||||
icon={<WrenchIcon className="h-9 w-9" />}
|
||||
title={t('blocking.maintenance.title')}
|
||||
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 && (
|
||||
<div className="mb-6 rounded-xl bg-dark-800/50 p-4">
|
||||
<p className="mb-1 text-sm text-dark-500">{t('blocking.maintenance.reason')}:</p>
|
||||
<p className="text-dark-300">{maintenanceInfo.reason}</p>
|
||||
<div className="rounded-xl border border-dark-700/30 bg-dark-800/50 p-4">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-dark-500">
|
||||
{t('blocking.maintenance.reason')}:
|
||||
</p>
|
||||
<p className="text-sm text-dark-300">{maintenanceInfo.reason}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</BlockingShell>
|
||||
);
|
||||
}
|
||||
|
||||
103
src/components/blocking/ServiceUnavailableScreen.tsx
Normal file
103
src/components/blocking/ServiceUnavailableScreen.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export { default as MaintenanceScreen } from './MaintenanceScreen';
|
||||
export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen';
|
||||
export { default as BlacklistedScreen } from './BlacklistedScreen';
|
||||
export { default as AccountDeletedScreen } from './AccountDeletedScreen';
|
||||
export { default as ServiceUnavailableScreen } from './ServiceUnavailableScreen';
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
} from '@/types';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock, BlockButtons } from './blocks';
|
||||
import type { BlockRendererProps } from './blocks';
|
||||
import type { BlockRendererProps, RenderBlock } from './blocks';
|
||||
import TvQuickConnect from './TvQuickConnect';
|
||||
import { BackIcon, BookOpenIcon, ChevronIcon } from '@/components/icons';
|
||||
|
||||
@@ -34,6 +34,14 @@ const RENDERERS: Record<string, React.ComponentType<BlockRendererProps>> = {
|
||||
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 {
|
||||
appConfig: AppConfig;
|
||||
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 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 currentPlatformData = currentPlatformKey
|
||||
@@ -185,6 +194,21 @@ export default function InstallationGuide({
|
||||
const blockType = appConfig.uiConfig?.installationGuidesBlockType || 'cards';
|
||||
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 (
|
||||
<div className="space-y-6 pb-6">
|
||||
{/* Header + platform dropdown */}
|
||||
@@ -242,7 +266,12 @@ export default function InstallationGuide({
|
||||
setActivePlatformKey(newPlatform);
|
||||
const data = appConfig.platforms[newPlatform] as RemnawavePlatformData | undefined;
|
||||
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);
|
||||
}
|
||||
}}
|
||||
@@ -312,12 +341,12 @@ export default function InstallationGuide({
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Blocks — for TV: first block, Quick Connect, last block */}
|
||||
{selectedApp && isTvPlatform && appConfig.subscriptionUrl ? (
|
||||
<>
|
||||
{selectedApp.blocks.length > 0 && (
|
||||
{/* Blocks rendered in the panel's active style. For the Happ Android TV
|
||||
app the TV connect widget is injected into a step (customNode), so it
|
||||
adapts to that style instead of breaking it. */}
|
||||
{selectedApp && (
|
||||
<Renderer
|
||||
blocks={selectedApp.blocks.slice(0, 1)}
|
||||
blocks={renderBlocks}
|
||||
isMobile={isMobile}
|
||||
isLight={isLight}
|
||||
getLocalizedText={getLocalizedText}
|
||||
@@ -325,28 +354,6 @@ export default function InstallationGuide({
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
isQrScannerSupported,
|
||||
retrieveLaunchParams,
|
||||
} from '@telegram-apps/sdk-react';
|
||||
import { blockButtonClass } from './blocks/buttonStyles';
|
||||
|
||||
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]);
|
||||
|
||||
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
|
||||
? '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';
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Code input */}
|
||||
<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>
|
||||
// Full-width buttons in the same outlined-accent language as the config blocks
|
||||
// (so the Happ TV block adapts to the subscription-page styles, not a one-off).
|
||||
const actionBtnClass = `${blockButtonClass('light', isLight)} flex w-full items-center justify-center`;
|
||||
|
||||
<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
|
||||
type="text"
|
||||
maxLength={5}
|
||||
@@ -240,52 +221,23 @@ export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) {
|
||||
<button
|
||||
onClick={() => sendToTV(code)}
|
||||
disabled={sending || code.length !== 5}
|
||||
className="btn-primary w-full justify-center py-3 disabled:opacity-50"
|
||||
className={`${actionBtnClass} disabled:opacity-50`}
|
||||
>
|
||||
{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')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR Scanner */}
|
||||
<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.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">
|
||||
{/* QR scan */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-dark-200">
|
||||
{t('subscription.tvQuickConnect.scanTitle')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-dark-400">
|
||||
{t('subscription.tvQuickConnect.scanDescription')}
|
||||
</p>
|
||||
|
||||
{!scanning && (
|
||||
<button onClick={startScan} className="btn-secondary mt-3 w-full justify-center py-3">
|
||||
<button onClick={startScan} className={actionBtnClass}>
|
||||
<svg
|
||||
className="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
@@ -307,17 +259,15 @@ export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) {
|
||||
{t('subscription.tvQuickConnect.scanBtn')}
|
||||
</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" />
|
||||
{scanning && (
|
||||
<button onClick={stopScan} className="btn-secondary w-full justify-center py-2.5">
|
||||
<button onClick={stopScan} className={actionBtnClass}>
|
||||
{t('subscription.tvQuickConnect.stopScan')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
|
||||
@@ -15,7 +15,11 @@ export function AccordionBlock({
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(0);
|
||||
|
||||
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;
|
||||
@@ -68,6 +72,7 @@ export function AccordionBlock({
|
||||
{getLocalizedText(block.description)}
|
||||
</p>
|
||||
{renderBlockButtons(block.buttons, 'light')}
|
||||
{block.customNode}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { CheckIcon, CopyIcon } from '@/components/icons';
|
||||
import type { RemnawaveButtonClient, LocalizedText } from '@/types';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { blockButtonClass } from './buttonStyles';
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'];
|
||||
@@ -57,14 +58,7 @@ export function BlockButtons({
|
||||
|
||||
if (!buttons || buttons.length === 0) return null;
|
||||
|
||||
const baseClass =
|
||||
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';
|
||||
const baseClass = blockButtonClass(variant, isLight);
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
|
||||
@@ -11,7 +11,11 @@ export function CardsBlock({
|
||||
renderBlockButtons,
|
||||
}: BlockRendererProps) {
|
||||
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;
|
||||
@@ -43,6 +47,7 @@ export function CardsBlock({
|
||||
{getLocalizedText(block.description)}
|
||||
</p>
|
||||
{renderBlockButtons(block.buttons, 'light')}
|
||||
{block.customNode}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,11 @@ export function MinimalBlock({
|
||||
renderBlockButtons,
|
||||
}: BlockRendererProps) {
|
||||
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;
|
||||
@@ -44,6 +48,7 @@ export function MinimalBlock({
|
||||
{getLocalizedText(block.description)}
|
||||
</p>
|
||||
{renderBlockButtons(block.buttons, 'subtle')}
|
||||
{block.customNode}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -11,7 +11,11 @@ export function TimelineBlock({
|
||||
renderBlockButtons,
|
||||
}: BlockRendererProps) {
|
||||
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;
|
||||
@@ -43,6 +47,7 @@ export function TimelineBlock({
|
||||
{getLocalizedText(block.description)}
|
||||
</p>
|
||||
{renderBlockButtons(block.buttons, 'light')}
|
||||
{block.customNode}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
16
src/components/connection/blocks/buttonStyles.ts
Normal file
16
src/components/connection/blocks/buttonStyles.ts
Normal 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';
|
||||
}
|
||||
@@ -3,4 +3,4 @@ export { TimelineBlock } from './TimelineBlock';
|
||||
export { AccordionBlock } from './AccordionBlock';
|
||||
export { MinimalBlock } from './MinimalBlock';
|
||||
export { BlockButtons } from './BlockButtons';
|
||||
export type { BlockRendererProps } from './types';
|
||||
export type { BlockRendererProps, RenderBlock } from './types';
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { ReactNode } from 'react';
|
||||
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 {
|
||||
blocks: RemnawaveBlockClient[];
|
||||
blocks: RenderBlock[];
|
||||
isMobile: boolean;
|
||||
isLight: boolean;
|
||||
getLocalizedText: (text: LocalizedText | undefined) => string;
|
||||
|
||||
@@ -283,7 +283,7 @@ export default function SubscriptionCardActive({
|
||||
>
|
||||
{t('dashboard.tariff')}
|
||||
</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')}
|
||||
</div>
|
||||
<div className="mt-0.5 font-mono text-[10px] text-dark-50/30">
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
PiFlag,
|
||||
PiArrowCounterClockwise,
|
||||
PiArrowClockwise,
|
||||
PiCloudWarning,
|
||||
PiRocket,
|
||||
PiFloppyDisk,
|
||||
PiPaperPlaneTilt,
|
||||
@@ -361,6 +362,10 @@ export const RestartIcon = ({ className }: IconProps) => (
|
||||
<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) => (
|
||||
<PiRocket className={cn('h-5 w-5', className)} />
|
||||
);
|
||||
|
||||
@@ -343,9 +343,11 @@ export function AppHeader({
|
||||
>
|
||||
<UserIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-dark-100">{displayName(user)}</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-dark-100">
|
||||
{displayName(user)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-dark-500">
|
||||
@{user?.username || `ID: ${user?.telegram_id}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useLocation, Link } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { useAuthStore } from '@/store/auth';
|
||||
import { useHaptic } from '@/platform';
|
||||
@@ -109,11 +110,13 @@ export function AppShell({ children }: AppShellProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Desktop navigation items
|
||||
const desktopNavItems = [
|
||||
// Desktop navigation — labels always visible (no hover-reveal gimmick)
|
||||
const desktopNav = [
|
||||
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
|
||||
{ path: '/subscriptions', label: t('nav.subscription'), icon: SubscriptionIcon },
|
||||
{ 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: '/info', label: t('nav.info'), icon: InfoIcon },
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
|
||||
@@ -128,6 +131,51 @@ export function AppShell({ children }: AppShellProps) {
|
||||
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
|
||||
|
||||
return (
|
||||
@@ -143,9 +191,17 @@ export function AppShell({ children }: AppShellProps) {
|
||||
|
||||
{/* 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">
|
||||
<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 */}
|
||||
<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">
|
||||
<span
|
||||
className={cn(
|
||||
@@ -169,88 +225,21 @@ export function AppShell({ children }: AppShellProps) {
|
||||
<span className="text-base font-semibold text-dark-100">{appName}</span>
|
||||
</Link>
|
||||
|
||||
{/* Center Navigation */}
|
||||
<nav className="flex min-w-0 items-center gap-1">
|
||||
{desktopNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
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>
|
||||
)}
|
||||
{/* Navigation — единая «капсула» (segmented control): все пункты видны
|
||||
всегда, без скролла/сжатия/сворачивания. Центрируется средней
|
||||
колонкой grid (justify-self-center), а не auto-margin'ами. */}
|
||||
<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">
|
||||
{desktopNav.map((item) => renderNavLink(item.path, item.label, item.icon))}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="mx-1 h-5 w-px shrink-0 bg-dark-700" />
|
||||
<Link
|
||||
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>
|
||||
<div className="mx-1 h-5 w-px shrink-0 bg-dark-700/60" />
|
||||
{renderNavLink('/admin', t('admin.nav.title'), ShieldIcon, true)}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Right side actions */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{/* Right side actions — правая колонка grid, прижата к краю, не сжимается */}
|
||||
<div className="flex shrink-0 items-center gap-2 justify-self-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
haptic.impact('light');
|
||||
@@ -258,7 +247,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
}}
|
||||
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',
|
||||
!canToggleTheme && 'pointer-events-none invisible',
|
||||
!canToggleTheme && 'hidden',
|
||||
)}
|
||||
aria-label={
|
||||
isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export { AppShell } from './AppShell';
|
||||
export { DesktopSidebar } from './DesktopSidebar';
|
||||
export { MobileBottomNav } from './MobileBottomNav';
|
||||
export { AppHeader } from './AppHeader';
|
||||
|
||||
@@ -400,8 +400,8 @@ export function ClassicPurchaseWizard({
|
||||
>
|
||||
{selectedServers.includes(server.uuid) && <CheckIcon />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-dark-100">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-dark-100">
|
||||
<Twemoji options={{ className: 'twemoji', folder: 'svg', ext: '.svg' }}>
|
||||
{server.name}
|
||||
</Twemoji>
|
||||
|
||||
@@ -105,9 +105,9 @@ export function TariffPurchaseForm({
|
||||
|
||||
return (
|
||||
<div ref={ref} className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-dark-100">{tariff.name}</h3>
|
||||
<button onClick={onBack} className="text-dark-400 hover:text-dark-200">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="min-w-0 truncate text-lg font-medium text-dark-100">{tariff.name}</h3>
|
||||
<button onClick={onBack} className="shrink-0 text-dark-400 hover:text-dark-200">
|
||||
← {t('common.back')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -168,15 +168,15 @@ export function ServerManagementSheet({
|
||||
: 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'
|
||||
} ${!country.is_available && !isCurrentlyConnected ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="shrink-0 text-lg">
|
||||
{willBeAdded ? '➕' : willBeRemoved ? '➖' : isSelected ? '✅' : '⚪'}
|
||||
</span>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 font-medium text-dark-100">
|
||||
{country.name}
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2 font-medium text-dark-100">
|
||||
<span className="truncate">{country.name}</span>
|
||||
{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}%
|
||||
</span>
|
||||
)}
|
||||
@@ -217,7 +217,7 @@ export function ServerManagementSheet({
|
||||
</div>
|
||||
</div>
|
||||
{country.country_code && (
|
||||
<span className="text-xl">{getFlagEmoji(country.country_code)}</span>
|
||||
<span className="shrink-0 text-xl">{getFlagEmoji(country.country_code)}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,25 @@ import type { Tariff } from '../../../types';
|
||||
// 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 {
|
||||
open: boolean;
|
||||
tariffId: number | null;
|
||||
@@ -69,16 +88,10 @@ export function SwitchTariffSheet({
|
||||
navigate('/subscriptions', { replace: true });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
// Backend signal: the subscription lapsed mid-flight. Hand the
|
||||
// selected tariff back to the parent so it can open the regular
|
||||
// purchase form instead.
|
||||
if (error instanceof AxiosError) {
|
||||
const detail = error.response?.data?.detail;
|
||||
if (
|
||||
typeof detail === 'object' &&
|
||||
detail?.error_code === 'subscription_expired' &&
|
||||
detail?.use_purchase_flow === true
|
||||
) {
|
||||
// Backend signal: this subscription can't be switched (it lapsed, or it's
|
||||
// a trial). Hand the selected tariff back to the parent so it opens the
|
||||
// regular purchase form instead.
|
||||
if (shouldUsePurchaseFlow(error)) {
|
||||
const targetTariff = tariffs.find((tariff) => tariff.id === tariffId);
|
||||
if (targetTariff) {
|
||||
onClose();
|
||||
@@ -86,7 +99,6 @@ export function SwitchTariffSheet({
|
||||
queryClient.invalidateQueries({ queryKey: ['purchase-options', subscriptionId] });
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -130,15 +142,15 @@ export function SwitchTariffSheet({
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between text-dark-300">
|
||||
<span>{t('subscription.switchTariff.currentTariff')}</span>
|
||||
<span className="font-medium text-dark-100">
|
||||
<div className="flex justify-between gap-2 text-dark-300">
|
||||
<span className="shrink-0">{t('subscription.switchTariff.currentTariff')}</span>
|
||||
<span className="min-w-0 truncate font-medium text-dark-100">
|
||||
{switchPreview.current_tariff_name || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-dark-300">
|
||||
<span>{t('subscription.switchTariff.newTariff')}</span>
|
||||
<span className="font-medium text-accent-400">
|
||||
<div className="flex justify-between gap-2 text-dark-300">
|
||||
<span className="shrink-0">{t('subscription.switchTariff.newTariff')}</span>
|
||||
<span className="min-w-0 truncate font-medium text-accent-400">
|
||||
{switchPreview.new_tariff_name}
|
||||
</span>
|
||||
</div>
|
||||
@@ -213,15 +225,10 @@ export function SwitchTariffSheet({
|
||||
|
||||
{switchMutation.isError &&
|
||||
(() => {
|
||||
// Suppress the toast when the subscription_expired
|
||||
// fallback already triggered: the parent is now
|
||||
// showing the regular purchase form, surfacing the
|
||||
// raw axios message here would be misleading.
|
||||
const detail =
|
||||
switchMutation.error instanceof AxiosError
|
||||
? switchMutation.error.response?.data?.detail
|
||||
: null;
|
||||
if (typeof detail === 'object' && detail?.error_code === 'subscription_expired') {
|
||||
// Suppress the toast when the purchase-flow fallback already
|
||||
// triggered (expired / trial): the parent is now showing the
|
||||
// regular purchase form, so the raw axios message would mislead.
|
||||
if (shouldUsePurchaseFlow(switchMutation.error)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -9,12 +9,15 @@ export interface MediaItem {
|
||||
type: string;
|
||||
file_id: string;
|
||||
caption?: string | null;
|
||||
/** Signed, expiring download token from the ticket response. */
|
||||
token?: string | null;
|
||||
}
|
||||
|
||||
interface MessageLike {
|
||||
has_media?: boolean;
|
||||
media_type?: string | null;
|
||||
media_file_id?: string | null;
|
||||
media_token?: string | null;
|
||||
media_caption?: string | null;
|
||||
media_items?: MediaItem[] | null;
|
||||
}
|
||||
@@ -33,6 +36,7 @@ function getItems(message: MessageLike): MediaItem[] {
|
||||
type: message.media_type,
|
||||
file_id: message.media_file_id,
|
||||
caption: message.media_caption,
|
||||
token: message.media_token,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -113,7 +117,7 @@ export function MessageMediaGrid({
|
||||
onClick={() => openFullscreen(originalIdx)}
|
||||
>
|
||||
<img
|
||||
src={ticketsApi.getMediaUrl(item.file_id)}
|
||||
src={ticketsApi.getMediaUrl(item.file_id, item.token)}
|
||||
alt={item.caption || 'Attached photo'}
|
||||
className="h-full w-full object-cover transition-opacity group-hover:opacity-90"
|
||||
loading="lazy"
|
||||
@@ -131,7 +135,7 @@ export function MessageMediaGrid({
|
||||
|
||||
{/* Non-photo media rendered inline */}
|
||||
{otherItems.map((item) => {
|
||||
const mediaUrl = ticketsApi.getMediaUrl(item.file_id);
|
||||
const mediaUrl = ticketsApi.getMediaUrl(item.file_id, item.token);
|
||||
if (item.type === 'video') {
|
||||
return (
|
||||
<div key={item.file_id}>
|
||||
@@ -203,7 +207,10 @@ export function MessageMediaGrid({
|
||||
onClick={closeFullscreen}
|
||||
>
|
||||
<img
|
||||
src={ticketsApi.getMediaUrl(photoItems[fullscreenIndex].file_id)}
|
||||
src={ticketsApi.getMediaUrl(
|
||||
photoItems[fullscreenIndex].file_id,
|
||||
photoItems[fullscreenIndex].token,
|
||||
)}
|
||||
alt={photoItems[fullscreenIndex].caption || 'Attached photo'}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
style={{ touchAction: 'pinch-zoom' }}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
preloadLogo,
|
||||
isLogoPreloaded,
|
||||
} from '@/api/branding';
|
||||
import { setFavicon, letterFaviconDataUri, roundedFaviconDataUri } from '@/utils/favicon';
|
||||
|
||||
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
|
||||
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
|
||||
@@ -42,18 +43,21 @@ export function useBranding() {
|
||||
document.title = appName || 'VPN';
|
||||
}, [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(() => {
|
||||
if (!logoUrl) return;
|
||||
|
||||
const link =
|
||||
document.querySelector<HTMLLinkElement>("link[rel*='icon']") ||
|
||||
document.createElement('link');
|
||||
link.type = 'image/x-icon';
|
||||
link.rel = 'shortcut icon';
|
||||
link.href = logoUrl;
|
||||
document.head.appendChild(link);
|
||||
}, [logoUrl]);
|
||||
if (!logoUrl) {
|
||||
setFavicon(letterFaviconDataUri(logoLetter));
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
roundedFaviconDataUri(logoUrl).then((rounded) => {
|
||||
if (!cancelled) setFavicon(rounded || logoUrl);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [logoUrl, logoLetter]);
|
||||
|
||||
// Fullscreen setting from server
|
||||
const { data: fullscreenSetting } = useQuery({
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
retrieveLaunchParams,
|
||||
retrieveRawInitData,
|
||||
themeParamsState,
|
||||
closeMiniApp as sdkCloseMiniApp,
|
||||
postEvent,
|
||||
} from '@telegram-apps/sdk-react';
|
||||
|
||||
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled';
|
||||
@@ -50,6 +52,34 @@ export function isInTelegramWebApp(): boolean {
|
||||
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 {
|
||||
try {
|
||||
const { tgWebAppPlatform } = retrieveLaunchParams();
|
||||
|
||||
@@ -579,7 +579,7 @@
|
||||
"buyTraffic": "Buy more traffic",
|
||||
"currentTrafficLimit": "Current limit: {{limit}} GB (used {{used}} GB)",
|
||||
"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",
|
||||
"unlimited": "Unlimited",
|
||||
"buyTrafficGb": "Buy {{gb}} GB",
|
||||
@@ -1756,7 +1756,8 @@
|
||||
"dailyLimit": "Daily Spin Limit (0 = unlimited)",
|
||||
"minSubDays": "Min subscription days for day payment",
|
||||
"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.",
|
||||
"prizes": {
|
||||
@@ -1783,7 +1784,9 @@
|
||||
"emoji": "Emoji",
|
||||
"color": "Color",
|
||||
"active": "Active",
|
||||
"worth": "Worth"
|
||||
"worth": "Worth",
|
||||
"probability": "Manual probability (0–1)",
|
||||
"probabilityHint": "Empty = auto by RTP"
|
||||
},
|
||||
"promo": {
|
||||
"title": "Promo Code Settings",
|
||||
@@ -1801,7 +1804,9 @@
|
||||
"times": "times",
|
||||
"times_one": "{{count}} time",
|
||||
"times_other": "{{count}} times",
|
||||
"topWins": "Top Wins"
|
||||
"topWins": "Top Wins",
|
||||
"loadError": "Failed to load statistics",
|
||||
"empty": "No spins yet"
|
||||
}
|
||||
},
|
||||
"broadcasts": {
|
||||
@@ -3978,6 +3983,12 @@
|
||||
"dailyChart": "Purchases & Revenue by Day",
|
||||
"tariffChart": "Tariff Distribution",
|
||||
"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",
|
||||
"revenueLabel": "Revenue",
|
||||
"gifts": "Gifts",
|
||||
@@ -4588,7 +4599,13 @@
|
||||
"vk": "VK"
|
||||
},
|
||||
"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": {
|
||||
"colors": "Theme Colors",
|
||||
@@ -4804,6 +4821,14 @@
|
||||
"openBot": "Open the bot",
|
||||
"retry": "I pressed /start, 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": {
|
||||
@@ -4834,6 +4859,36 @@
|
||||
"merging": "Merging..."
|
||||
},
|
||||
"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",
|
||||
"forMe": "For me",
|
||||
"asGift": "As a gift",
|
||||
@@ -5103,6 +5158,7 @@
|
||||
"subscriptions": {
|
||||
"buy": "Buy subscription",
|
||||
"buyAnother": "Buy another subscription",
|
||||
"browsePlans": "View plans and subscribe",
|
||||
"empty": "You have no subscriptions yet",
|
||||
"emptyDesc": "Choose a tariff to get started",
|
||||
"title": "My subscriptions"
|
||||
|
||||
@@ -429,7 +429,7 @@
|
||||
"buyTraffic": "خرید ترافیک بیشتر",
|
||||
"currentTrafficLimit": "محدودیت فعلی: {{limit}} GB (مصرف شده {{used}} GB)",
|
||||
"buyTrafficTitle": "خرید ترافیک بیشتر",
|
||||
"trafficWarning": "ترافیک خریداری شده به محدودیت فعلی اضافه میشود و به دوره بعدی منتقل نمیشود",
|
||||
"trafficWarning": "ترافیک خریداریشده به محدودیت فعلی اضافه میشود و تا ۳۰ روز از زمان خرید معتبر است",
|
||||
"trafficUnavailable": "خرید ترافیک برای اشتراک شما در دسترس نیست",
|
||||
"unlimited": "نامحدود",
|
||||
"buyTrafficGb": "خرید {{gb}} GB",
|
||||
@@ -1432,7 +1432,8 @@
|
||||
"promoPrefix": "پیشوند کد تخفیف",
|
||||
"spinCost": "هزینه چرخش",
|
||||
"limitsAndRtp": "محدودیتها و RTP",
|
||||
"promocodes": "کدهای تخفیف"
|
||||
"promocodes": "کدهای تخفیف",
|
||||
"promoValidityDays": "اعتبار کد تخفیف (روز)"
|
||||
},
|
||||
"starsNotEnabledGlobally": "پرداخت با ستاره در روشهای پرداخت فعال نیست. «Telegram Stars» را در بخش «روشهای پرداخت» فعال کنید.",
|
||||
"prizes": {
|
||||
@@ -1459,7 +1460,9 @@
|
||||
"emoji": "ایموجی",
|
||||
"color": "رنگ",
|
||||
"active": "فعال",
|
||||
"worth": "ارزش"
|
||||
"worth": "ارزش",
|
||||
"probability": "احتمال دستی (۰–۱)",
|
||||
"probabilityHint": "خالی = خودکار بر اساس RTP"
|
||||
},
|
||||
"promo": {
|
||||
"title": "تنظیمات کد تخفیف",
|
||||
@@ -1476,7 +1479,9 @@
|
||||
"prizeDistribution": "توزیع جوایز",
|
||||
"times": "بار",
|
||||
"times_other": "{{count}} بار",
|
||||
"topWins": "برترین برندهها"
|
||||
"topWins": "برترین برندهها",
|
||||
"loadError": "بارگذاری آمار ناموفق بود",
|
||||
"empty": "هنوز چرخشی انجام نشده"
|
||||
}
|
||||
},
|
||||
"broadcasts": {
|
||||
@@ -3694,6 +3699,12 @@
|
||||
"dailyChart": "خرید و درآمد روزانه",
|
||||
"tariffChart": "توزیع طرحها",
|
||||
"giftBreakdown": "هدایا در مقابل عادی",
|
||||
"funnelTitle": "قیف تبدیل",
|
||||
"giftClaimTitle": "فعالسازی هدیه",
|
||||
"giftClaimLabel": "دریافتشده / ارسالشده",
|
||||
"dailyRevenue": "درآمد روزانه",
|
||||
"byPaymentMethod": "بر اساس روش پرداخت",
|
||||
"bySource": "منابع ترافیک",
|
||||
"purchases": "خریدها",
|
||||
"revenueLabel": "درآمد",
|
||||
"gifts": "هدایا",
|
||||
@@ -4133,7 +4144,13 @@
|
||||
"vk": "VK"
|
||||
},
|
||||
"backToAccounts": "بازگشت به فهرست حسابها"
|
||||
}
|
||||
},
|
||||
"emailMergePasswordRequired": "این ایمیل قبلاً به حساب دیگری تعلق دارد. برای ادغام حسابها، رمز عبور آن حساب را وارد کنید.",
|
||||
"emailMergeCodeSent": "کد تأیید به آن ایمیل ارسال شد.",
|
||||
"emailMergeCodeDescription": "این ایمیل قبلاً به حساب دیگری تعلق دارد. کدی به آن ارسال کردیم — برای تأیید مالکیت و ادغام حسابها آن را وارد کنید.",
|
||||
"emailMergeCodeLabel": "کد ارسالشده در ایمیل",
|
||||
"emailMergeConfirm": "تأیید و ادغام",
|
||||
"emailMergeCodeInvalid": "کد نامعتبر یا منقضیشده"
|
||||
},
|
||||
"theme": {
|
||||
"colors": "رنگهای تم",
|
||||
@@ -4464,6 +4481,14 @@
|
||||
"openBot": "باز کردن ربات",
|
||||
"retry": "من /start را زدم، دوباره امتحان کن",
|
||||
"hint": "پس از اجرای /start، به اینجا بازگردید و «دوباره امتحان کنید» را بزنید."
|
||||
},
|
||||
"serviceUnavailable": {
|
||||
"title": "سرویس در دسترس نیست",
|
||||
"description": "در حال حاضر امکان اتصال به سرویس وجود ندارد. ممکن است در حال تعمیر باشد یا مشکل موقتی در اتصال وجود داشته باشد.",
|
||||
"retry": "تلاش مجدد",
|
||||
"checking": "در حال بررسی اتصال...",
|
||||
"hint": "بهمحض در دسترس قرار گرفتن سرویس، این صفحه بهطور خودکار بازخوانی میشود.",
|
||||
"close": "بستن"
|
||||
}
|
||||
},
|
||||
"merge": {
|
||||
@@ -4494,6 +4519,36 @@
|
||||
"merging": "در حال ادغام..."
|
||||
},
|
||||
"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": "صفحه یافت نشد",
|
||||
"forMe": "برای خودم",
|
||||
"asGift": "به عنوان هدیه",
|
||||
@@ -4737,6 +4792,7 @@
|
||||
"subscriptions": {
|
||||
"buy": "خرید اشتراک",
|
||||
"buyAnother": "خرید اشتراک دیگر",
|
||||
"browsePlans": "مشاهده تعرفهها و خرید اشتراک",
|
||||
"empty": "هنوز اشتراکی ندارید",
|
||||
"emptyDesc": "برای شروع تعرفهای انتخاب کنید",
|
||||
"title": "اشتراکهای من"
|
||||
|
||||
@@ -608,7 +608,7 @@
|
||||
"buyTraffic": "Докупить трафик",
|
||||
"currentTrafficLimit": "Текущий лимит: {{limit}} ГБ (использовано {{used}} ГБ)",
|
||||
"buyTrafficTitle": "Докупить трафик",
|
||||
"trafficWarning": "Докупленный трафик добавляется к текущему лимиту и не переносится на следующий период",
|
||||
"trafficWarning": "Докупленный трафик добавляется к текущему лимиту и действует 30 дней с момента покупки",
|
||||
"trafficUnavailable": "Докупка трафика недоступна для вашего тарифа",
|
||||
"unlimited": "Безлимит",
|
||||
"buyTrafficGb": "Купить {{gb}} ГБ",
|
||||
@@ -1782,7 +1782,8 @@
|
||||
"dailyLimit": "Дневной лимит вращений (0 = безлимит)",
|
||||
"minSubDays": "Мин. дней подписки для оплаты днями",
|
||||
"promocodes": "Промокоды",
|
||||
"promoPrefix": "Префикс промокодов"
|
||||
"promoPrefix": "Префикс промокодов",
|
||||
"promoValidityDays": "Срок действия промокода (дней)"
|
||||
},
|
||||
"starsNotEnabledGlobally": "Оплата звёздами не включена в платёжных методах. Включите метод «Telegram Stars» в разделе «Платёжные методы».",
|
||||
"prizes": {
|
||||
@@ -1809,7 +1810,9 @@
|
||||
"emoji": "Эмодзи",
|
||||
"color": "Цвет",
|
||||
"active": "Активен",
|
||||
"worth": "Стоимость"
|
||||
"worth": "Стоимость",
|
||||
"probability": "Ручная вероятность (0–1)",
|
||||
"probabilityHint": "Пусто — авто по RTP"
|
||||
},
|
||||
"promo": {
|
||||
"title": "Настройки промокода",
|
||||
@@ -1828,7 +1831,9 @@
|
||||
"times_one": "{{count}} раз",
|
||||
"times_few": "{{count}} раза",
|
||||
"times_many": "{{count}} раз",
|
||||
"topWins": "Топ выигрышей"
|
||||
"topWins": "Топ выигрышей",
|
||||
"loadError": "Не удалось загрузить статистику",
|
||||
"empty": "Пока нет вращений"
|
||||
}
|
||||
},
|
||||
"broadcasts": {
|
||||
@@ -4523,6 +4528,12 @@
|
||||
"dailyChart": "Покупки и доход по дням",
|
||||
"tariffChart": "Распределение по тарифам",
|
||||
"giftBreakdown": "Подарки vs обычные",
|
||||
"funnelTitle": "Воронка конверсии",
|
||||
"giftClaimTitle": "Активация подарков",
|
||||
"giftClaimLabel": "забрано / отправлено",
|
||||
"dailyRevenue": "Выручка по дням",
|
||||
"byPaymentMethod": "По способам оплаты",
|
||||
"bySource": "Источники трафика",
|
||||
"purchases": "Покупки",
|
||||
"revenueLabel": "Доход",
|
||||
"gifts": "Подарки",
|
||||
@@ -5140,7 +5151,13 @@
|
||||
"vk": "VK"
|
||||
},
|
||||
"backToAccounts": "К списку аккаунтов"
|
||||
}
|
||||
},
|
||||
"emailMergePasswordRequired": "Этот email уже привязан к другому аккаунту. Чтобы объединить аккаунты, введите пароль от него.",
|
||||
"emailMergeCodeSent": "Код подтверждения отправлен на тот email.",
|
||||
"emailMergeCodeDescription": "Этот email уже привязан к другому аккаунту. Мы отправили на него код — введите его, чтобы подтвердить владение и объединить аккаунты.",
|
||||
"emailMergeCodeLabel": "Код из письма",
|
||||
"emailMergeConfirm": "Подтвердить и объединить",
|
||||
"emailMergeCodeInvalid": "Неверный или просроченный код"
|
||||
},
|
||||
"theme": {
|
||||
"colors": "Цвета темы",
|
||||
@@ -5359,6 +5376,14 @@
|
||||
"openBot": "Открыть бота",
|
||||
"retry": "Я нажал /start, попробовать снова",
|
||||
"hint": "После /start вернитесь сюда и нажмите «Попробовать снова»."
|
||||
},
|
||||
"serviceUnavailable": {
|
||||
"title": "Сервис недоступен",
|
||||
"description": "Не удаётся подключиться к сервису. Возможно, идут технические работы или временные неполадки со связью.",
|
||||
"retry": "Повторить попытку",
|
||||
"checking": "Проверяем соединение...",
|
||||
"hint": "Страница обновится автоматически, как только сервис снова станет доступен.",
|
||||
"close": "Закрыть"
|
||||
}
|
||||
},
|
||||
"merge": {
|
||||
@@ -5389,6 +5414,36 @@
|
||||
"merging": "Объединение..."
|
||||
},
|
||||
"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": "Страница не найдена",
|
||||
"forMe": "Для себя",
|
||||
"asGift": "В подарок",
|
||||
@@ -5665,6 +5720,7 @@
|
||||
"subscriptions": {
|
||||
"buy": "Купить подписку",
|
||||
"buyAnother": "Купить ещё подписку",
|
||||
"browsePlans": "Посмотреть тарифы и купить подписку",
|
||||
"empty": "У вас пока нет подписок",
|
||||
"emptyDesc": "Выберите тариф, чтобы начать пользоваться сервисом",
|
||||
"title": "Мои подписки"
|
||||
|
||||
@@ -429,7 +429,7 @@
|
||||
"buyTraffic": "购买更多流量",
|
||||
"currentTrafficLimit": "当前限制:{{limit}} GB(已使用 {{used}} GB)",
|
||||
"buyTrafficTitle": "购买更多流量",
|
||||
"trafficWarning": "购买的流量将添加到当前限制,不会延续到下一个周期",
|
||||
"trafficWarning": "购买的流量将添加到当前限制,自购买之日起30天内有效",
|
||||
"trafficUnavailable": "您的套餐不支持购买流量",
|
||||
"unlimited": "无限制",
|
||||
"buyTrafficGb": "购买 {{gb}} GB",
|
||||
@@ -1509,7 +1509,8 @@
|
||||
"promoPrefix": "优惠码前缀",
|
||||
"spinCost": "旋转费用",
|
||||
"limitsAndRtp": "限制和RTP",
|
||||
"promocodes": "促销码"
|
||||
"promocodes": "促销码",
|
||||
"promoValidityDays": "促销码有效期(天)"
|
||||
},
|
||||
"starsNotEnabledGlobally": "星星支付未在支付方式中启用。请在「支付方式」中启用「Telegram Stars」。",
|
||||
"prizes": {
|
||||
@@ -1536,7 +1537,9 @@
|
||||
"emoji": "表情",
|
||||
"color": "颜色",
|
||||
"active": "启用",
|
||||
"worth": "价值"
|
||||
"worth": "价值",
|
||||
"probability": "手动概率 (0–1)",
|
||||
"probabilityHint": "留空 = 按 RTP 自动"
|
||||
},
|
||||
"promo": {
|
||||
"title": "优惠码设置",
|
||||
@@ -1553,7 +1556,9 @@
|
||||
"prizeDistribution": "奖品分布",
|
||||
"times": "次",
|
||||
"times_other": "{{count}} 次",
|
||||
"topWins": "最高奖品"
|
||||
"topWins": "最高奖品",
|
||||
"loadError": "无法加载统计数据",
|
||||
"empty": "暂无抽奖记录"
|
||||
}
|
||||
},
|
||||
"broadcasts": {
|
||||
@@ -3693,6 +3698,12 @@
|
||||
"dailyChart": "每日购买与收入",
|
||||
"tariffChart": "套餐分布",
|
||||
"giftBreakdown": "礼物 vs 普通",
|
||||
"funnelTitle": "转化漏斗",
|
||||
"giftClaimTitle": "礼物激活",
|
||||
"giftClaimLabel": "已领取 / 已发送",
|
||||
"dailyRevenue": "每日收入",
|
||||
"byPaymentMethod": "按支付方式",
|
||||
"bySource": "流量来源",
|
||||
"purchases": "购买",
|
||||
"revenueLabel": "收入",
|
||||
"gifts": "礼物",
|
||||
@@ -4132,7 +4143,13 @@
|
||||
"vk": "VK"
|
||||
},
|
||||
"backToAccounts": "返回账户列表"
|
||||
}
|
||||
},
|
||||
"emailMergePasswordRequired": "该邮箱已属于另一个账户。要合并账户,请输入该账户的密码。",
|
||||
"emailMergeCodeSent": "确认码已发送至该邮箱。",
|
||||
"emailMergeCodeDescription": "该邮箱已属于另一个账户。我们已向其发送验证码——请输入以确认所有权并合并账户。",
|
||||
"emailMergeCodeLabel": "邮件中的验证码",
|
||||
"emailMergeConfirm": "确认并合并",
|
||||
"emailMergeCodeInvalid": "验证码无效或已过期"
|
||||
},
|
||||
"theme": {
|
||||
"colors": "主题颜色",
|
||||
@@ -4345,6 +4362,14 @@
|
||||
"openBot": "打开机器人",
|
||||
"retry": "我已按 /start,重试",
|
||||
"hint": "运行 /start 后,返回此处并点击「重试」。"
|
||||
},
|
||||
"serviceUnavailable": {
|
||||
"title": "服务不可用",
|
||||
"description": "暂时无法连接到服务。可能正在进行维护,或存在临时的网络问题。",
|
||||
"retry": "重试",
|
||||
"checking": "正在检查连接...",
|
||||
"hint": "服务恢复后,本页面将自动刷新。",
|
||||
"close": "关闭"
|
||||
}
|
||||
},
|
||||
"banSystem": {
|
||||
@@ -4493,6 +4518,36 @@
|
||||
"merging": "合并中..."
|
||||
},
|
||||
"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": "页面未找到",
|
||||
"forMe": "为自己",
|
||||
"asGift": "作为礼物",
|
||||
@@ -4736,6 +4791,7 @@
|
||||
"subscriptions": {
|
||||
"buy": "购买订阅",
|
||||
"buyAnother": "再购买一个订阅",
|
||||
"browsePlans": "查看套餐并订阅",
|
||||
"empty": "您还没有订阅",
|
||||
"emptyDesc": "选择套餐以开始使用",
|
||||
"title": "我的订阅"
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useAuthStore } from './store/auth';
|
||||
import { AppWithNavigator } from './AppWithNavigator';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { initLogoPreload } from './api/branding';
|
||||
import { checkBackendOnStartup } from './api/health';
|
||||
import { getCachedFullscreenEnabled, isTelegramMobile } from './hooks/useTelegramSDK';
|
||||
import { applyTelegramLanguage } from './i18n';
|
||||
import './styles/globals.css';
|
||||
@@ -106,6 +107,11 @@ if (isTelegramEnv && !alreadyInitialized) {
|
||||
// are only available post-init()).
|
||||
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) {
|
||||
requestIdleCallback(() => initLogoPreload());
|
||||
} else {
|
||||
|
||||
@@ -88,9 +88,11 @@ export default function AdminApplicationReview() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="font-medium text-dark-100">{displayName}</span>
|
||||
{app.username && <span className="text-sm text-dark-500">@{app.username}</span>}
|
||||
<div className="mb-1 flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-medium text-dark-100">{displayName}</span>
|
||||
{app.username && (
|
||||
<span className="shrink-0 text-sm text-dark-500">@{app.username}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,20 +2,7 @@ import { useState, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { Cell, Funnel, FunnelChart, LabelList, ResponsiveContainer } from 'recharts';
|
||||
import {
|
||||
adminLandingsApi,
|
||||
resolveLocaleDisplay,
|
||||
@@ -23,21 +10,32 @@ import {
|
||||
type LandingPurchaseItem,
|
||||
} from '../api/landings';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { useChartColors } from '../hooks/useChartColors';
|
||||
import { CHART_COMMON } from '../constants/charts';
|
||||
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 {
|
||||
ChartIcon,
|
||||
EmailIcon,
|
||||
TelegramSmallIcon,
|
||||
ArrowRightIcon,
|
||||
GiftIcon,
|
||||
EyeIcon,
|
||||
CheckCircleIcon,
|
||||
BanknotesIcon,
|
||||
PercentIcon,
|
||||
TicketIcon,
|
||||
CardIcon,
|
||||
WalletIcon,
|
||||
ChevronLeftIcon as ChevronLeftSmall,
|
||||
ChevronRightIcon as ChevronRightSmall,
|
||||
} from '@/components/icons';
|
||||
|
||||
const TARIFF_PALETTE = ['#818cf8', '#34d399', '#f59e0b', '#ec4899', '#06b6d4', '#8b5cf6'];
|
||||
const GIFT_COLOR = '#a855f7';
|
||||
const FUNNEL_COLORS = ['#f59e0b', '#34d399'];
|
||||
const GIFT_DONUT = { regular: '#818cf8', gift: '#a855f7' };
|
||||
|
||||
const PURCHASE_STATUS_STYLES: Record<string, string> = {
|
||||
pending: 'bg-warning-500/20 text-warning-400',
|
||||
@@ -181,7 +179,8 @@ export default function AdminLandingStats() {
|
||||
const isValidId = !isNaN(numericId);
|
||||
const navigate = useNavigate();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
const colors = useChartColors();
|
||||
const divisor = CHART_COMMON.KOPEKS_DIVISOR;
|
||||
const money = (kopeks: number) => formatWithCurrency(kopeks / divisor);
|
||||
|
||||
// Purchases list state
|
||||
const [purchaseOffset, setPurchaseOffset] = useState(0);
|
||||
@@ -234,49 +233,59 @@ export default function AdminLandingStats() {
|
||||
const purchaseTotalPages = Math.ceil(purchaseTotal / PURCHASES_PAGE_SIZE);
|
||||
const purchaseCurrentPage = Math.floor(purchaseOffset / PURCHASES_PAGE_SIZE) + 1;
|
||||
|
||||
// Prepare daily chart data
|
||||
const dailyData = useMemo(() => {
|
||||
// Daily counts (created / paid) — flat data for MultiSeriesAreaChart
|
||||
const dailyCounts = useMemo(() => {
|
||||
if (!stats) return [];
|
||||
return stats.daily_stats.map((item) => ({
|
||||
label: (() => {
|
||||
const d = new Date(item.date + 'T00:00:00');
|
||||
return `${d.getDate()}.${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
})(),
|
||||
created: item.created,
|
||||
purchases: item.purchases,
|
||||
revenue: item.revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR,
|
||||
gifts: item.gifts,
|
||||
}));
|
||||
}, [stats]);
|
||||
const createdLabel = t('admin.landings.stats.created', 'Created');
|
||||
const paidLabel = t('admin.landings.stats.paid', 'Paid');
|
||||
return stats.daily_stats.flatMap((d) => [
|
||||
{ date: d.date, key: createdLabel, value: d.created },
|
||||
{ date: d.date, key: paidLabel, value: d.purchases },
|
||||
]);
|
||||
}, [stats, t]);
|
||||
|
||||
// Prepare tariff chart data
|
||||
const tariffData = useMemo(() => {
|
||||
// Daily revenue — for SimpleAreaChart
|
||||
const dailyRevenue = useMemo(() => {
|
||||
if (!stats) return [];
|
||||
return stats.tariff_stats.map((item) => ({
|
||||
name: item.tariff_name,
|
||||
purchases: item.purchases,
|
||||
revenue: item.revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR,
|
||||
}));
|
||||
}, [stats]);
|
||||
return stats.daily_stats.map((d) => ({ date: d.date, value: d.revenue_kopeks / divisor }));
|
||||
}, [stats, divisor]);
|
||||
|
||||
// Donut data for gift vs regular
|
||||
const donutData = useMemo(() => {
|
||||
// Tariff breakdown (by revenue)
|
||||
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 [];
|
||||
return [
|
||||
{
|
||||
name: t('admin.landings.stats.regular'),
|
||||
value: stats.total_regular,
|
||||
color: colors.referrals,
|
||||
},
|
||||
{ name: t('admin.landings.stats.gifts'), value: stats.total_gifts, color: GIFT_COLOR },
|
||||
{ name: t('admin.landings.stats.created', 'Created'), value: stats.total_created },
|
||||
{ name: t('admin.landings.stats.paid', 'Paid'), value: stats.total_successful },
|
||||
];
|
||||
}, [stats, t, colors.referrals]);
|
||||
|
||||
// Bar chart height based on tariff count
|
||||
const barChartHeight = useMemo(() => {
|
||||
const count = tariffData.length;
|
||||
return Math.max(220, count * 45 + 40);
|
||||
}, [tariffData.length]);
|
||||
}, [stats, t]);
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
@@ -309,6 +318,9 @@ export default function AdminLandingStats() {
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="animate-fade-in">
|
||||
@@ -336,377 +348,185 @@ export default function AdminLandingStats() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
{/* Summary Cards */}
|
||||
<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">
|
||||
<div className="text-xl font-bold sm:text-2xl">
|
||||
<span className="text-warning-400">{stats.total_created}</span>
|
||||
<span className="mx-1 text-dark-600">/</span>
|
||||
<span className="text-success-400">{stats.total_successful}</span>
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.landings.stats.created', 'Created')} /{' '}
|
||||
{t('admin.landings.stats.paid', 'paid')}
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
{formatWithCurrency(stats.total_revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR)}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.landings.stats.revenue')}</div>
|
||||
</div>
|
||||
<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>
|
||||
<div className="text-xs text-dark-500">{t('admin.landings.stats.giftPurchases')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||
<div className="text-xl font-bold text-dark-200 sm:text-2xl">
|
||||
{stats.conversion_rate}%
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.landings.stats.conversionRate')}</div>
|
||||
</div>
|
||||
<StatCard
|
||||
label={t('admin.landings.stats.created', 'Created')}
|
||||
value={stats.total_created}
|
||||
icon={<EyeIcon className="h-5 w-5" />}
|
||||
tone="warning"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.landings.stats.paid', 'Paid')}
|
||||
value={stats.total_successful}
|
||||
icon={<CheckCircleIcon className="h-5 w-5" />}
|
||||
tone="success"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.landings.stats.revenue')}
|
||||
value={money(stats.total_revenue_kopeks)}
|
||||
icon={<BanknotesIcon className="h-5 w-5" />}
|
||||
tone="success"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.landings.stats.conversionRate')}
|
||||
value={`${stats.conversion_rate}%`}
|
||||
icon={<PercentIcon className="h-5 w-5" />}
|
||||
tone="accent"
|
||||
/>
|
||||
</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">
|
||||
{/* Daily Purchases & Revenue */}
|
||||
<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.dailyChart')}
|
||||
</h3>
|
||||
{dailyData.length === 0 ? (
|
||||
<div className="flex h-[220px] items-center justify-center text-sm text-dark-500">
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">
|
||||
{t('admin.landings.stats.funnelTitle', 'Conversion funnel')}
|
||||
</h4>
|
||||
{stats.total_created === 0 ? (
|
||||
<div className="flex h-[180px] items-center justify-center text-sm text-dark-500">
|
||||
{t('admin.landings.stats.noPurchases')}
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<AreaChart data={dailyData} margin={CHART_COMMON.CHART.MARGIN}>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`landingPurchaseGrad-${numericId}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
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}
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={170}>
|
||||
<FunnelChart>
|
||||
<Funnel dataKey="value" data={funnelData} isAnimationActive>
|
||||
{/* Center value labels (dark text reads on the light segments)
|
||||
— left/right labels can clip in the half-width column. */}
|
||||
<LabelList
|
||||
position="center"
|
||||
dataKey="value"
|
||||
strokeWidth={0}
|
||||
>
|
||||
{donutData.map((entry, index) => (
|
||||
<Cell key={index} fill={entry.color} />
|
||||
fill="#0a0f1a"
|
||||
stroke="none"
|
||||
fontSize={14}
|
||||
/>
|
||||
{funnelData.map((_, i) => (
|
||||
<Cell key={i} fill={FUNNEL_COLORS[i % FUNNEL_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<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,
|
||||
}}
|
||||
itemStyle={{ color: colors.label }}
|
||||
/>
|
||||
</PieChart>
|
||||
</Funnel>
|
||||
</FunnelChart>
|
||||
</ResponsiveContainer>
|
||||
{/* Center text */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-lg font-bold text-dark-100">{stats.total_purchases}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: colors.referrals }}
|
||||
<div className="mt-2 flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs text-dark-400">
|
||||
{funnelData.map((s, i) => (
|
||||
<span key={i} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: FUNNEL_COLORS[i % FUNNEL_COLORS.length] }}
|
||||
/>
|
||||
<span className="text-sm text-dark-300">
|
||||
{t('admin.landings.stats.regular')}: {stats.total_regular}
|
||||
{s.name}: <span className="text-dark-200">{s.value}</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="text-accent-400">{stats.conversion_rate}%</span>
|
||||
</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 */}
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="bento-card">
|
||||
{/* Header row: title + status filter */}
|
||||
<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>
|
||||
@@ -745,9 +565,7 @@ export default function AdminLandingStats() {
|
||||
<PurchaseCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
formatPrice={(kopeks) =>
|
||||
formatWithCurrency(kopeks / CHART_COMMON.KOPEKS_DIVISOR)
|
||||
}
|
||||
formatPrice={(kopeks) => money(kopeks)}
|
||||
lang={i18n.language}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@@ -87,9 +87,11 @@ export default function AdminPartnerCampaignAssign() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-dark-500">
|
||||
<span className="font-mono">?start={campaign.start_parameter}</span>
|
||||
<span>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-3 text-xs text-dark-500">
|
||||
<span className="min-w-0 truncate font-mono">
|
||||
?start={campaign.start_parameter}
|
||||
</span>
|
||||
<span className="shrink-0">
|
||||
{campaign.registrations_count}{' '}
|
||||
{t('admin.campaigns.overview.registrations').toLowerCase()}
|
||||
</span>
|
||||
|
||||
@@ -129,12 +129,14 @@ export default function AdminPartners() {
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<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">
|
||||
{partner.first_name || partner.username || `#${partner.user_id}`}
|
||||
</h3>
|
||||
{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 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 className="mb-3 flex items-start justify-between gap-4">
|
||||
<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">
|
||||
{app.first_name || app.username || `#${app.user_id}`}
|
||||
</h3>
|
||||
{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>
|
||||
{app.company_name && (
|
||||
|
||||
@@ -339,18 +339,18 @@ export default function AdminPromoOfferSend() {
|
||||
<div ref={searchRef} className="relative">
|
||||
{selectedUser ? (
|
||||
// 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 gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-dark-600">
|
||||
<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 min-w-0 items-center gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-dark-600">
|
||||
<UserIcon />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-dark-100">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-dark-100">
|
||||
{selectedUser.full_name ||
|
||||
selectedUser.username ||
|
||||
`ID: ${selectedUser.telegram_id}`}
|
||||
</div>
|
||||
<div className="text-xs text-dark-400">
|
||||
<div className="truncate text-xs text-dark-400">
|
||||
{selectedUser.username && `@${selectedUser.username} · `}
|
||||
Telegram: {selectedUser.telegram_id}
|
||||
</div>
|
||||
@@ -358,7 +358,7 @@ export default function AdminPromoOfferSend() {
|
||||
</div>
|
||||
<button
|
||||
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" />
|
||||
</button>
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function AdminPromocodeStats() {
|
||||
<AdminBackButton to="/admin/promocodes" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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}
|
||||
</div>
|
||||
|
||||
@@ -229,11 +229,11 @@ export default function AdminRemnawaveSquadDetail() {
|
||||
key={idx}
|
||||
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}`)}
|
||||
</span>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -697,8 +697,10 @@ export default function AdminTariffCreate() {
|
||||
>
|
||||
{isSelected && <CheckIcon />}
|
||||
</div>
|
||||
<span className="flex-1 text-sm font-medium">{squad.name}</span>
|
||||
<span className="text-xs text-dark-500">
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{squad.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-dark-500">
|
||||
{squad.members_count} {t('admin.tariffs.externalSquadUsers')}
|
||||
</span>
|
||||
</button>
|
||||
@@ -1059,7 +1061,9 @@ export default function AdminTariffCreate() {
|
||||
>
|
||||
{isSelected && <CheckIcon />}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -443,7 +443,7 @@ export default function AdminTickets() {
|
||||
</span>
|
||||
</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>
|
||||
{t('admin.tickets.from')}: {formatUser(selectedTicket)}
|
||||
{selectedTicket.user?.telegram_id && (
|
||||
|
||||
@@ -40,8 +40,13 @@ import {
|
||||
StarIcon,
|
||||
TicketIcon,
|
||||
TrashIcon,
|
||||
WalletIcon,
|
||||
WheelIcon,
|
||||
XMarkIcon,
|
||||
} 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 { toNumber } from '../utils/inputHelpers';
|
||||
|
||||
@@ -166,6 +171,7 @@ export default function AdminWheel() {
|
||||
const confirmDelete = useDestructiveConfirm();
|
||||
const { capabilities } = usePlatform();
|
||||
const notify = useNotify();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>('settings');
|
||||
const [expandedPrizeId, setExpandedPrizeId] = useState<number | null>(null);
|
||||
@@ -184,6 +190,7 @@ export default function AdminWheel() {
|
||||
daily_spin_limit: number | '';
|
||||
min_subscription_days_for_day_payment: number | '';
|
||||
promo_prefix: string;
|
||||
promo_validity_days: number | '';
|
||||
} | null>(null);
|
||||
|
||||
// Fetch config
|
||||
@@ -193,7 +200,11 @@ export default function AdminWheel() {
|
||||
});
|
||||
|
||||
// Fetch statistics
|
||||
const { data: stats } = useQuery({
|
||||
const {
|
||||
data: stats,
|
||||
isLoading: statsLoading,
|
||||
error: statsError,
|
||||
} = useQuery({
|
||||
queryKey: ['admin-wheel-stats'],
|
||||
queryFn: () => adminWheelApi.getStatistics(),
|
||||
enabled: activeTab === 'statistics',
|
||||
@@ -214,6 +225,7 @@ export default function AdminWheel() {
|
||||
daily_spin_limit: config.daily_spin_limit,
|
||||
min_subscription_days_for_day_payment: config.min_subscription_days_for_day_payment,
|
||||
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.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
|
||||
const updateConfigMutation = useMutation({
|
||||
@@ -669,6 +682,26 @@ export default function AdminWheel() {
|
||||
className="input w-full"
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -702,6 +735,10 @@ export default function AdminWheel() {
|
||||
settingsForm.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}
|
||||
@@ -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-1">
|
||||
<p className="text-sm font-medium text-warning-400">
|
||||
{t('admin.wheel.prizes.unsavedOrder') || 'Есть несохраненные изменения порядка'}
|
||||
{t('admin.wheel.prizes.unsavedOrder')}
|
||||
</p>
|
||||
<p className="text-xs text-warning-400/70">
|
||||
{t('admin.wheel.prizes.unsavedOrderHint') ||
|
||||
'Сохраните изменения или отмените их'}
|
||||
{t('admin.wheel.prizes.unsavedOrderHint')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleDiscardOrderChanges}
|
||||
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 onClick={handleDiscardOrderChanges} className="btn-secondary">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSavePrizeOrder}
|
||||
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
|
||||
? t('common.saving') || 'Сохранение...'
|
||||
: t('common.save') || 'Сохранить'}
|
||||
{reorderPrizesMutation.isPending ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -833,79 +864,82 @@ export default function AdminWheel() {
|
||||
)}
|
||||
|
||||
{/* Statistics Tab */}
|
||||
{activeTab === 'statistics' && stats && (
|
||||
<div className="space-y-4">
|
||||
{/* Stats cards */}
|
||||
<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>
|
||||
{activeTab === 'statistics' && statsLoading && (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</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 && (
|
||||
<div className="card p-4">
|
||||
<h3 className="mb-3 font-semibold text-dark-100">
|
||||
{t('admin.wheel.statistics.topWins')}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{stats.top_wins.slice(0, 5).map((win, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<span className="text-dark-300">{win.username || `User #${win.user_id}`}</span>
|
||||
<span className="text-dark-100">
|
||||
{win.prize_display_name} ({(win.prize_value_kopeks / 100).toFixed(0)}₽)
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<BreakdownList
|
||||
title={t('admin.wheel.statistics.topWins')}
|
||||
items={stats.top_wins.slice(0, 8).map((win, i) => ({
|
||||
key: `${win.user_id}-${i}`,
|
||||
label: `${win.username || `#${win.user_id}`} · ${win.prize_display_name}`,
|
||||
value: win.prize_value_kopeks / 100,
|
||||
}))}
|
||||
valueFormatter={(v) => formatWithCurrency(v, 0)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1097,6 +1131,33 @@ function InlinePrizeForm({
|
||||
</label>
|
||||
</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 */}
|
||||
{formData.prize_type === 'promocode' && (
|
||||
<div className="space-y-3 rounded-lg bg-dark-700/50 p-3">
|
||||
|
||||
@@ -116,7 +116,7 @@ export default function AdminWithdrawals() {
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* User and amount */}
|
||||
<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.first_name || `#${item.user_id}`}
|
||||
|
||||
@@ -275,10 +275,10 @@ export default function Balance() {
|
||||
key={sub.id}
|
||||
onClick={() => handlePromocodeActivate(sub.id)}
|
||||
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="text-dark-400">
|
||||
<span className="truncate">{sub.tariff_name}</span>
|
||||
<span className="shrink-0 text-dark-400">
|
||||
{t('balance.promocode.daysLeft', '{{count}} дн.', { count: sub.days_left })}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -318,6 +318,10 @@ export default function ConnectedAccounts() {
|
||||
const [emailConfirmPassword, setEmailConfirmPassword] = useState('');
|
||||
const [emailError, setEmailError] = 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 { data: emailAuthConfig } = useQuery<EmailAuthEnabled>({
|
||||
@@ -396,6 +400,15 @@ export default function ConnectedAccounts() {
|
||||
navigate(`/merge/${response.merge_token}`, { replace: true });
|
||||
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'));
|
||||
setEmailError(null);
|
||||
setEmailValue('');
|
||||
@@ -409,7 +422,12 @@ export default function ConnectedAccounts() {
|
||||
},
|
||||
onError: (err: { response?: { data?: { detail?: string } } }) => {
|
||||
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'));
|
||||
} else if (detail?.includes('already have a verified email')) {
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
setEmailError(null);
|
||||
@@ -623,19 +671,19 @@ export default function ConnectedAccounts() {
|
||||
{data?.providers.map((provider) => (
|
||||
<motion.div key={provider.provider} variants={staggerItem}>
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<ProviderIcon provider={provider.provider} />
|
||||
<div>
|
||||
<p className="font-medium text-dark-100">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-dark-100">
|
||||
{t(`profile.accounts.providers.${provider.provider}`)}
|
||||
</p>
|
||||
{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 className="flex items-center gap-2">
|
||||
<div className="flex shrink-0 flex-col items-end gap-1.5">
|
||||
{provider.linked ? (
|
||||
<>
|
||||
<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">
|
||||
<p className="mb-4 text-sm text-dark-400">
|
||||
{t('profile.linkEmailDescription')}
|
||||
{emailMergeCodePending
|
||||
? t('profile.emailMergeCodeDescription')
|
||||
: t('profile.linkEmailDescription')}
|
||||
</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">
|
||||
<div>
|
||||
<label htmlFor="email-link-input" className="label">
|
||||
@@ -710,7 +804,9 @@ export default function ConnectedAccounts() {
|
||||
className="input"
|
||||
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>
|
||||
<label htmlFor="email-link-confirm" className="label">
|
||||
@@ -742,6 +838,7 @@ export default function ConnectedAccounts() {
|
||||
{t('profile.linkEmail')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -198,14 +198,14 @@ export default function Contests() {
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{contests.map((contest) => (
|
||||
<div key={contest.id} className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{contest.name}</h3>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="break-words text-lg font-semibold">{contest.name}</h3>
|
||||
{contest.description && (
|
||||
<p className="mt-1 text-sm text-dark-400">{contest.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-accent-400">
|
||||
<div className="flex shrink-0 items-center gap-1 text-accent-400">
|
||||
<TrophyIcon />
|
||||
<span className="text-sm font-medium">
|
||||
+{t('contests.days', { count: contest.prize_days })}
|
||||
|
||||
@@ -198,6 +198,12 @@ export default function Dashboard() {
|
||||
? multiSubData !== undefined && (multiSubData.subscriptions?.length ?? 0) === 0
|
||||
: 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
|
||||
useEffect(() => {
|
||||
if (!isOnboardingCompleted && !subLoading && !refLoading && !blockingType) {
|
||||
@@ -273,8 +279,10 @@ export default function Dashboard() {
|
||||
{/* Pending Gift Activations */}
|
||||
{pendingGifts && pendingGifts.length > 0 && <PendingGiftCard gifts={pendingGifts} />}
|
||||
|
||||
{/* Multi-tariff: show subscription cards (max 3) */}
|
||||
{isMultiTariff && multiSubData?.subscriptions && (
|
||||
{/* Multi-tariff: show subscription cards (max 3) — только когда подписки
|
||||
реально есть. Пустой случай (нет подписок) ведёт блок ниже (триал/покупка),
|
||||
иначе кнопка покупки дублировалась. */}
|
||||
{isMultiTariff && multiSubData?.subscriptions && multiSubData.subscriptions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-sm font-medium opacity-60">
|
||||
@@ -299,12 +307,23 @@ export default function Dashboard() {
|
||||
{t('dashboard.showAll', 'Показать все')} ({multiSubData.subscriptions.length})
|
||||
</Link>
|
||||
)}
|
||||
{hasActivePaid ? (
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<span className="text-base">+</span> {t('subscriptions.buyAnother', 'Купить ещё тариф')}
|
||||
<span className="text-base">+</span>{' '}
|
||||
{t('subscriptions.buyAnother', 'Купить ещё тариф')}
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
{trialInfo?.is_available && (
|
||||
<TrialOfferCard
|
||||
trialInfo={trialInfo}
|
||||
balanceKopeks={balanceData?.balance_kopeks || 0}
|
||||
@@ -354,14 +378,12 @@ export default function Dashboard() {
|
||||
activateTrialMutation={activateTrialMutation}
|
||||
trialError={trialError}
|
||||
/>
|
||||
{/* Новый пользователь не обязан активировать триал, чтобы попасть в
|
||||
витрину — даём явный путь к покупке подписки. Раньше при доступном
|
||||
триале это был единственный экран без кнопки покупки, и на дашборде
|
||||
(вход по умолчанию) юзер оставался заперт (Telegram-баг #605056/#605063). */}
|
||||
)}
|
||||
<Link
|
||||
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', 'Посмотреть тарифы и купить подписку')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
321
src/pages/GiftClaim.tsx
Normal file
321
src/pages/GiftClaim.tsx
Normal 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">“{gift.gift_message}”</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>
|
||||
);
|
||||
}
|
||||
@@ -476,8 +476,9 @@ export default function GiftResult() {
|
||||
const s = d?.status;
|
||||
if (s === 'delivered' || s === 'failed' || s === 'pending_activation' || s === 'expired')
|
||||
return false;
|
||||
// Code-only gifts stay in 'paid' status — stop polling
|
||||
if (s === 'paid' && d?.is_code_only) return false;
|
||||
// Claimable gifts (code-only AND directed) stay in 'paid' until claimed —
|
||||
// stop polling; the buyer shares the link.
|
||||
if (s === 'paid' && d?.is_claimable) return false;
|
||||
|
||||
// Check poll timeout
|
||||
if (Date.now() - pollStart.current > MAX_POLL_MS) {
|
||||
@@ -511,8 +512,8 @@ export default function GiftResult() {
|
||||
);
|
||||
}
|
||||
|
||||
const isCodeOnlyPaid =
|
||||
status?.status === 'paid' && status?.is_code_only && status?.purchase_token != null;
|
||||
const isClaimablePaid =
|
||||
status?.status === 'paid' && status?.is_claimable && status?.purchase_token != null;
|
||||
const isDelivered = status?.status === 'delivered';
|
||||
const isPendingActivation = status?.status === 'pending_activation';
|
||||
const isFailed = status?.status === 'failed' || status?.status === 'expired';
|
||||
@@ -531,7 +532,7 @@ export default function GiftResult() {
|
||||
>
|
||||
{isError ? (
|
||||
<PollErrorState />
|
||||
) : isCodeOnlyPaid ? (
|
||||
) : isClaimablePaid ? (
|
||||
<CodeOnlySuccessState
|
||||
purchaseToken={status.purchase_token!}
|
||||
tariffName={status.tariff_name}
|
||||
|
||||
@@ -205,9 +205,9 @@ export default function Polls() {
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{polls.map((poll) => (
|
||||
<div key={poll.id} className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">{poll.title}</h3>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="break-words text-lg font-semibold">{poll.title}</h3>
|
||||
{poll.description && (
|
||||
<p className="mt-1 text-sm text-dark-400">{poll.description}</p>
|
||||
)}
|
||||
@@ -219,7 +219,7 @@ export default function Polls() {
|
||||
</div>
|
||||
</div>
|
||||
{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" />
|
||||
<span className="text-sm font-medium">+{poll.reward_amount}</span>
|
||||
</div>
|
||||
|
||||
@@ -60,9 +60,9 @@ function CopyableField({ label, value }: { label: string; value: string }) {
|
||||
|
||||
return (
|
||||
<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="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>
|
||||
<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() {
|
||||
const { t } = useTranslation();
|
||||
const { token } = useParams<{ token: string }>();
|
||||
@@ -586,7 +693,12 @@ export default function PurchaseSuccess() {
|
||||
queryFn: () => landingApi.getPurchaseStatus(token!),
|
||||
enabled: !!token && !pollTimedOut,
|
||||
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 (Date.now() - pollStart.current > MAX_POLL_MS) {
|
||||
setPollTimedOut(true);
|
||||
@@ -652,6 +764,10 @@ export default function PurchaseSuccess() {
|
||||
const isPendingActivation = purchaseStatus?.status === 'pending_activation';
|
||||
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.
|
||||
// Recipient arrives via email link with ?activate=1 and sees the activate button instead.
|
||||
const isGiftPendingActivation = isPendingActivation && purchaseStatus?.is_gift && !isActivateHint;
|
||||
@@ -672,6 +788,15 @@ export default function PurchaseSuccess() {
|
||||
>
|
||||
{isError ? (
|
||||
<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 ? (
|
||||
<CabinetCredentialsState
|
||||
cabinetEmail={purchaseStatus.cabinet_email!}
|
||||
|
||||
@@ -7,6 +7,13 @@ import { fireAnalyticsEvent, getYandexCid } from '../hooks/useAnalyticsCounters'
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { landingApi } from '../api/landings';
|
||||
import {
|
||||
brandingApi,
|
||||
getCachedBranding,
|
||||
setCachedBranding,
|
||||
preloadLogo,
|
||||
getLogoBlobUrl,
|
||||
} from '../api/branding';
|
||||
import type {
|
||||
LandingConfig,
|
||||
LandingTariff,
|
||||
@@ -14,12 +21,16 @@ import type {
|
||||
LandingPaymentMethod,
|
||||
PurchaseRequest,
|
||||
} 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 LanguageSwitcher from '../components/LanguageSwitcher';
|
||||
import { cn } from '../lib/utils';
|
||||
import { getApiErrorMessage } from '../utils/api-error';
|
||||
import { formatPrice } from '../utils/format';
|
||||
import { setFavicon, letterFaviconDataUri, roundedFaviconDataUri } from '../utils/favicon';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
|
||||
function detectContactType(value: string): 'email' | 'telegram' {
|
||||
@@ -772,6 +783,42 @@ export default function QuickPurchase() {
|
||||
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 handleDiscountExpired = useCallback(() => {
|
||||
@@ -900,15 +947,18 @@ export default function QuickPurchase() {
|
||||
}
|
||||
}, [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(() => {
|
||||
if (!config?.meta_title) return;
|
||||
const pageTitle = config?.meta_title || config?.title;
|
||||
if (!pageTitle) return;
|
||||
const prev = document.title;
|
||||
document.title = config.meta_title;
|
||||
document.title = pageTitle;
|
||||
return () => {
|
||||
document.title = prev;
|
||||
};
|
||||
}, [config?.meta_title]);
|
||||
}, [config?.meta_title, config?.title]);
|
||||
|
||||
// SEO: set meta description
|
||||
useEffect(() => {
|
||||
@@ -1069,8 +1119,16 @@ export default function QuickPurchase() {
|
||||
const showTariffCards = visibleTariffs.length > 1;
|
||||
|
||||
return (
|
||||
<div className={cn('min-h-dvh overflow-x-hidden', !config.background_config && 'bg-dark-950')}>
|
||||
{config.background_config && <StaticBackgroundRenderer config={config.background_config} />}
|
||||
<div className="min-h-dvh overflow-x-hidden">
|
||||
{/* 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">
|
||||
{/* Language switcher */}
|
||||
<div className="mb-4 flex justify-end">
|
||||
|
||||
@@ -60,11 +60,13 @@ export function CampaignDetailPanel({ campaignId, className }: CampaignDetailPan
|
||||
<div className="space-y-5">
|
||||
{/* Info */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-dark-500">
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="shrink-0 text-dark-500">
|
||||
{t('admin.referralNetwork.campaign.startParam')}
|
||||
</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 className="flex justify-end">
|
||||
<span
|
||||
@@ -133,17 +135,19 @@ export function CampaignDetailPanel({ campaignId, className }: CampaignDetailPan
|
||||
{campaign.top_referrers.map((referrer, index) => (
|
||||
<div
|
||||
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">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-dark-700 text-[10px] font-medium text-dark-300">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<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}
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
<span className="truncate text-dark-200">
|
||||
{referrer.username ? `@${referrer.username}` : `#${referrer.user_id}`}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
@@ -61,9 +61,9 @@ export function UserDetailPanel({ userId, className }: UserDetailPanelProps) {
|
||||
{/* Identity */}
|
||||
<div className="space-y-2">
|
||||
{user.username && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-dark-500">@</span>
|
||||
<span className="font-mono text-dark-200">{user.username}</span>
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="shrink-0 text-dark-500">@</span>
|
||||
<span className="min-w-0 truncate font-mono text-dark-200">{user.username}</span>
|
||||
</div>
|
||||
)}
|
||||
{user.tg_id && (
|
||||
@@ -196,11 +196,11 @@ export function UserDetailPanel({ userId, className }: UserDetailPanelProps) {
|
||||
</span>
|
||||
</div>
|
||||
{user.campaign_name && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-dark-400">
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="shrink-0 text-dark-400">
|
||||
{t('admin.referralNetwork.user.fromCampaign')}
|
||||
</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>
|
||||
|
||||
@@ -62,6 +62,12 @@ export default function Subscriptions() {
|
||||
const subscriptions = data?.subscriptions ?? [];
|
||||
const isMultiTariff = data?.multi_tariff_enabled ?? false;
|
||||
const hasNoSubscriptions = !isLoading && subscriptions.length === 0;
|
||||
// Есть ли хотя бы одна НАСТОЯЩАЯ (платная, не триал) живая подписка. От этого
|
||||
// зависит CTA: «+ Купить ещё» — только если уже есть платная; иначе показываем
|
||||
// явную «Посмотреть тарифы и купить подписку» (триал/истёкшие — это ещё не покупка).
|
||||
const hasActivePaid = subscriptions.some(
|
||||
(s) => !s.is_trial && (s.status === 'active' || s.status === 'limited'),
|
||||
);
|
||||
|
||||
// Если у юзера нет подписок — проверяем доступность триала, иначе
|
||||
// (в multi-tariff) ему вообще негде увидеть оффер.
|
||||
@@ -107,7 +113,8 @@ export default function Subscriptions() {
|
||||
<h1 className="text-2xl font-bold" style={{ color: g.text }}>
|
||||
{t('subscriptions.title', 'Мои подписки')}
|
||||
</h1>
|
||||
{!isLoading && subscriptions.length > 0 && (
|
||||
{/* «+ Купить ещё» — только если уже есть платная активная подписка */}
|
||||
{!isLoading && hasActivePaid && (
|
||||
<button
|
||||
onClick={() => navigate('/subscription/purchase')}
|
||||
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>
|
||||
|
||||
{/* Есть подписки, но платной активной нет (только триал/истёкшие) —
|
||||
даём ЯВНУЮ 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 */}
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
@@ -152,9 +171,9 @@ export default function Subscriptions() {
|
||||
(Telegram-баг #605056/#605063). */}
|
||||
<button
|
||||
onClick={() => navigate('/subscription/purchase')}
|
||||
className="w-full rounded-xl border px-6 py-3 text-sm font-medium transition-colors"
|
||||
style={{ background: g.innerBg, borderColor: g.cardBorder, color: g.text }}
|
||||
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"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
{t('subscriptions.browsePlans', 'Посмотреть тарифы и купить подписку')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
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 {
|
||||
// Find prize index by matching display_name + emoji (both must match for safety)
|
||||
let prizeIndex = prizes.findIndex(
|
||||
let prizeIndex = result.prize_id != null ? prizes.findIndex((p) => p.id === result.prize_id) : -1;
|
||||
|
||||
// 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,
|
||||
);
|
||||
|
||||
// Fallback: match by display_name only
|
||||
if (prizeIndex === -1) {
|
||||
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) {
|
||||
// Can't determine prize position — random angle
|
||||
return Math.random() * 360;
|
||||
if (prizeIndex === -1) prizeIndex = neutralIndex(prizes); // unknown → neutral, never random
|
||||
|
||||
return rotationForIndex(prizes, prizeIndex);
|
||||
}
|
||||
|
||||
const sectorAngle = 360 / prizes.length;
|
||||
const baseAngle = prizeIndex * sectorAngle + sectorAngle / 2;
|
||||
const offset = (Math.random() - 0.5) * sectorAngle * 0.6; // ±30% of sector
|
||||
const stopAngle = 360 - baseAngle + offset;
|
||||
|
||||
return stopAngle;
|
||||
/**
|
||||
* Rotation used when the real result is unknown (e.g. the Stars-payment poll timed
|
||||
* out). Lands on the neutral ("Nothing") sector — never a random/winning angle.
|
||||
*/
|
||||
function neutralRotation(prizes: WheelPrize[]): number {
|
||||
return rotationForIndex(prizes, neutralIndex(prizes));
|
||||
}
|
||||
|
||||
export default function Wheel() {
|
||||
@@ -144,7 +163,10 @@ export default function Wheel() {
|
||||
// Found a new spin! Return it as SpinResult
|
||||
return {
|
||||
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_value: latestSpin.prize_value,
|
||||
prize_display_name: latestSpin.prize_display_name,
|
||||
@@ -219,13 +241,11 @@ export default function Wheel() {
|
||||
|
||||
if (result) {
|
||||
pendingStarsResultRef.current = result;
|
||||
// Calculate rotation so the wheel lands on the correct prize sector
|
||||
const rotation = config?.prizes
|
||||
? calculateRotationForPrize(config.prizes, result)
|
||||
: Math.random() * 360;
|
||||
setTargetRotation(rotation);
|
||||
// Land on the ACTUAL won sector (matched by prize_id).
|
||||
setTargetRotation(calculateRotationForPrize(config?.prizes ?? [], result));
|
||||
} 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 = {
|
||||
success: true,
|
||||
prize_id: null,
|
||||
@@ -239,7 +259,7 @@ export default function Wheel() {
|
||||
promocode: null,
|
||||
error: null,
|
||||
};
|
||||
setTargetRotation(Math.random() * 360);
|
||||
setTargetRotation(neutralRotation(config?.prizes ?? []));
|
||||
}
|
||||
|
||||
setIsSpinning(true);
|
||||
@@ -249,7 +269,8 @@ export default function Wheel() {
|
||||
|
||||
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 = {
|
||||
success: true,
|
||||
prize_id: null,
|
||||
@@ -263,7 +284,7 @@ export default function Wheel() {
|
||||
promocode: null,
|
||||
error: null,
|
||||
};
|
||||
setTargetRotation(Math.random() * 360);
|
||||
setTargetRotation(neutralRotation(config?.prizes ?? []));
|
||||
setIsSpinning(true);
|
||||
});
|
||||
} else if (status !== 'cancelled') {
|
||||
@@ -578,16 +599,16 @@ export default function Wheel() {
|
||||
key={sub.id}
|
||||
onClick={() => setSelectedSubscriptionId(sub.id)}
|
||||
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
|
||||
? 'bg-accent-500/15 text-accent-400'
|
||||
: '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', 'Подписка')}
|
||||
</span>
|
||||
<span className="text-xs opacity-60">
|
||||
<span className="shrink-0 text-xs opacity-60">
|
||||
{sub.days_left} {t('common.units.days', 'дней')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -217,6 +217,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
const user = await authApi.getMe();
|
||||
await get().checkAdminStatus();
|
||||
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 {
|
||||
clearSession();
|
||||
}
|
||||
@@ -237,6 +242,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
} catch {
|
||||
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 {
|
||||
clearSession();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export type BlockingType =
|
||||
| 'channel_subscription'
|
||||
| 'blacklisted'
|
||||
| 'account_deleted'
|
||||
| 'backend_unavailable'
|
||||
| null;
|
||||
|
||||
interface MaintenanceInfo {
|
||||
@@ -54,6 +55,9 @@ interface BlockingState {
|
||||
setChannelSubscription: (info: ChannelSubscriptionInfo) => void;
|
||||
setBlacklisted: (info: BlacklistedInfo) => 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;
|
||||
}
|
||||
|
||||
@@ -100,6 +104,15 @@ export const useBlockingStore = create<BlockingState>((set) => ({
|
||||
blacklistedInfo: null,
|
||||
}),
|
||||
|
||||
setBackendUnavailable: () =>
|
||||
set({
|
||||
blockingType: 'backend_unavailable',
|
||||
maintenanceInfo: null,
|
||||
channelInfo: null,
|
||||
blacklistedInfo: null,
|
||||
accountDeletedInfo: null,
|
||||
}),
|
||||
|
||||
clearBlocking: () =>
|
||||
set({
|
||||
blockingType: null,
|
||||
|
||||
@@ -486,6 +486,8 @@ export interface TicketMediaItem {
|
||||
type: 'photo' | 'video' | 'document';
|
||||
file_id: string;
|
||||
caption?: string | null;
|
||||
/** Signed, expiring download token (response only). */
|
||||
token?: string | null;
|
||||
}
|
||||
|
||||
export interface TicketMessage {
|
||||
@@ -495,6 +497,8 @@ export interface TicketMessage {
|
||||
has_media: boolean;
|
||||
media_type: 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_items?: TicketMediaItem[] | null;
|
||||
created_at: string;
|
||||
|
||||
94
src/utils/favicon.ts
Normal file
94
src/utils/favicon.ts
Normal 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();
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
deleteCloudStorageItem,
|
||||
} from '@telegram-apps/sdk-react';
|
||||
import { isInTelegramWebApp } from '../hooks/useTelegramSDK';
|
||||
import { API } from '../config/constants';
|
||||
import { reportPossibleBackendDown } from '../api/health';
|
||||
|
||||
const TOKEN_KEYS = {
|
||||
ACCESS: 'access_token',
|
||||
@@ -205,6 +207,17 @@ class TokenRefreshManager {
|
||||
private subscribers: ((token: string | null) => void)[] = [];
|
||||
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 {
|
||||
this.refreshEndpoint = endpoint;
|
||||
}
|
||||
@@ -214,6 +227,8 @@ class TokenRefreshManager {
|
||||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
this.lastFailureWasTransport = false;
|
||||
|
||||
const refreshToken = tokenStorage.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
return null;
|
||||
@@ -238,7 +253,10 @@ class TokenRefreshManager {
|
||||
const response = await axios.post<{ access_token?: string }>(
|
||||
this.refreshEndpoint,
|
||||
{ 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;
|
||||
@@ -249,7 +267,16 @@ class TokenRefreshManager {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
4
src/vite-env.d.ts
vendored
4
src/vite-env.d.ts
vendored
@@ -7,6 +7,8 @@ interface ImportMetaEnv {
|
||||
readonly VITE_TELEGRAM_BOT_USERNAME?: string;
|
||||
readonly VITE_APP_NAME?: 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 {
|
||||
@@ -17,6 +19,8 @@ interface ImportMeta {
|
||||
interface TelegramWebAppGlobal {
|
||||
onEvent?: (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 */
|
||||
|
||||
@@ -27,6 +27,13 @@ export default defineConfig({
|
||||
// Strip /api prefix: /api/cabinet/auth -> /cabinet/auth
|
||||
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: {
|
||||
|
||||
Reference in New Issue
Block a user