Merge pull request #155 from BEDOLAGA-DEV/refactor/full-codebase-cleanup

Refactor/full codebase cleanup
This commit is contained in:
c0mrade
2026-02-06 17:09:12 +03:00
committed by GitHub
120 changed files with 1351 additions and 2856 deletions

2
.gitignore vendored
View File

@@ -46,3 +46,5 @@ miniapp/
# AI & Agents context
.ai/
CLAUDE.md
**/CLAUDE.md

838
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,6 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@kastov/cryptohapp": "^1.1.2",
"@lottiefiles/dotlottie-react": "^0.8.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -34,41 +33,42 @@
"@radix-ui/react-visually-hidden": "^1.2.4",
"@tanstack/react-query": "^5.8.0",
"@telegram-apps/sdk-react": "^3.3.9",
"autoprefixer": "^10.4.24",
"axios": "^1.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dompurify": "^3.3.1",
"framer-motion": "^12.29.2",
"i18next": "^23.7.0",
"i18next-browser-languagedetector": "^7.2.0",
"i18next": "^25.8.4",
"i18next-browser-languagedetector": "^8.2.0",
"jsencrypt": "^3.5.4",
"ogl": "^1.0.11",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^13.5.0",
"react-router-dom": "^6.30.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^16.5.4",
"react-router": "^7.13.0",
"tailwind-merge": "^3.4.0",
"zustand": "^4.4.0"
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/dompurify": "^3.0.5",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@types/node": "^25.2.1",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"autoprefixer": "^10.4.16",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-react-refresh": "^0.5.0",
"globals": "^17.1.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"postcss": "^8.4.31",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^3.3.5",
"tailwindcss": "^3.4.19",
"typescript": "^5.2.2",
"typescript-eslint": "^8.54.0",
"vite": "^7.3.1"

View File

@@ -1,5 +1,5 @@
import { lazy, Suspense } from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { Routes, Route, Navigate, useLocation } from 'react-router';
import { useAuthStore } from './store/auth';
import { useBlockingStore } from './store/blocking';
import Layout from './components/layout/Layout';

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useCallback } from 'react';
import { BrowserRouter, useLocation, useNavigate } from 'react-router-dom';
import { BrowserRouter, useLocation, useNavigate } from 'react-router';
import {
showBackButton,
hideBackButton,
@@ -7,6 +7,7 @@ import {
offBackButtonClick,
} from '@telegram-apps/sdk-react';
import App from './App';
import { ErrorBoundary } from './components/ErrorBoundary';
import { PlatformProvider } from './platform/PlatformProvider';
import { ThemeColorsProvider } from './providers/ThemeColorsProvider';
import { WebSocketProvider } from './providers/WebSocketProvider';
@@ -66,17 +67,19 @@ export function AppWithNavigator() {
return (
<BrowserRouter>
{isTelegram && <TelegramBackButton />}
<PlatformProvider>
<ThemeColorsProvider>
<TooltipProvider>
<ToastProvider>
<WebSocketProvider>
<App />
</WebSocketProvider>
</ToastProvider>
</TooltipProvider>
</ThemeColorsProvider>
</PlatformProvider>
<ErrorBoundary level="page">
<PlatformProvider>
<ThemeColorsProvider>
<TooltipProvider>
<ToastProvider>
<WebSocketProvider>
<App />
</WebSocketProvider>
</ToastProvider>
</TooltipProvider>
</ThemeColorsProvider>
</PlatformProvider>
</ErrorBoundary>
</BrowserRouter>
);
}

View File

@@ -7,6 +7,7 @@ import {
safeRedirectToLogin,
} from '../utils/token';
import { useBlockingStore } from '../store/blocking';
import { API } from '../config/constants';
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
@@ -57,6 +58,7 @@ const getTelegramInitData = (): string | null => {
export const apiClient = axios.create({
baseURL: API_BASE_URL,
timeout: API.TIMEOUT_MS,
headers: {
'Content-Type': 'application/json',
},

View File

@@ -13,6 +13,7 @@ export interface ContestInfo {
export interface ContestGameData {
round_id: number;
game_type: string;
// Dynamic game data from backend - shape depends on game_type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
game_data: Record<string, any>;
instructions: string;

View File

@@ -10,3 +10,22 @@ export { promoApi } from './promo';
export { notificationsApi } from './notifications';
export { infoApi } from './info';
export { adminSettingsApi } from './adminSettings';
export { adminApi, statsApi } from './admin';
export { adminAppsApi } from './adminApps';
export { adminBroadcastsApi } from './adminBroadcasts';
export { adminEmailTemplatesApi } from './adminEmailTemplates';
export { adminPaymentMethodsApi } from './adminPaymentMethods';
export { adminPaymentsApi } from './adminPayments';
export { adminRemnawaveApi } from './adminRemnawave';
export { adminUsersApi } from './adminUsers';
export { banSystemApi } from './banSystem';
export { brandingApi } from './branding';
export { campaignsApi } from './campaigns';
export { currencyApi } from './currency';
export { promocodesApi } from './promocodes';
export { promoOffersApi } from './promoOffers';
export { serversApi } from './servers';
export { tariffsApi } from './tariffs';
export { themeColorsApi } from './themeColors';
export { ticketNotificationsApi } from './ticketNotifications';
export { wheelApi, adminWheelApi } from './wheel';

View File

@@ -1,44 +0,0 @@
import axios, { AxiosError } from 'axios';
export interface MiniappCreatePaymentPayload {
initData: string;
method: string;
amountKopeks?: number | null;
option?: string | null;
}
export interface MiniappCreatePaymentResponse {
payment_url: string;
amount_kopeks?: number;
extra?: Record<string, unknown>;
}
export const miniappApi = {
// Create payment inside Telegram Mini App (same flow as miniapp/index.html)
createPayment: async (
payload: MiniappCreatePaymentPayload,
): Promise<MiniappCreatePaymentResponse> => {
try {
const response = await axios.post<MiniappCreatePaymentResponse>(
'/miniapp/payments/create',
{
initData: payload.initData || '',
method: payload.method,
amountKopeks: payload.amountKopeks ?? null,
option: payload.option ?? null,
},
{
headers: { 'Content-Type': 'application/json' },
},
);
return response.data;
} catch (error) {
const axiosError = error as AxiosError<{ detail?: string; message?: string }>;
const message =
axiosError.response?.data?.detail ||
axiosError.response?.data?.message ||
'Failed to create payment';
throw new Error(message);
}
},
};

View File

@@ -9,6 +9,7 @@ export interface PromoOffer {
is_active: boolean;
is_claimed: boolean;
claimed_at: string | null;
// Dynamic extra data from backend
// eslint-disable-next-line @typescript-eslint/no-explicit-any
extra_data: Record<string, any> | null;
}

View File

@@ -31,6 +31,7 @@ export interface PromoOffer {
claimed_at: string | null;
is_active: boolean;
effect_type: string;
// Dynamic extra data from backend - shape depends on effect_type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
extra_data: Record<string, any>;
created_at: string;

View File

@@ -3,9 +3,10 @@ import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { authApi } from '../api/auth';
import { isValidEmail } from '../utils/validation';
import { UI } from '../config/constants';
import { useAuthStore } from '../store/auth';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
import { useBackButton } from '@/platform';
import { useTelegramSDK } from '../hooks/useTelegramSDK';
// Icons
const CloseIcon = () => (
@@ -50,13 +51,13 @@ function useIsMobile() {
return isMobile;
}
const RESEND_COOLDOWN = 60; // seconds
const RESEND_COOLDOWN = UI.RESEND_COOLDOWN_SEC;
export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { setUser } = useAuthStore();
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramSDK();
const isMobileScreen = useIsMobile();
const emailInputRef = useRef<HTMLInputElement>(null);
@@ -88,9 +89,6 @@ export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailM
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleClose]);
// Telegram back button - using platform hook
useBackButton(handleClose);
// Scroll lock
useEffect(() => {
const scrollY = window.scrollY;
@@ -181,18 +179,13 @@ export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailM
},
});
const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email.trim());
};
const handleSendCode = () => {
setError(null);
if (!newEmail.trim()) {
setError(t('profile.emailRequired'));
return;
}
if (!validateEmail(newEmail)) {
if (!isValidEmail(newEmail.trim())) {
setError(t('profile.invalidEmail'));
return;
}

View File

@@ -0,0 +1,93 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
interface ErrorBoundaryProps {
children: ReactNode;
level?: 'app' | 'page' | 'widget';
onReset?: () => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('[ErrorBoundary]', error, errorInfo);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
this.props.onReset?.();
};
render() {
if (!this.state.hasError) {
return this.props.children;
}
const { level = 'page' } = this.props;
if (level === 'app') {
return (
<div className="flex min-h-screen items-center justify-center bg-dark-900 p-4">
<div className="max-w-md text-center">
<div className="mb-4 text-4xl"></div>
<h1 className="mb-2 text-xl font-bold text-dark-50">Something went wrong</h1>
<p className="mb-6 text-dark-400">
An unexpected error occurred. Please try reloading the page.
</p>
<button
onClick={() => window.location.reload()}
className="rounded-xl bg-accent-500 px-6 py-3 font-medium text-white transition-colors hover:bg-accent-600"
>
Reload page
</button>
</div>
</div>
);
}
if (level === 'widget') {
return (
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-center">
<p className="text-sm text-error-400">Failed to load this section</p>
<button
onClick={this.handleReset}
className="mt-2 text-sm text-accent-400 hover:text-accent-300"
>
Try again
</button>
</div>
);
}
// level === 'page' (default)
return (
<div className="flex min-h-[50vh] items-center justify-center p-4">
<div className="max-w-md text-center">
<div className="mb-4 text-4xl"></div>
<h1 className="mb-2 text-xl font-bold text-dark-50">Something went wrong</h1>
<p className="mb-6 text-sm text-dark-400">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<button
onClick={this.handleReset}
className="rounded-xl bg-accent-500 px-6 py-3 font-medium text-white transition-colors hover:bg-accent-600"
>
Try again
</button>
</div>
</div>
);
}
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigate, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useCurrency } from '../hooks/useCurrency';

View File

@@ -1,385 +0,0 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { promoApi } from '../api/promo';
const SparklesIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
);
const ClockIcon = () => (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
const XCircleIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
const WarningIcon = () => (
<svg
className="h-12 w-12"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
);
const formatTimeLeft = (expiresAt: string, t: (key: string) => string): string => {
const now = new Date();
// Ensure UTC parsing - if no timezone specified, assume UTC
let expires: Date;
if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) {
expires = new Date(expiresAt);
} else {
// No timezone - treat as UTC
expires = new Date(expiresAt + 'Z');
}
const diffMs = expires.getTime() - now.getTime();
if (diffMs <= 0) return '';
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 24) {
const days = Math.floor(hours / 24);
return `${days}${t('promo.time.days')}`;
}
if (hours > 0) {
return `${hours}${t('promo.time.hours')} ${minutes}${t('promo.time.minutes')}`;
}
return `${minutes}${t('promo.time.minutes')}`;
};
// ------- Confirmation Modal -------
interface DeactivateConfirmModalProps {
isOpen: boolean;
discountPercent: number;
isDeactivating: boolean;
onConfirm: () => void;
onCancel: () => void;
}
function DeactivateConfirmModal({
isOpen,
discountPercent,
isDeactivating,
onConfirm,
onCancel,
}: DeactivateConfirmModalProps) {
const { t } = useTranslation();
// Escape key to close
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isDeactivating) {
e.preventDefault();
onCancel();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, isDeactivating, onCancel]);
// Scroll lock
useEffect(() => {
if (!isOpen) return;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
if (!isOpen) return null;
const modalContent = (
<div
className="fixed inset-0 z-[100] flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-labelledby="deactivate-modal-title"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={isDeactivating ? undefined : onCancel}
/>
{/* Modal */}
<div
className="relative mx-4 w-full max-w-sm overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Warning header */}
<div className="flex flex-col items-center bg-gradient-to-br from-warning-500/20 to-red-500/20 px-6 pb-6 pt-8">
<div className="mb-3 text-warning-400">
<WarningIcon />
</div>
<h2 id="deactivate-modal-title" className="text-center text-lg font-bold text-dark-100">
{t('promo.deactivate.confirmTitle')}
</h2>
<p className="mt-2 text-center text-sm text-dark-300">
{t('promo.deactivate.confirmDescription', { percent: discountPercent })}
</p>
</div>
{/* Warning text */}
<div className="px-6 py-4">
<div className="rounded-lg border border-warning-500/20 bg-warning-500/10 px-4 py-3">
<p className="text-center text-sm text-warning-400">{t('promo.deactivate.warning')}</p>
</div>
</div>
{/* Action buttons */}
<div className="flex gap-3 px-6 pb-6">
<button
onClick={onCancel}
disabled={isDeactivating}
className="flex-1 rounded-xl bg-dark-800 py-3 font-semibold text-dark-300 transition-colors hover:bg-dark-700 hover:text-dark-100 disabled:opacity-50"
>
{t('common.cancel')}
</button>
<button
onClick={onConfirm}
disabled={isDeactivating}
className="flex-1 rounded-xl bg-gradient-to-r from-red-500 to-red-600 py-3 font-semibold text-white shadow-lg shadow-red-500/25 transition-all hover:from-red-400 hover:to-red-500 active:from-red-600 active:to-red-700 disabled:opacity-50"
>
{isDeactivating ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('promo.deactivate.deactivating')}
</span>
) : (
t('promo.deactivate.confirm')
)}
</button>
</div>
</div>
</div>
);
if (typeof document !== 'undefined') {
return createPortal(modalContent, document.body);
}
return modalContent;
}
// ------- Main Component -------
export default function PromoDiscountBadge() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState(false);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [deactivateError, setDeactivateError] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const { data: activeDiscount } = useQuery({
queryKey: ['active-discount'],
queryFn: promoApi.getActiveDiscount,
staleTime: 30000,
refetchInterval: 60000, // Refresh every minute
});
const deactivateMutation = useMutation({
mutationFn: async () => {
const source = activeDiscount?.source;
if (source && source.startsWith('promocode:')) {
await promoApi.deactivateDiscount();
} else {
await promoApi.clearActiveDiscount();
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['active-discount'] });
queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
setShowConfirmModal(false);
setIsOpen(false);
setDeactivateError(null);
},
onError: (error: unknown) => {
const axiosErr = error as { response?: { data?: { detail?: string } } };
setDeactivateError(axiosErr.response?.data?.detail || t('promo.deactivate.error'));
},
});
const handleCloseConfirmModal = useCallback(() => {
if (!deactivateMutation.isPending) {
setShowConfirmModal(false);
setDeactivateError(null);
}
}, [deactivateMutation.isPending]);
const handleConfirmDeactivate = useCallback(() => {
setDeactivateError(null);
deactivateMutation.mutate();
}, [deactivateMutation]);
// Close dropdown on click outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Don't render if no active discount
if (!activeDiscount || !activeDiscount.is_active || !activeDiscount.discount_percent) {
return null;
}
const timeLeft = activeDiscount.expires_at ? formatTimeLeft(activeDiscount.expires_at, t) : null;
const handleClick = () => {
setIsOpen(!isOpen);
};
const handleGoToSubscription = () => {
setIsOpen(false);
navigate('/subscription');
};
const handleDeactivateClick = () => {
setDeactivateError(null);
setShowConfirmModal(true);
};
return (
<>
<div className="relative" ref={dropdownRef}>
{/* Badge button */}
<button
onClick={handleClick}
className="group relative flex items-center gap-1 rounded-lg bg-success-500/15 px-2.5 py-1.5 transition-all hover:bg-success-500/25"
title={t('promo.activeDiscount', 'Active discount')}
>
<span className="text-sm font-bold text-success-400">
-{activeDiscount.discount_percent}%
</span>
</button>
{/* Dropdown - mobile: fixed centered, desktop: absolute right */}
{isOpen && (
<>
{/* Mobile backdrop */}
<div
className="fixed inset-0 z-40 bg-black/30 sm:hidden"
onClick={() => setIsOpen(false)}
/>
<div className="fixed left-4 right-4 top-20 z-50 animate-fade-in overflow-hidden rounded-xl border border-dark-700/50 bg-dark-800 shadow-xl sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:mt-2 sm:w-72">
{/* Header */}
<div className="border-b border-dark-700/50 bg-gradient-to-r from-success-500/20 to-accent-500/20 p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-success-500/20 text-success-400">
<SparklesIcon />
</div>
<div>
<div className="font-semibold text-dark-100">{t('promo.discountActive')}</div>
<div className="text-2xl font-bold text-success-400">
-{activeDiscount.discount_percent}%
</div>
</div>
</div>
</div>
{/* Content */}
<div className="space-y-3 p-4">
<p className="text-sm text-dark-300">{t('promo.discountDescription')}</p>
{/* Time remaining */}
{timeLeft && (
<div className="flex items-center gap-2 rounded-lg bg-dark-900/50 px-3 py-2 text-sm text-dark-400">
<ClockIcon />
<span>
{t('promo.expiresIn')}:{' '}
<span className="font-medium text-warning-400">{timeLeft}</span>
</span>
</div>
)}
{/* Deactivation error */}
{deactivateError && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-400">
{deactivateError}
</div>
)}
{/* CTA Button */}
<button
onClick={handleGoToSubscription}
className="btn-primary w-full py-2.5 text-sm font-medium"
>
{t('promo.useNow')}
</button>
{/* Deactivate Button */}
<button
onClick={handleDeactivateClick}
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dark-600/50 bg-dark-900/50 py-2 text-sm text-dark-400 transition-colors hover:border-red-500/30 hover:bg-red-500/10 hover:text-red-400"
aria-label={t('promo.deactivate.button')}
>
<XCircleIcon />
<span>{t('promo.deactivate.button')}</span>
</button>
</div>
</div>
</>
)}
</div>
{/* Deactivation Confirmation Modal */}
<DeactivateConfirmModal
isOpen={showConfirmModal}
discountPercent={activeDiscount.discount_percent}
isDeactivating={deactivateMutation.isPending}
onConfirm={handleConfirmDeactivate}
onCancel={handleCloseConfirmModal}
/>
</>
);
}

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { promoApi, PromoOffer } from '../api/promo';
import { ClockIcon, CheckIcon } from './icons';
import { usePlatform } from '@/platform/hooks/usePlatform';

View File

@@ -6,11 +6,11 @@
import { useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useSuccessNotification } from '../store/successNotification';
import { useCurrency } from '../hooks/useCurrency';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
import { useBackButton, useHaptic } from '@/platform';
import { useTelegramSDK } from '../hooks/useTelegramSDK';
import { useHaptic } from '@/platform';
// Icons
const CheckCircleIcon = () => (
@@ -80,7 +80,7 @@ export default function SuccessNotificationModal() {
const navigate = useNavigate();
const { isOpen, data, hide } = useSuccessNotification();
const { formatAmount, currencySymbol } = useCurrency();
const { safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramWebApp();
const { safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramSDK();
const haptic = useHaptic();
const safeBottom = isTelegramWebApp
@@ -106,9 +106,6 @@ export default function SuccessNotificationModal() {
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, handleClose]);
// Telegram back button - using platform hook
useBackButton(isOpen ? handleClose : null);
// Haptic feedback on open
useEffect(() => {
if (isOpen) {

View File

@@ -1,579 +0,0 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { COLOR_PRESETS, ColorPreset } from '../data/colorPresets';
import { hexToHsl, hslToHex, isValidHex, HSLColor } from '../utils/colorConversion';
import { ThemeColors, DEFAULT_THEME_COLORS } from '../types/theme';
import { applyThemeColors } from '../hooks/useThemeColors';
interface ThemeBentoPickerProps {
currentColors: ThemeColors;
onColorsChange: (colors: ThemeColors) => void;
onSave: () => void;
isSaving: boolean;
}
const CheckIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
);
const ChevronDownIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
const MoonIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
/>
</svg>
);
const SunIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
/>
</svg>
);
const StatusIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
/>
</svg>
);
const PaletteIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z"
/>
</svg>
);
function PresetCard({
preset,
isSelected,
onClick,
}: {
preset: ColorPreset;
isSelected: boolean;
onClick: () => void;
}) {
const { i18n } = useTranslation();
const isRu = i18n.language === 'ru';
return (
<button
onClick={onClick}
className={`group relative flex h-full w-full flex-col rounded-2xl p-3 text-left transition-all duration-200 ${
isSelected
? 'z-10 scale-[1.02] border-2 border-accent-500 bg-dark-800/90 shadow-lg shadow-accent-500/20'
: 'border border-dark-700/50 bg-dark-900/60 hover:scale-[1.01] hover:border-dark-600/60 hover:bg-dark-800/70'
}`}
>
<div
className="relative mb-2.5 h-12 w-full shrink-0 overflow-hidden rounded-xl"
style={{ backgroundColor: preset.preview.background }}
>
<div
className="absolute bottom-1.5 left-1.5 h-6 w-6 rounded-lg shadow-md"
style={{ backgroundColor: preset.preview.accent }}
/>
<div
className="absolute bottom-2.5 right-2 h-1 w-10 rounded-full opacity-60"
style={{ backgroundColor: preset.preview.text }}
/>
<div
className="absolute bottom-5 right-2 h-1 w-7 rounded-full opacity-40"
style={{ backgroundColor: preset.preview.text }}
/>
</div>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<h4 className="truncate text-xs font-semibold text-dark-100">
{isRu ? preset.nameRu : preset.name}
</h4>
</div>
{isSelected && (
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent-500 text-white">
<CheckIcon />
</div>
)}
</div>
</button>
);
}
function HSLSlider({
label,
value,
onChange,
max,
gradient,
suffix = '',
}: {
label: string;
value: number;
onChange: (value: number) => void;
max: number;
gradient: string;
suffix?: string;
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-dark-300">{label}</label>
<span className="font-mono text-xs tabular-nums text-dark-500">
{value}
{suffix}
</span>
</div>
<input
type="range"
min="0"
max={max}
value={value}
onChange={(e) => onChange(parseInt(e.target.value))}
className="h-2.5 w-full cursor-pointer appearance-none rounded-full"
style={{ background: gradient }}
/>
</div>
);
}
function CompactColorInput({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (color: string) => void;
}) {
const [localValue, setLocalValue] = useState(value);
const [isEditing, setIsEditing] = useState(false);
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleChange = (newValue: string) => {
let formatted = newValue.toUpperCase();
if (!formatted.startsWith('#')) {
formatted = '#' + formatted;
}
setLocalValue(formatted);
if (isValidHex(formatted)) {
onChange(formatted);
}
};
const handleBlur = () => {
setIsEditing(false);
if (!isValidHex(localValue)) {
setLocalValue(value);
}
};
return (
<div className="group flex items-center gap-2 rounded-xl bg-dark-800/40 p-2 transition-colors hover:bg-dark-800/60">
<button
type="button"
onClick={() => setIsEditing(true)}
className="h-8 w-8 shrink-0 rounded-lg border border-dark-600/50 shadow-inner transition-transform hover:scale-105"
style={{ backgroundColor: value }}
/>
<div className="min-w-0 flex-1">
<label className="mb-0.5 block text-[10px] uppercase leading-none tracking-wide text-dark-500">
{label}
</label>
{isEditing ? (
<input
type="text"
value={localValue}
onChange={(e) => handleChange(e.target.value)}
onBlur={handleBlur}
onKeyDown={(e) => e.key === 'Enter' && handleBlur()}
autoFocus
className="w-full bg-transparent font-mono text-xs text-dark-200 outline-none"
maxLength={7}
/>
) : (
<button
type="button"
onClick={() => setIsEditing(true)}
className="text-left font-mono text-xs text-dark-300 transition-colors hover:text-dark-100"
>
{value.toUpperCase()}
</button>
)}
</div>
</div>
);
}
function CollapsibleSection({
title,
icon,
isOpen,
onToggle,
children,
badge,
}: {
title: string;
icon: React.ReactNode;
isOpen: boolean;
onToggle: () => void;
children: React.ReactNode;
badge?: string;
}) {
return (
<div className="overflow-hidden rounded-2xl border border-dark-700/40 bg-dark-900/50">
<button
onClick={onToggle}
className="flex w-full items-center justify-between px-4 py-3 transition-colors hover:bg-dark-800/30"
>
<div className="flex items-center gap-2.5">
<div className="text-dark-400">{icon}</div>
<span className="text-sm font-medium text-dark-200">{title}</span>
{badge && (
<span className="rounded-md bg-dark-700/50 px-1.5 py-0.5 font-mono text-[10px] text-dark-400">
{badge}
</span>
)}
</div>
<div
className={`text-dark-500 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
>
<ChevronDownIcon />
</div>
</button>
<div
className={`grid transition-all duration-200 ease-out ${
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
}`}
>
<div className="overflow-hidden">
<div className="border-t border-dark-700/30 px-4 pb-4 pt-1">{children}</div>
</div>
</div>
</div>
);
}
export function ThemeBentoPicker({
currentColors,
onColorsChange,
onSave,
isSaving,
}: ThemeBentoPickerProps) {
const { t } = useTranslation();
const [hsl, setHsl] = useState<HSLColor>(() => hexToHsl(currentColors.accent));
const [hexInput, setHexInput] = useState(currentColors.accent);
const [hasChanges, setHasChanges] = useState(false);
const [isPresetsOpen, setIsPresetsOpen] = useState(false);
const [isAccentOpen, setIsAccentOpen] = useState(false);
const [isDarkOpen, setIsDarkOpen] = useState(false);
const [isLightOpen, setIsLightOpen] = useState(false);
const [isStatusOpen, setIsStatusOpen] = useState(false);
const selectedPresetId = useMemo(() => {
const match = COLOR_PRESETS.find(
(p) =>
p.colors.accent.toLowerCase() === currentColors.accent.toLowerCase() &&
p.colors.darkBackground.toLowerCase() === currentColors.darkBackground.toLowerCase() &&
p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase(),
);
return match?.id ?? null;
}, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground]);
useEffect(() => {
setHsl(hexToHsl(currentColors.accent));
setHexInput(currentColors.accent);
}, [currentColors.accent]);
const updateColor = useCallback(
(key: keyof ThemeColors, value: string) => {
const newColors = { ...currentColors, [key]: value };
onColorsChange(newColors);
applyThemeColors(newColors);
setHasChanges(true);
},
[currentColors, onColorsChange],
);
const updateAccentFromHsl = useCallback(
(newHsl: HSLColor) => {
setHsl(newHsl);
const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l);
setHexInput(newHex);
updateColor('accent', newHex);
},
[updateColor],
);
const handleHexInputChange = (value: string) => {
setHexInput(value);
if (isValidHex(value)) {
const newHsl = hexToHsl(value);
setHsl(newHsl);
updateColor('accent', value);
}
};
const handlePresetSelect = (preset: ColorPreset) => {
onColorsChange(preset.colors);
applyThemeColors(preset.colors);
setHasChanges(true);
};
const hueGradient = useMemo(() => {
return `linear-gradient(to right,
hsl(0, ${hsl.s}%, ${hsl.l}%),
hsl(60, ${hsl.s}%, ${hsl.l}%),
hsl(120, ${hsl.s}%, ${hsl.l}%),
hsl(180, ${hsl.s}%, ${hsl.l}%),
hsl(240, ${hsl.s}%, ${hsl.l}%),
hsl(300, ${hsl.s}%, ${hsl.l}%),
hsl(360, ${hsl.s}%, ${hsl.l}%)
)`;
}, [hsl.s, hsl.l]);
const saturationGradient = useMemo(() => {
return `linear-gradient(to right,
hsl(${hsl.h}, 0%, ${hsl.l}%),
hsl(${hsl.h}, 100%, ${hsl.l}%)
)`;
}, [hsl.h, hsl.l]);
const lightnessGradient = useMemo(() => {
return `linear-gradient(to right,
hsl(${hsl.h}, ${hsl.s}%, 0%),
hsl(${hsl.h}, ${hsl.s}%, 50%),
hsl(${hsl.h}, ${hsl.s}%, 100%)
)`;
}, [hsl.h, hsl.s]);
return (
<div className="space-y-5">
<CollapsibleSection
title={t('admin.theme.quickPresets', 'Quick Presets')}
icon={<PaletteIcon />}
badge={`${COLOR_PRESETS.length}`}
isOpen={isPresetsOpen}
onToggle={() => setIsPresetsOpen(!isPresetsOpen)}
>
<div className="grid auto-rows-fr grid-cols-2 gap-4 p-1 sm:grid-cols-3">
{COLOR_PRESETS.map((preset, index) => (
<div
key={preset.id}
className="min-h-[100px]"
style={{ '--stagger': index } as React.CSSProperties}
>
<PresetCard
preset={preset}
isSelected={selectedPresetId === preset.id}
onClick={() => handlePresetSelect(preset)}
/>
</div>
))}
</div>
</CollapsibleSection>
<div className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-dark-400">
{t('admin.theme.customizeColors', 'Customize Colors')}
</h3>
<CollapsibleSection
title={t('admin.theme.accentColor', 'Accent Color')}
icon={<PaletteIcon />}
badge={hexInput.toUpperCase()}
isOpen={isAccentOpen}
onToggle={() => setIsAccentOpen(!isAccentOpen)}
>
<div className="space-y-4">
<div
className="relative h-14 w-full overflow-hidden rounded-xl shadow-inner"
style={{
background: `linear-gradient(135deg, ${hexInput} 0%, ${hslToHex(hsl.h, hsl.s, Math.max(20, hsl.l - 20))} 100%)`,
}}
>
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent" />
<div className="absolute bottom-2 right-3 font-mono text-xs text-white/80 drop-shadow">
{hexInput.toUpperCase()}
</div>
</div>
<HSLSlider
label={t('admin.theme.hue', 'Hue')}
value={hsl.h}
onChange={(h) => updateAccentFromHsl({ ...hsl, h })}
max={360}
gradient={hueGradient}
suffix="°"
/>
<HSLSlider
label={t('admin.theme.saturation', 'Saturation')}
value={hsl.s}
onChange={(s) => updateAccentFromHsl({ ...hsl, s })}
max={100}
gradient={saturationGradient}
suffix="%"
/>
<HSLSlider
label={t('admin.theme.lightness', 'Lightness')}
value={hsl.l}
onChange={(l) => updateAccentFromHsl({ ...hsl, l })}
max={100}
gradient={lightnessGradient}
suffix="%"
/>
<div>
<label className="mb-1.5 block text-xs font-medium text-dark-300">
{t('admin.theme.hexCode', 'HEX Code')}
</label>
<input
type="text"
value={hexInput}
onChange={(e) => handleHexInputChange(e.target.value)}
placeholder="#3b82f6"
maxLength={7}
className="input w-full font-mono text-sm uppercase"
/>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection
title={t('theme.darkTheme', 'Dark Theme')}
icon={<MoonIcon />}
isOpen={isDarkOpen}
onToggle={() => setIsDarkOpen(!isDarkOpen)}
>
<div className="grid grid-cols-2 gap-2">
<CompactColorInput
label={t('theme.background', 'Background')}
value={currentColors.darkBackground || DEFAULT_THEME_COLORS.darkBackground}
onChange={(c) => updateColor('darkBackground', c)}
/>
<CompactColorInput
label={t('theme.surface', 'Surface')}
value={currentColors.darkSurface || DEFAULT_THEME_COLORS.darkSurface}
onChange={(c) => updateColor('darkSurface', c)}
/>
<CompactColorInput
label={t('theme.text', 'Text')}
value={currentColors.darkText || DEFAULT_THEME_COLORS.darkText}
onChange={(c) => updateColor('darkText', c)}
/>
<CompactColorInput
label={t('theme.textSecondary', 'Secondary')}
value={currentColors.darkTextSecondary || DEFAULT_THEME_COLORS.darkTextSecondary}
onChange={(c) => updateColor('darkTextSecondary', c)}
/>
</div>
</CollapsibleSection>
<CollapsibleSection
title={t('theme.lightTheme', 'Light Theme')}
icon={<SunIcon />}
isOpen={isLightOpen}
onToggle={() => setIsLightOpen(!isLightOpen)}
>
<div className="grid grid-cols-2 gap-2">
<CompactColorInput
label={t('theme.background', 'Background')}
value={currentColors.lightBackground || DEFAULT_THEME_COLORS.lightBackground}
onChange={(c) => updateColor('lightBackground', c)}
/>
<CompactColorInput
label={t('theme.surface', 'Surface')}
value={currentColors.lightSurface || DEFAULT_THEME_COLORS.lightSurface}
onChange={(c) => updateColor('lightSurface', c)}
/>
<CompactColorInput
label={t('theme.text', 'Text')}
value={currentColors.lightText || DEFAULT_THEME_COLORS.lightText}
onChange={(c) => updateColor('lightText', c)}
/>
<CompactColorInput
label={t('theme.textSecondary', 'Secondary')}
value={currentColors.lightTextSecondary || DEFAULT_THEME_COLORS.lightTextSecondary}
onChange={(c) => updateColor('lightTextSecondary', c)}
/>
</div>
</CollapsibleSection>
<CollapsibleSection
title={t('theme.statusColors', 'Status Colors')}
icon={<StatusIcon />}
isOpen={isStatusOpen}
onToggle={() => setIsStatusOpen(!isStatusOpen)}
>
<div className="grid grid-cols-3 gap-2">
<CompactColorInput
label={t('theme.success', 'Success')}
value={currentColors.success || DEFAULT_THEME_COLORS.success}
onChange={(c) => updateColor('success', c)}
/>
<CompactColorInput
label={t('theme.warning', 'Warning')}
value={currentColors.warning || DEFAULT_THEME_COLORS.warning}
onChange={(c) => updateColor('warning', c)}
/>
<CompactColorInput
label={t('theme.error', 'Error')}
value={currentColors.error || DEFAULT_THEME_COLORS.error}
onChange={(c) => updateColor('error', c)}
/>
</div>
</CollapsibleSection>
</div>
<div className="rounded-2xl border border-dark-700/40 bg-dark-900/50 p-4">
<h4 className="mb-3 text-xs font-medium uppercase tracking-wide text-dark-400">
{t('theme.preview', 'Preview')}
</h4>
<div className="flex flex-wrap gap-2">
<button className="btn-primary text-sm">{t('theme.previewButton', 'Primary')}</button>
<button className="btn-secondary text-sm">
{t('theme.previewSecondary', 'Secondary')}
</button>
<span className="badge-success">{t('theme.success', 'Success')}</span>
<span className="badge-warning">{t('theme.warning', 'Warning')}</span>
<span className="badge-error">{t('theme.error', 'Error')}</span>
</div>
</div>
{hasChanges && (
<div className="flex animate-fade-in justify-end">
<button onClick={onSave} disabled={isSaving} className="btn-primary">
{isSaving ? t('common.saving', 'Saving...') : t('common.save', 'Save')}
</button>
</div>
)}
</div>
);
}

View File

@@ -1,12 +1,12 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { ticketNotificationsApi } from '../api/ticketNotifications';
import { useAuthStore } from '../store/auth';
import { useToast } from './Toast';
import { useWebSocket, WSMessage } from '../hooks/useWebSocket';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
import { useTelegramSDK } from '../hooks/useTelegramSDK';
import type { TicketNotification } from '../types';
const BellIcon = () => (
@@ -37,7 +37,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
const { showToast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramSDK();
// Calculate dropdown top position (account for fullscreen safe area + TG buttons)
const dropdownTop = isFullscreen

View File

@@ -4,7 +4,7 @@
*/
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { useWebSocket, WSMessage } from '../hooks/useWebSocket';

View File

@@ -1,4 +1,4 @@
import { Link } from 'react-router-dom';
import { Link } from 'react-router';
import { usePlatform } from '@/platform';
import { BackIcon } from './icons';

View File

@@ -1,14 +0,0 @@
import type { ReactNode } from 'react';
interface AdminLayoutProps {
children: ReactNode;
className?: string;
}
/**
* AdminLayout - wrapper for all admin pages.
* Animations removed to prevent black flash during transitions.
*/
export function AdminLayout({ children, className }: AdminLayoutProps) {
return <div className={className}>{children}</div>;
}

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { brandingApi, setCachedBranding } from '../../api/branding';
import { setCachedAnimationEnabled } from '../AnimatedBackground';
import { setCachedFullscreenEnabled } from '../../hooks/useTelegramWebApp';
import { setCachedFullscreenEnabled } from '../../hooks/useTelegramSDK';
import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons';
import { Toggle } from './Toggle';

View File

@@ -1,75 +0,0 @@
import { useTranslation } from 'react-i18next';
import { AdminBackButton, StarIcon, CloseIcon, MENU_SECTIONS } from './index';
interface SettingsSidebarProps {
activeSection: string;
setActiveSection: (section: string) => void;
mobileMenuOpen: boolean;
setMobileMenuOpen: (open: boolean) => void;
favoritesCount: number;
}
export function SettingsSidebar({
activeSection,
setActiveSection,
mobileMenuOpen,
setMobileMenuOpen,
favoritesCount,
}: SettingsSidebarProps) {
const { t } = useTranslation();
return (
<aside
className={`fixed inset-y-0 left-0 z-50 h-screen w-64 flex-shrink-0 transform border-r border-dark-700/50 bg-dark-900 transition-transform duration-200 ease-in-out lg:sticky lg:top-0 ${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'} `}
>
{/* Header */}
<div className="border-b border-dark-700/50 p-4">
<div className="flex items-center gap-3">
<AdminBackButton className="rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700" />
<h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1>
<button
onClick={() => setMobileMenuOpen(false)}
className="ml-auto rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700 lg:hidden"
>
<CloseIcon />
</button>
</div>
</div>
{/* Menu */}
<nav className="max-h-[calc(100vh-80px)] space-y-1 overflow-y-auto p-2">
{MENU_SECTIONS.map((section, sectionIdx) => (
<div key={section.id}>
{sectionIdx > 0 && <div className="my-3 border-t border-dark-700/50" />}
{section.items.map((item) => {
const isActive = activeSection === item.id;
const hasIcon = item.iconType === 'star';
return (
<button
key={item.id}
onClick={() => {
setActiveSection(item.id);
setMobileMenuOpen(false);
}}
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 transition-all ${
isActive
? 'bg-accent-500/10 text-accent-400'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200'
}`}
>
{hasIcon && <StarIcon filled={isActive && item.id === 'favorites'} />}
<span className="font-medium">{t(`admin.settings.${item.id}`)}</span>
{item.id === 'favorites' && favoritesCount > 0 && (
<span className="ml-auto rounded-full bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
{favoritesCount}
</span>
)}
</button>
);
})}
</div>
))}
</nav>
</aside>
);
}

View File

@@ -59,6 +59,8 @@ export function ThemeTab() {
// Local draft state
const [draftColors, setDraftColors] = useState<ThemeColors>(DEFAULT_THEME_COLORS);
const savedColorsRef = useRef<ThemeColors>(DEFAULT_THEME_COLORS);
const draftColorsRef = useRef(draftColors);
draftColorsRef.current = draftColors;
// Sync server data into draft and saved snapshot when it arrives
useEffect(() => {
@@ -79,51 +81,17 @@ export function ThemeTab() {
};
// Only sync if saved snapshot matches current draft (no unsaved changes)
if (
colorsEqual(savedColorsRef.current, draftColors) ||
colorsEqual(savedColorsRef.current, draftColorsRef.current) ||
colorsEqual(savedColorsRef.current, DEFAULT_THEME_COLORS)
) {
setDraftColors(colors);
}
savedColorsRef.current = colors;
}
}, [serverColors]); // eslint-disable-line react-hooks/exhaustive-deps
}, [serverColors]);
const hasUnsavedChanges = !colorsEqual(draftColors, savedColorsRef.current);
// Update a single color in the draft and apply preview instantly
const updateDraftColor = useCallback(
(key: keyof ThemeColors, value: string) => {
setDraftColors((prev) => {
const next = { ...prev, [key]: value };
applyThemeColors(next);
queryClient.setQueryData(['theme-colors'], next);
return next;
});
},
[queryClient],
);
// Apply a full preset to draft
const applyPreset = useCallback(
(colors: Partial<ThemeColors>) => {
setDraftColors((prev) => {
const next = { ...prev, ...colors };
applyThemeColors(next);
queryClient.setQueryData(['theme-colors'], next);
return next;
});
},
[queryClient],
);
// Cancel: revert draft to saved
const handleCancel = useCallback(() => {
const saved = savedColorsRef.current;
setDraftColors(saved);
applyThemeColors(saved);
queryClient.setQueryData(['theme-colors'], saved);
}, [queryClient]);
// Mutations
const updateColorsMutation = useMutation({
mutationFn: themeColorsApi.updateColors,
@@ -181,6 +149,42 @@ export function ThemeTab() {
},
});
// Update a single color in the draft and apply preview instantly
const updateDraftColor = useCallback(
(key: keyof ThemeColors, value: string) => {
setDraftColors((prev) => {
const next = { ...prev, [key]: value };
applyThemeColors(next);
queryClient.setQueryData(['theme-colors'], next);
return next;
});
},
[queryClient],
);
// Apply a full preset and auto-save to server
const applyPreset = useCallback(
(colors: Partial<ThemeColors>) => {
setDraftColors((prev) => {
const next = { ...prev, ...colors };
applyThemeColors(next);
queryClient.setQueryData(['theme-colors'], next);
// Auto-save preset to server so it persists across navigation
updateColorsMutation.mutate(next);
return next;
});
},
[queryClient, updateColorsMutation],
);
// Cancel: revert draft to saved
const handleCancel = useCallback(() => {
const saved = savedColorsRef.current;
setDraftColors(saved);
applyThemeColors(saved);
queryClient.setQueryData(['theme-colors'], saved);
}, [queryClient]);
return (
<div className="space-y-6">
{/* Theme toggles */}
@@ -414,17 +418,21 @@ export function ThemeTab() {
</button>
</>
)}
<button
onClick={() => resetColorsMutation.mutate()}
disabled={resetColorsMutation.isPending}
className="rounded-xl bg-dark-700 px-4 py-2 text-sm text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
>
{t('admin.settings.resetAllColors')}
</button>
</div>
</div>
)}
</div>
{/* Reset all colors */}
<div className="flex justify-end">
<button
onClick={() => resetColorsMutation.mutate()}
disabled={resetColorsMutation.isPending}
className="rounded-xl bg-dark-700 px-4 py-2 text-sm text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
>
{t('admin.settings.resetAllColors')}
</button>
</div>
</div>
);
}

View File

@@ -1,6 +1,5 @@
// Components
export * from './AdminBackButton';
export * from './AdminLayout';
export * from './icons';
export * from './Toggle';
export * from './SettingInput';
@@ -10,7 +9,6 @@ export * from './BrandingTab';
export * from './ThemeTab';
export * from './FavoritesTab';
export * from './SettingsTab';
export * from './SettingsSidebar';
export * from './SettingsMobileTabs';
export * from './SettingsSearch';

View File

@@ -6,7 +6,7 @@ export default function PageLoader({ variant = 'dark' }: PageLoaderProps) {
const spinnerColor = variant === 'dark' ? 'border-accent-500' : 'border-blue-500';
return (
<div className="flex min-h-[50vh] items-center justify-center">
<div className="flex min-h-screen items-center justify-center">
<div
className={`h-10 w-10 border-[3px] ${spinnerColor} animate-spin rounded-full border-t-transparent`}
/>

View File

@@ -1,56 +0,0 @@
import { forwardRef, type ReactNode } from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import { Button, type ButtonProps } from '@/components/primitives/Button';
import { fadeIn, fadeInTransition } from '@/components/motion/transitions';
export interface EmptyStateProps {
icon?: ReactNode;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
variant?: ButtonProps['variant'];
};
className?: string;
}
export const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(
({ icon, title, description, action, className }, ref) => {
return (
<motion.div
ref={ref}
className={cn('flex flex-col items-center justify-center py-12 text-center', className)}
variants={fadeIn}
initial="initial"
animate="animate"
transition={fadeInTransition}
>
{/* Icon */}
{icon && (
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-dark-800/50 text-dark-400">
{icon}
</div>
)}
{/* Title */}
<h3 className="text-lg font-semibold text-dark-100">{title}</h3>
{/* Description */}
{description && <p className="mt-2 max-w-sm text-sm text-dark-400">{description}</p>}
{/* Action */}
{action && (
<div className="mt-6">
<Button variant={action.variant || 'primary'} onClick={action.onClick}>
{action.label}
</Button>
</div>
)}
</motion.div>
);
},
);
EmptyState.displayName = 'EmptyState';

View File

@@ -1 +0,0 @@
export { EmptyState, type EmptyStateProps } from './EmptyState';

View File

@@ -1,3 +1,2 @@
export * from './Card';
export * from './StatCard';
export * from './EmptyState';

View File

@@ -1,4 +1,4 @@
import { Link, useLocation } from 'react-router-dom';
import { Link, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { useState, useEffect } from 'react';

View File

@@ -1,26 +1,19 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useLocation, useNavigate, Link } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { useLocation, Link } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/store/auth';
import { useBackButton, useHaptic } from '@/platform';
import { useHaptic } from '@/platform';
import { useTelegramSDK } from '@/hooks/useTelegramSDK';
import { useTheme } from '@/hooks/useTheme';
import { useBranding } from '@/hooks/useBranding';
import { useFeatureFlags } from '@/hooks/useFeatureFlags';
import { useScrollRestoration } from '@/hooks/useScrollRestoration';
import { themeColorsApi } from '@/api/themeColors';
import { referralApi } from '@/api/referral';
import { wheelApi } from '@/api/wheel';
import { contestsApi } from '@/api/contests';
import { pollsApi } from '@/api/polls';
import {
brandingApi,
getCachedBranding,
setCachedBranding,
preloadLogo,
isLogoPreloaded,
} from '@/api/branding';
import { setCachedFullscreenEnabled } from '@/hooks/useTelegramSDK';
import { isLogoPreloaded } from '@/api/branding';
import { cn } from '@/lib/utils';
import { UI } from '@/config/constants';
import WebSocketNotifications from '@/components/WebSocketNotifications';
import SuccessNotificationModal from '@/components/SuccessNotificationModal';
@@ -176,9 +169,6 @@ const MoonIcon = ({ className }: { className?: string }) => (
</svg>
);
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
interface AppShellProps {
children: React.ReactNode;
}
@@ -186,20 +176,17 @@ interface AppShellProps {
export function AppShell({ children }: AppShellProps) {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const { isAdmin, isAuthenticated, logout } = useAuthStore();
const {
isFullscreen,
safeAreaInset,
contentSafeAreaInset,
requestFullscreen,
isTelegramWebApp,
platform,
isMobile,
} = useTelegramSDK();
const { isAdmin, logout } = useAuthStore();
const { isFullscreen, safeAreaInset, contentSafeAreaInset, platform, isMobile } =
useTelegramSDK();
const haptic = useHaptic();
const { toggleTheme, isDark } = useTheme();
// Extracted hooks
const { appName, logoLetter, hasCustomLogo, logoUrl } = useBranding();
const { referralEnabled, wheelEnabled, hasContests, hasPolls } = useFeatureFlags();
useScrollRestoration();
// Theme toggle visibility
const { data: enabledThemes } = useQuery({
queryKey: ['enabled-themes'],
@@ -214,147 +201,6 @@ export function AppShell({ children }: AppShellProps) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
// Scroll position restoration for admin pages
const scrollPositions = useRef<Record<string, number>>({});
// Disable browser's automatic scroll restoration
useEffect(() => {
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
}, []);
// Continuously save scroll position for current path
useEffect(() => {
const currentPath = location.pathname;
// Only track scroll for admin pages
if (!currentPath.startsWith('/admin')) return;
const handleScroll = () => {
scrollPositions.current[currentPath] = window.scrollY;
};
// Save on scroll
window.addEventListener('scroll', handleScroll, { passive: true });
// Restore scroll position immediately (synchronous)
const savedPosition = scrollPositions.current[currentPath];
if (savedPosition !== undefined && savedPosition > 0) {
// Immediate restore
window.scrollTo({ top: savedPosition, behavior: 'instant' });
}
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [location.pathname]);
// Branding
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: async () => {
const data = await brandingApi.getBranding();
setCachedBranding(data);
preloadLogo(data);
return data;
},
initialData: getCachedBranding() ?? undefined,
staleTime: 60000,
enabled: isAuthenticated,
});
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;
// Set document title
useEffect(() => {
document.title = appName || 'VPN';
}, [appName]);
// Update favicon
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]);
// Fullscreen setting from server
const { data: fullscreenSetting } = useQuery({
queryKey: ['fullscreen-enabled'],
queryFn: brandingApi.getFullscreenEnabled,
staleTime: 60000,
});
// Apply fullscreen setting when loaded from server
// Only apply on mobile Telegram (iOS/Android) - desktop doesn't need fullscreen
useEffect(() => {
if (!fullscreenSetting || !isTelegramWebApp) return;
// Update cache for future app starts
setCachedFullscreenEnabled(fullscreenSetting.enabled);
// Request fullscreen if enabled, not already fullscreen, and on mobile Telegram
if (fullscreenSetting.enabled && !isFullscreen && isMobile) {
requestFullscreen();
}
}, [fullscreenSetting, isTelegramWebApp, isFullscreen, requestFullscreen, isMobile]);
// Feature flags
const { data: referralTerms } = useQuery({
queryKey: ['referral-terms'],
queryFn: referralApi.getReferralTerms,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
const { data: wheelConfig } = useQuery({
queryKey: ['wheel-config'],
queryFn: wheelApi.getConfig,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
const { data: contestsCount } = useQuery({
queryKey: ['contests-count'],
queryFn: contestsApi.getCount,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
const { data: pollsCount } = useQuery({
queryKey: ['polls-count'],
queryFn: pollsApi.getCount,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
// BackButton for Telegram Mini App
// Don't show back button on main tab pages (bottom nav) - users navigate via tabs
const mainTabPaths = ['/', '/subscription', '/balance', '/referral', '/support', '/wheel'];
const isMainTabPage = mainTabPaths.includes(location.pathname);
const handleBack = useCallback(() => {
if (mobileMenuOpen) {
setMobileMenuOpen(false);
return;
}
navigate(-1);
}, [mobileMenuOpen, navigate]);
useBackButton(isMainTabPage ? null : handleBack);
// Keyboard detection for hiding bottom nav
useEffect(() => {
const handleFocusIn = (e: FocusEvent) => {
@@ -405,7 +251,8 @@ export function AppShell({ children }: AppShellProps) {
// Calculate header height based on fullscreen mode (only on mobile Telegram)
// On iOS: contentSafeAreaInset.top includes status bar + dynamic island + Telegram header
// On Android: safeAreaInset.top only includes status bar, need to add Telegram header height (~48px)
const telegramHeaderHeight = platform === 'android' ? 48 : 45;
const telegramHeaderHeight =
platform === 'android' ? UI.TELEGRAM_HEADER_ANDROID_PX : UI.TELEGRAM_HEADER_IOS_PX;
const headerHeight = isMobileFullscreen
? 64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + telegramHeaderHeight
: 64;
@@ -421,7 +268,7 @@ 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/80 backdrop-blur-xl lg:block">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-6">
<div className="mx-auto grid h-14 max-w-6xl grid-cols-[auto_1fr_auto] items-center gap-4 px-6">
{/* Logo */}
<Link to="/" className="flex items-center gap-2.5" onClick={handleNavClick}>
<div className="relative flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-lg bg-dark-800">
@@ -448,7 +295,7 @@ export function AppShell({ children }: AppShellProps) {
</Link>
{/* Center Navigation */}
<nav className="flex items-center gap-1">
<nav className="flex items-center justify-center gap-1">
{desktopNavItems.map((item) => (
<Link
key={item.path}
@@ -465,7 +312,7 @@ export function AppShell({ children }: AppShellProps) {
<span>{item.label}</span>
</Link>
))}
{referralTerms?.is_enabled && (
{referralEnabled && (
<Link
to="/referral"
onClick={handleNavClick}
@@ -502,19 +349,20 @@ export function AppShell({ children }: AppShellProps) {
</nav>
{/* Right side actions */}
<div className="flex items-center gap-2">
{canToggleTheme && (
<button
onClick={() => {
haptic.impact('light');
toggleTheme();
}}
className="flex h-8 w-8 items-center justify-center rounded-lg text-dark-400 transition-colors hover:bg-dark-800/50 hover:text-dark-200"
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
>
{isDark ? <MoonIcon className="h-4 w-4" /> : <SunIcon className="h-4 w-4" />}
</button>
)}
<div className="flex items-center justify-end gap-2">
<button
onClick={() => {
haptic.impact('light');
toggleTheme();
}}
className={cn(
'rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-dark-400 transition-all duration-200 hover:bg-dark-700 hover:text-accent-400',
!canToggleTheme && 'pointer-events-none invisible',
)}
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
>
{isDark ? <MoonIcon className="h-5 w-5" /> : <SunIcon className="h-5 w-5" />}
</button>
<TicketNotificationBell isAdmin={location.pathname.startsWith('/admin')} />
<LanguageSwitcher />
<button
@@ -522,10 +370,10 @@ export function AppShell({ children }: AppShellProps) {
haptic.impact('light');
logout();
}}
className="flex h-8 w-8 items-center justify-center rounded-lg text-dark-400 transition-colors hover:bg-dark-800/50 hover:text-dark-200"
className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-dark-400 transition-all duration-200 hover:bg-dark-700 hover:text-accent-400"
title={t('nav.logout')}
>
<LogoutIcon className="h-4 w-4" />
<LogoutIcon className="h-5 w-5" />
</button>
</div>
</div>
@@ -541,10 +389,10 @@ export function AppShell({ children }: AppShellProps) {
safeAreaInset={safeAreaInset}
contentSafeAreaInset={contentSafeAreaInset}
telegramPlatform={platform}
wheelEnabled={wheelConfig?.is_enabled}
referralEnabled={referralTerms?.is_enabled}
hasContests={(contestsCount?.count ?? 0) > 0}
hasPolls={(pollsCount?.count ?? 0) > 0}
wheelEnabled={wheelEnabled}
referralEnabled={referralEnabled}
hasContests={hasContests}
hasPolls={hasPolls}
/>
{/* Desktop spacer */}
@@ -559,8 +407,8 @@ export function AppShell({ children }: AppShellProps) {
{/* Mobile Bottom Navigation */}
<MobileBottomNav
isKeyboardOpen={isKeyboardOpen}
referralEnabled={referralTerms?.is_enabled}
wheelEnabled={wheelConfig?.is_enabled}
referralEnabled={referralEnabled}
wheelEnabled={wheelEnabled}
/>
</div>
);

View File

@@ -154,6 +154,14 @@ export function Aurora() {
const background = isDark ? themeColors.darkBackground : themeColors.lightBackground;
const surface = isDark ? themeColors.darkSurface : themeColors.lightSurface;
// Refs for initial color values (WebGL context shouldn't recreate on color change)
const backgroundRef = useRef(background);
backgroundRef.current = background;
const surfaceRef = useRef(surface);
surfaceRef.current = surface;
const accentRef = useRef(themeColors.accent);
accentRef.current = themeColors.accent;
// Initialize WebGL context once (only depends on isEnabled)
useEffect(() => {
if (!isEnabled || !containerRef.current) return;
@@ -175,7 +183,11 @@ export function Aurora() {
const geometry = new Triangle(gl);
const colorStops = generateColorStops(background, surface, themeColors.accent);
const colorStops = generateColorStops(
backgroundRef.current,
surfaceRef.current,
accentRef.current,
);
const colorStopsArray = colorStops
.map((hex) => {
const c = new Color(hex);
@@ -238,7 +250,7 @@ export function Aurora() {
rendererRef.current = null;
programRef.current = null;
};
}, [isEnabled]); // eslint-disable-line react-hooks/exhaustive-deps
}, [isEnabled]);
// Update color uniforms reactively without recreating WebGL context
useEffect(() => {

View File

@@ -1,4 +1,4 @@
import { Link, useLocation } from 'react-router-dom';
import { Link, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { useQuery } from '@tanstack/react-query';

View File

@@ -1,4 +1,4 @@
import { Link, useLocation } from 'react-router-dom';
import { Link, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';

View File

@@ -1,57 +0,0 @@
import { motion } from 'framer-motion';
/**
* Animated moving gradient background.
* Uses CSS variables for colors to support theme switching.
* Lightweight - pure CSS gradients with Framer Motion animation.
*/
export function MovingGradient() {
return (
<div className="fixed inset-0 -z-10 overflow-hidden">
{/* Base background */}
<div className="absolute inset-0 bg-dark-950" />
{/* Animated gradient layer */}
<motion.div
className="absolute inset-0 opacity-60"
style={{
background:
'radial-gradient(ellipse 80% 50% at 20% 40%, rgba(var(--color-accent-500), 0.12) 0%, transparent 50%), radial-gradient(ellipse 60% 40% at 80% 60%, rgba(var(--color-accent-500), 0.08) 0%, transparent 50%)',
}}
animate={{
backgroundPosition: ['0% 0%', '100% 100%', '0% 0%'],
}}
transition={{
duration: 20,
repeat: Infinity,
ease: 'linear',
}}
/>
{/* Second animated layer - moves in opposite direction */}
<motion.div
className="absolute inset-0 opacity-40"
style={{
background:
'radial-gradient(ellipse 70% 60% at 70% 30%, rgba(var(--color-accent-500), 0.1) 0%, transparent 50%), radial-gradient(ellipse 50% 50% at 30% 70%, rgba(var(--color-accent-500), 0.06) 0%, transparent 50%)',
}}
animate={{
backgroundPosition: ['100% 100%', '0% 0%', '100% 100%'],
}}
transition={{
duration: 25,
repeat: Infinity,
ease: 'linear',
}}
/>
{/* Subtle noise texture overlay */}
<div
className="absolute inset-0 opacity-[0.02]"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
}}
/>
</div>
);
}

View File

@@ -7,13 +7,6 @@ export const springTransition: Transition = {
damping: 30,
};
// Smooth spring for larger movements
export const smoothSpring: Transition = {
type: 'spring',
stiffness: 300,
damping: 25,
};
// Expo easing curve (Linear-style)
export const easeOutExpo = [0.16, 1, 0.3, 1] as const;
@@ -42,27 +35,6 @@ export const slideUpTransition: Transition = {
ease: easeOutExpo,
};
// Slide down animation (for dropdowns, popovers)
export const slideDown: Variants = {
initial: { opacity: 0, y: -8 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0 },
};
// Slide from right (for sheets, sidebars)
export const slideRight: Variants = {
initial: { opacity: 0, x: 20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: 20 },
};
// Slide from left
export const slideLeft: Variants = {
initial: { opacity: 0, x: -20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -20 },
};
// Scale animation (for modals, dialogs)
export const scale: Variants = {
initial: { opacity: 0, scale: 0.95 },
@@ -92,16 +64,6 @@ export const staggerContainer: Variants = {
},
};
// Fast stagger for dense lists
export const fastStaggerContainer: Variants = {
initial: {},
animate: {
transition: {
staggerChildren: 0.03,
},
},
};
// Stagger item (use with staggerContainer)
// Exit is instant to avoid visual glitches in Telegram Mini App
export const staggerItem: Variants = {

View File

@@ -1,5 +1,5 @@
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { AnimatePresence, motion } from 'framer-motion';
import * as DialogPrimitive from '@radix-ui/react-dialog';

View File

@@ -10,6 +10,12 @@ import {
import { cn } from '@/lib/utils';
import { backdrop, backdropTransition, scale, scaleTransition } from '../../motion/transitions';
export {
Trigger as DialogTrigger,
Portal as DialogPortal,
Close as DialogClose,
} from '@radix-ui/react-dialog';
// Close icon
const CloseIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
@@ -41,15 +47,6 @@ export const Dialog = ({ children, open, onOpenChange, ...props }: DialogProps)
);
};
// Trigger
export const DialogTrigger = DialogPrimitive.Trigger;
// Portal
export const DialogPortal = DialogPrimitive.Portal;
// Close
export const DialogClose = DialogPrimitive.Close;
// Overlay
export type DialogOverlayProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>;
@@ -86,7 +83,7 @@ export const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(
const { open } = useContext(DialogContext);
return (
<DialogPortal forceMount>
<DialogPrimitive.Portal forceMount>
<AnimatePresence mode="wait">
{open && (
<>
@@ -131,7 +128,7 @@ export const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(
</>
)}
</AnimatePresence>
</DialogPortal>
</DialogPrimitive.Portal>
);
},
);

View File

@@ -5,6 +5,15 @@ import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform';
import { dropdown, dropdownTransition } from '../../motion/transitions';
export {
Root as DropdownMenu,
Trigger as DropdownMenuTrigger,
Group as DropdownMenuGroup,
Portal as DropdownMenuPortal,
Sub as DropdownMenuSub,
RadioGroup as DropdownMenuRadioGroup,
} from '@radix-ui/react-dropdown-menu';
// Icons
const CheckIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
@@ -36,24 +45,6 @@ const DotIcon = () => (
</svg>
);
// Root
export const DropdownMenu = DropdownMenuPrimitive.Root;
// Trigger
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
// Group
export const DropdownMenuGroup = DropdownMenuPrimitive.Group;
// Portal
export const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
// Sub
export const DropdownMenuSub = DropdownMenuPrimitive.Sub;
// RadioGroup
export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
// SubTrigger
export interface DropdownMenuSubTriggerProps extends ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.SubTrigger

View File

@@ -4,6 +4,13 @@ import { forwardRef, type ComponentPropsWithoutRef } from 'react';
import { cn } from '@/lib/utils';
import { dropdown, dropdownTransition } from '../../motion/transitions';
export {
Root as Popover,
Trigger as PopoverTrigger,
Anchor as PopoverAnchor,
Close as PopoverClose,
} from '@radix-ui/react-popover';
// Close icon
const CloseIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
@@ -17,18 +24,6 @@ const CloseIcon = () => (
</svg>
);
// Root
export const Popover = PopoverPrimitive.Root;
// Trigger
export const PopoverTrigger = PopoverPrimitive.Trigger;
// Anchor
export const PopoverAnchor = PopoverPrimitive.Anchor;
// Close
export const PopoverClose = PopoverPrimitive.Close;
// Content
export interface PopoverContentProps extends ComponentPropsWithoutRef<
typeof PopoverPrimitive.Content

View File

@@ -5,6 +5,8 @@ import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform';
import { dropdown, dropdownTransition } from '../../motion/transitions';
export { Root as Select, Group as SelectGroup } from '@radix-ui/react-select';
// Icons
const ChevronDownIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-dark-400">
@@ -30,9 +32,6 @@ const CheckIcon = () => (
</svg>
);
// Root
export const Select = SelectPrimitive.Root;
// Trigger
export interface SelectTriggerProps extends ComponentPropsWithoutRef<
typeof SelectPrimitive.Trigger
@@ -149,9 +148,6 @@ export const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
SelectItem.displayName = 'SelectItem';
// Group
export const SelectGroup = SelectPrimitive.Group;
// Label
export type SelectLabelProps = ComponentPropsWithoutRef<typeof SelectPrimitive.Label>;

View File

@@ -10,7 +10,6 @@ import {
} from 'react';
import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform';
import { useBackButton } from '@/platform';
import {
backdrop,
backdropTransition,
@@ -18,6 +17,12 @@ import {
sheetTransition,
} from '../../motion/transitions';
export {
Trigger as SheetTrigger,
Portal as SheetPortal,
Close as SheetClose,
} from '@radix-ui/react-dialog';
// Close icon
const CloseIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
@@ -79,15 +84,6 @@ export const Sheet = ({ children, open, onOpenChange, onClose, ...props }: Sheet
);
};
// Trigger
export const SheetTrigger = DialogPrimitive.Trigger;
// Portal
export const SheetPortal = DialogPrimitive.Portal;
// Close
export const SheetClose = DialogPrimitive.Close;
// Overlay
export type SheetOverlayProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>;
@@ -138,9 +134,6 @@ export const SheetContent = forwardRef<HTMLDivElement, SheetContentProps>(
const { open, onClose } = useContext(SheetContext);
const { haptic } = usePlatform();
const dragControls = useDragControls();
// Back button integration
useBackButton(open ? onClose : null);
const handleDragEnd = useCallback(
(_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
const velocity = info.velocity.y;
@@ -157,7 +150,7 @@ export const SheetContent = forwardRef<HTMLDivElement, SheetContentProps>(
);
return (
<SheetPortal forceMount>
<DialogPrimitive.Portal forceMount>
<AnimatePresence mode="wait">
{open && (
<>
@@ -221,7 +214,7 @@ export const SheetContent = forwardRef<HTMLDivElement, SheetContentProps>(
</>
)}
</AnimatePresence>
</SheetPortal>
</DialogPrimitive.Portal>
);
},
);

View File

@@ -4,14 +4,11 @@ import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react
import { cn } from '@/lib/utils';
import { tooltip, tooltipTransition } from '../../motion/transitions';
// Provider - wrap your app with this
export const TooltipProvider = TooltipPrimitive.Provider;
// Root
export const Tooltip = TooltipPrimitive.Root;
// Trigger
export const TooltipTrigger = TooltipPrimitive.Trigger;
export {
Provider as TooltipProvider,
Root as Tooltip,
Trigger as TooltipTrigger,
} from '@radix-ui/react-tooltip';
// Content
export type TooltipContentProps = ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>;
@@ -66,10 +63,10 @@ export const SimpleTooltip = ({
delayDuration = 200,
className,
}: SimpleTooltipProps) => (
<Tooltip delayDuration={delayDuration}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipPrimitive.Root delayDuration={delayDuration}>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipContent side={side} align={align} className={className}>
{content}
</TooltipContent>
</Tooltip>
</TooltipPrimitive.Root>
);

View File

@@ -1,4 +1,4 @@
import { Link } from 'react-router-dom';
import { Link } from 'react-router';
import { forwardRef, useCallback } from 'react';
import { useHaptic } from '@/platform';

View File

@@ -1,6 +1,5 @@
import { useEffect, useRef, useCallback, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useBackButton } from '@/platform';
import { useHaptic } from '@/platform';
export interface SheetProps {
@@ -105,9 +104,6 @@ export function Sheet({
const haptic = useHaptic();
// BackButton integration
useBackButton(isOpen ? onClose : null);
// Calculate current height based on snap point
const currentHeight = `${snapPoints[currentSnapIndex] * 100}vh`;

View File

@@ -131,69 +131,3 @@ export function Skeleton({
return renderSkeleton(0);
}
// Convenience components for common use cases
export function TextSkeleton({
lines = 1,
className = '',
lastLineWidth = '60%',
}: {
lines?: number;
className?: string;
lastLineWidth?: string;
}) {
return (
<div className={`space-y-2 ${className}`}>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton key={i} variant="text" width={i === lines - 1 ? lastLineWidth : '100%'} />
))}
</div>
);
}
export function AvatarSkeleton({
size = 40,
className = '',
}: {
size?: number;
className?: string;
}) {
return <Skeleton variant="avatar" width={size} height={size} className={className} />;
}
export function CardSkeleton({
count = 1,
className = '',
}: {
count?: number;
className?: string;
}) {
return <Skeleton variant="card" count={count} className={className} />;
}
export function ListSkeleton({
count = 3,
className = '',
}: {
count?: number;
className?: string;
}) {
return (
<div className={`space-y-2 ${className}`}>
<Skeleton variant="list" count={count} />
</div>
);
}
export function BentoSkeleton({
count = 1,
className = '',
}: {
count?: number;
className?: string;
}) {
return <Skeleton variant="bento" count={count} className={className} />;
}
// Default export for backwards compatibility
export default BentoSkeleton;

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useMemo, memo } from 'react';
import { useEffect, useRef, useState, memo } from 'react';
import type { WheelPrize } from '../../api/wheel';
interface FortuneWheelProps {
@@ -24,20 +24,6 @@ const FortuneWheel = memo(function FortuneWheel({
const wheelRef = useRef<SVGGElement>(null);
const accumulatedRotation = useRef(0);
const [displayRotation, setDisplayRotation] = useState(0);
const [lightPhase, setLightPhase] = useState(0);
// Animated lights effect - always running, speed depends on spinning state
useEffect(() => {
// Faster animation when spinning, slower when idle
const interval = setInterval(
() => {
setLightPhase((p) => (p + 1) % 20);
},
isSpinning ? 100 : 300,
); // 100ms when spinning, 300ms when idle
return () => clearInterval(interval);
}, [isSpinning]);
useEffect(() => {
if (isSpinning && targetRotation !== null && wheelRef.current) {
@@ -57,18 +43,6 @@ const FortuneWheel = memo(function FortuneWheel({
}
}, [isSpinning, targetRotation, onSpinComplete]);
// Memoize light pattern calculation
const lightPattern = useMemo(() => {
const numLights = isSpinning ? 3 : 4; // 3 lights when spinning, 4 when idle
return Array.from({ length: 20 }, (_, i) => {
// Calculate distance from current lightPhase
const distance = (i - lightPhase + 20) % 20;
// Light is on if within range [0, numLights)
return distance < numLights;
});
}, [isSpinning, lightPhase]);
if (prizes.length === 0) {
return (
<div className="mx-auto flex aspect-square w-full max-w-md items-center justify-center">
@@ -257,36 +231,53 @@ const FortuneWheel = memo(function FortuneWheel({
strokeWidth="2"
/>
{/* LED lights on outer ring - positioned toward outer edge to avoid bleeding into sectors */}
{Array.from({ length: 20 }).map((_, i) => {
const angle = (i * 18 - 90) * (Math.PI / 180);
const ledRadius = outerRadius + 3;
const dotX = center + ledRadius * Math.cos(angle);
const dotY = center + ledRadius * Math.sin(angle);
const isLit = lightPattern[i] ?? i % 2 === 0;
return (
<g key={`led-${i}`}>
{isLit && (
{/* LED chase animation — pure CSS, no React re-renders */}
<style>
{`
@keyframes ledChase {
0%, 100% { fill: #374151; stroke: #1F2937; }
10%, 30% { fill: #FEF08A; stroke: #FDE047; }
}
@keyframes ledGlow {
0%, 100% { opacity: 0; }
10%, 30% { opacity: 0.4; }
}
.led-dot { animation: ledChase 6s linear infinite; }
.led-glow { opacity: 0; animation: ledGlow 6s linear infinite; }
.led-spinning .led-dot { animation-duration: 2s; }
.led-spinning .led-glow { animation-duration: 2s; }
`}
</style>
<g className={isSpinning ? 'led-spinning' : undefined}>
{Array.from({ length: 20 }).map((_, i) => {
const angle = (i * 18 - 90) * (Math.PI / 180);
const ledRadius = outerRadius + 3;
const dotX = center + ledRadius * Math.cos(angle);
const dotY = center + ledRadius * Math.sin(angle);
// Delay as fraction of full cycle — CSS handles speed via animation-duration
const delay = `${(i / 20) * 6}s`;
return (
<g key={`led-${i}`}>
<circle
className="led-glow"
cx={dotX}
cy={dotY}
r={5}
fill="#FEF08A"
opacity={0.4}
style={{ filter: 'blur(2px)' }}
style={{ filter: 'blur(2px)', animationDelay: delay }}
/>
)}
<circle
cx={dotX}
cy={dotY}
r={3.5}
fill={isLit ? '#FEF08A' : '#374151'}
stroke={isLit ? '#FDE047' : '#1F2937'}
strokeWidth="1"
/>
</g>
);
})}
<circle
className="led-dot"
cx={dotX}
cy={dotY}
r={3.5}
strokeWidth="1"
style={{ animationDelay: delay }}
/>
</g>
);
})}
</g>
{/* Rotating wheel group */}
<g

30
src/config/constants.ts Normal file
View File

@@ -0,0 +1,30 @@
// Storage keys
export const STORAGE_KEYS = {
THEME: 'cabinet-theme',
ENABLED_THEMES: 'cabinet-enabled-themes',
FAVORITE_SETTINGS: 'admin_favorite_settings',
USER_THEME_PREFS: 'user_theme_preferences',
} as const;
// WebSocket
export const WS = {
MAX_RECONNECT_ATTEMPTS: 5,
PING_INTERVAL_MS: 25000,
MAX_RECONNECT_DELAY_MS: 30000,
} as const;
// UI
export const UI = {
RESEND_COOLDOWN_SEC: 60,
TELEGRAM_HEADER_ANDROID_PX: 48,
TELEGRAM_HEADER_IOS_PX: 45,
} as const;
// API
export const API = {
TIMEOUT_MS: 30000,
BALANCE_STALE_TIME_MS: 30000,
TRAFFIC_CACHE_MS: 30000,
TRAFFIC_WARN_PERCENT: 70,
TRAFFIC_CRITICAL_PERCENT: 90,
} as const;

View File

@@ -276,7 +276,3 @@ export const COLOR_PRESETS: ColorPreset[] = [
},
},
];
export function getPresetById(id: string): ColorPreset | undefined {
return COLOR_PRESETS.find((preset) => preset.id === id);
}

79
src/hooks/useBranding.ts Normal file
View File

@@ -0,0 +1,79 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/store/auth';
import { useTelegramSDK, setCachedFullscreenEnabled } from '@/hooks/useTelegramSDK';
import {
brandingApi,
getCachedBranding,
setCachedBranding,
preloadLogo,
isLogoPreloaded,
} from '@/api/branding';
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
export function useBranding() {
const { isAuthenticated } = useAuthStore();
const { isFullscreen, isTelegramWebApp, requestFullscreen, isMobile } = useTelegramSDK();
// Branding data
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: async () => {
const data = await brandingApi.getBranding();
setCachedBranding(data);
preloadLogo(data);
return data;
},
initialData: getCachedBranding() ?? undefined,
staleTime: 60000,
enabled: isAuthenticated,
});
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;
// Set document title
useEffect(() => {
document.title = appName || 'VPN';
}, [appName]);
// Update favicon
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]);
// Fullscreen setting from server
const { data: fullscreenSetting } = useQuery({
queryKey: ['fullscreen-enabled'],
queryFn: brandingApi.getFullscreenEnabled,
staleTime: 60000,
});
useEffect(() => {
if (!fullscreenSetting || !isTelegramWebApp) return;
setCachedFullscreenEnabled(fullscreenSetting.enabled);
if (fullscreenSetting.enabled && !isFullscreen && isMobile) {
requestFullscreen();
}
}, [fullscreenSetting, isTelegramWebApp, isFullscreen, requestFullscreen, isMobile]);
return {
appName,
logoLetter,
hasCustomLogo,
logoUrl,
isLogoPreloaded,
};
}

View File

@@ -1,3 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { currencyApi, type ExchangeRates } from '../api/currency';
@@ -40,65 +41,93 @@ export function useCurrency() {
const currencySymbol = t('common.currency');
// Format amount with currency conversion
const formatAmount = (rubAmount: number, decimals: number = 2): string => {
if (isRussian) {
return rubAmount.toFixed(decimals);
}
const formatAmount = useCallback(
(rubAmount: number, decimals: number = 2): string => {
if (isRussian) {
return rubAmount.toFixed(decimals);
}
// Convert to target currency
const convertedAmount = currencyApi.convertFromRub(
rubAmount,
targetCurrency as keyof ExchangeRates,
exchangeRates,
);
// Convert to target currency
const convertedAmount = currencyApi.convertFromRub(
rubAmount,
targetCurrency as keyof ExchangeRates,
exchangeRates,
);
// For IRR (Iranian Toman), use no decimals as amounts are large
if (targetCurrency === 'IRR') {
return Math.round(convertedAmount).toLocaleString('fa-IR');
}
// For IRR (Iranian Toman), use no decimals as amounts are large
if (targetCurrency === 'IRR') {
return Math.round(convertedAmount).toLocaleString('fa-IR');
}
return convertedAmount.toFixed(decimals);
};
return convertedAmount.toFixed(decimals);
},
[isRussian, targetCurrency, exchangeRates],
);
// Format amount with currency symbol
const formatWithCurrency = (rubAmount: number, decimals: number = 2): string => {
return `${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
};
const formatWithCurrency = useCallback(
(rubAmount: number, decimals: number = 2): string => {
return `${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
},
[formatAmount, currencySymbol],
);
// Format amount with + sign (for earnings/bonuses)
const formatPositive = (rubAmount: number, decimals: number = 2): string => {
return `+${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
};
const formatPositive = useCallback(
(rubAmount: number, decimals: number = 2): string => {
return `+${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
},
[formatAmount, currencySymbol],
);
// Get raw converted amount (for calculations)
const convertAmount = (rubAmount: number): number => {
if (isRussian) {
return rubAmount;
}
return currencyApi.convertFromRub(
rubAmount,
targetCurrency as keyof ExchangeRates,
exchangeRates,
);
};
const convertAmount = useCallback(
(rubAmount: number): number => {
if (isRussian) {
return rubAmount;
}
return currencyApi.convertFromRub(
rubAmount,
targetCurrency as keyof ExchangeRates,
exchangeRates,
);
},
[isRussian, targetCurrency, exchangeRates],
);
// Convert from user's currency back to rubles
const convertToRub = (amount: number): number => {
if (isRussian) {
return amount;
}
return currencyApi.convertToRub(amount, targetCurrency as keyof ExchangeRates, exchangeRates);
};
const convertToRub = useCallback(
(amount: number): number => {
if (isRussian) {
return amount;
}
return currencyApi.convertToRub(amount, targetCurrency as keyof ExchangeRates, exchangeRates);
},
[isRussian, targetCurrency, exchangeRates],
);
return {
exchangeRates,
targetCurrency,
isRussian,
currencySymbol,
formatAmount,
formatWithCurrency,
formatPositive,
convertAmount,
convertToRub,
};
return useMemo(
() => ({
exchangeRates,
targetCurrency,
isRussian,
currencySymbol,
formatAmount,
formatWithCurrency,
formatPositive,
convertAmount,
convertToRub,
}),
[
exchangeRates,
targetCurrency,
isRussian,
currencySymbol,
formatAmount,
formatWithCurrency,
formatPositive,
convertAmount,
convertToRub,
],
);
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { STORAGE_KEYS } from '../config/constants';
const STORAGE_KEY = 'admin_favorite_settings';
const STORAGE_KEY = STORAGE_KEYS.FAVORITE_SETTINGS;
export function useFavoriteSettings() {
const [favorites, setFavorites] = useState<string[]>(() => {

View File

@@ -0,0 +1,49 @@
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/store/auth';
import { referralApi } from '@/api/referral';
import { wheelApi } from '@/api/wheel';
import { contestsApi } from '@/api/contests';
import { pollsApi } from '@/api/polls';
export function useFeatureFlags() {
const { isAuthenticated } = useAuthStore();
const { data: referralTerms } = useQuery({
queryKey: ['referral-terms'],
queryFn: referralApi.getReferralTerms,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
const { data: wheelConfig } = useQuery({
queryKey: ['wheel-config'],
queryFn: wheelApi.getConfig,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
const { data: contestsCount } = useQuery({
queryKey: ['contests-count'],
queryFn: contestsApi.getCount,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
const { data: pollsCount } = useQuery({
queryKey: ['polls-count'],
queryFn: pollsApi.getCount,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
return {
referralEnabled: referralTerms?.is_enabled,
wheelEnabled: wheelConfig?.is_enabled,
hasContests: (contestsCount?.count ?? 0) > 0,
hasPolls: (pollsCount?.count ?? 0) > 0,
};
}

View File

@@ -0,0 +1,40 @@
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router';
/**
* Saves and restores scroll position for admin pages.
* Disables browser's automatic scroll restoration.
*/
export function useScrollRestoration() {
const location = useLocation();
const scrollPositions = useRef<Record<string, number>>({});
// Disable browser's automatic scroll restoration
useEffect(() => {
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
}, []);
// Save/restore scroll for admin pages
useEffect(() => {
const currentPath = location.pathname;
if (!currentPath.startsWith('/admin')) return;
const handleScroll = () => {
scrollPositions.current[currentPath] = window.scrollY;
};
window.addEventListener('scroll', handleScroll, { passive: true });
const savedPosition = scrollPositions.current[currentPath];
if (savedPosition !== undefined && savedPosition > 0) {
window.scrollTo({ top: savedPosition, behavior: 'instant' });
}
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [location.pathname]);
}

View File

@@ -1,30 +0,0 @@
import { useCallback } from 'react';
import { useTelegramSDK } from './useTelegramSDK';
/**
* Hook for drag-and-drop operations in Telegram Mini App.
* Note: Vertical swipes are now globally disabled at app init,
* so this hook just provides no-op callbacks for compatibility.
*/
export function useTelegramDnd() {
const { isTelegramWebApp } = useTelegramSDK();
const onDragStart = useCallback(() => {
// No-op: swipes are globally disabled
}, []);
const onDragEnd = useCallback(() => {
// No-op: swipes are globally disabled
}, []);
const onDragCancel = useCallback(() => {
// No-op: swipes are globally disabled
}, []);
return {
onDragStart,
onDragEnd,
onDragCancel,
isTelegramWebApp,
};
}

View File

@@ -1,36 +0,0 @@
/**
* @deprecated This hook is deprecated. Use useTelegramSDK instead.
* This file is kept for backward compatibility and re-exports from useTelegramSDK.
*/
import { useTelegramSDK } from './useTelegramSDK';
// Re-export everything from useTelegramSDK for backward compatibility
export {
getCachedFullscreenEnabled,
setCachedFullscreenEnabled,
isInTelegramWebApp,
isTelegramMobile,
} from './useTelegramSDK';
/**
* @deprecated Use useTelegramSDK instead
* Hook for Telegram WebApp API integration - backward compatible wrapper
*/
export function useTelegramWebApp() {
const sdk = useTelegramSDK();
return {
isTelegramWebApp: sdk.isTelegramWebApp,
isFullscreen: sdk.isFullscreen,
isFullscreenSupported: sdk.isFullscreenSupported,
safeAreaInset: sdk.safeAreaInset,
contentSafeAreaInset: sdk.contentSafeAreaInset,
requestFullscreen: sdk.requestFullscreen,
exitFullscreen: sdk.exitFullscreen,
toggleFullscreen: sdk.toggleFullscreen,
disableVerticalSwipes: sdk.disableVerticalSwipes,
enableVerticalSwipes: sdk.enableVerticalSwipes,
webApp: null,
};
}

View File

@@ -1,11 +1,12 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme';
import { themeColorsApi } from '../api/themeColors';
import { STORAGE_KEYS } from '../config/constants';
type Theme = 'dark' | 'light';
const THEME_KEY = 'cabinet-theme';
const ENABLED_THEMES_KEY = 'cabinet-enabled-themes';
const THEME_KEY = STORAGE_KEYS.THEME;
const ENABLED_THEMES_KEY = STORAGE_KEYS.ENABLED_THEMES;
// Fetch enabled themes from API
async function fetchEnabledThemes(): Promise<EnabledThemes> {
@@ -82,18 +83,21 @@ export function useTheme() {
return enabled.dark ? 'dark' : 'light';
});
const themeRef = useRef(theme);
themeRef.current = theme;
// Fetch enabled themes on mount
useEffect(() => {
fetchEnabledThemes().then((data) => {
setEnabledThemes(data);
setIsLoading(false);
// If current theme is disabled, switch to enabled one
if (!data[theme]) {
if (!data[themeRef.current]) {
const newTheme = data.dark ? 'dark' : 'light';
setThemeState(newTheme);
}
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, []);
// Listen for localStorage changes (when admin updates enabled themes from other tabs)
useEffect(() => {

View File

@@ -2,66 +2,7 @@ import { useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { themeColorsApi } from '../api/themeColors';
import { ThemeColors, DEFAULT_THEME_COLORS, SHADE_LEVELS, ColorPalette } from '../types/theme';
// Convert hex to RGB values
function hexToRgb(hex: string): { r: number; g: number; b: number } {
// Handle shorthand hex
if (hex.length === 4) {
hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3];
}
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return { r, g, b };
}
// Convert hex to HSL
function hexToHsl(hex: string): { h: number; s: number; l: number } {
const { r, g, b } = hexToRgb(hex);
const rNorm = r / 255;
const gNorm = g / 255;
const bNorm = b / 255;
const max = Math.max(rNorm, gNorm, bNorm);
const min = Math.min(rNorm, gNorm, bNorm);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case rNorm:
h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6;
break;
case gNorm:
h = ((bNorm - rNorm) / d + 2) / 6;
break;
case bNorm:
h = ((rNorm - gNorm) / d + 4) / 6;
break;
}
}
return { h: h * 360, s: s * 100, l: l * 100 };
}
// Convert HSL to RGB values
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
s /= 100;
l /= 100;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color);
};
return { r: f(0), g: f(8), b: f(4) };
}
import { hexToRgb, hexToHsl, hslToRgb } from '../utils/colorConversion';
// Convert RGB to string format for CSS variable
function rgbToString(r: number, g: number, b: number): string {

View File

@@ -7,8 +7,9 @@ import {
DEFAULT_USER_PREFERENCES,
BORDER_RADIUS_VALUES,
} from '../types/theme';
import { STORAGE_KEYS } from '../config/constants';
const STORAGE_KEY = 'user_theme_preferences';
const STORAGE_KEY = STORAGE_KEYS.USER_THEME_PREFS;
/**
* Parse preferences from storage string

View File

@@ -20,6 +20,7 @@ import {
isFullscreen,
} from '@telegram-apps/sdk-react';
import { AppWithNavigator } from './AppWithNavigator';
import { ErrorBoundary } from './components/ErrorBoundary';
import { initLogoPreload } from './api/branding';
import { getCachedFullscreenEnabled, isTelegramMobile } from './hooks/useTelegramSDK';
import './i18n';
@@ -100,8 +101,10 @@ const queryClient = new QueryClient({
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<AppWithNavigator />
</QueryClientProvider>
<ErrorBoundary level="app">
<QueryClientProvider client={queryClient}>
<AppWithNavigator />
</QueryClientProvider>
</ErrorBoundary>
</React.StrictMode>,
);

View File

@@ -1,8 +1,7 @@
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminAppsApi } from '../api/adminApps';
import { useBackButton } from '../platform/hooks/useBackButton';
import { usePlatform } from '../platform/hooks/usePlatform';
export default function AdminApps() {
@@ -11,8 +10,6 @@ export default function AdminApps() {
const queryClient = useQueryClient();
const { capabilities } = usePlatform();
useBackButton(() => navigate('/admin'));
// RemnaWave status
const { data: status } = useQuery({
queryKey: ['remnawave-status'],

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
banSystemApi,
@@ -206,6 +206,8 @@ export default function AdminBanSystem() {
const [report, setReport] = useState<BanReportResponse | null>(null);
const [health, setHealth] = useState<BanHealthResponse | null>(null);
const [reportHours, setReportHours] = useState(24);
const reportHoursRef = useRef(reportHours);
reportHoursRef.current = reportHours;
const [settingLoading, setSettingLoading] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -240,19 +242,7 @@ export default function AdminBanSystem() {
const [searchQuery, setSearchQuery] = useState('');
const [actionLoading, setActionLoading] = useState<string | null>(null);
useEffect(() => {
loadStatus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (status?.enabled && status?.configured) {
loadTabData(activeTab);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, status]);
const loadStatus = async () => {
const loadStatus = useCallback(async () => {
try {
setLoading(true);
const data = await banSystemApi.getStatus();
@@ -265,71 +255,84 @@ export default function AdminBanSystem() {
} finally {
setLoading(false);
}
};
}, [t]);
const loadTabData = async (tab: TabType) => {
try {
setLoading(true);
setError(null);
const loadTabData = useCallback(
async (tab: TabType) => {
try {
setLoading(true);
setError(null);
switch (tab) {
case 'dashboard': {
const statsData = await banSystemApi.getStats();
setStats(statsData);
break;
}
case 'users': {
const usersData = await banSystemApi.getUsers({ limit: 50 });
setUsers(usersData);
break;
}
case 'punishments': {
const punishmentsData = await banSystemApi.getPunishments();
setPunishments(punishmentsData);
break;
}
case 'nodes': {
const nodesData = await banSystemApi.getNodes();
setNodes(nodesData);
break;
}
case 'agents': {
const agentsData = await banSystemApi.getAgents();
setAgents(agentsData);
break;
}
case 'violations': {
const violationsData = await banSystemApi.getTrafficViolations();
setViolations(violationsData);
break;
}
case 'settings': {
const settingsData = await banSystemApi.getSettings();
setSettings(settingsData);
break;
}
case 'traffic': {
const trafficData = await banSystemApi.getTraffic();
setTraffic(trafficData);
break;
}
case 'reports': {
const reportData = await banSystemApi.getReport(reportHours);
setReport(reportData);
break;
}
case 'health': {
const healthData = await banSystemApi.getHealth();
setHealth(healthData);
break;
switch (tab) {
case 'dashboard': {
const statsData = await banSystemApi.getStats();
setStats(statsData);
break;
}
case 'users': {
const usersData = await banSystemApi.getUsers({ limit: 50 });
setUsers(usersData);
break;
}
case 'punishments': {
const punishmentsData = await banSystemApi.getPunishments();
setPunishments(punishmentsData);
break;
}
case 'nodes': {
const nodesData = await banSystemApi.getNodes();
setNodes(nodesData);
break;
}
case 'agents': {
const agentsData = await banSystemApi.getAgents();
setAgents(agentsData);
break;
}
case 'violations': {
const violationsData = await banSystemApi.getTrafficViolations();
setViolations(violationsData);
break;
}
case 'settings': {
const settingsData = await banSystemApi.getSettings();
setSettings(settingsData);
break;
}
case 'traffic': {
const trafficData = await banSystemApi.getTraffic();
setTraffic(trafficData);
break;
}
case 'reports': {
const reportData = await banSystemApi.getReport(reportHoursRef.current);
setReport(reportData);
break;
}
case 'health': {
const healthData = await banSystemApi.getHealth();
setHealth(healthData);
break;
}
}
} catch {
setError(t('banSystem.loadError'));
} finally {
setLoading(false);
}
} catch {
setError(t('banSystem.loadError'));
} finally {
setLoading(false);
},
[t],
);
useEffect(() => {
loadStatus();
}, [loadStatus]);
useEffect(() => {
if (status?.enabled && status?.configured) {
loadTabData(activeTab);
}
};
}, [activeTab, status, loadTabData]);
const handleSearch = async () => {
if (!searchQuery.trim()) {
@@ -403,8 +406,7 @@ export default function AdminBanSystem() {
if (activeTab === 'reports' && status?.enabled) {
loadTabData('reports');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reportHours]);
}, [reportHours, activeTab, status, loadTabData]);
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
@@ -10,7 +10,6 @@ import {
CombinedBroadcastCreateRequest,
} from '../api/adminBroadcasts';
import { AdminBackButton } from '../components/admin';
import { useBackButton } from '../platform/hooks/useBackButton';
// Icons
const BroadcastIcon = () => (
@@ -119,8 +118,6 @@ export default function AdminBroadcastCreate() {
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
useBackButton(() => navigate('/admin/broadcasts'));
// Channel selection
const [channel, setChannel] = useState<BroadcastChannel>('telegram');

View File

@@ -1,9 +1,8 @@
import { useParams, useNavigate } from 'react-router-dom';
import { useParams, useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminBroadcastsApi, type BroadcastChannel } from '../api/adminBroadcasts';
import { AdminBackButton } from '../components/admin';
import { useBackButton } from '../platform/hooks/useBackButton';
// Icons
@@ -159,8 +158,6 @@ export default function AdminBroadcastDetail() {
const broadcastId = id ? parseInt(id, 10) : null;
useBackButton(() => navigate('/admin/broadcasts'));
// Fetch broadcast details
const {
data: broadcast,

View File

@@ -1,9 +1,8 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminBroadcastsApi } from '../api/adminBroadcasts';
import { useBackButton } from '../platform/hooks/useBackButton';
import { usePlatform } from '../platform/hooks/usePlatform';
// Icons
@@ -131,9 +130,6 @@ export default function AdminBroadcasts() {
const navigate = useNavigate();
const { capabilities } = usePlatform();
// Use native Telegram back button in Mini App
useBackButton(() => navigate('/admin'));
const [page, setPage] = useState(0);
const limit = 20;

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
@@ -11,8 +11,6 @@ import {
} from '../api/campaigns';
import { AdminBackButton } from '../components/admin';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
import { useBackButton } from '../platform/hooks/useBackButton';
// Icons
const CampaignIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -156,8 +154,6 @@ export default function AdminCampaignCreate() {
const navigate = useNavigate();
const queryClient = useQueryClient();
useBackButton(() => navigate('/admin/campaigns'));
// Form state
const [name, setName] = useState('');
const [startParameter, setStartParameter] = useState('');

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useParams, useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
@@ -12,7 +12,6 @@ import {
import { AdminBackButton } from '../components/admin';
import { CheckIcon, CampaignIcon } from '../components/icons';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
import { useBackButton } from '../platform/hooks/useBackButton';
// Bonus type config
const bonusTypeConfig: Record<
@@ -133,8 +132,6 @@ export default function AdminCampaignEdit() {
const campaignId = parseInt(id || '0');
useBackButton(() => navigate('/admin/campaigns'));
// Fetch campaign
const {
data: campaign,

View File

@@ -1,10 +1,9 @@
import { useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useParams, useNavigate, Link } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { campaignsApi, CampaignBonusType } from '../api/campaigns';
import { AdminBackButton } from '../components/admin';
import { useBackButton } from '../platform/hooks/useBackButton';
// Icons
const CopyIcon = () => (
@@ -98,8 +97,6 @@ export default function AdminCampaignStats() {
const [copied, setCopied] = useState(false);
const [showUsers, setShowUsers] = useState(false);
useBackButton(() => navigate('/admin/campaigns'));
// Fetch stats
const {
data: stats,

View File

@@ -1,11 +1,10 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { campaignsApi, CampaignListItem, CampaignBonusType } from '../api/campaigns';
import { PlusIcon, EditIcon, TrashIcon, CheckIcon, XIcon, ChartIcon } from '../components/icons';
import { useBackButton } from '../platform/hooks/useBackButton';
import { usePlatform } from '../platform/hooks/usePlatform';
// Bonus type labels and colors
@@ -67,9 +66,6 @@ export default function AdminCampaigns() {
const queryClient = useQueryClient();
const { capabilities } = usePlatform();
// Use native Telegram back button in Mini App
useBackButton(() => navigate('/admin'));
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
// Queries

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import {
statsApi,
@@ -10,7 +10,6 @@ import {
type RecentPaymentsResponse,
} from '../api/admin';
import { useCurrency } from '../hooks/useCurrency';
import { useBackButton } from '../platform/hooks/useBackButton';
import { usePlatform } from '../platform/hooks/usePlatform';
// Icons - styled like main navigation
@@ -365,9 +364,6 @@ export default function AdminDashboard() {
const { formatAmount, currencySymbol } = useCurrency();
const { capabilities } = usePlatform();
// Use native Telegram back button in Mini App
useBackButton(() => navigate('/admin'));
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -380,7 +376,7 @@ export default function AdminDashboard() {
const [payments, setPayments] = useState<RecentPaymentsResponse | null>(null);
const [referrersTab, setReferrersTab] = useState<'earnings' | 'invited'>('earnings');
const fetchStats = async () => {
const fetchStats = useCallback(async () => {
try {
setLoading(true);
setError(null);
@@ -392,9 +388,9 @@ export default function AdminDashboard() {
} finally {
setLoading(false);
}
};
}, [t]);
const fetchExtendedStats = async () => {
const fetchExtendedStats = useCallback(async () => {
try {
const [referrersData, campaignsData, paymentsData] = await Promise.all([
statsApi.getTopReferrers(10),
@@ -407,7 +403,7 @@ export default function AdminDashboard() {
} catch (err) {
console.error('Failed to load extended stats:', err);
}
};
}, []);
useEffect(() => {
fetchStats();
@@ -418,8 +414,7 @@ export default function AdminDashboard() {
fetchExtendedStats();
}, 30000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [fetchStats, fetchExtendedStats]);
const handleRestartNode = async (uuid: string) => {
try {

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useLocation, useNavigate, useParams } from 'react-router';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminEmailTemplatesApi } from '../api/adminEmailTemplates';
@@ -23,7 +23,11 @@ export default function AdminEmailTemplatePreview() {
const state = location.state as PreviewState | null;
// Preview mutation
const previewMutation = useMutation({
const {
mutate: loadPreview,
isPending: previewPending,
isError: previewError,
} = useMutation({
mutationFn: () => {
if (!type || !lang || !state) {
throw new Error('Missing required data');
@@ -45,9 +49,8 @@ export default function AdminEmailTemplatePreview() {
navigate('/admin/email-templates');
return;
}
previewMutation.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [type, lang]);
loadPreview();
}, [type, lang, state, navigate, loadPreview]);
// Write preview HTML into iframe
useEffect(() => {
@@ -85,11 +88,11 @@ export default function AdminEmailTemplatePreview() {
{/* Preview content */}
<div className="flex-1 overflow-hidden rounded-xl border border-dark-700 bg-white">
{previewMutation.isPending ? (
{previewPending ? (
<div className="flex h-full items-center justify-center bg-dark-800">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : previewMutation.isError ? (
) : previewError ? (
<div className="flex h-full flex-col items-center justify-center gap-4 bg-dark-800">
<p className="text-dark-400">{t('common.error')}</p>
<button

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {

View File

@@ -1,4 +1,4 @@
import { Link } from 'react-router-dom';
import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import { RemnawaveIcon } from '../components/icons';

View File

@@ -1,10 +1,9 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useParams, useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
import type { PromoGroupSimple } from '../types';
import { useBackButton } from '../platform/hooks/useBackButton';
import { usePlatform } from '../platform/hooks/usePlatform';
const BackIcon = () => (
@@ -72,9 +71,6 @@ export default function AdminPaymentMethodEdit() {
const queryClient = useQueryClient();
const { capabilities } = usePlatform();
// Use native Telegram back button in Mini App
useBackButton(() => navigate('/admin/payment-methods'));
// Fetch payment methods
const { data: methods, isLoading } = useQuery({
queryKey: ['admin-payment-methods'],

View File

@@ -1,8 +1,7 @@
import { useState, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useBackButton } from '../platform/hooks/useBackButton';
import { usePlatform } from '../platform/hooks/usePlatform';
import {
DndContext,
@@ -11,9 +10,8 @@ import {
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from '@dnd-kit/core';
import { useTelegramDnd } from '../hooks/useTelegramDnd';
import {
arrayMove,
SortableContext,
@@ -239,9 +237,6 @@ export default function AdminPaymentMethods() {
const queryClient = useQueryClient();
const { capabilities } = usePlatform();
// Use native Telegram back button in Mini App
useBackButton(() => navigate('/admin'));
const [methods, setMethods] = useState<PaymentMethodConfig[]>([]);
const [orderChanged, setOrderChanged] = useState(false);
const [toastMessage, setToastMessage] = useState<string | null>(null);
@@ -278,40 +273,18 @@ export default function AdminPaymentMethods() {
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
// Telegram swipe behavior for drag-and-drop
const {
onDragStart: onTelegramDragStart,
onDragEnd: onTelegramDragEnd,
onDragCancel: onTelegramDragCancel,
} = useTelegramDnd();
const handleDragStart = useCallback(
(_event: DragStartEvent) => {
onTelegramDragStart();
},
[onTelegramDragStart],
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
onTelegramDragEnd();
const { active, over } = event;
if (over && active.id !== over.id) {
setMethods((prev) => {
const oldIndex = prev.findIndex((m) => m.method_id === active.id);
const newIndex = prev.findIndex((m) => m.method_id === over.id);
if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
setOrderChanged(true);
}
},
[onTelegramDragEnd],
);
const handleDragCancel = useCallback(() => {
onTelegramDragCancel();
}, [onTelegramDragCancel]);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setMethods((prev) => {
const oldIndex = prev.findIndex((m) => m.method_id === active.id);
const newIndex = prev.findIndex((m) => m.method_id === over.id);
if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
setOrderChanged(true);
}
}, []);
const handleSaveOrder = () => {
saveOrderMutation.mutate(methods.map((m) => m.method_id));
@@ -367,12 +340,7 @@ export default function AdminPaymentMethods() {
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : methods.length > 0 ? (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext
items={methods.map((m) => m.method_id)}
strategy={verticalListSortingStrategy}

View File

@@ -1,11 +1,10 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminPaymentsApi } from '../api/adminPayments';
import { useCurrency } from '../hooks/useCurrency';
import type { PendingPayment, PaginatedResponse } from '../types';
import { useBackButton } from '../platform/hooks/useBackButton';
import { usePlatform } from '../platform/hooks/usePlatform';
// BackIcon
@@ -28,9 +27,6 @@ export default function AdminPayments() {
const { formatAmount, currencySymbol } = useCurrency();
const { capabilities } = usePlatform();
// Use native Telegram back button in Mini App
useBackButton(() => navigate('/admin'));
const [page, setPage] = useState(1);
const [methodFilter, setMethodFilter] = useState<string>('');
const [checkingPaymentId, setCheckingPaymentId] = useState<string | null>(null);

View File

@@ -1,5 +1,5 @@
import { useState, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
@@ -9,7 +9,6 @@ import {
PromoGroupUpdateRequest,
} from '../api/promocodes';
import { AdminBackButton } from '../components/admin';
import { useBackButton } from '../platform/hooks/useBackButton';
// Icons
const PlusIcon = () => (
@@ -56,8 +55,6 @@ export default function AdminPromoGroupCreate() {
const queryClient = useQueryClient();
const isEdit = !!id;
useBackButton(() => navigate('/admin/promo-groups'));
// Form state
const [name, setName] = useState('');
const [serverDiscount, setServerDiscount] = useState<number | ''>(0);

View File

@@ -1,9 +1,8 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { promocodesApi, PromoGroup } from '../api/promocodes';
import { useBackButton } from '../platform/hooks/useBackButton';
import { usePlatform } from '../platform/hooks/usePlatform';
// Icons
@@ -61,9 +60,6 @@ export default function AdminPromoGroups() {
const queryClient = useQueryClient();
const { capabilities } = usePlatform();
// Use native Telegram back button in Mini App
useBackButton(() => navigate('/admin'));
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
// Query

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
@@ -12,7 +12,6 @@ import {
} from '../api/promoOffers';
import { adminUsersApi, UserListItem } from '../api/adminUsers';
import { AdminBackButton } from '../components/admin';
import { useBackButton } from '../platform/hooks/useBackButton';
// Icons
const SendIcon = () => (
@@ -76,8 +75,6 @@ export default function AdminPromoOfferSend() {
const navigate = useNavigate();
const queryClient = useQueryClient();
useBackButton(() => navigate('/admin/promo-offers'));
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(null);
const [sendMode, setSendMode] = useState<'segment' | 'user'>('segment');
const [selectedTarget, setSelectedTarget] = useState<TargetSegment>('active');

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useParams, useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
@@ -10,7 +10,6 @@ import {
} from '../api/promoOffers';
import { AdminBackButton } from '../components/admin';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
import { useBackButton } from '../platform/hooks/useBackButton';
const getOfferTypeIcon = (offerType: string): string => {
return OFFER_TYPE_CONFIG[offerType as OfferType]?.icon || '🎁';
@@ -22,8 +21,6 @@ export default function AdminPromoOfferTemplateEdit() {
const { id } = useParams<{ id: string }>();
const queryClient = useQueryClient();
useBackButton(() => navigate('/admin/promo-offers'));
// Form state
const [name, setName] = useState('');
const [messageText, setMessageText] = useState('');

View File

@@ -1,10 +1,9 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import i18n from '../i18n';
import { promoOffersApi, PromoOfferLog, OFFER_TYPE_CONFIG, OfferType } from '../api/promoOffers';
import { useBackButton } from '../platform/hooks/useBackButton';
import { usePlatform } from '../platform/hooks/usePlatform';
// Icons
@@ -108,9 +107,6 @@ export default function AdminPromoOffers() {
const navigate = useNavigate();
const { capabilities } = usePlatform();
// Use native Telegram back button in Mini App
useBackButton(() => navigate('/admin'));
const [activeTab, setActiveTab] = useState<'templates' | 'logs'>('templates');
// Queries

View File

@@ -1,5 +1,5 @@
import { useState, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
@@ -10,7 +10,6 @@ import {
PromoCodeUpdateRequest,
PromoGroup,
} from '../api/promocodes';
import { useBackButton } from '../platform/hooks/useBackButton';
import { usePlatform } from '../platform/hooks/usePlatform';
// Icons
@@ -50,9 +49,6 @@ export default function AdminPromocodeCreate() {
const { capabilities } = usePlatform();
const isEdit = !!id;
// Use native Telegram back button in Mini App
useBackButton(() => navigate('/admin/promocodes'));
// Form state
const [code, setCode] = useState('');
const [type, setType] = useState<PromoCodeType>('balance');

View File

@@ -1,10 +1,9 @@
import { useParams, useNavigate } from 'react-router-dom';
import { useParams, useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { promocodesApi, PromoCodeType } from '../api/promocodes';
import { AdminBackButton } from '../components/admin';
import { useBackButton } from '../platform/hooks/useBackButton';
// Icons
const EditIcon = () => (
@@ -89,8 +88,6 @@ export default function AdminPromocodeStats() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
useBackButton(() => navigate('/admin/promocodes'));
const {
data: promocode,
isLoading,

View File

@@ -1,10 +1,9 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { promocodesApi, PromoCode, PromoCodeType } from '../api/promocodes';
import { useBackButton } from '../platform/hooks/useBackButton';
import { usePlatform } from '../platform/hooks/usePlatform';
// Icons
@@ -113,9 +112,6 @@ export default function AdminPromocodes() {
const queryClient = useQueryClient();
const { capabilities } = usePlatform();
// Use native Telegram back button in Mini App
useBackButton(() => navigate('/admin'));
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
const [copiedCode, setCopiedCode] = useState<string | null>(null);

View File

@@ -1,5 +1,5 @@
import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
@@ -9,7 +9,6 @@ import {
SystemStatsResponse,
AutoSyncStatus,
} from '../api/adminRemnawave';
import { useBackButton } from '../platform/hooks/useBackButton';
import { usePlatform } from '../platform/hooks/usePlatform';
import {
ServerIcon,
@@ -878,9 +877,6 @@ export default function AdminRemnawave() {
const queryClient = useQueryClient();
const { capabilities } = usePlatform();
// Use native Telegram back button in Mini App
useBackButton(() => navigate('/admin'));
// State
const [activeTab, setActiveTab] = useState<TabType>('overview');
const [syncResults, setSyncResults] = useState<

View File

@@ -1,11 +1,9 @@
import { useParams, useNavigate } from 'react-router-dom';
import { useParams, useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminRemnawaveApi, SquadWithLocalInfo } from '../api/adminRemnawave';
import { AdminBackButton } from '../components/admin';
import { ServerIcon, UsersIcon, CheckIcon, XIcon } from '../components/icons';
import { useBackButton } from '../platform/hooks/useBackButton';
// Country flags helper
const getCountryFlag = (code: string | null | undefined): string => {
if (!code) return '🌍';
@@ -55,8 +53,6 @@ export default function AdminRemnawaveSquadDetail() {
const { uuid } = useParams<{ uuid: string }>();
const navigate = useNavigate();
useBackButton(() => navigate('/admin/remnawave'));
// Fetch all squads and find the one we need
const {
data: squadsData,

View File

@@ -1,12 +1,11 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useParams, useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { serversApi, ServerUpdateRequest } from '../api/servers';
import { AdminBackButton } from '../components/admin';
import { ServerIcon } from '../components/icons';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
import { useBackButton } from '../platform/hooks/useBackButton';
// Country flags (simple emoji mapping)
const getCountryFlag = (code: string | null): string => {
@@ -49,8 +48,6 @@ export default function AdminServerEdit() {
const serverId = parseInt(id || '0');
useBackButton(() => navigate('/admin/servers'));
const {
data: server,
isLoading,

View File

@@ -1,9 +1,8 @@
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { serversApi, ServerListItem } from '../api/servers';
import { SyncIcon, EditIcon, CheckIcon, XIcon, UsersIcon, GiftIcon } from '../components/icons';
import { useBackButton } from '../platform/hooks/useBackButton';
import { usePlatform } from '../platform/hooks/usePlatform';
// BackIcon
@@ -58,9 +57,6 @@ export default function AdminServers() {
const queryClient = useQueryClient();
const { capabilities } = usePlatform();
// Use native Telegram back button in Mini App
useBackButton(() => navigate('/admin'));
// Queries
const { data: serversData, isLoading } = useQuery({
queryKey: ['admin-servers'],

View File

@@ -1,12 +1,11 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminSettingsApi, SettingDefinition } from '../api/adminSettings';
import { themeColorsApi } from '../api/themeColors';
import { useFavoriteSettings } from '../hooks/useFavoriteSettings';
import { MENU_SECTIONS, MenuItem, formatSettingKey } from '../components/admin';
import { useBackButton } from '../platform/hooks/useBackButton';
import { usePlatform } from '../platform/hooks/usePlatform';
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
import { BrandingTab } from '../components/admin/BrandingTab';
@@ -50,9 +49,6 @@ export default function AdminSettings() {
const navigate = useNavigate();
const { capabilities } = usePlatform();
// Use native Telegram back button in Mini App
useBackButton(() => navigate('/admin'));
// State
const [activeSection, setActiveSection] = useState('branding');
const [searchQuery, setSearchQuery] = useState('');

View File

@@ -1,5 +1,5 @@
import { useState, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
@@ -12,7 +12,6 @@ import {
} from '../api/tariffs';
import { AdminBackButton } from '../components/admin';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
import { useBackButton } from '../platform/hooks/useBackButton';
// Icons
const PlusIcon = () => (
@@ -92,8 +91,6 @@ export default function AdminTariffCreate() {
const queryClient = useQueryClient();
const isEdit = !!id;
useBackButton(() => navigate('/admin/tariffs'));
// Step: null = type selection, 'period' or 'daily' = form
const [tariffType, setTariffType] = useState<TariffType>(null);

View File

@@ -1,9 +1,8 @@
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { tariffsApi, TariffListItem } from '../api/tariffs';
import { useDestructiveConfirm, useNotify } from '@/platform';
import { useBackButton } from '../platform/hooks/useBackButton';
import { usePlatform } from '../platform/hooks/usePlatform';
// Icons
@@ -76,9 +75,6 @@ export default function AdminTariffs() {
const notify = useNotify();
const { capabilities } = usePlatform();
// Use native Telegram back button in Mini App
useBackButton(() => navigate('/admin'));
// Queries
const { data: tariffsData, isLoading } = useQuery({
queryKey: ['admin-tariffs'],

View File

@@ -1,10 +1,9 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminApi } from '../api/admin';
import { AdminBackButton } from '../components/admin';
import { useBackButton } from '../platform/hooks/useBackButton';
const SettingsIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -22,8 +21,6 @@ export default function AdminTicketSettings() {
const navigate = useNavigate();
const queryClient = useQueryClient();
useBackButton(() => navigate('/admin/tickets'));
const {
data: settings,
isLoading,

View File

@@ -1,10 +1,9 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminApi, AdminTicket, AdminTicketDetail, AdminTicketMessage } from '../api/admin';
import { ticketsApi } from '../api/tickets';
import { useBackButton } from '../platform/hooks/useBackButton';
import { usePlatform } from '../platform/hooks/usePlatform';
function AdminMessageMedia({
@@ -123,9 +122,6 @@ export default function AdminTickets() {
const queryClient = useQueryClient();
const { capabilities } = usePlatform();
// Use native Telegram back button in Mini App
useBackButton(() => navigate('/admin'));
const [selectedTicketId, setSelectedTicketId] = useState<number | null>(null);
const [statusFilter, setStatusFilter] = useState<string>('');
const [replyText, setReplyText] = useState('');

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useParams, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { useCurrency } from '../hooks/useCurrency';
@@ -11,7 +11,6 @@ import {
type UpdateSubscriptionRequest,
} from '../api/adminUsers';
import { AdminBackButton } from '../components/admin';
import { useBackButton } from '../platform/hooks/useBackButton';
// ============ Icons ============
@@ -82,8 +81,6 @@ export default function AdminUserDetail() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
useBackButton(() => navigate('/admin/users'));
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
const locale = localeMap[i18n.language] || 'ru-RU';

View File

@@ -1,10 +1,9 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useCurrency } from '../hooks/useCurrency';
import { useToast } from '../components/Toast';
import { adminUsersApi, type UserListItem, type UsersStatsResponse } from '../api/adminUsers';
import { useBackButton } from '../platform/hooks/useBackButton';
import { usePlatform } from '../platform/hooks/usePlatform';
// ============ Icons ============
@@ -398,9 +397,6 @@ export default function AdminUsers() {
const { showToast } = useToast();
const { capabilities } = usePlatform();
// Use native Telegram back button in Mini App
useBackButton(() => navigate('/admin'));
const [users, setUsers] = useState<UserListItem[]>([]);
const [stats, setStats] = useState<UsersStatsResponse | null>(null);
const [loading, setLoading] = useState(true);

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
@@ -11,7 +11,7 @@ import {
closestCenter,
type DragEndEvent,
} from '@dnd-kit/core';
import { useTelegramDnd } from '../hooks/useTelegramDnd';
import {
arrayMove,
SortableContext,
@@ -26,7 +26,6 @@ import { useDestructiveConfirm } from '@/platform';
import { useNotify } from '@/platform/hooks/useNotify';
import FortuneWheel from '../components/wheel/FortuneWheel';
import { ColorPicker } from '@/components/ColorPicker';
import { useBackButton } from '../platform/hooks/useBackButton';
import { usePlatform } from '../platform/hooks/usePlatform';
// Icons
@@ -295,9 +294,6 @@ export default function AdminWheel() {
const { capabilities } = usePlatform();
const notify = useNotify();
// Use native Telegram back button in Mini App
useBackButton(() => navigate('/admin'));
const [activeTab, setActiveTab] = useState<Tab>('settings');
const [expandedPrizeId, setExpandedPrizeId] = useState<number | null>(null);
const [isCreating, setIsCreating] = useState(false);
@@ -433,22 +429,13 @@ export default function AdminWheel() {
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
// Telegram DnD support
const {
onDragStart: onTelegramDragStart,
onDragEnd: onTelegramDragEnd,
onDragCancel: onTelegramDragCancel,
} = useTelegramDnd();
const handleDragStart = useCallback(() => {
onTelegramDragStart();
// Collapse expanded card to avoid collision detection issues with varying heights
setExpandedPrizeId(null);
}, [onTelegramDragStart]);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
onTelegramDragEnd();
const { active, over } = event;
if (over && active.id !== over.id && config) {
// Use current displayed order (either local or from config)
@@ -468,7 +455,7 @@ export default function AdminWheel() {
}
}
},
[config, localPrizeOrder, onTelegramDragEnd],
[config, localPrizeOrder],
);
// Save prize order
@@ -894,7 +881,6 @@ export default function AdminWheel() {
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={onTelegramDragCancel}
>
<SortableContext
items={displayedPrizes.map((p) => p.id)}

View File

@@ -1,12 +1,13 @@
import { useState, useEffect, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useSearchParams, useNavigate } from 'react-router';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuthStore } from '../store/auth';
import { balanceApi } from '../api/balance';
import { useCurrency } from '../hooks/useCurrency';
import { API } from '../config/constants';
import { useToast } from '../components/Toast';
import type { PaginatedResponse, Transaction } from '../types';
@@ -47,19 +48,18 @@ export default function Balance() {
const { showToast } = useToast();
const paymentHandledRef = useRef(false);
// Fetch balance directly from API with no caching
// Fetch balance from API
const { data: balanceData, refetch: refetchBalance } = useQuery({
queryKey: ['balance'],
queryFn: balanceApi.getBalance,
staleTime: 0,
staleTime: API.BALANCE_STALE_TIME_MS,
refetchOnMount: 'always',
});
// Refresh user data on mount to sync balance in store
useEffect(() => {
refreshUser();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [refreshUser]);
// Handle payment return from payment gateway
useEffect(() => {

View File

@@ -1,11 +1,11 @@
import { useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { openLink as sdkOpenLink } from '@telegram-apps/sdk-react';
import { subscriptionApi } from '../api/subscription';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
import { useBackButton, useHaptic } from '@/platform';
import { useTelegramSDK } from '../hooks/useTelegramSDK';
import { useHaptic } from '@/platform';
import { resolveTemplate, hasTemplates } from '../utils/templateEngine';
import { useAuthStore } from '../store/auth';
import type { AppConfig, LocalizedText } from '../types';
@@ -15,7 +15,7 @@ export default function Connection() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const { user } = useAuthStore();
const { isTelegramWebApp } = useTelegramWebApp();
const { isTelegramWebApp } = useTelegramSDK();
const { impact: hapticImpact } = useHaptic();
const hapticRef = useRef(hapticImpact);
@@ -45,13 +45,6 @@ export default function Connection() {
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleGoBack]);
const handleBackButton = useCallback(() => {
hapticRef.current('light');
handleGoBack();
}, [handleGoBack]);
useBackButton(handleBackButton);
const resolveUrl = useCallback(
(url: string): string => {
if (!hasTemplates(url) || !appConfig?.subscriptionUrl) return url;

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/auth';
import { subscriptionApi } from '../api/subscription';
@@ -10,6 +10,7 @@ import { wheelApi } from '../api/wheel';
import Onboarding, { useOnboarding } from '../components/Onboarding';
import PromoOffersSection from '../components/PromoOffersSection';
import { useCurrency } from '../hooks/useCurrency';
import { API } from '../config/constants';
// Icons
const ArrowRightIcon = () => (
@@ -57,14 +58,13 @@ export default function Dashboard() {
// Refresh user data on mount
useEffect(() => {
refreshUser();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [refreshUser]);
// Fetch balance from API with no caching
// Fetch balance from API
const { data: balanceData } = useQuery({
queryKey: ['balance'],
queryFn: balanceApi.getBalance,
staleTime: 0,
staleTime: API.BALANCE_STALE_TIME_MS,
refetchOnMount: 'always',
});
@@ -72,7 +72,7 @@ export default function Dashboard() {
queryKey: ['subscription'],
queryFn: subscriptionApi.getSubscription,
retry: false,
staleTime: 0,
staleTime: API.BALANCE_STALE_TIME_MS,
refetchOnMount: 'always',
});
@@ -167,7 +167,7 @@ export default function Dashboard() {
const lastRefresh = localStorage.getItem('traffic_refresh_ts');
const now = Date.now();
const cacheMs = 30 * 1000;
const cacheMs = API.TRAFFIC_CACHE_MS;
if (lastRefresh && now - parseInt(lastRefresh, 10) < cacheMs) {
const elapsed = now - parseInt(lastRefresh, 10);
@@ -179,8 +179,7 @@ export default function Dashboard() {
}
refreshTrafficMutation.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subscription]);
}, [subscription, refreshTrafficMutation]);
// User has no subscription if API returns has_subscription: false
const hasNoSubscription = subscriptionResponse?.has_subscription === false && !subLoading;
@@ -243,8 +242,8 @@ export default function Dashboard() {
// Calculate traffic percentage color
const getTrafficColor = (percent: number) => {
if (percent > 90) return 'bg-error-500';
if (percent > 70) return 'bg-warning-500';
if (percent > API.TRAFFIC_CRITICAL_PERCENT) return 'bg-error-500';
if (percent > API.TRAFFIC_WARN_PERCENT) return 'bg-warning-500';
return 'bg-success-500';
};

Some files were not shown because too many files have changed in this diff Show More