mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Merge pull request #155 from BEDOLAGA-DEV/refactor/full-codebase-cleanup
Refactor/full codebase cleanup
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -46,3 +46,5 @@ miniapp/
|
||||
|
||||
# AI & Agents context
|
||||
.ai/
|
||||
CLAUDE.md
|
||||
**/CLAUDE.md
|
||||
|
||||
838
package-lock.json
generated
838
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
93
src/components/ErrorBoundary.tsx
Normal file
93
src/components/ErrorBoundary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router';
|
||||
import { usePlatform } from '@/platform';
|
||||
import { BackIcon } from './icons';
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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`}
|
||||
/>
|
||||
|
||||
@@ -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';
|
||||
@@ -1 +0,0 @@
|
||||
export { EmptyState, type EmptyStateProps } from './EmptyState';
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './Card';
|
||||
export * from './StatCard';
|
||||
export * from './EmptyState';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router';
|
||||
import { forwardRef, useCallback } from 'react';
|
||||
import { useHaptic } from '@/platform';
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
30
src/config/constants.ts
Normal 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;
|
||||
@@ -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
79
src/hooks/useBranding.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[]>(() => {
|
||||
|
||||
49
src/hooks/useFeatureFlags.ts
Normal file
49
src/hooks/useFeatureFlags.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
40
src/hooks/useScrollRestoration.ts
Normal file
40
src/hooks/useScrollRestoration.ts
Normal 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]);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user